Cachoeira da complexidade e arquitetura sob demanda

Logomarca


Quando se fala em "código incorreto", as pessoas quase certamente querem dizer "código complexo", entre outros problemas populares. A questão da complexidade é que ela surge do nada. Um dia você inicia seu projeto bastante simples, outro dia você o encontra em ruínas. E ninguém sabe como e quando isso aconteceu.


Mas, isso finalmente acontece por uma razão! A complexidade do código entra na sua base de código de duas maneiras possíveis: com grandes partes e adições incrementais. E as pessoas são ruins em revisar e encontrar os dois.


Quando um grande pedaço de código entra, o revisor será desafiado a encontrar o local exato em que o código é complexo e o que fazer a respeito. Então, a revisão terá que provar o ponto: por que esse código é complexo em primeiro lugar. E outros desenvolvedores podem discordar. Todos nós conhecemos esse tipo de revisão de código!


Número de linhas para revisão e proporção de comentários


A segunda maneira de complexidade que entra no seu código é a adição incremental: quando você envia uma ou duas linhas à função existente. E é extremamente difícil perceber que sua função estava correta há um commit atrás, mas agora é muito complexa. É preciso uma boa parte da concentração, revisão de habilidades e boas práticas de navegação de código para realmente identificá-lo. A maioria das pessoas (como eu!) Não tem essas habilidades e permite que a complexidade entre regularmente na base de código.


Então, o que pode ser feito para impedir que seu código fique complexo? Precisamos usar a automação! Vamos aprofundar a complexidade do código e as maneiras de encontrá-lo e finalmente resolvê-lo.


Neste artigo, vou guiá-lo por lugares onde a complexidade vive e como combatê-la lá. Em seguida, discutiremos o quão bem o código e a automação simples escritos possibilitam uma oportunidade dos estilos de desenvolvimento "Refatoração contínua" e "Arquitetura sob demanda".


Complexidade explicada


Alguém pode perguntar: o que exatamente é "complexidade de código"? E embora pareça familiar, existem obstáculos ocultos na compreensão da localização exata da complexidade. Vamos começar com as partes mais primitivas e depois passar para entidades de nível superior.


Lembre-se, que este artigo se chama "Cachoeira da Complexidade"? Mostrarei como a complexidade das primitivas mais simples transborda para as abstrações mais altas.


wemake-python-styleguide python como a linguagem principal dos meus exemplos e wemake-python-styleguide como a principal ferramenta de aprendizado para encontrar as violações no meu código e ilustrar meu argumento.


Expressões


Todo o seu código consiste em expressões simples como a + 1 e print(x) . Embora as expressões sejam simples, elas podem transbordar seu código com complexidade em algum momento. Exemplo: imagine que você tenha um dicionário que represente algum modelo de User e use-o assim:


 def format_username(user) -> str: if not user['username']: return user['email'] elif len(user['username']) > 12: return user['username'][:12] + '...' return '@' + user['username'] 

Parece bem simples, não é? De fato, ele contém dois problemas de complexidade baseados em expressões. Ele usa a string overuses 'username' e usa o número mágico 12 (por que usamos esse número em primeiro lugar, por que não 13 ou 10 ?). É difícil encontrar esse tipo de coisa sozinho. Veja como seria a versão melhor:


 #: That's how many chars fit in the preview box. LENGTH_LIMIT: Final = 12 def format_username(user) -> str: username = user['username'] if not username: return user['email'] elif len(username) > LENGTH_LIMIT: # See? It is now documented return username[:LENGTH_LIMIT] + '...' return '@' + username 

Existem diferentes problemas com a expressão também. Também podemos ter expressões some_object.some_attr : quando você usa o atributo some_object.some_attr qualquer lugar, em vez de criar uma nova variável local. Também podemos ter condições lógicas muito complexas ou acesso a pontos muito profundo .


Solução : crie novas variáveis, argumentos ou constantes. Crie e use novas funções ou métodos utilitários, se necessário.


Linhas


Expressões formam linhas de código (por favor, não confunda linhas com instruções: uma única instrução pode ter várias linhas e várias instruções podem estar localizadas em uma única linha).


A primeira e a métrica de complexidade mais óbvia para uma linha é o seu comprimento. Sim, você ouviu corretamente. É por isso que nós (programadores) preferimos manter a regra de 80 caracteres por linha e não porque ela era usada anteriormente nas máquinas de escrever teletipagem. Ultimamente, existem muitos rumores sobre isso, dizendo que não faz sentido usar 80 caracteres para o seu código em 2k19. Mas, obviamente, isso não é verdade.


