Código linear é mais fácil de ler
(blog.separateconcerns.com)- Em vez de separar níveis de abstração dividindo em funções pequenas, a posição é que um código linear, que segue de cima para baixo, facilita acompanhar o fluxo completo
- Ao extrair funções para criar uma estrutura top-down, pode ser necessário ficar indo e voltando para conferir entre funções com nomes parecidos, como
bakeebakePizza - Assim como a posição do pré-aquecimento do forno ou o resultado de passar a pizza duas vezes pela mesma função, funções pequenas podem revelar a intenção, mas também esconder o comportamento real
- Se comentários por etapa forem adicionados ao código linear, é possível explicar a intenção do trabalho sem aumentar referências indiretas, o que pode torná-lo mais fácil de ler do que adicionar mais abstração
- Extrair funções pequenas usadas apenas uma vez causa perda de linearidade e, como no exemplo da criação do forno, no código real isso pode até expor problemas de desempenho
Quando a linearidade é mais importante do que extrair funções
- O exemplo do Google Testing Blog compara duas implementações de
createPizzae considera que a implementação da direita é mais fácil de ler por não misturar níveis de abstração e por ser top-down - A posição contrária considera mais importante o fato de a implementação da esquerda ser um código lido linearmente, de cima para baixo na tela
- Na implementação da direita, para entender o comportamento completo é preciso navegar por várias definições de funções pequenas
- Até na forma de apresentação, parte do código da direita foi omitida, o que faz as duas implementações parecerem ter tamanho parecido, mas na prática a da direita é maior
- A extração de funções pode fazer com que o nome sozinho não seja suficiente para entender bem o comportamento
- Quando existem
bakeebakePizza, não fica claro de imediato qual função aquece o forno - Para verificar se passar a mesma pizza duas vezes é idempotente ou se isso estraga o resultado, é preciso olhar a implementação interna
- Quando existem
Código linear com comentários e o exemplo do forno
- A versão mais fácil de ler é avaliada como sendo o código linear da esquerda com os nomes das funções da direita colocados como comentários
- Comentários como
Prepare pizza,Add toppings,Heat oven,Bake pizzaeBox and slicedeixam clara a intenção de cada etapa - A legibilidade não vem de camadas extras de abstração e referências indiretas, mas de explicar corretamente o que está sendo feito naquele momento
- Comentários como
- A conclusão fica mais próxima de dizer que não se deve extrair de um código linear funções pequenas usadas só uma vez
- Considera-se que os benefícios de extrair funções pequenas não compensam a perda de linearidade
- O tratamento do forno no exemplo também é estruturalmente estranho
- Como o pré-aquecimento do forno é uma ação autocontida, faria mais sentido ser um método do próprio forno
- Criar e pré-aquecer um novo forno toda vez que se faz uma pizza não corresponde a um uso realista
- Esse tipo de estrutura também aparece em código real e, às vezes, pode causar problemas de desempenho
- Em vez de criar um novo forno dentro de
createPizza, é bem provável que ele devesse ser recebido como parâmetro- Fornecer o forno está mais próximo de ser responsabilidade do chamador
- Se o fluxo é colocar a pizza na caixa, talvez uma interface que retorne a caixa, e não a pizza, seja mais natural
1 comentários
Opiniões do Hacker News
É uma questão de estilo e, como na culinária, sal demais ou de menos estraga o prato.
Espero que ninguém aqui esteja propondo uma função-deus de 1000 linhas, e um máximo de 5 linhas por função também não é algo legível. Saber onde dividir exige julgamento, bom senso e iteração. Só porque a primeira abstração que você tentou não ficou boa, isso não significa que deva abandonar abstrações; depois de algumas refatorações, podem surgir classes e APIs que se encaixem bem no domínio de negócio.
Ao mesmo tempo, não se deve ser apressado demais com abstrações nem agir como se algumas linhas duplicadas fossem uma ferida fatal. Abstração prematura tende a amarrar códigos que não precisam evoluir juntos. Extrair uma função chamada em apenas um lugar para esconder uma unidade de trabalho pode deixar um algoritmo mais limpo, e é especialmente útil para ocultar boilerplate ou a mistura de preocupações de infraestrutura, como lógica de negócio e tratamento de conexão com o banco de dados. Mas deve ser usado com cautela, e é melhor evitar quebrar etapas que deveriam estar no mesmo nível de abstração.
Uma pessoa com quem trabalhei extraía toda condição booleana para uma função porque era “mais legível”, e não escrevia nenhum comentário porque “comentários são ruins”. Não gosto desse livro porque ele cria fanáticos que seguem cegamente esse tipo de mau conselho.
Às vezes, o domínio pode exigir uma função-deus de 1000 linhas, e a lógica e as operações reunidas em um só lugar podem ser muito mais fáceis de ler do que 20 funções de 50 linhas. De qualquer forma, para entender o todo, você terá que ler as 20; e alguém pode tentar reutilizar algumas delas, ajustando-as para 2 ou 3 requisitos que não existiam na tarefa original e acoplando uma lógica específica a casos de uso não relacionados.
Se essa função for pura, acho que tanto faz se tem 1000 ou 10000 linhas: ainda pode estar tudo bem.
Receitas culinárias já são altamente abstraídas. Quando dizem “refogue levemente a cebola”, pressupõem que você já sabe como cortar cebola e o algoritmo de refogar levemente. Se tudo fosse escrito inline, ficaria ilegível.
Código é parecido. Se você excluir abstrações rigidamente, acaba descendo até o nível mais baixo que a linguagem permite, e isso certamente não é código legível. Por exemplo, se você tentar fazer a decodificação Unicode manualmente em vez de usar o método
decodedo Python, ficará muito difícil entender o que o programa realmente faz. Ninguém faz isso simplesmente porque a linguagem oferece abstrações simples e bem verificadas; então qual é a diferença em criar suas próprias abstrações simples e bem verificadas para usar em toda a lógica de negócio?A parte difícil é criar abstrações tão bem escolhidas que ninguém precise mexer nelas de novo.
Só porque as linhas de código parecem parecidas agora, isso não significa que elas devam continuar iguais ou ser mantidas iguais no futuro. Se você força a união de dois casos de uso diferentes só porque “o código é quase repetido”, com o tempo isso tende a virar uma abstração que não abstrai nada.
Quando os casos de uso divergem demais, a implementação acaba empurrando muita lógica para o lado do chamador ou expondo as diferenças por meio de flags, mantendo internamente duas implementações diferentes lado a lado. A primeira opção é uma abstração rasa, de pouco valor; a segunda é menos clara do que duas implementações independentes.
O código de exemplo é simples demais, então é óbvio que o código linear fica mais fácil de ler, mas essa ideia não escala bem
Também é preciso considerar a reutilização e a facilidade de testes unitários; se todo o código for colocado em uma única função, todas as variáveis locais — que podem ou não ter relação com o bloco de código que está sendo lido — ficam dentro do escopo, o que pode tornar o raciocínio mais difícil
Dito isso, olhando para trás, para quando eu tinha menos experiência, muitas vezes peguei código linear perfeitamente bom e o modularizei demais, transformando-o em um código menos manutenível, que obrigava a ficar pulando de um lado para outro. O formato em que foi escrito inicialmente tem a vantagem de estar mais próximo do fluxo de pensamento que eu tinha na cabeça na época, e o leitor provavelmente também tende a interpretá-lo assim. Refatoração em excesso pode fazer isso desaparecer
No fim, programação é mais próxima de um ofício, e a experiência ajuda a escolher o que faz sentido em cada situação
O objetivo era um só. Tratava-se de converter páginas HTML individuais, usadas em um canto do app em uma plataforma, em um carrossel que imitava a sensação nativa de outra plataforma; era algo extremamente específico daquela plataforma e daquela área do app
Eu poderia ter transformado cada um dos 9 escopos em funções, mas isso faria os desenvolvedores quererem reutilizá-las. Cada etapa tinha suposições sutis sobre o que havia acontecido na etapa anterior e, para transformá-las em funções separadas, seria preciso reavaliar essas suposições, generalizá-las e verificar se cada método funcionava de forma independente. Não havia motivo para gastar esse custo em código que quase certamente não seria necessário em nenhum outro lugar
A depuração não era mais difícil, havia testes ponta a ponta, e o estado das etapas intermediárias não vazava para fora da função. Na prática, outros 2 desenvolvedores contribuíram com mudanças ao longo do tempo, tudo funcionou bem, e a implementação foi rápida
Código linear escala bem e resolve problemas. Nem sempre é a forma desejada, mas em muito mais situações do que se imagina ele torna a vida bem mais fácil
A reação inicial ao ver o monstro de 2000 linhas não foi boa, mas bastavam 5 minutos olhando para perceber que era difícil encontrar defeitos reais; com alguns testes, restavam apenas medos que não se concretizavam
Em algum momento você percebe que essas dezenas de funções precisam ser chamadas em uma ordem específica e que cada uma é usada apenas uma vez. No fim, isso força qualquer pessoa que queira usar essas funções de forma útil a conhecer uma ordem de combinação quase mágica
O principal motivo pelo qual funções lineares enormes muitas vezes são mais legíveis e desejáveis é que elas permitem manter vários conceitos e relações em um único bloco, sem troca de contexto, o que ajuda na compreensão. Um defensor extremo disso é Arthur Whitney, inventor da linguagem K, que escreve código extremamente conciso — quase incompreensível para outras pessoas — para colocar o máximo possível em uma única tela
Como exemplo pessoal, achei muito mais fácil ler, entender e depurar uma enorme função de tratamento de mensagens do Windows, ou seja,
WndProc, com a lógica de negócio dentro de um grandeswitch, do que a versão em Visual C++ que dividia os manipuladores de mensagens em funções separadasOutro caso foi em código de exemplo para microcontroladores: havia uma versão em que o exemplo de uso do ADC estava todo em um único arquivo, e outra dividida em vários arquivos como
main.c,config.c,interrupts.c,timer.cetc.; mesmo com menos de 200 linhas, a segunda era difícil de entender por causa da troca de contextoEsses trechos de código geralmente viram funções
privatede uma classe e mantêm estado. Por serem funçõesprivate, também são difíceis de testar de verdadeAgora você acaba com um monte de funções
privatechamadas apenas uma vez e que normalmente modificam estado por efeitos colaterais. Se elas ficam logo ao lado do chamador, ainda podem ser legíveis em casos simples, mas com o tempo alguém adiciona outra função entre a função chamadora e a função extraídaAí, a menos que você olhe o grafo de chamadas ou pesquise no arquivo da classe, trechos de código cujo ponto de chamada você não sabe passam a modificar diferentes estados por efeitos colaterais
Se for tornar o código não linear, gostaria que ao menos se considerasse transformar a função
privateextraída em uma função interna da função chamadora, quando a linguagem tiver suporte a isso. Assim fica claro que ela não é chamada em nenhum outro lugarEm bases de código reais, isso também não é uma escolha binária; é mais uma arte de combinar as duas abordagens para chegar a uma forma legível e manutenível
Será que as pessoas vão testar todos esses ramos? Ou vão escrever apenas um teste que coloca uma pizza e só verificar se mais ou menos funciona? Testar vários ramos de fora costuma ser trabalhoso, e é mais chato do que testar funções pequenas e especializadas, então parece mais provável que façam a segunda opção
Dizer que “código linear não escala” é, na verdade, o contrário. O verdadeiro pesadelo em bases de código grandes são funções pequenas e concisas com pilhas de chamadas profundamente aninhadas.
Não fica claro onde adicionar código novo, é preciso rastrear todos os caminhos pelos quais o código pode ser chamado, a dificuldade de entender o impacto de uma mudança cresce exponencialmente, e também surgem sub-rotinas duplicadas.
Em 99% dos casos, você não criou uma boa abstração; então é melhor simplesmente usar código linear. Prefiro copiar/colar a semânticas de função duvidosas.
print_table(), alguém vai encontrá-la, usá-la no próprio código e colocar pequenas flags para ajustar a saída ao seu caso de uso.Depois de 12 meses, fica assim:
print_table(rows,headers = None,is_unicode = False,left_align = False,align = [],remove_emoji = None,max_width = 80,potato_mode = 7,_debug_frontend = not FLAGS.dont_debug,ellipsis_for = 0,no_print = False,)Se olharmos para os dois conceitos como ortogonais, exceto pelo fato de que legibilidade pode afetar escalabilidade, código linear não escala tão bem quanto código modular. Vale a pena conhecer essa dicotomia e levá-la em conta dependendo da situação.
Mesmo assim, continuo discordando. Se uma função pequena for uma função pura, ela não causa problemas de legibilidade. Isso significa que ela não mexe em estado, não injeta lógica no código, e que injeção de dependências e passagem de funções para outras funções devem ser explicitamente minimizadas.
Se você cria um pipeline de funções puras que passam apenas dados, o código fica legível e escalável. É muito menos comum precisar reescrever lógica por causa de falhas de design, e, ao compor funções puras, o código vira algo parecido com Lego. Refatorar também passa a se parecer mais com reorganizar e recombinar primitivos existentes.
O código de exemplo teria distraído menos se ao menos tivesse tentado manter a metáfora da pizza de forma significativa, ou se não fosse código Go de baixo nível.
prepareé um nome horrível para uma função. Um Gopher experiente provavelmente teria dado um nome comoNewPizzaFromOrder.Não vejo motivo para deixar
addToppingscomo uma função separada. Se fosse mesmo necessário, eu pessoalmente a transformaria em um método dePizza, comofunc (p *Pizza) WithToppings(topping ...Topping) *Pizza { /* ... */ }. Como pizzas de verdade são mutáveis, o método alteraria o receptor.Também não entendo por que instanciar um forno novo toda vez que se assa uma pizza. Deveria começar com um forno já existente, chamar
oven.Preheat()e depoisoven.Bake(pizza). Indo além,oven.Preheat()poderia retornar um novo tipo deOvenque expõe.Bake(), impedindo em tempo de compilação o erro de assar sem pré-aquecer. Em outro lugar poderia haver uma interfaceBaker, e talvez uma implementaçãoToasterOvenpara a qual o pré-aquecimento não é tão importante e, portanto, não é necessário.Mesmo sem mudar o código, eu teria reorganizado a ordem das declarações para acompanhar um fluxo previsível. Assim, ao passar os olhos por funções que chamam umas às outras, não seria preciso ficar pulando para cima e para baixo na página.
Estou sem tempo, então paro por aqui, mas esse código já é um exemplo ruim demais até para iniciar uma discussão sobre “qual dos dois é mais legível”.
John Carmack disse praticamente a mesma coisa, e sigo isso desde então. Código linear é naturalmente mais fácil de ler porque acompanha a ordem de execução e minimiza os saltos do olhar.
Alguns códigos precisam ser não lineares por reutilização, e nesse caso a execução vira um grafo. Se o código não se aproveita da reutilização em estrutura de grafo, não há necessidade de introduzir vértices onde uma única aresta basta.
http://number-none.com/blow/blog/programming/2014/09/26/carm...
Nesse caso, acho que o código da esquerda teria ficado melhor se fosse algo como
pizza.Toppings = get_pizza_toppings(order.kind), mantendo a alteração da pizza como foco na função principal.Concordo em certa medida que código linear é mais legível, mas isso por si só não o torna uma boa prática de código
Acho que um bom código linear é mais legível, mas sua manutenibilidade e facilidade de teste são muito piores. Tenho décadas de experiência e também atuo como avaliador externo de alunos de CS; ao longo dos anos, a única boa prática que vi no mundo real e que era realmente certa foi manter as funções pequenas
Não sou particularmente fã de abstração, nem acho que a duplicação de código deva ser evitada a todo custo, mas, se você criar funções o mais próximas possível de um único propósito, seu eu do futuro vai agradecer
Se um código como o do exemplo rodar em produção por 10 anos, cada trecho vai mudar. Com sorte, os comentários também serão atualizados, mas na maioria das vezes isso não vai acontecer. Os testes unitários também vão ficar grandes e difíceis de lidar, tornando-se cada vez mais desleixados, e alguém pode esquecer de ajustar uma parte do teste que não parece estar claramente ligada à mudança. O código também provavelmente ficará menos legível com o tempo. Não por intenção ou incompetência, mas por motivos humanos, como pressão de prazo
Em um mundo perfeito, não precisaríamos separar preocupações, mas vivemos em um mundo imperfeito; quanto menores forem as funções e menos responsabilidades elas tiverem, mais fácil será lidar com essa imperfeição ao longo do tempo
Se você está fazendo um objeto passar por um fluxo de certos estados, acho melhor dividir isso e marcar as transições com tipos, ou então escrever como uma única função grande. Por exemplo, se
bakePizzarecebe umaRawPizzae retorna umaBakedPizza, a ordem das chamadas pode ser imposta em tempo de compilaçãoPrefiro a primeira opção por legibilidade, correção e facilidade de teste, mas, na maioria das linguagens de programação, mudar o tipo de um objeto exige criar um novo objeto e tem custo em tempo de execução. Se for um caminho quente do código, a mutação in-place faz sentido e, nesse caso, é melhor manter em uma única função linear
https://mitpress.mit.edu/9780262045490/
E-mail relacionado de John Carmack: http://number-none.com/blow/blog/programming/2014/09/26/carm...
Discussão: https://news.ycombinator.com/item?id=12120752
Concordo fortemente. Antes eu estava no campo oposto
A tensão básica aqui está entre a localidade do comportamento, de um lado, e o desejo de mostrar claramente uma visão de “sumário” de alto nível, do outro. Em código legível, a localidade é mais importante. Como o texto diz, a perspectiva de sumário pode ser deixada suficientemente clara com comentários de seção
Há também um motivo mais importante para preferir código linear. Ao navegar por uma base de código inteira, é muito mais fácil quando os “blocos” — isto é, funções, classes ou unidades impostas pela linguagem — correspondem aproximadamente a casos de uso de negócio. Caso contrário, o espaço de busca fica grande demais, e você precisa reconstruir o todo por conta própria a partir dos pedaços. A estrutura do código deveria fazer esse trabalho por você
Se várias “coisas” estão todas relacionadas a uma única tarefa, como cadastro ou compra, é melhor mantê-las juntas também no código. Fica muito mais fácil de encontrar e alterar. Só divida em subfunções quando houver necessidade de reutilização; não divida apenas por organização
[0] https://htmx.org/essays/locality-of-behaviour/
O principal motivo é estado. Quanto mais longa a função, maior o escopo das variáveis locais. Qualquer variável pode ser alterada em qualquer ponto da função, e o fluxo de dados não fica imediatamente claro. Com mais funções, o escopo permanece pequeno e o fluxo de dados fica mais explícito
Como efeito colateral, a indentação também diminui
Ao mesmo tempo, não gosto de funções pequenas demais, porque fica difícil encontrar onde o trabalho real acontece
Imagine um processamento de fechamento diário com 10 etapas não reutilizáveis que precisam ser executadas em ordem, cada uma com 100 linhas. Cada etapa usa dados parecidos com os da etapa anterior, mas não iguais. Você escolheria mesmo uma única função de 1000 linhas?
Ambas são lidas de forma linear. A versão que extrai funções pequenas tem um sumário no topo da página e resume o fluxo de dados entre as etapas. Se a ideia é ler tudo, parece uma ordem de leitura atraente
Mas, para manter essa legibilidade, quando a ordem das etapas mudar, a posição das funções também precisa ser movida. Se forem funções
privatee chamadas apenas pelo sumário, tudo bem. Mas nada obriga a manter a ordem, nem obriga a pensar no fluxo completo de leituraQuando uma função começa a ser reutilizada, muitas vezes deixa de ser possível linearizá-la. Às vezes as pessoas desistem e ordenam alfabeticamente, ou então fica simplesmente aleatório
Pela minha experiência, quanto mais uma pessoa está familiarizada com o código, mais ela acha que o caminho correto é empurrá-lo para funções pequenas
Como ela já construiu o modelo mental daquele código, a implementação mais limpa para ela é a que tem pouquíssimas linhas
Mas, quando a próxima pessoa chega, precisa ir e voltar por vários lugares, fazendo push/pop na pilha mental para criar o mesmo modelo mental sem o contexto original, e isso é muito mais difícil
Por exemplo, com que frequência você lê o código-fonte da biblioteca padrão da linguagem que usa? Quase nunca; normalmente você olha a assinatura do método e, se for algo um pouco complexo ou novo, lê a documentação
O ponto central de uma interface é fazer você se preocupar apenas com o que o método faz, não com como ele foi implementado. Isso é explicado por uma combinação de contexto, nomes e documentação. Mas muitos desenvolvedores não entendem isso ou não se importam, e acabam escrevendo código que não faz sentido, seja ele linear ou modular
Por exemplo, se em uma classe de serviço você precisa chamar um método para obter certos dados, outro método para obter outros dados e um terceiro método para obter dados que precisam ser combinados com os dois anteriores, qual é o significado desse serviço? É como expor toda a complexidade interna para fora
Não se trata de impor métodos pequenos. Vinte funções de 5 linhas, chamadas apenas uma vez, que fazem algo muito específico e precisam ser chamadas na ordem correta, não fazem sentido. Isso não é código limpo; está mais para programação cargo cult
O importante é criar abstrações adequadas para que o código faça sentido tanto para novos integrantes quanto para membros experientes da equipe, seja fácil de raciocinar e esconda a complexidade no lugar certo. Não é fácil, mas é possível
Meu filho, embora fosse bastante inteligente, teve dificuldades na escola, e um dos vários especialistas explicou que a escola geralmente ensina de forma bottom-up, enquanto meu filho é um aprendiz muito top-down. Ele precisa primeiro de uma visão geral antes de entrar nos detalhes; outras pessoas entendem primeiro os detalhes e depois montam a visão geral. A escola normalmente ensina para esse segundo grupo
Pode haver uma diferença parecida entre programadores
Se o desenvolvedor anterior escreveu uma função
BakePizza, você pode assumir que a pizza será assada corretamente e seguir para a próxima linha. Se, tentando entender como o restaurante funciona, você se perder em detalhes como a temperatura do forno, vai acabar sem entender como o restaurante opera e ainda esquecer a temperatura exata do fornoO editor deveria ter um toggle para inline temporariamente uma função. Assim, não seria mais necessário ficar indo e voltando