3 pontos por GN⁺ 2025-05-18 | 1 comentários | Compartilhar no WhatsApp
  • Mover as instruções if para o ponto de chamada ajuda a reduzir a complexidade do código
  • Concentrar a verificação de condições e o tratamento de ramificações em um só lugar facilita identificar duplicações e checagens desnecessárias
  • Usar a refatoração de dissolução de enum ajuda a evitar que a mesma condição se espalhe por várias partes do código
  • Laços for baseados em operações em lote são eficazes para melhorar o desempenho e otimizar tarefas repetitivas
  • Combinar o padrão de subir o if e descer o for pode aumentar ao mesmo tempo a legibilidade e a eficiência do código

Uma breve nota sobre duas regras relacionadas

  • Quando há uma condição if dentro de uma função, recomenda-se pensar se ela pode ser movida para o ponto onde a função é chamada
  • Como no exemplo, em vez de verificar a precondition (pré-condição) dentro da função, o ideal é deixar essa verificação para o chamador ou garantir a pré-condição por meio do tipo (ou de assert)
  • A abordagem de elevar (push up) as verificações de pré-condição afeta o código como um todo e, no resultado geral, reduz a quantidade de verificações de condição desnecessárias

Concentração do fluxo de controle e das condições

  • O fluxo de controle e as instruções if são grandes fontes de complexidade e bugs no código
  • É útil concentrar as condições em níveis mais altos, como no ponto de chamada, deixando a lógica de ramificação reunida em uma função e delegando o trabalho real a sub-rotinas lineares (straight-line)
  • Quando as ramificações e o fluxo de controle ficam reunidos em um só lugar, fica mais fácil identificar ramificações duplicadas e condições desnecessárias

Exemplos:

  • Quando há ifs aninhados dentro da função f, fica mais fácil reconhecer código morto (Dead Branch)
  • Quando as ramificações ficam distribuídas por várias funções (g, h), essa análise se torna mais difícil

Refatoração de dissolução de enum (Dissolving enum Refactor)

  • Quando o código embute a mesma lógica de ramificação em um enum ou algo semelhante, é possível puxar a condição para cima e separar com mais clareza as ramificações e o trabalho executado
  • Ao aplicar essa abordagem, evita-se que a mesma condição apareça repetidas vezes no código

Exemplo:

  • Em uma situação em que a mesma condição de ramificação está expressa nas funções f, g e também no enum E,
  • é possível simplificar todo o código com uma única ramificação condicional em nível superior

Pensamento orientado a dados (Data Oriented Thinking) e operações em lote

  • A maioria dos programas funciona com vários objetos (entidades). No caminho crítico (Hot Path), o desempenho costuma ser determinado pelo processamento de muitos objetos
  • É recomendável introduzir o conceito de lote (batch), tomando operações sobre conjuntos de objetos como padrão e tratando operações sobre um único objeto como caso especial

Exemplo:

  • Tendo algo como frobnicate_batch(walruses) como função de processamento em lote padrão,

  • o processamento de objetos individuais pode ser tratado como um caso especial por meio de um laço for

  • Essa abordagem tem papel importante na otimização de desempenho; em cargas grandes, reduz o custo inicial e aumenta a flexibilidade de ordenação

  • Também permite aproveitar operações SIMD (como struct-of-array), processando certos campos em lote antes de prosseguir com o trabalho completo

Casos práticos e padrão recomendado

  • Assim como na multiplicação de polinômios baseada em FFT, é possível maximizar o desempenho ao permitir operações simultâneas em vários pontos
  • As regras de mover condições para cima e laços para baixo podem ser aplicadas em conjunto

Exemplo:

  • Em vez de verificar a mesma condição repetidamente dentro de um laço, ao mover a condição para fora dele reduz-se a quantidade de ramificações no loop e fica mais fácil otimizar e vetorizar
  • Essa abordagem garante alta eficiência também em planos de dados de sistemas de grande porte, como no design do TigerBeetle

