Prefira duplicação a uma abstração errada (2016)
(sandimetz.com)- A duplicação de código é muito mais barata do que uma abstração errada, e generalizar cedo demais aumenta o custo de manutenção no longo prazo
- Mesmo uma extração que parecia razoável no início pode, conforme os requisitos mudam aos poucos, acumular parâmetros e condicionais e obscurecer a intenção original
- Quando uma abstração compartilhada começa a carregar várias ideias diferentes, o código se transforma em um procedimento centrado em condições, e quanto mais recursos novos se adicionam, mais frágil ele fica
- É preciso tomar cuidado com a falácia do custo afundado, que tenta preservar o esforço já investido no código; se necessário, deve-se fazer inline da abstração de volta nos pontos de chamada e deixar apenas o código realmente necessário
- Se uma abstração errada ficou evidente, reintroduzir a duplicação para observar novamente o que os requisitos atuais têm em comum e só então extrair de novo costuma ser mais rápido
Como surge uma abstração errada
- A frase “duplication is far cheaper than the wrong abstraction” fazia parte de uma palestra da RailsConf 2014, mas continuou sendo citada desde então
- Um caminho de falha comum é o seguinte
- O desenvolvedor A encontra uma duplicação
- Extrai a duplicação para um método ou classe, dá um nome e cria uma nova abstração
- Substitui o código repetido nos pontos de chamada por chamadas à nova abstração
- Com o tempo, surge um novo requisito que é parecido, mas não exatamente igual
- O desenvolvedor B tenta preservar a abstração existente, adiciona parâmetros e insere condicionais que seguem caminhos diferentes conforme os valores
- Depois disso, a cada novo requisito aumentam os parâmetros e as condicionais, e o código vai ficando cada vez mais difícil de entender
- Um código que já foi criado tende a parecer um investimento que precisa ser preservado
- Entra em ação a sensação de que seria um desperdício abandonar o esforço já feito
- Quanto mais complexo e difícil de entender o código, mais ele parece importante e trabalhoso, e mais difícil fica descartá-lo
- Isso se conecta à falácia do custo afundado
Voltar à duplicação e extrair de novo
- Se novos requisitos continuam sendo implementados sobre uma abstração errada, o código compartilhado passa a girar em torno de condicionais e fica mais instável a cada funcionalidade adicionada
- Nesse momento, o caminho mais rápido não é insistir mais, e sim voltar atrás
- Fazer inline do código abstraído novamente em cada ponto de chamada, reintroduzindo a duplicação
- Verificar, com base nos parâmetros passados por cada ponto de chamada, apenas o código que realmente é executado
- Remover o código desnecessário para aquele ponto de chamada
- O processo de inline elimina ao mesmo tempo a abstração e as condicionais, reduzindo cada ponto de chamada a apenas o código de que ele precisa
- Mesmo códigos que pareciam chamar a mesma abstração talvez estivessem, na prática, executando caminhos de código bastante distintos em cada ponto de chamada
- Só depois de remover completamente a abstração anterior é que se pode observar novamente a duplicação e extrair uma nova abstração adequada aos requisitos atuais
- Se parâmetros e caminhos condicionais continuam sendo adicionados ao código compartilhado, há uma grande chance de que aquela abstração não seja mais adequada
- Ela pode até ter sido a abstração correta no início
- Mas as mudanças nos requisitos podem ter tornado inviável mantê-la no mesmo formato
- Numa abstração errada, reintroduzir a duplicação não é retroceder, e sim avançar melhor
5 comentários
Não sei se esse é um tema que precise de uma interpretação tão binária.
Ah, me identifico muito com isso.
O que não está organizado dá para organizar, mas
parece que o custo para desfazer o que já está organizado é bem maior.
O ponytail postou, então logo apareceu um texto desses, haha
É sempre um antagonismo.
Comentários do Hacker News
Acho que o princípio de fonte única da verdade (single source of truth) deve ser sempre mantido
Se for código duplicado que vira bug quando diverge, ele deve ser refatorado. Caso contrário, surge um acoplamento de longa distância que futuros desenvolvedores dificilmente perceberão até o bug explodir
Ainda assim, se isso não viola esse princípio, abstração é só uma conveniência; se começou a ficar incômoda, então não está cumprindo seu papel e não há motivo para usá-la. Se uma função precisa de vários flags para ter comportamentos customizados, há uma boa chance de ser uma abstração ruim ou uma violação do princípio da responsabilidade única
Se realmente for preciso muita customização, muitas vezes é melhor receber uma função/functor como argumento. Por exemplo, em vez de
solve(f:double -> double, max_iters = 99, x_abs_tol = 1e-15, x_rel_tol = 1e-15, ...), pode ser algo comosolve(f:double -> double, stopping_criteria: StoppingCriteriaClass)Não está claro se dois pontos do código usam o mesmo algoritmo, ou uma versão ligeiramente diferente, e mais importante ainda, se vão mudar pelo mesmo motivo
O aforismo do título diz que forçar coisas diferentes a parecerem iguais dói mais do que duplicar coisas iguais e depois separá-las. Também acho isso correto. No segundo caso, basta fazer a mesma mudança duas vezes ou refatorar para introduzir uma abstração; no primeiro, você fica empilhando remendos na abstração ou precisa desfazê-la
Isso é especialmente ruim porque quebra a localidade (locality), e na hora de alterar algo essa é a propriedade que realmente importa. Quero apenas fazer essa mudança sem me preocupar se vai causar efeitos colaterais em partes não relacionadas do sistema
Um exemplo típico é quando sincronizar
pyproject.toml/requirements.txtrealmente acaba sendo a melhor opção, e isso talvez se aplique de forma mais ampla. A premissa é que a situação já saiu dos trilhos a ponto de uma fonte única da verdade ser inviável; isso é mais redução de danos do que curaJá passei muitas vezes por situações em que dois trechos de código pareciam parecidos num certo momento, eu abstraí demais, e depois eles acabaram se separando
Especialmente desenvolvedores juniores às vezes tratam duplicação como se fosse a raiz de todo mal
Penso nisso de vez em quando. Recentemente esbarrei nesse problema num projeto pessoal lidando com sprites 2D de unidades de RTS, em que os sprites das unidades estavam organizados de maneira consistente numa spritesheet: 5 sprites em 8 direções, com 3 dessas direções espelhadas, e a ordem era stand, move, attack, die
Então fiz um loader que recebia action + direction e devolvia o array de sprites a ser reproduzido
Só que aí apareceram sprites de explosão sem direção, sprites de cadáver com 4 direções e apenas 2 espelhamentos e, além disso, orcs e humanos compartilhavam a maior parte, exceto os quatro primeiros
Pensei por um instante em qual seria a abstração comum para tudo isso, mas no fim apenas separei parte do código de carregamento, criei
UnitLoader,CorpseLoadereEffectLoader, e segui em frente. Pode até existir uma abstração melhor, já que os três loaders lidam com coisas um pouco parecidas, mas posso descobrir isso depois. É mais fácil remover a duplicação mais tarde, na hora certa, do que tentar criar agora umEverythingLoadercomplexo para cobrir todos os casosEm programação, existe o instinto de simplificar código por meio de generalização, mas a realidade é bagunçada, então muitas vezes simplificamos demais. Como no texto, com o tempo e com novos requisitos, fica claro que foi uma simplificação precoce demais
Dá até para transformar isso no aforismo “abstração prematura é a raiz de muita porcaria”
No nível acima disso, a interpretação do layout da spritesheet e o tratamento dos modos de reprodução têm várias variações, e talvez não exista uma abstração comum que sirva para todos os casos
Em vez de forçar uma abstração invisível ou tentar encaixar tudo numa abstração incompleta, prefiro fazer como você fez. Esperar até que a abstração fique totalmente clara e a necessidade dela seja óbvia é uma boa coisa
Do lado oposto do DRY existe o antídoto WET. A ideia é escrever tudo duas ou três vezes. Mais importante ainda, abstrações deveriam surgir apenas para casos de uso realmente comprovados, normalmente aqueles que primeiro apareceram como duplicação. Código escrito para casos de uso futuros que ainda não existem frequentemente atrapalha a abstração do que você realmente tem, e sempre acho engraçado quando isso acontece
As partes difíceis e chatas podem ficar para quando você chegar aos 10% finais do projeto
Além disso, às vezes um “bug” criado por duplicação vira um recurso engraçado de que os jogadores gostam
Na época em que eu usava OOP, sofria por causa de abstrações, mas depois que migrei para uma abordagem quase puramente funcional, duplicação de código ficou rara
Basta criar uma função e chamá-la em dois lugares. A principal questão de abstração está nas estruturas de dados, mas interfaces em TypeScript são essencialmente duck typing, então aqui também isso não costuma dar muito problema
Por isso, duplicação de código causada por problemas de abstração é rara. Duplicação de código causada por desenvolvedores em silos é muito mais comum
A maioria das linguagens modernas consegue incorporar bem teoria de programação funcional, e não é obrigatório conhecer Haskell. Cada pessoa pensa de um jeito, mas a ideia de que peças pequenas, simples e às vezes flexíveis formam o todo funciona muito bem para mim
É o oposto de uma máquina de transformação de formas grande, complexa e que faz tudo
Quando a equipe passa de certo tamanho e já não dá para cada pessoa saber o que todas as outras estão fazendo, a duplicação de código se torna bastante inevitável. Isso continua sendo verdade mesmo que todo mundo escreva em estilo funcional
Na prática, isso aconteceu comigo na empresa no mês passado. Escrevi uma nova função helper pura e a coloquei no topo do arquivo, e uma semana depois um colega me avisou que já existia no fim do mesmo arquivo uma helper parecida, com assinatura diferente, mas funcionalmente equivalente
No mesmo contexto do texto principal, quem já passou pelos dois casos provavelmente vai concordar. Uma base de código subprojetada é muito mais fácil de lidar do que uma base de código superprojetada
O pior código que já tive de manter era código que tentava seguir DRY. Só que não tentava entender a intenção original desse princípio.
A única forma de sair daquela bagunça foi reintroduzir duplicação de código em larga escala
Isso me faz lembrar de duas apresentações: Data-Oriented Design and C++ [1], do Mike Acton, e The Complexity of Simplicity [2], do Brian Cantrill
A apresentação do Mike diz que uma solução de código não precisa modelar o mundo real, que dados diferentes criam problemas diferentes e, portanto, exigem soluções diferentes. É difícil transmitir bem o suficiente a palestra, mas ela me influenciou muito
A apresentação do Brian trata da abstração em geral e de como é difícil encontrar a abstração “correta”
Anos atrás, pouco tempo depois de sair da faculdade, eu estava implementando um pool de conexões em Rust, e a implementação mais sensata era fazer o objeto de conexão manter uma referência fraca ao pool para que ele fosse devolvido automaticamente quando desse
dropMeu gerente, que era muito experiente, não gostou da ideia porque “a biblioteca segura o livro; o livro não segura a biblioteca”
Não achei que isso fosse um motivo convincente o bastante para mudar o design, mas ele não queria tratar o problema sem passar pela lente dessa metáfora
No fim, o impasse só se resolveu quando outro gerente sugeriu: “o livro da biblioteca não contém a biblioteca, mas atrás tem um carimbo com o nome da biblioteca que indica onde devolvê-lo”. Esse gerente aparentemente achou essa extensão da analogia razoável
Se eu fosse mais experiente na época, talvez tivesse encontrado uma forma de conversar dentro dessa metáfora sem ceder no ponto principal, mas até hoje me pareceu totalmente bizarro insistir nessa metáfora como estrutura padrão, em vez de considerar as abstrações do código e os efeitos na experiência de uso da biblioteca
Ninguém quer ouvir. Ninguém mesmo. Em 90% das empresas há o chamado desenvolvedor sênior que fica em êxtase ao criar uma nova abstração
Superprojeto, abstração e otimização prematura são os três grandes desastres da engenharia
Ao mesmo tempo, fico feliz que eles existam, porque assim sempre haverá trabalho
De forma parecida, alguns desenvolvedores parecem achar que toda string inline ou constante numérica é um mal absoluto. Vi isto em um PR
HTTPS_SCHEME = 'https'DOMAIN = 'www.example.com'url = HTTPS_SCHEME + '://' + DOMAINNão vejo o que isso ganha além de seguir “não coloque constantes hardcoded” como um cargo cult. Além disso, as definições das constantes estavam no topo do arquivo, e o código que montava a URL estava a centenas de linhas de distância
Regex também não precisa ficar no topo do arquivo; pode ficar onde é usada. A linguagem é inteligente e provavelmente vai perceber que é uma constante
Se for uma função muito pequena, é só usar uma lambda. Preferiria que não criassem uma função de uma linha, usada uma ou duas vezes, em algum lugar muito distante
Se em testes ou staging for preciso trocar https por http, separar o esquema e o domínio e manter as constantes em cima ou em outro arquivo faz sentido. Também importa se
urlé montada em vários lugares ou só em umColocar constantes nomeadas no topo do arquivo é um estilo extremamente comum e, às vezes, faz parte do padrão de código da equipe
Pode haver outros motivos, então vale lembrar de Chesterton’s Fence. De todo modo, não é uma boa ideia concluir de cara que é cargo cult. Alguém também poderia dizer que usar literais inline é cargo cult do mesmo jeito. Se parecer estranho, pergunte: pode haver um bom motivo, ou talvez ninguém tenha pensado muito nisso e até fiquem felizes se você refatorar e inline as constantes
Se você extrai isso para uma constante, aí precisa abrir os projetos um por um e usar Find Usages de novo
Com microsserviços, dá para ter as duas coisas
Se você mantém um serviço, não tem motivo para se importar com o código que está em outro. É código de outro time; por que se importar? Você nem precisa saber que aquele time existe. Em sistemas grandes, às vezes nem é realisticamente possível conhecer a existência de todas as aplicações
Por apenas $19.95, transformamos um ponto único de falha em vários pontos únicos de falha!
É melhor usar arquitetura orientada a serviços, mas simplesmente fazer o deploy do monólito. Fica mais fácil de testar e você ainda evita a camada extra de serialização/desserialização
A maioria dos profissionais sêniores sabe que não se deve seguir o DRY cegamente. Mesmo assim, muitos de nós se sentem desconfortáveis com a ideia de manter várias fontes de código duplicadas
Para lidar com isso, é preciso examinar de perto o modelo simples em que dois chamadores dependem de código em comum. Se o código compartilhado precisa mudar por causa da necessidade de apenas um dos chamadores, então esse código não pertence ao que é comum
O objetivo equivocado do DRY é tentar resolver isso com encapsulamento. O encapsulamento transfere o trabalho de refatoração dos chamadores para o código comum. Mas isso não é a direção desejada, porque o impacto de atualizar o código comum é muito maior do que o de atualizar os chamadores
É possível seguir o DRY sem recorrer ao encapsulamento. É melhor ter várias abstrações finas das quais os chamadores precisam estar cientes. Em OOP, isso envolve aprender SRP e IoC; na programação procedural, isso aparece naturalmente na forma de chamar uma série de funções auxiliares