Princípio de responsabilidade única. Não é tão simples quanto parece

imagem Princípio de responsabilidade única, ele é o princípio de responsabilidade única,
ele é o princípio da variabilidade uniforme - um sujeito extremamente escorregadio de entender e uma pergunta tão nervosa na entrevista de um programador.


O primeiro conhecimento sério desse princípio ocorreu para mim no início do primeiro ano, quando os jovens e os verdes foram levados para a floresta para formar verdadeiros alunos das larvas.


Na floresta, fomos divididos em grupos de 8 a 9 pessoas em cada um e organizamos uma competição - qual grupo beberá uma garrafa de vodka mais rapidamente, desde que a primeira pessoa do grupo derrame vodka em um copo, as segundas bebidas e a terceira morda. Após concluir sua operação, a unidade fica no final da fila do grupo.


O caso em que o tamanho da fila era múltiplo de três e era uma boa implementação do SRP.


Definição 1. Responsabilidade única.


A definição oficial do princípio da responsabilidade única (SRP) sugere que cada objeto tem sua própria responsabilidade e razão de existência, e essa responsabilidade possui apenas uma.


Considere o objeto Tippler.
Para cumprir o princípio do SRP, dividimos as responsabilidades em três:


  • Um derrama ( PourOperation )
  • Um drinques ( DrinkUpOperation )
  • Um lanche ( TakeBiteOperation )

Cada um dos participantes do processo é responsável por um componente do processo, ou seja, tem uma responsabilidade atômica - beber, derramar ou dar uma mordida.


A bebida, por sua vez, é a fachada para essas operações:


lass Tippler { //... void Act(){ _pourOperation.Do() //  _drinkUpOperation.Do() //  _takeBiteOperation.Do() //  } } 

imagem

Porque


O programador humano escreve o código para o homem-macaco, e o homem-macaco é desatento, estúpido e sempre com pressa em algum lugar. Ele pode manter e entender cerca de 3 a 7 termos por vez.
No caso de bebida alcoólica, esses termos são três. No entanto, se escrevermos o código com uma folha, mãos, óculos, massacres e debates intermináveis ​​sobre política aparecerão nele. E tudo isso estará no corpo de um método. Tenho certeza que você viu esse código em sua prática. Não é o teste mais humano para a psique.


Por outro lado, o homem-macaco é preso por modelar objetos do mundo real em sua cabeça. Em sua imaginação, ele pode juntá-los, coletar novos objetos e desmontá-los da mesma maneira. Imagine um modelo de carro antigo. Você pode abrir a porta com sua imaginação, desaparafusar a guarnição da porta e ver os mecanismos de elevação da janela, dentro dos quais haverá engrenagens. Mas você não pode ver todos os componentes da máquina ao mesmo tempo, em uma "lista". Pelo menos o "homem macaco" não pode.


Portanto, programadores humanos decompõem mecanismos complexos em um conjunto de elementos menos complexos e funcionais. No entanto, a decomposição pode ser feita de diferentes maneiras: em muitos carros antigos - o duto sai pela porta e nos modernos - a falha na eletrônica da trava impede que o motor dê partida, o que ocorre durante o reparo.


Portanto, o SRP é um princípio que explica COMO decompor, ou seja, onde desenhar a linha de separação .


Ele diz que a decomposição deve basear-se no princípio da separação da "responsabilidade", isto é, de acordo com as tarefas de vários objetos.


imagem

Vamos voltar à bebida e às vantagens que uma pessoa macaco obtém ao se decompor:


  • O código tornou-se extremamente claro em todos os níveis.
  • Vários programadores podem escrever código de uma vez (cada um escreve um elemento separado)
  • O teste automatizado é simplificado - quanto mais simples o elemento, mais fácil é testar
  • Dessas três operações, no futuro, você pode adicionar um glutão (usando apenas TakeBitOperation ), um alcoólatra (usando apenas DrinkUpOperation diretamente da garrafa) e satisfazer muitos outros requisitos de negócios.

E, claro, os contras:


  • Terá que criar mais tipos.
  • Um bebedor bebe pela primeira vez algumas horas depois do que podia

Definição 2. Variabilidade unificada.


Permita cavalheiros! A classe de beber também cumpre uma única responsabilidade - bebe! E, em geral, a palavra "responsabilidade" é um conceito extremamente vago. Alguém é responsável pelo destino da humanidade e alguém é responsável por elevar os pingüins tombados no poste.


Considere duas implementações de bingo. A primeira, mencionada acima, contém três classes - despeje, beba e dê uma mordida.


O segundo é escrito através da metodologia Forward e Only Forward e contém toda a lógica do método Act :


 //      .    lass BrutTippler { //... void Act(){ //  if(!_hand.TryDischarge(from:_bottle, to:_glass, size:_glass.Capacity)) throw new OverdrunkException(); //  if(!_hand.TryDrink(from: _glass, size: _glass.Capacity)) throw new OverdrunkException(); // for(int i = 0; i< 3; i++){ var food = _foodStore.TakeOrDefault(); if(food==null) throw new FoodIsOverException(); _hand.TryEat(food); } } } 

Ambas as classes, do ponto de vista de um observador externo, parecem exatamente iguais e cumprem a única responsabilidade de "beber".


Vergonha!


Em seguida, navegamos na Internet e descobrimos outra definição de SRP - o princípio da variabilidade uniforme.


Esta definição afirma que " o módulo tem um e apenas um motivo para a mudança ". Ou seja, "Responsabilidade é uma ocasião para mudança".


Agora tudo se encaixa. Separadamente, você pode alterar os procedimentos de vazamento, bebida e mordida, e na própria bebida só podemos alterar a sequência e a composição das operações, por exemplo, mover o lanche antes de beber ou adicionar uma leitura de brinde.


Na abordagem Forward e Only Forward, tudo o que pode ser alterado é alterado apenas no método Act . Pode ser legível e eficaz no caso em que há pouca lógica e raramente muda, mas geralmente termina com métodos terríveis de 500 linhas cada, com mais números if do que o necessário para a entrada da Rússia na OTAN.


Definição 3. Localização de mudanças.


Os bebedores geralmente não entendem por que acordaram no apartamento de outra pessoa ou onde está o celular. É hora de adicionar log detalhado.


Vamos começar a registrar com o processo de vazamento:


 class PourOperation: IOperation{ PourOperation(ILogger log /*....*/){/*...*/} //... void Do(){ _log.Log($"Before pour with {_hand} and {_bottle}"); //Pour business logic ... _log.Log($"After pour with {_hand} and {_bottle}"); } } 

Encapsulando-o no PourOperation , agimos com sabedoria em termos de responsabilidade e encapsulamento, mas agora, com o princípio da variabilidade, estamos envergonhados. Além da operação em si, que pode ser alterada, o próprio registro se torna variável. Teremos que separar e criar um logger especial para a operação de vazamento:


 interface IPourLogger{ void LogBefore(IHand, IBottle){} void LogAfter(IHand, IBottle){} void OnError(IHand, IBottle, Exception){} } class PourOperation: IOperation{ PourOperation(IPourLogger log /*....*/){/*...*/} //... void Do(){ _log.LogBefore(_hand, _bottle); try{ //... business logic _log.LogAfter(_hand, _bottle"); } catch(exception e){ _log.OnError(_hand, _bottle, e) } } } 

Um leitor meticuloso notará que LogAfter , LogBefore e OnError também podem ser alterados individualmente e, por analogia com as etapas anteriores, criará três classes: PourLoggerBefore , PourLoggerAfter e PourErrorLogger .


E lembrando que existem três operações para uma compulsão - temos nove classes de registro. Como resultado, toda a bebida consiste em 14 (!!!) classes.


Hipérbole? Dificilmente! Um homem-macaco com uma granada de decomposição esmagará o “vazador” em um decantador, um copo, operadores de vazamento, um serviço de abastecimento de água, um modelo físico de colisão de moléculas e no próximo trimestre tentará desvendar as dependências sem variáveis ​​globais. E acredite em mim - ele não vai parar.


É neste ponto que muitos chegam à conclusão de que os SRPs são contos dos reinos cor-de-rosa e partem para torcer o macarrão ...


... nunca sabendo da existência da terceira definição de Srp:


" Coisas semelhantes às mudanças devem ser armazenadas em um só lugar ." ou " O que muda juntos deve ser mantido em um só lugar "


Ou seja, se alterarmos o log da operação, devemos alterá-lo em um só lugar.


Este é um ponto muito importante - uma vez que todas as explicações SRP acima mencionadas disseram que os tipos devem ser divididos enquanto são divididos, ou seja, impuseram uma "restrição superior" ao tamanho do objeto, e agora estamos falando de um "limite inferior" . Em outras palavras, o SRP não apenas exige "esmagamento ao esmagar", mas também não exagera - "não esmague coisas vinculadas" . Não complique desnecessariamente. Esta é a grande batalha da navalha de Occam com o homem-macaco!


imagem

Agora a bebida deve ser mais fácil. Além de não dividir o logger do IPourLogger em três classes, também podemos combinar todos os loggers em um tipo:


 class OperationLogger{ public OperationLogger(string operationName){/*..*/} public void LogBefore(object[] args){/*...*/} public void LogAfter(object[] args){/*..*/} public void LogError(object[] args, exception e){/*..*/} } 

E se o quarto tipo de operação for adicionado a nós, o registro estará pronto para isso. E o código das operações em si é limpo e livre de ruídos de infraestrutura.


Como resultado, temos 5 aulas para resolver o problema da bebida:


  • Operação de vazamento
  • Operação de bebida
  • Operação de atolamento
  • Logger
  • Fachada dos Boolers

Cada um deles é responsável estritamente por uma funcionalidade, tem um motivo para a mudança. Todas as regras semelhantes às alterações estão próximas.


Exemplos da vida real


Serialização e desserialização

Como parte do desenvolvimento do protocolo de transferência de dados, é necessário serializar e desserializar algum tipo de "Usuário" em uma string.


 User{ String Name; Int Age; } 

Você pode pensar que a serialização e a desserialização precisam ser feitas em classes separadas:


 UserDeserializer{ String deserialize(User){...} } UserSerializer{ User serialize(String){...} } 

Como cada um deles tem sua própria responsabilidade e uma razão para a mudança.


Mas eles têm um motivo comum de mudança - "alterando o formato da serialização de dados".
E ao alterar esse formato, a serialização e a desserialização sempre mudam.


De acordo com o princípio de localizar alterações, devemos combiná-las em uma classe:


 UserSerializer{ String deserialize(User){...} User serialize(String){...} } 

Isso evita a complexidade desnecessária e a necessidade de lembrar que toda vez que você altera o serializador, você precisa se lembrar do desserializador.


Contar e salvar

Você precisa calcular a receita anual da empresa e salvá-la no arquivo C: \ results.txt.


Resolvemos isso rapidamente com um método:


 void SaveGain(Company company){ //     //   } 

Já na definição da tarefa, está claro que existem duas subtarefas - "Calcular receita" e "Salvar receita". Cada um deles tem um motivo para alterações - "uma alteração na metodologia de cálculo" e "uma alteração no formato de salvamento". Essas alterações não se sobrepõem. Além disso, não podemos responder monossilábicamente à pergunta - “o que o método SaveGain faz?”. Este método AND calcula a receita E salva os resultados.


Portanto, você precisa dividir esse método em dois:


 Gain CalcGain(Company company){..} void SaveGain(Gain gain){..} 

Prós:


  • pode ser testado separadamente CalcGain
  • mais fácil de localizar bugs e fazer alterações
  • legibilidade do código aumentada
  • o risco de erro em cada um dos métodos é reduzido devido à sua simplificação

Lógica de negócios sofisticada

Certa vez, escrevemos um serviço para registro automático de um cliente B2B. E havia um método GOD com 200 linhas de conteúdo semelhante:


  • Vá para 1C e obtenha uma conta
  • Com esta conta, vá para o módulo de pagamento e chegue lá
  • Verifique se uma conta com essa conta não foi criada no servidor principal
  • Crie uma nova conta
  • O resultado do registro no módulo de pagamento e o número 1c são adicionados ao serviço de resultados do registro
  • Adicionar informações da conta a esta tabela
  • Crie um número de ponto para este cliente no serviço de pontos. Dê a esta conta de serviço número 1s.

Havia cerca de 10 operações comerciais com uma conexão terrível nesta lista. O objeto da conta era necessário para quase todos. A identificação do ponto e o nome do cliente foram necessários em metade das chamadas.


Após uma hora de refatoração, conseguimos separar o código de infraestrutura e algumas nuances do trabalho com a conta em métodos / classes separados. O método God ficou mais fácil, mas restavam 100 linhas de código que não queriam ser desvendadas.


Apenas alguns dias depois chegou a compreensão de que a essência desse método "aliviado" é o algoritmo de negócios. E que a descrição inicial de TK era bastante complicada. E é uma tentativa de quebrar esse método em pedaços que serão uma violação do SRP, e não vice-versa.


Formalismo.


É hora de deixar nossa bebida em paz. Limpe as lágrimas - nós definitivamente voltaremos a ela de alguma forma. Agora formalizamos o conhecimento deste artigo.


Formalismo 1. Definição de SRP


  1. Separe os elementos para que cada um deles seja responsável por uma coisa.
  2. Responsabilidade significa "causa de mudança". Ou seja, cada elemento tem apenas um motivo para a mudança, em termos de lógica de negócios.
  3. Potenciais alterações na lógica de negócios. deve ser localizado. Itens mutáveis ​​juntos devem estar próximos.

Formalismo 2. Critérios necessários para o auto-exame.


Não atendi a critérios suficientes para a implementação do SRP. Mas existem condições necessárias:


1) Faça uma pergunta a você mesmo - o que essa classe / método / módulo / serviço faz? você deve respondê-lo com uma definição simples. (graças a Brightori )


explicações

No entanto, às vezes é muito difícil encontrar uma definição simples


2) Corrigir um erro ou adicionar um novo recurso afeta o número mínimo de arquivos / classes. Idealmente, um.


explicações

Como a responsabilidade (por um recurso ou bug) é encapsulada em um único arquivo / classe, você sabe exatamente onde procurar e o que editar. Por exemplo: o recurso de alterar a saída do log de operação exigirá a alteração apenas do logger. Não é necessário executar o restante do código.


Outro exemplo é a adição de um novo controle de interface do usuário semelhante aos anteriores. Se isso força você a adicionar 10 entidades diferentes e 15 conversores diferentes - parece que você “quebrou”.


3) Se vários desenvolvedores estão trabalhando em diferentes recursos do seu projeto, a probabilidade de um conflito de mesclagem, ou seja, a probabilidade de vários desenvolvedores mudarem o mesmo arquivo / classe ao mesmo tempo, é mínima.


explicações

Se, ao adicionar uma nova operação "Despeje a vodka embaixo da mesa", você precisará tocar o lenhador, a operação de beber e derramar - então parece que as responsabilidades estão divididas de forma torta. Obviamente, isso nem sempre é possível, mas você precisa tentar reduzir esse número.


4) Ao esclarecer uma pergunta sobre lógica de negócios (de um desenvolvedor ou gerente), você entra estritamente em uma classe / arquivo e recebe informações apenas a partir daí.