A ideia é simples. Você pode ter o dobro de lógica em uma linha com 160 caracteres do que em apenas 80 caracteres. É por isso que esse limite deve ser definido e imposto. Lembre-se, essa não é uma escolha estilística . É uma métrica de complexidade!


A segunda métrica de complexidade da linha principal é menos conhecida e menos utilizada. Chama-se Jones Complexity . A idéia por trás disso é simples: contamos nós de código (ou ast ) em uma única linha para obter sua complexidade. Vamos dar uma olhada no exemplo. Essas duas linhas são fundamentalmente diferentes em termos de complexidade, mas têm exatamente a mesma largura em caracteres:


 print(first_long_name_with_meaning, second_very_long_name_with_meaning, third) print(first * 5 + math.pi * 2, matrix.trans(*matrix), display.show(matrix, 2)) 

Vamos contar os nós no primeiro: uma chamada, três nomes. Quatro nós totalmente. O segundo tem vinte e um nós ast . Bem, a diferença é clara. É por isso que usamos a métrica Jones Complexity para permitir a primeira linha longa e proibir a segunda com base em uma complexidade interna, não apenas no comprimento bruto.


O que fazer com linhas com uma pontuação alta na Jones Complexity?


Solução : divida-as em várias linhas ou crie novas variáveis ​​intermediárias, funções utilitárias, novas classes etc.


 print( first * 5 + math.pi * 2, matrix.trans(*matrix), display.show(matrix, 2), ) 

Agora é muito mais legível!


Estruturas


O próximo passo é analisar as estruturas de linguagem, como if , for , with , etc, formadas a partir de linhas e expressões. Eu tenho que dizer que este ponto é muito específico da linguagem. Vou mostrar várias regras dessa categoria usando python também.


Vamos começar com if . O que pode ser mais fácil do que um bom if ? Na verdade, if começar a ficar complicado muito rápido. Aqui está um exemplo de como se pode reimplement switch if :


 if isinstance(some, int): ... elif isinstance(some, float): ... elif isinstance(some, complex): ... elif isinstance(some, str): ... elif isinstance(some, bytes): ... elif isinstance(some, list): ... 

Qual é o problema com este código? Bem, imagine que temos dezenas de tipos de dados que devem ser cobertos, incluindo os costumes dos quais ainda não temos conhecimento. Então esse código complexo é um indicador de que estamos escolhendo um padrão errado aqui. Precisamos refatorar nosso código para corrigir esse problema. Por exemplo, pode-se usar typeclass es ou singledispatch . Eles fazem o mesmo trabalho, mas melhor.


python nunca para para nos divertir. Por exemplo, você pode escrever with um número arbitrário de casos , o que é muito complexo mentalmente e confuso:


 with first(), second(), third(), fourth(): ... 

Você também pode escrever compreensões com qualquer número de expressões if e for , o que pode levar a códigos complexos e ilegíveis:


 [ (x, y, z) for x in x_coords for y in y_coords for z in z_coords if x > 0 if y > 0 if z > 0 if x + y <= z if x + z <= y if y + z <= x ] 

Compare-o com a versão simples e legível:


 [ (x, y, z) for x, y, x in itertools.product(x_coords, y_coords, z_coords) if valid_coordinates(x, y, z) ] 

Você também pode incluir acidentalmente multiple statements inside a try caso de multiple statements inside a try , o que não é seguro, pois pode gerar e manipular uma exceção em um local esperado:


 try: user = fetch_user() # Can also fail, but don't expect that log.save_user_operation(user.email) # Can fail, and we know it except MyCustomException as exc: ... 

E isso não é nem 10% dos casos que podem e vão dar errado com seu código python . Existem muitos, muitos outros casos extremos que devem ser rastreados e analisados.


Solução : A única solução possível é usar um bom ponteiro para o idioma de sua escolha. E refatorar lugares complexos que esse linter destaca. Caso contrário, você precisará reinventar a roda e definir políticas personalizadas para exatamente os mesmos problemas.


Funções


Expressões, instruções e estruturas formam funções. A complexidade dessas entidades flui para funções. E é aí que as coisas começam a ficar intrigantes. Porque as funções têm literalmente dezenas de métricas de complexidade: boas e ruins.