Conclusão

  • Ao combinar o padrão de mover o if (condição) para a camada superior (chamador, controle) e o for (repetição) para a camada inferior (operação, processamento de dados), é possível melhorar ao mesmo tempo a legibilidade, a eficiência e o desempenho do código
  • Pensar do ponto de vista de espaços vetoriais abstratos e de operações sobre conjuntos oferece uma ferramenta melhor para resolver problemas do que ramificações repetitivas
  • Em resumo: if para cima, for para baixo!

1 comentários

 
GN⁺ 2025-05-18
Comentários do Hacker News
  • Meu modelo mental peculiar é que vários estados ou fluxos do programa formam uma estrutura em árvore. As condicionais servem para podar os galhos dessa árvore. Quero podar o mais cedo possível para reduzir a quantidade de galhos que preciso processar depois. Quero evitar a situação de avaliar e limpar cada galho individualmente para no fim acabar tendo que cortar todos de uma vez. Vendo por um ângulo um pouco diferente, a condicional é o processo de "descobrir trabalho desnecessário", e o loop é o "trabalho de verdade". No fim das contas, a função que eu quero é uma que se concentre em uma de duas coisas: explorar a árvore do programa ou executar o trabalho real
    • Quero propor um modelo alternativo. Classes são substantivos, e funções são verbos
    • Meu modelo mental se ajusta ao mundo em que existe o código concreto que eu escrevo. Isso varia conforme características do domínio, padrões de código existentes, etapas do pipeline de dados, perfil de desempenho etc. No começo eu tentava criar esse tipo de regra ou heurística, mas depois de escrever muito código percebi que essas regras abstratas na prática não significam muita coisa. Em muitos casos, elas só valem dentro daquela “ilha de código” em que alguém escolheu um nome de função ou até uma única palavra, mas em codebases reais normalmente existe um motivo para essas funções não terem sido unificadas. O exemplo fala de “duplicação e condição morta”, mas isso é uma regra aplicada com a suposição conveniente de que aquela função só é chamada em um único lugar. Na prática, muitas vezes as partes estão separadas por outros motivos
    • Acho que é um modelo muito bom
  • Uma regra mais geral é manter as condicionais o mais perto possível da origem da entrada. O ponto é identificar o mais cedo possível as entradas que chegam ao programa vindas de fora, incluindo dados trazidos de outros serviços, e estabelecer o máximo de garantias antes de chegar à lógica central, especialmente antes das partes que consomem muitos recursos. Também é muito bom expressar isso explicitamente nos tipos
    • Isso não dificulta entender quais premissas a lógica central assume? Não obriga a examinar toda a cadeia de chamadas do código?
  • O conselho “se a condição if está dentro da função, considere movê-la para o chamador” tem contraexemplos demais. Se uma função é chamada em 37 lugares, vou repetir o mesmo if em todos eles? Dá para dizer que funções como getaddrinfo ou EnterCriticalSection deveriam mover esse if para fora? Acho que esse tipo de transformação só faz sentido quando há umas duas chamadas no máximo e quando essa decisão está fora das responsabilidades da função. Uma abordagem é delegar para uma função auxiliar que faz só a parte da condicional. E, quando for preciso mover a condição para fora do loop, você pode deixar o chamador usar diretamente um helper de condição mais baixo nível. Mas o centro dessa discussão é “otimização”. E otimização muitas vezes entra em conflito com um projeto de programa melhor. Pode ser um projeto melhor quando o chamador não precisa saber da condição. Esse dilema também aparece bastante em OOP. A decisão representada por um “if” na verdade pode acontecer por despacho de método. Tirar esse despacho de dentro do loop também pode entrar em atrito com princípios de design. Um exemplo é desenhar uma imagem em um canvas usando um método como blit, em vez de chamar putpixel repetidamente
    • Se a função é chamada em 37 lugares, então algum refactoring provavelmente é necessário. Para responder à pergunta: depende. DRY parece a resposta certa, mas é preciso olhar o código real do exemplo para decidir. Se for uma biblioteca, ela está numa fronteira de propriedade, então cada lado precisa gerenciar seus próprios dados e responsabilidades. Funções como EnterCriticalSection devem mesmo fazer validações fortes na entrada, inclusive com condicionais. Já em código de aplicação, pode ser perfeitamente aceitável mover o if para o chamador. Em bibliotecas ou código central, faz sentido empurrar o fluxo de controle para as bordas. Dentro do domínio que você controla, é bom manter o fluxo de controle nas extremidades. Mas esse tipo de regra é sempre idiomática, então alguém capaz de julgar o contexto precisa aplicar isso com bom senso
  • O exemplo de refatoração “dissolving enum” é, na prática, um padrão de polimorfismo. Dá para substituir o match por chamadas de método polimórficas. O objetivo dessa abordagem é separar o momento em que a decisão inicial da ramificação é tomada do momento em que o comportamento de fato é executado. A distinção entre casos fica carregada pelo objeto, aqui o valor do enum, ou por um closure, então não é preciso repeti-la a cada chamada. Se a distinção entre casos mudar, basta alterar o ponto de bifurcação e não os lugares onde o comportamento acontece. A desvantagem é o trade-off entre a conveniência de enxergar diretamente a ramificação de comportamento de cada caso e o fato de surgir uma dependência, no nível do código, da lista de casos
  • Às vezes eu gosto de manter a condicional dentro da função. Assim eu deliberadamente impeço que o chamador erre a ordem de chamada da função. Por exemplo, quando é preciso garantir idempotência: primeiro verifica se aquilo já foi processado e, se não, executa. Se eu mover essa condição para o ponto de chamada, a idempotência só fica garantida se todos os chamadores seguirem esse procedimento corretamente, então a abstração deixa de fornecer essa garantia. Fico curioso sobre como aplicar essa filosofia nesse tipo de situação. Outro exemplo é quando eu quero fazer uma série de verificações dentro de uma transação de banco de dados e só depois executar a operação; aí fico na dúvida de onde colocar essas verificações
    • Acho que você mesmo já respondeu à própria pergunta. Se a condição é movida para o ponto de chamada, a função deixa de ser idempotente e obviamente não pode mais garantir isso. Se você está colocando lógica de gerenciamento de estado dentro de cada função para garantir idempotência, talvez esteja escrevendo um código bem estranho e concentrando lógica de negócio demais em uma única função. Código idempotente se divide mais ou menos em dois grupos. O primeiro é quando o modelo de dados ou a própria operação já é idempotente. Nesse caso não é preciso se preocupar muito com a ordem de execução. O segundo é quando você está construindo uma abstração de idempotência sobre operações de negócio mais complexas. Aí entra lógica mais pesada, como rollback ou abstrações de aplicação atômica, e isso já não é algo que caiba simplesmente dentro de uma única função
    • Outra forma de organizar isso é criar uma função interna sem as verificações e, do lado de fora, uma função wrapper que faz os checks e chama a função interna
  • Um scanner de complexidade de código tende no fim a empurrar os if para baixo. Mas este texto recomenda o oposto: levar os if para cima, para funções mais de alto nível. Assim dá para tratar a lógica de decisão complexa de forma centralizada em uma função, enquanto o trabalho concreto de fato é delegado a sub-rotinas
    • A solução é separar “decisão” e “execução”. Foi uma ideia que aprendi com Bertrand Meyer. Algo como if (weShouldDoThis()) { doThis(); }. Se você extrair cada verificação para uma função separada, fica mais fácil testar e gerenciar a complexidade
    • Relatórios de scanners de código precisam ser vistos com bastante desconfiança. Ferramentas como sonarqube disparam um monte de alertas de “code smell” que não correspondem a bugs reais. Ao tentar corrigir esse tipo de “código que não tem problema”, o risco de introduzir bugs novos aumenta, e você ainda perde tempo que poderia usar para tratar problemas de verdade
    • Esse tipo de otimização costuma ser, na maior parte das vezes, um “ótimo local”. Ou seja, quando aparecem novos requisitos ou casos de exceção, a lógica de ramificação passa a ser necessária fora do loop. E, quando você acaba com condicionais misturadas dentro e fora do loop, tudo fica mais difícil de entender. Se você tem certeza de que a condição só é necessária dentro do loop, então deixe assim; caso contrário, prefiro investir um pouco mais no design desde o começo e aceitar um código mais verboso, desde que ele fique mais fácil de entender. Tive essa experiência usando Haskell. Quando você persegue a forma mais concisa e otimizada da lógica como se fosse o formato ideal, basta uma pequena mudança de requisito para que o código deixe de expressar a intenção do design e passe a ser só lógica pura, e pequenas mudanças acabam causando um grande desenrolar do código
    • Scanner de complexidade de código sempre foi algo que me incomodou. Eles reclamam até de funções grandes que são fáceis de ler. Manter a lógica em um lugar só ajuda a entender o contexto geral, mas, ao quebrar em várias funções, é preciso tomar cuidado para não perder o contexto real
    • Ontem houve uma thread sobre LLMs em que falaram de “ferramentas não confiáveis que todo desenvolvedor aceita”. Agora eu sei a resposta...
  • Em alguns casos, pode fazer mais sentido seguir o caminho oposto e usar SIMD. Por exemplo, em AVX-512 e afins, código com desvios pode ser tratado como código sem branch usando registradores de máscara vetorial. Nesse caso, um if dentro do for pode ser mais fácil de manter e ter acesso à memória mais eficiente do que um if fora do for. Um exemplo concreto: se a operação for somar 1 aos ímpares e subtrair 2 dos pares, no código original cada iteração do loop precisaria ramificar, mas com SIMD dá para processar 16 int de uma vez, sem branch nenhuma. Se o compilador fizer a vetorização corretamente, ele pode transformar o código original nessa versão otimizada sem branch
    • Acho que o código “before” apresentado não se encaixa muito bem no ponto principal do texto; na verdade, a versão otimizada com SIMD é que parece combinar melhor com a ideia do autor. No exemplo, o if dentro do for é uma ramificação dependente dos dados, então não dá para puxá-lo facilmente para cima. Se o algoritmo tivesse uma condição fora do loop, algo como if (length % 2 == 1) { ... } else { ... }, aí sim mover essa condição para cima do for seria obviamente o certo. Na versão com SIMD, o if desaparece completamente, e esse parece o tipo ideal de padrão de código que o autor do texto também gostaria de ver
    • Eu também pensei imediatamente em código que ramifica com base no valor de cada elemento no loop for. Alguém sabe o quão difícil é para um compilador vetorizar esse tipo de código automaticamente? Tenho curiosidade sobre onde está esse limite
  • Pessoalmente, não acho que isso seja uma regra “boa”. Há casos em que ela se aplica, mas tudo varia demais conforme o contexto para cravar uma conclusão. Parece regra de ortografia do inglês: há exceções demais, então na prática é difícil tratá-la como uma regra de verdade
  • Link da discussão da época (2023) (662 pontos, 295 comentários) https://news.ycombinator.com/item?id=38282950
  • Vi algo parecido em 99 Bottles of OOP, da Sandi Metz. Não é o meu estilo, mas concordo que pode ser útil levar a lógica de ramificação para o topo da pilha de chamadas. Isso fica ainda mais evidente em codebases que passam flags por várias camadas. https://sandimetz.com/99bottles
    • Isso me lembrou imediatamente o texto “The Wrong Abstraction”, da mesma autora. Ramificações dentro do for criam a abstração de que “o for é a regra e a ramificação é o comportamento”. Só que, quando surgem novos requisitos, essa abstração quebra, e você começa a enfiar parâmetros ou exceções até o código ficar difícil de entender. Se o código tivesse sido escrito sem essa abstração desde o início, o resultado poderia ter ficado mais claro e mais fácil de manter. https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction