Leve o If para cima, o For para baixo
(matklad.github.io)- Ao mover desvios condicionais dentro de uma função para o lado do chamador, e empurrar o trabalho sobre itens iterados para baixo na forma de operações em lote em vez de chamadas individuais, é possível simplificar ao mesmo tempo o fluxo do código e o desempenho
- Ao levar o
ifpara cima, fica mais fácil garantir pré-condições por meio de tipos ouassert, reduzindo verificações duplicadas e ramificações desnecessárias dentro da função - Quando a lógica de ramificação fica espalhada por várias funções, torna-se difícil encontrar condições mortas ou decisões repetidas; criar um enum e depois fazer
matchnovamente também pode ser uma forma de repetir a mesma ramificação como estrutura de dados - Ao levar o
forpara baixo, vários objetos podem ser processados de uma vez, amortizando custos de inicialização e abrindo espaço para otimizações como reordenação da ordem de processamento ou vetorização - Usar as duas regras em conjunto reduz a reavaliação de condições dentro de loops e as ramificações em hot loops, aproximando a estrutura de transferir o custo de decisão do plano de controle para o processamento em lote do plano de dados
Levar o if para o lado do chamador
- Se houver uma condição
ifdentro de uma função, vale primeiro avaliar se essa condição pode ser movida para o chamador- Em vez de uma função que recebe
Option<Walrus>e retorna em caso deNone, uma função que já recebeWalrusé uma forma mais simples - Em vez de a função verificar uma pré-condição e “não fazer nada”, o chamador pode verificar antes e garantir isso por meio de tipos ou
assert
- Em vez de uma função que recebe
- A abordagem de mover verificações de pré-condição para cima pode se espalhar por todo o caminho de chamada e, como resultado, criar um incentivo para reduzir a quantidade de verificações
- Fluxo de controle e
iftendem a aumentar a complexidade do código e a possibilidade de bugs- Uma estrutura em que a lógica de ramificação complexa fica concentrada em uma única função de nível superior, enquanto o trabalho real é delegado a funções inferiores lineares, é mais fácil de ler
- Quando um fluxo de controle complexo fica reunido em uma única função visível em uma tela, em vez de espalhado por todo o arquivo, é mais fácil perceber condições duplicadas e condições mortas
- A refatoração “dissolving enum” é útil quando a criação de um enum e um
matchrepetem a mesma ramificação- Se
f()criaE::FooouE::Barde acordo com uma condição, eg()fazmatchnovamente para chamarfoo()oubar(), a ramificação aparece duas vezes - Ao puxar a condição para cima,
main()passa a chamar diretamentefoo(x)oubar(y)usando a mesma condição
- Se
Levar o for para baixo, na direção de operações em lote
- No pensamento orientado a dados, programas normalmente lidam com grupos de vários objetos, e hot paths costumam esquentar justamente porque processam muitas entidades
- É útil introduzir o conceito de lote de objetos e tratar a versão escalar como um caso especial da versão em lote
frobnicate_batch(walruses)é uma forma melhor do quefor walrus in walruses { frobnicate(walrus) }
- O principal benefício do processamento em lote é o desempenho
- Ao lidar com vários alvos de uma vez, é possível amortizar o custo de inicialização
- A ordem de processamento pode ser alterada com flexibilidade, e diminui a necessidade de processar entidades em uma ordem específica
- É possível usar vetorização ou técnicas de struct-of-array, processando primeiro um campo de todas as entidades e depois passando para outro campo
- Como caso extremo de desempenho, há o link para Vectorized Interpreters Talk
- Um exemplo interessante é a multiplicação de polinômios baseada em FFT
- Avaliar um polinômio simultaneamente em vários pontos pode ser mais rápido do que fazer várias avaliações de pontos individuais
- As regras de
ifeforpodem ser aplicadas em conjunto- Se a condição estiver fora do loop,
conditionnão será reavaliada repetidamente - A ramificação é removida do hot loop
- Abre-se a possibilidade de vetorização
- Se a condição estiver fora do loop,
- O mesmo padrão funciona tanto no nível microscópico quanto no macroscópico
- A arquitetura do TigerBeetle processa lotes de objetos simultaneamente no plano de dados, amortizando o custo de decisão do plano de controle
- A principal motivação da regra do
foré desempenho, mas ela também pode ajudar na expressividadejQueryoperava sobre coleções de elementos e foi bem-sucedido no passado- A linguagem dos espaços vetoriais abstratos às vezes é uma ferramenta de pensamento melhor do que conjuntos de equações coordenada por coordenada
1 comentários
Comentários do Hacker News
Meu modelo mental estranho é que existe uma árvore de estados possíveis e do fluxo do programa, e as condicionais fazem a poda dessa árvore
É melhor podar o mais cedo possível, para que o trabalho aconteça em menos galhos
No fim, eu gostaria que uma função se concentrasse em uma de duas coisas: percorrer a árvore do programa ou realizar o trabalho de fato
Expressões são avaliadas sendo repetidamente “reescritas” de acordo com regras de redução. Por exemplo,
(1 + 2) + 4vira3 + 4e depois7Aqui existem regras de congruência, que determinam qual subexpressão avaliar em seguida, e regras de computação, que de fato alteram a expressão e mudam o estado do programa
Linguagens estritas, ou seja, não preguiçosas, em geral fazem com que todas as subexpressões sejam avaliadas antes da expressão pai, mas construções especiais como condicionais e loops infinitos são exceções
Nas condicionais, a regra de computação é aplicada antes que as regras de congruência mandem avaliar todas as subexpressões, literalmente podando a árvore da expressão
[1]: Recomendo Benjamin C. Pierce, Types and Programming Languages
É preciso olhar para as características do domínio, os padrões da base de código existente, em que etapa do pipeline de dados isso está, as características de desempenho etc.
Antes eu tentava criar regras e heurísticas para organizar código assim, mas depois de escrever código o suficiente você passa a aceitar que o nível de abstração está errado e não vale a pena insistir nisso por muito tempo
O fato de esse tipo de discussão depender de nomes de função inventados ou variáveis de uma letra também é sugestivo. Em uma “ilha de código” sem contexto externo, quase qualquer regra pode parecer plausível
Só assumindo confortavelmente que
gé o único chamador dehe continuará sendo assim é que dá para dizer que essa regra revelou um ramo mortoEm uma base de código real, normalmente existe um motivo para
gehnão terem sido unidas desde o inícioUma regra mais geral é colocar o
ifperto da origem da entrada: https://gieseanw.wordpress.com/2024/06/24/dont-push-ifs-up-p...A ideia central é localizar esse ponto, inclusive pontos de entrada vindos de fora para dentro do programa e dados trazidos de outros serviços, e refinar isso para criar o máximo possível de garantias antes de chegar à lógica central, especialmente às partes que consomem muitos recursos
Se possível, é melhor codificar essas garantias nos tipos
Um palpite solto do tipo “se há uma condição
ifdentro da função, considere movê-la para o chamador” tem contraexemplos demaisSe a função é chamada em 37 lugares, todos eles deveriam repetir o
if?Se a função fosse
getaddrinfoouEnterCriticalSection, você deveria empurrar oifpara fora e deixar isso com o usuário da API?Acho que essa transformação só faz sentido para funções internas chamadas em no máximo dois lugares, e quando essa decisão está fora das responsabilidades da função
Outra forma é fazer a função executar apenas o
ife chamar duas funções auxiliaresSe o chamador precisa puxar a condição para fora por estar dentro de um loop, pode usar um helper de baixo nível de “interpretação da condição”. Se o chamador só precisa de um único
if, e não de um dentro e outro fora do loop, pode usar uma função de conveniência que esconda oifAinda assim, isso é uma otimização, e otimizações frequentemente entram em conflito com uma boa estrutura de programa
Em orientação a objetos, a decisão de
ifdentro do chamado aparece como despacho de métodos, isto é, escolher qual método chamarTécnicas para tirar o despacho de métodos de dentro do loop também podem ir contra o fluxo do design
Por exemplo, se eu quiser preencher um objeto canvas com uma imagem raster, não vou querer percorrer os pixels da imagem chamando
canvas.putpixel(x, y, color). Deveria existir um método para fazer blit da imagem no canvas ou em uma região retangular deleconsiderEste texto mira um problema de design bem específico, que aparece especialmente ao usar coisas como tagged unions
DRY parece a resposta certa, mas só dá para julgar vendo um exemplo real de código
Se for uma função de biblioteca, a posição dela é especial. Ela está na fronteira de propriedade, os dados atravessam domínios e, em termos de DDD, cruzam um contexto delimitado. Então ela precisa proteger o próprio território
EnterCriticalSectiondeveria ser visto como uma fronteira de domínio, com validação forte na entrada, incluindo validação da condiçãoif, sugerindo um caminho de código válidoPor outro lado, ao escrever uma aplicação, se uma função comum da aplicação contém um
if, em geral dá para empurrá-lo para fora com segurançaMesmo dentro de bibliotecas ou trechos importantes de código, é seguro elevar o
ifpara as bordas, e não deixá-lo enterrado profundamenteO conselho de gerenciar o seu próprio domínio, não exigir isso do domínio dos outros, e dentro desse domínio mover o fluxo de controle para as bordas parece razoável
Claro, um idioma é só um idioma, e no mundo real isso precisa ser avaliado por alguém que entenda o contexto e consiga julgar de forma sensata
O exemplo de “refatoração por decomposição de enum” é, em essência, polimorfismo
Dá para trocar
matchpor uma chamada de método polimórfica sobre o enumO objetivo é separar o ponto em que a distinção de casos é criada, ou seja, o primeiro
if, do ponto em quefoooubaré executado com base nissoA distinção de casos é carregada pelo objeto — aqui, o valor do enum ou um closure — e não precisa ser repetida no ponto de chamada
Em outras palavras, se a distinção de casos mudar, basta alterar o ponto onde ela é criada, sem precisar mexer nos pontos que disparam o comportamento de cada caso
Mas há um trade-off. Pode ser útil ver diretamente os casos individuais considerados no ponto em que o comportamento é executado, porém isso adiciona uma dependência em nível de código à lista de casos individuais
Scanners de complexidade de código acabam forçando você a empurrar o
ifpara baixo. O texto recomenda o contrárioQuando se sobe o
if, o fluxo de controle muitas vezes fica centralizado em uma função, e essa função tem uma lógica de ramificação complexa, mas delega o trabalho real a sub-rotinas lineares⁰ https://docs.sonarsource.com/sonarqube-server/latest/user-gu...
if (weShouldDoThis()) { doThis(); }Isso complementa o functional core e shell imperativo, ou faz parte disso
Se deixar as verificações separadas, fica mais fácil testar, e se a complexidade incomodar, basta extrair cada cláusula da verificação para uma função
Em especial, o Sonar reporta “code smells”, não bugs de verdade
Ao corrigir esses itens “que não são bugs”, o risco de introduzir um novo erro sobe de zero para algum valor maior que zero, e você ainda pode desperdiçar tempo de desenvolvimento que deveria ser usado em problemas reais de produção
“Local” aqui quer dizer: só até que os requisitos mudem ou uma exceção apareça e passe a ser necessário ramificar também fora do loop
Quando há ramificação tanto dentro quanto fora do loop, fica mais difícil raciocinar sobre o código
Se você está bastante confiante de que a condição só vai afetar a parte de dentro do loop, pode deixá-la ali
Mas, se não é difícil imaginar requisitos que também exijam ramificação fora do loop, talvez seja melhor projetar dessa forma desde o início
O código pode ficar mais verboso, mas mais fácil de acompanhar, e com menor chance de virar espaguete depois
Esse é o motivo de eu ter parado de usar Haskell. Haskell incentiva a escrever a lógica mais concisa e “localmente ótima”, mas isso acaba priorizando a expressão da própria lógica, em vez da intenção dela. Pequenas mudanças de requisito podiam exigir uma desmontagem terrível depois
É muito mais fácil ler a lógica quando ela está toda em um lugar só, e eu só tentaria quebrá-la em partes quando os detalhes começassem a atrapalhar a visão do todo
Às vezes prefiro manter a lógica condicional dentro do chamado, porque isso impede que o chamador faça as coisas na ordem errada por engano
Por exemplo, se você quiser criar uma operação idempotente, pode primeiro verificar se ela já foi concluída e, se não, executá-la
Se você empurrar essa condição para fora do chamador, então cada ponto que invoca a função precisará verificar por conta própria se está chamando do jeito correto para obter a garantia de idempotência, e não haverá como abstrair essa garantia
Também há casos em que é preciso executar uma série de verificações antes de fazer algo dentro de uma transação de banco de dados. Como aplicar essa filosofia e ainda manter as verificações dentro do limite da transação?
Se você empurra a condição para fora do chamador, então a função deixa de ser idempotente, então naturalmente ela não pode mais oferecer essa garantia
Ainda assim, se uma função individual precisa implementar gerenciamento de estado para fornecer idempotência, provavelmente isso já indica uma estrutura bem estranha, e parece que lógica demais está acontecendo dentro de uma única função
Código idempotente em geral se divide em duas categorias
Uma delas é quando o modelo de dados e a própria operação realizada são inerentemente idempotentes. Ou é uma operação sem estado, ou é uma operação no estilo
PUT, em que os dados de entrada contêm todo o estado que deve ser gravadoA outra é um processo de negócio mais complexo, em que você cria uma abstração idempotente oferecendo uma abstração de aplicação atômica que executa rollback ou garante que falhas parciais não corrompam o estado
No primeiro caso, não é preciso verificar a ordem das operações. Como é inerentemente idempotente, basta executar de novo
No segundo caso, não dá para aplicar uma abstração simples. É preciso registrar a operação desejada, garantir que ela seja concluída ou falhe, e depois garantir que essa conclusão ou falha persista de forma permanente
Esse tipo de lógica não é algo para colocar dentro de uma única função e compor com outras operações
Esses conselhos são muito opinativos, então não devem ser tratados como regra prática
Nem acho que haja uma regra prática aqui. Se eu fosse inventar uma, provavelmente diria o contrário
ifdeve ser empurrado para baixo por causa de DRYSe a performance permitir, vale considerar subir o
for. Assim dá para usarfilter/map/reducee composição de funções para escolher que ação aplicar a qual objeto, e efetivamente vetorizar o códigoEmpurrar o
ifpara baixo normalmente impede a vetorizaçãoOs casos mencionados no texto são exemplos que não seguem DRY, especialmente quando o tipo é marcado internamente e ramificações semelhantes precisam se multiplicar por muitas funções mais abaixo na pilha
Não tenho certeza de que isso seja uma regra “boa” a seguir
Em alguns casos pode ser, mas é algo dependente demais do contexto para tirar uma conclusão
Parece semelhante a uma regra como “i antes de e, exceto depois de c”. Há exceções demais, a ponto de na prática ser o mesmo que não haver regra nenhuma
Obtive uma versão parecida em 99 Bottles of OOP, da Sandi Metz
No geral não faz muito o meu estilo, mas ao trabalhar em uma base de código em que muitos flags são passados para baixo por várias camadas, o ponto de mover as bifurcações da lógica para cima na pilha de chamadas foi bem convincente
https://sandimetz.com/99bottles
Colocar a ramificação dentro do loop
foré uma abstração que diz: “o loopforé a regra, e a ramificação é o comportamento”Mas é muito comum que novos requisitos quebrem essa abstração
Aí você precisa contornar isso, e o código resultante acaba com uma abstração que se aplica em alguns casos e em outros não, ou então fica difícil de acompanhar porque você empurra um monte de parâmetros para dentro da abstração para tentar fazer com que ela se aplique em todo lugar
Se você não tivesse criado essa abstração para começo de conversa, o código resultante talvez fosse mais fácil de modificar e entender
https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction
Para legibilidade de código, é melhor empurrar tudo para baixo
printInvoice(invoice, options)é muito melhor queif(printerReady){ if(printerHasInk){ if(printerHasPaper){ if(invoiceFormatIsPortrait){ ... }}}}O mesmo vale para loops:
printInvoices(invoices)é melhor quefor(invoice of invoices){ printInvoice(invoice) }No fim, a legibilidade do código é muito importante, mas encapsulamento é ainda mais importante, então é preciso misturar os dois com bom senso
printInvoicedeve imprimir a faturaSe uma das condições indicadas pelo nome for falsa e, por isso, a fatura não puder ser impressa, o que acontece?
Você pode lançar uma exceção ou retornar um valor sentinela ou um tipo de erro, mas nesse caso não fica imediatamente claro o que deve ser feito
Especialmente em linguagens como Java ou C++, onde se evita usar exceções para fluxo de controle normal e erros em estilo monádico não são comuns, estruturar de uma forma mais próxima do segundo estilo pode ser melhor
Porém, se formato retrato não representa um erro, então isso deveria ser tratado pela impressora de faturas
Encapsulamento parece ser principalmente uma ferramenta para legibilidade do código no longo prazo, refatorações e mudanças locais, e para a capacidade de raciocinar sobre o comportamento global cuidando apenas de objetos locais
Por isso, comparar legibilidade com encapsulamento e dizer que um é mais importante que o outro parece um erro de categoria
Em vez disso, deveria ser assim
if(!printerReady){ return; }if(!printerHasInk){ return; }if(!printerHasPaper){ return; }if(!invoiceFormatIsPortrait){ return; }É muito mais fácil de ler do que uma seta que só cresce
Ainda assim, colocar o loop numa função própria e já ter tratado as demais premissas continua sendo uma boa ideia
A própria impressora jamais deveria ficar girando à toa quando não há papel. Eu colocaria essa verificação dentro da função
maybe_print_invoice, e gosto bem mais disso