Começaremos com os mais conhecidos: complexidade ciclomática e comprimento da função medidos em linhas de código. A complexidade ciclomática indica quantas voltas seu fluxo de execução pode levar: é quase igual ao número de testes de unidade necessários para cobrir completamente o código-fonte. É uma boa métrica, porque respeita a semântica e ajuda o desenvolvedor a refatorar. Por outro lado, o comprimento de uma função é uma métrica ruim. Não combina com a métrica Jones Complexity explicada anteriormente, já que já sabemos: várias linhas são mais fáceis de ler do que uma grande linha com tudo o que está dentro. Vamos nos concentrar apenas nas boas métricas e ignorar as ruins.


Com base na minha experiência, várias métricas úteis de complexidade devem ser contadas em vez do tamanho da função regular:


  • Número de decoradores de funções; menor é melhor
  • Número de argumentos; menor é melhor
  • Número de anotações; maior é melhor
  • Número de variáveis ​​locais; menor é melhor
  • Número de retornos, rendimentos, aguarda; menor é melhor
  • Número de declarações e expressões; menor é melhor

A combinação de todas essas verificações permite realmente escrever funções simples (todas as regras também são aplicadas aos métodos).


Quando você tentar fazer algumas coisas desagradáveis ​​com sua função, certamente quebrará pelo menos uma métrica. E isso irá decepcionar o nosso linter e explodir sua construção. Como resultado, sua função será salva.


Solução : quando uma função é muito complexa, a única solução que você tem é dividir essa função em várias.


Aulas


O próximo nível de abstração após as funções são classes. E como você já adivinhou, eles são ainda mais complexos e fluidos do que funções. Como as classes podem conter várias funções internas (chamadas método) e ter outros recursos exclusivos, como herança e mixins, atributos no nível da classe e decoradores no nível da classe. Portanto, temos que verificar todos os métodos como funções e o próprio corpo da classe.


Para as aulas, precisamos medir as seguintes métricas:


  • Número de decoradores em nível de classe; menor é melhor
  • Número de classes base; menor é melhor
  • Número de atributos públicos em nível de classe; menor é melhor
  • Número de atributos públicos no nível da instância; menor é melhor
  • Número de métodos; menor é melhor

Quando qualquer uma dessas situações é excessivamente complicada - precisamos tocar o alarme e falhar na construção!


Solução : refatorar sua classe com falha! Divida uma classe complexa existente em várias simples ou crie novas funções utilitárias e use a composição.


Menção notável: também é possível rastrear métricas de coesão e acoplamento para validar a complexidade do seu projeto de POO.


Módulos


Os módulos contêm várias instruções, funções e classes. E como você já mencionou, geralmente aconselhamos dividir funções e classes em novas. É por isso que temos que ficar de olho na complexidade do módulo: ele literalmente flui para módulos de classes e funções.


Para analisar a complexidade do módulo, precisamos verificar:


  • O número de importações e nomes importados; menor é melhor
  • O número de classes e funções; menor é melhor
  • A complexidade média de funções e classes dentro; menor é melhor

O que fazemos no caso de um módulo complexo?


Solução : sim, você acertou. Dividimos um módulo em vários.


Pacotes


Pacotes contêm vários módulos. Felizmente, isso é tudo o que eles fazem.


Portanto, o número de módulos em um pacote pode em breve começar a ser muito grande, então você acabará com muitos deles. E é a única complexidade que pode ser encontrada com os pacotes.


Solução : você precisa dividir pacotes em subpacotes e pacotes de diferentes níveis.


Efeito cascata complexidade


Agora, cobrimos quase todos os tipos possíveis de abstração em sua base de código. O que aprendemos com isso? O principal argumento, por enquanto, é que a maioria dos problemas pode ser resolvida com a ejeção da complexidade para o mesmo nível ou para o nível de abstração superior.


Cachoeira da complexidade


Isso nos leva à idéia mais importante deste artigo: não deixe seu código exceder a complexidade. Vou dar vários exemplos de como isso geralmente acontece.


Imagine que você está implementando um novo recurso. E essa é a única alteração que você faz:


 +++ if user.is_active and user.has_sub() and sub.is_due(tz.now() + delta): --- if user.is_active and user.has_sub(): 

Parece ok, eu passaria esse código na revisão. E nada de ruim aconteceria. Mas o ponto que estou perdendo é que a complexidade ultrapassou essa linha! É isso que o wemake-python-styleguide relatará:


wemake-python-styleguide-output


Ok, agora temos que resolver essa complexidade. Vamos fazer uma nova variável:


 class Product(object): ... def can_be_purchased(self, user_id) -> bool: ... is_sub_paid = sub.is_due(tz.now() + delta) if user.is_active and user.has_sub() and is_sub_paid: ... ... ... 