explicações

Recursos, regras ou algoritmos são escritos de forma compacta, cada um em um único local, e não espalhados por sinalizadores pelo espaço de código.


5) A nomeação é clara.


explicações

Nossa classe ou método é responsável por uma coisa, e a responsabilidade se reflete em seu nome.


AllManagersManagerService - provavelmente, classe de Deus
LocalPayment - provavelmente não


Formalismo 3. O método de desenvolvimento de Occam-first.


No início do projeto, o homem-macaco não conhece e não sente todas as sutilezas do problema que está sendo resolvido e pode causar um erro. Você pode cometer erros de maneiras diferentes:


  • Faça objetos muito grandes colando responsabilidades diferentes
  • Divida, dividindo uma única responsabilidade em muitos tipos diferentes
  • Limites de responsabilidade definidos incorretamente

É importante lembrar a regra: "é melhor cometer um grande erro" ou "não tenho certeza - não se divida". Se, por exemplo, sua classe coletar duas responsabilidades, ainda será compreensível e poderá ser dividida em duas com uma alteração mínima no código do cliente. Coletar um copo de fragmentos de vidro geralmente é mais difícil devido ao contexto espalhado por vários arquivos e à falta de dependências necessárias no código do cliente.


É hora de terminar


O escopo do SRP não se limita ao OOP e ao SOLID. É aplicável a métodos, funções, classes, módulos, microsserviços e serviços. Isso se aplica ao desenvolvimento "figax-figax-and-in-prod" e "rocket-sainz", tornando o mundo um pouco melhor em todos os lugares. Se você pensar bem, esse é quase o princípio fundamental de toda a engenharia. A engenharia mecânica, os sistemas de controle e, de fato, todos os sistemas complexos são construídos a partir de componentes, e a "fragmentação incompleta" priva os projetistas de flexibilidade, "fragmentação" - de eficiência e limites incorretos - da razão e tranqüilidade.


imagem

O SRP não é inventado pela natureza e não faz parte da ciência exata. Ele sai das nossas limitações biológicas e psicológicas, e é apenas uma maneira de controlar e desenvolver sistemas complexos usando o cérebro de um macaco humano. Ele nos diz como decompor o sistema. A redação original exigia bastante telepatia, mas espero que este artigo tenha dissipado levemente a cortina de fumaça.

Source: https://habr.com/ru/post/pt454290/


All Articles