3 pontos por GN⁺ 2025-05-18 | 1 comentários | Compartilhar no WhatsApp
  • 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 if para cima, fica mais fácil garantir pré-condições por meio de tipos ou assert, 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 match novamente também pode ser uma forma de repetir a mesma ramificação como estrutura de dados
  • Ao levar o for para 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 if dentro 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 de None, uma função que já recebe Walrus é 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
  • 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 if tendem 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 match repetem a mesma ramificação
    • Se f() cria E::Foo ou E::Bar de acordo com uma condição, e g() faz match novamente para chamar foo() ou bar(), a ramificação aparece duas vezes
    • Ao puxar a condição para cima, main() passa a chamar diretamente foo(x) ou bar(y) usando a mesma condição

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 que for 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 if e for podem ser aplicadas em conjunto
    • Se a condição estiver fora do loop, condition não será reavaliada repetidamente
    • A ramificação é removida do hot loop
    • Abre-se a possibilidade de vetorização
  • 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 expressividade
    • jQuery operava 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

 
GN⁺ 2025-05-18
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

    • Essa perspectiva combina muito bem com o que aparece na semântica de pequenos passos da teoria de linguagens de programação ou do cálculo lambda
      Expressões são avaliadas sendo repetidamente “reescritas” de acordo com regras de redução. Por exemplo, (1 + 2) + 4 vira 3 + 4 e depois 7
      Aqui 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
    • Meu modelo mental é adaptá-lo ao mundo concreto em que o código que estou escrevendo está inserido
      É 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 de h e continuará sendo assim é que dá para dizer que essa regra revelou um ramo morto
      Em uma base de código real, normalmente existe um motivo para g e h não terem sido unidas desde o início
    • Para jogar um modelo adjacente na conversa, classes são substantivos e funções são verbos
    • Não é um modelo tão estranho assim; se você levar isso até o fim, acaba chegando bem perto do modelo de execução do Prolog
  • Uma regra mais geral é colocar o if perto 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 if dentro da função, considere movê-la para o chamador” tem contraexemplos demais
    Se a função é chamada em 37 lugares, todos eles deveriam repetir o if?
    Se a função fosse getaddrinfo ou EnterCriticalSection, você deveria empurrar o if para 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 if e chamar duas funções auxiliares
    Se 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 o if
    Ainda 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 if dentro do chamado aparece como despacho de métodos, isto é, escolher qual método chamar
    Té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 dele

    • Se a função é chamada em 37 lugares, isso talvez queira dizer mais que neste caso você poderia dividi-la em duas funções que implementam os ramos verdadeiro/falso e fazê-las ser chamadas em 21 e 16 lugares, respectivamente
    • A palavra-chave aqui é consider
      Este texto mira um problema de design bem específico, que aparece especialmente ao usar coisas como tagged unions
    • Se a função é chamada em 37 lugares, o código precisa ser refatorado, mas ainda assim a resposta é “depende”
      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
      EnterCriticalSection deveria ser visto como uma fronteira de domínio, com validação forte na entrada, incluindo validação da condição if, sugerindo um caminho de código válido
      Por 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ça
      Mesmo dentro de bibliotecas ou trechos importantes de código, é seguro elevar o if para as bordas, e não deixá-lo enterrado profundamente
      O 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 match por uma chamada de método polimórfica sobre o enum
    O objetivo é separar o ponto em que a distinção de casos é criada, ou seja, o primeiro if, do ponto em que foo ou bar é executado com base nisso
    A 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 if para baixo. O texto recomenda o contrário
    Quando 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...

    • A solução é separar decisão e execução, e essa ideia veio de Bertrand Meyer
      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
    • Relatórios de scanner de código não devem ser aceitos como evangelho; é preciso tratá-los com desconfiança
      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
    • Pela minha experiência, isso com frequência é um ótimo local
      “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
    • Sempre desgostei de scanners de complexidade de código desde que vi reclamações sobre funções grandes que eram totalmente fáceis de ler
      É 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
    • Ontem, em uma thread sobre LLMs, alguém perguntou “qual é outra ferramenta não confiável que as pessoas aceitam na programação?”, e agora tenho uma resposta
  • À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?

    • Talvez dê para escrever uma função sem verificações e, em seguida, ter uma função wrapper que só faz as verificações e chama a função interna
    • Na verdade, a própria pergunta já contém a resposta
      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 gravado
      A 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
    if deve ser empurrado para baixo por causa de DRY
    Se a performance permitir, vale considerar subir o for. Assim dá para usar filter/map/reduce e composição de funções para escolher que ação aplicar a qual objeto, e efetivamente vetorizar o código

    • Parece que inverteram o nome, ou então que os motivos apresentados não sustentam a conclusão
      Empurrar o if para baixo normalmente impede a vetorização
      Os 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

    • Isso imediatamente me lembrou The Wrong Abstraction, da mesma autora
      Colocar a ramificação dentro do loop for é uma abstração que diz: “o loop for é 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 que if(printerReady){ if(printerHasInk){ if(printerHasPaper){ if(invoiceFormatIsPortrait){ ... }}}}
    O mesmo vale para loops: printInvoices(invoices) é melhor que for(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

    • A função printInvoice deve imprimir a fatura
      Se 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
    • Dizer “empurre tudo para baixo para melhorar a legibilidade” e depois mostrar o anti-pattern da seta não é grande coisa
      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
    • Isso pode ser tanto o driver da impressora no PC quanto o circuito interno da própria impressora, então as opiniões podem variar
      A própria impressora jamais deveria ficar girando à toa quando não há papel. Eu colocaria essa verificação dentro da função
    • Se fosse no mundo Elixir, eu provavelmente daria a essa função um nome como maybe_print_invoice, e gosto bem mais disso