Agora, a complexidade da linha está resolvida. Mas espere um pouco. E se a nossa função tiver muitas variáveis ​​agora? Porque criamos uma nova variável sem verificar seu número dentro da função primeiro. Nesse caso, teremos que dividir esse método em vários como este:


 class Product(object): ... def can_be_purchased(self, user_id) -> bool: ... if self._has_paid_sub(user, sub, delta): ... ... def _has_paid_sub(self, user, sub, delta) -> bool: is_sub_paid = sub.is_due(tz.now() + delta) return user.is_active and user.has_sub() and is_sub_paid ... 

Agora terminamos! Certo? Não, porque agora precisamos verificar a complexidade da classe Product . Imagine que agora ele possui muitos métodos, pois criamos um novo _has_paid_sub .


Ok, executamos nosso linter para verificar a complexidade novamente. Acontece que nossa classe de Product é realmente muito complexa no momento. Nossas ações? Dividimos em várias classes!


 class Policy(object): ... class SubcsriptionPolicy(Policy): ... def can_be_purchased(self, user_id) -> bool: ... if self._has_paid_sub(user, sub, delta): ... ... def _has_paid_sub(self, user, sub, delta) -> bool: is_sub_paid = sub.is_due(tz.now() + delta) return user.is_active and user.has_sub() and is_sub_paid class Product(object): _purchasing_policy: Policy ... ... 

Por favor, diga-me que é a última iteração! Sinto muito, mas agora temos que verificar a complexidade do módulo. E adivinhe? Agora temos muitos membros do módulo. Então, temos que dividir os módulos em outros! Em seguida, verificamos a complexidade do pacote. E também possivelmente dividi-lo em vários subpacotes.


Você viu isso? Devido às regras de complexidade bem definidas, nossa modificação de linha única acabou sendo uma enorme sessão de refatoração com vários novos módulos e classes. E não tomamos uma única decisão: todos os nossos objetivos de refatoração foram conduzidos pela complexidade interna e pelo prazo que a revela.


É o que chamo de processo de "refatoração contínua". Você é forçado a fazer a refatoração. Sempre.


Esse processo também tem uma consequência interessante. Ele permite que você tenha "Architecture on Demand". Deixe-me explicar. Com a filosofia "Architecture on Demand", você sempre começa pequeno. Por exemplo, com um único arquivo logic/domains/user.py E você começa a colocar tudo relacionado ao User lá. Porque neste momento você provavelmente não sabe como será sua arquitetura. E você não se importa. Você tem apenas três funções.


Algumas pessoas caem na armadilha da arquitetura versus a complexidade do código. Eles podem complicar demais sua arquitetura desde o início com as camadas completas de repositório / serviço / domínio. Ou eles podem complicar demais o código-fonte sem uma separação clara. Lute e viva assim por anos (se eles conseguirem viver por anos com o código assim!).


O conceito "Arquitetura sob demanda" resolve esses problemas. Você começa pequeno, quando chega a hora - você divide e refatora as coisas:


  1. Você começa com logic/domains/user.py e coloca tudo lá
  2. Mais tarde, você cria logic/domains/user/repository.py quando tiver coisas relacionadas ao banco de dados suficientes
  3. Em seguida, divida-o em logic/domains/user/repository/queries.py e logic/domains/user/repository/commands.py quando a complexidade solicitar que você faça isso
  4. Então você cria logic/domains/user/services.py com coisas relacionadas ao http
  5. Em seguida, você cria um novo módulo chamado logic/domains/order.py
  6. E assim por diante

É isso. É uma ferramenta perfeita para equilibrar sua arquitetura e complexidade de código. E obtenha a arquitetura que você realmente precisa no momento.


Conclusão


O linter bom faz muito mais do que encontrar vírgulas ausentes e aspas ruins. Um bom linter permite confiar nele nas decisões de arquitetura e ajudá-lo no processo de refatoração.


Por exemplo, wemake-python-styleguide pode ajudá-lo com a complexidade do código-fonte python , permitindo:


  • Lute com sucesso contra a complexidade em todos os níveis
  • Aplique a enorme quantidade de padrões de nomes, práticas recomendadas e verificações de consistência
  • Integre-o facilmente a uma base de código herdada com a ajuda da opção diff ou da ferramenta flakehell , para que as antigas violações sejam perdoadas, mas novas não serão permitidas
  • Ative-o no seu [CI] (), mesmo como uma ação do Github

Não deixe a complexidade estourar seu código, use um bom linter !

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


All Articles