Suba os ifs, desça os fors
(matklad.github.io)- O ponto central são duas regras de estruturação de código: puxar as ramificações condicionais para o lado do chamador e empurrar o processamento repetitivo para o lado das operações em lote
- Ao subir o
if, fica mais fácil impor pré-condições com tipos ouassert, e o fluxo de controle complexo fica reunido em um só lugar, facilitando encontrar condições duplicadas e ramificações mortas - Uma estrutura que cria um
enume logo depois fazmatchnele novamente repete a mesma condição como ramificação, estrutura de dados e nova ramificação, então pode haver espaço para simplificar para chamadasfoo(x)ebar(y) - Ao descer o
for, operações em lote se tornam a unidade básica, em vez de iteração sobre objetos individuais, permitindo amortizar custos de inicialização, mudar a ordem de processamento, vetorizar e otimizar com struct-of-array - Tirar a condição de dentro do loop reduz ramificações no hot loop e também leva a estruturas como a do TigerBeetle, que amortizam o custo de tomada de decisão do plano de controle por meio do processamento em lote no plano de dados
Subir o if
- Uma condição
ifdentro de uma função deve primeiro ser avaliada para ver se pode ser movida para o chamador- O exemplo considera que uma função que recebe
Walrusdiretamente é uma forma melhor do que uma que recebeOption<Walrus>e retorna quando éNone - Em vez de verificar uma pré-condição dentro da função e “não fazer nada”, é mais claro o chamador verificar e impor sua validade por meio de tipos ou
assert
- O exemplo considera que uma função que recebe
- A refatoração de subir verificações de pré-condição pode se propagar ao longo do caminho de chamadas e, como resultado, reduzir o número de verificações no sistema como um todo
- Fluxo de controle e
ifaumentam a complexidade e tendem a ser fonte de bugs- Ao subir o
if, a lógica complexa de ramificação fica concentrada em uma função, enquanto o trabalho real desce para sub-rotinas lineares - É mais fácil encontrar duplicações e condições mortas quando um fluxo de controle complexo está em uma única função visível na tela do que espalhado por todo o arquivo
- Ao subir o
- A refatoração “dissolving enum” é uma forma de eliminar o padrão em que uma ramificação é transformada em estrutura de dados e depois ramificada de novo
- Se
f()criaE::Foo(x)ouE::Bar(y)conforme uma condição, eg(e)depois usamatchpara chamarfoo(x)oubar(y), a mesma condição se repete em várias formas - Ao puxar a condição para
main(), a estrutura se simplifica paraif condition { foo(x) } else { bar(y) }
- Se
Descer o for
- Do ponto de vista orientado a dados, programas e caminhos quentes geralmente lidam com muitos objetos, então introduz-se um lote de objetos e toma-se a operação em lote como caso padrão
- A versão escalar para objeto individual se torna um caso especial da operação em lote
frobnicate_batch(walruses)é uma forma melhor do quefor walrus in walruses { frobnicate(walrus) }
- O principal benefício é desempenho
- Ao lidar com o lote inteiro de uma vez, é possível amortizar o custo de inicialização
- Também é possível mudar a ordem de processamento com flexibilidade e adotar vetorização ou uma abordagem struct-of-array, processando primeiro um campo de todas as entidades
- Como exemplo extremo, há o 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 realizar várias avaliações em pontos individuais
- As regras de
ifeforpodem ser aplicadas juntas- A boa forma é uma estrutura em que
for walrus in walrusesé executado por ramificação, fora deif condition - A má forma é uma estrutura que avalia
if conditiona cada vez dentro do loopfor - Isso evita reavaliar a condição, remove ramificações do hot loop e pode viabilizar vetorização
- A boa forma é uma estrutura em que
- Esse padrão se aplica tanto no nível micro quanto no macro
- A arquitetura do TigerBeetle processa lotes de objetos simultaneamente no plano de dados, amortizando o custo das decisões do plano de controle
- Embora a principal motivação do conselho sobre
forseja desempenho, operar no nível de coleções, como no jQuery, também pode ajudar na expressividade
1 comentários
Opiniões no Hacker News
No começo, achei inesperado que este texto estivesse recebendo tanta resistência, mas, ao lembrar que isso é um conselho de design orientado a dados, ficou compreensível
Como muitas pessoas neste fórum, eu também passo a maior parte do tempo criando apps web corporativos e, nesse contexto, esse conselho pode parecer besteira
Se no trabalho do dia a dia você não precisa se preocupar com o cache de instruções, parece correta a heurística de que, em geral, dá para ignorar esse conselho
Se quiser ter uma noção de quando esse conselho se torna importante, vale procurar “Typical C++ Bullshit”, de Mike Acton; este texto parece uma organização mais fácil de entender daquele conteúdo
Eu simpatizo bastante com as preocupações de Casey Muratori, mas a maior parte do software corporativo precisa otimizar mais para modificabilidade e correção do que para desempenho, isto é, para “programação ao longo do tempo”
Desenvolvedores tentam dividir lógicas de negócio complexas em pequenos métodos private dentro de classes para deixá-las DRY, e “suba o if” ajuda a impedir que a lógica de ramificação fique espalhada por vários métodos
“Desça o for” também é importante. Muitos fluxos de chamada acabam terminando em consultas caras ao banco de dados, e é comum que um loop em nível superior provoque várias chamadas ao DB em níveis inferiores
Muitas vezes essa repetição pode ser substituída por uma cláusula
whereou umjoinem SQL, e também é melhor empurrar agregações e filtragens para perto do DAO, em vez de buscar um monte de objetos e iterar sobre eles, pois assim fica mais fácil otimizar perto do DBPorém, como todo princípio de design, não se deve aplicá-lo como dogma; é preciso julgar conscientemente
É uma pena ver modificabilidade e desempenho como coisas opostas. Na prática, ainda não vi evidências convincentes de que os dois realmente entrem em conflito
Um dos aspectos mais importantes do desempenho é tornar as coisas pequenas. Código pequeno, estruturas de dados pequenas e um número menor de instruções executadas são essenciais, e “pensar no cache de instruções” é, no fundo, escrever código pequeno
Quanto menor o código, menos espaço há para bugs entrarem, mais fácil é entendê-lo de uma vez só e melhor é garantir cobertura de testes, o que favorece a correção. A modificabilidade também melhora, porque, quanto menor o código, menores são as mudanças
É claro que existem otimizações que tornam o código mais complexo, como paralelização, caching e otimizações de baixo nível, mas isso é apenas parte da otimização de desempenho; um programador que leva desempenho a sério não faz esse tipo de coisa sem profiling e análise
Mesmo que
if/forefor/ifnão sejam muito diferentes funcionalmente, um deles pode ser mais rápido; então, se é possível usar a forma correta sem custo, não há motivo para escolher deliberadamente a mais lentaProgramas simples tendem a ter uma pilha de chamadas curta e a evitar abstrações complexas e caras, portanto usam menos memória e rodam mais rápido
Ao buscar desempenho de nível máximo, o programa pode ficar complexo, mas a verdadeira simplicidade geralmente traz desempenho razoável
“Descer o for” é principalmente um conselho orientado a dados, mas “subir o if” está mais próximo de tornar o programa mais simples. Mais precisamente, trata-se de aumentar a localidade do código-fonte, e o ponto central é reunir a lógica de ramificação em um só lugar: https://loup-vaillant.fr/articles/source-of-readability
Especialmente “desça os loops” é poderoso em apps CRUD. Processar criações e atualizações em lote sempre que possível frequentemente economiza um tempo muito maior do que gargalos de CPU
A diferença entre
items.map(insertToDb/postToServer)einsertToDb/postToServer(items)quase sempre é de várias ordens de grandezaJá vi esse tipo de otimização reduzir o tempo de tarefas de segundos ou minutos para milissegundos, e muitas vezes a API também fica mais limpa e os logs mais fáceis de ler
Quanto mais experiência acumulo, mais sinto que programadores demais se preocupam com pequenas unidades de “código bonito”, mas não se preocupam o suficiente com o design da base de código como um todo
Funções concisas e bem lapidadas são boas, mas textos assim podem virar discussões improdutivas de bike-shedding em PRs ou debates
Se a função está um pouco bagunçada, onde colocar
ifefor, se usarmapefilter, não me importo muito, desde que o nome da função seja adequado, a interface e os tipos sejam expressivos, o propósito seja claro, haja documentação e os efeitos colaterais não sejam usados em excessoSe pensarmos em onde validar quando há valores de entrada, segundo essa heurística a validação deve acontecer no ponto mais alto em que a entrada é recebida
Isso também ajuda a entender o código e, do ponto de vista de prova, é tecnicamente necessário, pois as pré-condições precisam ser propagadas para cima
O primeiro conselho definitivamente não é uma discussão trivial; o segundo é um pouco mais ambíguo
Elas tentam evitar que juniores deem um tiro no próprio pé, mas muitas vezes ainda não têm senso suficiente do custo da complexidade e de como o código muda no longo prazo
Por outro lado, depois de mais de 20 anos, a maioria passa a valorizar simplicidade acima da maior parte dos elementos técnicos
Perguntas como “quão cedo devo ramificar?” e “o que este código realmente precisa fazer?” tendem a dar as respostas mais valiosas no longo prazo
Perguntas sobre abstração e encapsulamento, por sua vez, tendem a levar ao tipo de discussão mencionado acima, e o fato de a maioria dos problemas de segurança aparecer no código de pessoas que só veem o “panorama geral” ocorre justamente porque elas não entendem bem o que aquela base de código supostamente bem projetada faz de fato
Em software sensível a desempenho, no qual design orientado a dados se encaixa bem, é preciso prestar mais atenção a essas pequenas unidades. É assim que as otimizações do compilador funcionam
Então as regras do jogo mudam. O significado das instruções passa a importar, código autoexplicativo passa a ter mais valor, comentários de código ficam apenas para fins de raciocínio, e a “documentação” se aproxima de especificações e manuais de usuário
No máximo dá para agrupar funções; depois disso, é cada um por si
“Mover o if para cima” tem a desvantagem de que pré-condições e pós-condições não ficam diretamente visíveis dentro da definição da função, e precisam ser verificadas em cada ponto de chamada
Em grandes projetos com várias pessoas, uma função assim pode ser reutilizada fora do contexto pretendido e levar a bugs
Usar um framework de contratos resolveria isso, mas você acabaria escrevendo as condições duas vezes, no contrato e no código; com tipos dependentes acontece o mesmo
É interessante a abordagem de marcar uma região de código pertencente a um contexto específico e definir funções que só podem ser chamadas dentro desse contexto
Em Python, seria possível usar um decorator como
@requires_context("VALIDATED_XY")e um context managervalidated_xypara fazer a função ser chamada apenas em uma região validadaO runtime não conhece o significado desse contexto, mas, com ferramentas e testes, dá para projetar de modo que o contexto desejado só seja estabelecido quando determinadas condições forem satisfeitas
Em linguagens como Haskell, isso poderia ser imposto no nível de tipos com algo como a mônada identidade; mesmo sem imposição no nível de tipos, pode ser uma forma interessante de proteger áreas de código “unsafe”
Portanto, as pré-condições continuam diretamente visíveis na definição da função, só que aparecem como parte da assinatura de tipo, não como uma instrução
ifEsse é um padrão comum em Rust, usado no texto, e, diferentemente de uma checagem com
if, é uma pré-condição estrita verificada em tempo de compilação, não em tempo de execução; se ela não for satisfeita, o programa não compilaPor exemplo, receber algo como
do_something(position: ValidatedPosition)e, depois de validar umPositioncomum, convertê-lo paraValidatedPositionantes de passá-lo adianteNa prática, você encapsularia a validação dentro do construtor de
ValidatedPosition, mas o ponto central é que, se você tentar passar umPositiondiretamente, o mypy avisará que foi passado o tipo erradoA verificação de tipos do Python não é tão abrangente quanto a do Rust, mas está se tornando cada vez mais útil quando queremos garantir que os dados transmitidos tenham sido tratados adequadamente
Se uma função tem pré-condições, é claro que você pode afirmá-las no início da função. Por exemplo, como o sistema de tipos de Java permite null, uma função que precisa de um objeto deve lançar uma exceção se receber null
Cada ponto de chamada tem a responsabilidade de chamar a função apenas quando as pré-condições forem verdadeiras. Isso é natural pela própria definição de pré-condição
Chamar uma função violando suas pré-condições é um bug do chamador. Pode ser necessário haver código dentro da função para verificar isso e evitar comportamento indefinido, mas é preciso distinguir essas asserções do fluxo de controle real do programa, e o texto trata deste último
fn frobnicate(walrus: Walrus), o programa não compila se você passar algo que não seja umWalruspossuídoMesmo que seja genérico, o argumento precisa satisfazer os limites de trait, e, dependendo de como o argumento é usado dentro da função, o compilador exige os limites necessários na definição da função
publiceprivateera, em certa medida, servir para marcar o código de acordo com um contexto específicoOu talvez seja necessária uma semântica mais específica que atravesse as áreas cobertas por
public,privateeprotectedno ecossistema .NETO primeiro exemplo não é ruim por causa do
ife dofor, mas por outro motivoEm geral, quando existe um “contêiner” de alguma coisa, é melhor escrever a função para o “objeto” de nível de domínio que está dentro dele, e não para o contêiner
Em Clojure, ao usar um agent, você não escreve funções para o agent; escreve funções para o tipo de objeto que o agent pode conter
Em Elixir também, as funções centrais de domínio operam sobre a estrutura de dados de domínio interna, não sobre o PID, e quando necessário a chamada ao GenServer é delegada a elas
Isso torna tudo mais flexível e permite separar de forma mais limpa o domínio central, “fazer frobnicate em um Walrus”, da preocupação da aplicação, “pode haver ou não um Walrus e, se houver, fazer frobnicate nele”
Validar no início é bom, mas também é importante emitir claramente um erro de validação em vez de deixar a função explodir de repente com um erro estranho
Haskell resolve isso com newtype, oferecendo um “contêiner transparente que certifica que a validação adequada já foi feita”
O conselho que eu realmente gostaria de enfatizar para as pessoas é preferir o “if triste”. Em vez de aninhar o caminho normal em várias camadas, quase sempre é mais legível e mais fácil de manter verificar as condições inválidas uma a uma no topo e corrigi-las ou interromper imediatamente
As pessoas têm a tendência natural de se concentrar no caso esperado, mas o código, ao contrário, quer que se dê atenção primeiro aos casos excepcionais. Todo
ifcria uma carga mental e, se for preciso buscar informações em um sistema externo ou encerrar cedo por erro, dá para se livrar imediatamente dessa carga quando a detecção e o tratamento ficam juntoswalrus.frobnicate(), isso me parece mais uma questão de preferência sintática do que o ponto central que o autor quer defenderEu não diria que isso é necessariamente um mau conselho, mas também não é necessariamente um bom conselho
O fato de a linguagem escolhida ser Rust diz bastante. Um sistema de tipos forte evita muita programação defensiva que é necessária em outras linguagens
Se um programador C não verificar a validade de um ponteiro passado para uma função e causar uma desreferência de NULL, eu não gostaria de trabalhar na mesma equipe que essa pessoa
Então pelo menos alguns
ifcertamente devem ficar mais embaixo, e é bom que os erros sejam propagados corretamente para cimaSobre
for, não tenho uma opinião tão forte, mas em C, como parâmetros de array decaem para ponteiros, acho que a iteração também deve ficar em cima, não embaixo. Na função em que o array foi criado, dá para confiar no tamanho; na função para a qual ele foi passado como argumento, nãoA documentação deve substituir um sistema de tipos forte; verificações em tempo de execução não devem fazer esse papel
Código cheio de verificações de NULL e outras medidas defensivas é muito mais difícil de ler
Dá para dizer que, em fronteiras de bibliotecas, verificações mais defensivas são necessárias, e este texto está dizendo exatamente para empurrar essas verificações para cima
Código crítico para segurança pode ser diferente, mas na maioria dos casos basta que desreferências acidentais de NULL sejam capturadas por testes, sanitizers e fuzzing
Para verificar valores de ponteiro inválidos, seria preciso verificar todo o enorme conjunto de possíveis valores incorretos; se você verifica apenas NULL, na prática não está verificando valores inválidos
Se a pré-condição da função for “o parâmetro
pnão pode ser NULL”, tudo bem verificar. Mas se a pré-condição for “pdeve ser um ponteiro válido”, boa sorte para encontrar uma asserção adequadaHoje em dia uso bastante T-SQL, e não é possível declarar parâmetros ou variáveis como
NOT NULLPor isso é melhor verificar NULL o mais cedo que for razoavelmente possível, normalmente no topo da stored procedure
Caso contrário, NULL pode se propagar inesperadamente para dentro da hierarquia de chamadas e criar problemas menos claros
Felizmente, dados de tabelas podem ser declarados como
NOT NULL, então esse tipo de bug normalmente não corrompe os dados, mas é muito mais fácil capturá-lo cedoPorém, se existe uma lógica que escreve no DB dependendo de determinado valor de parâmetro e esse valor inesperadamente é NULL, um valor errado pode ser escrito, ou um valor necessário pode deixar de ser escrito, o que na prática pode corromper os dados
Portanto, programação defensiva é a resposta
Sem o contexto adequado, esse conselho parece bem estranho e talvez até seja um mau conselho
Laços e instruções
ifsão ambos operações de fluxo de controle, então parte do argumento do texto não faz muito sentidoO argumento mais forte parece ser desempenho, mas desempenho geralmente deveria ser uma das últimas preocupações, especialmente quando se trata de conselhos heurísticos
Infelizmente, o autor transformou isso em um bordão, e espero que não se espalhe
Se for possível escrever o código melhorado como no exemplo do autor, a condição é constante durante a execução do laço. Se não for uma condição cara sendo avaliada a cada vez, a predição de desvio dá conta da maior parte
Se a condição for uma expressão booleana formada por valores const, talvez o compilador também consiga descobrir
Achei que o texto inteiro seguiria para este tipo de exemplo: dividir
walrusde acordo com a condição e chamarfrobnicate_batch(fwalrus)etransmogrify_batch(twalrus)Mas, na prática, ele foi para um exemplo em que um único condicional envolve o laço inteiro em dois ramos
É surpreendente que programadores vindos de uma formação em engenharia de software errem isso com frequência
Eu comecei a programar na área científica, e lá é absolutamente necessário pensar nessas coisas
Só de escolher mal a ordem dos laços, uma simulação pode levar 1 semana em vez de 1 hora
Por causa desse contexto, acabo fazendo instintivamente pequenas otimizações ao definir a ordem adequada de
foreif, e código que não faz isso simplesmente parece erradoNão tenho certeza de que uma regra geral como essa possa realmente ser aplicada a código real
Regras desse tipo muitas vezes parecem dogmas mal colocados, e, mesmo que um post de blog comece dizendo que é uma heurística, programadores mais jovens nem sempre a recebem assim
Algumas semanas atrás, o YouTube continuou me recomendando um vídeo chamado “I'm a never-nester”, que parecia defender que nunca se deve aninhar
if, e achei bem ridículoComo no exemplo do texto, ele marcava como “ruim” um código com
if conditiondentro de umfor, mas, na maior parte do código escrito de fato desse jeito,conditiondepende dewalrus, então não dá para mover oifpara cimaSe desse, o fato de a mesma condição ser reavaliada a cada iteração seria tão óbvio que programadores tenderiam naturalmente a evitar isso
Mas, quando um júnior ou estudante lê um conselho que soa como dogma, pode acabar produzindo um código pior ao tentar segui-lo rigidamente
match (condition_a, condition_b)não fosse melhor do queifaninhadosifdeve ser “movido para cima”O objetivo do código é executar uma certa operação em walrus dependendo da condição, mas, na prática, o
ifestá sendo usado no lugar de polimorfismo e do sistema de tiposPor que walrus deveria ter duas funções que precisam ser chamadas em cenários diferentes? Por que não ter uma única função e dois tipos, e passar o tipo correto?
Mesmo na estrutura atual, é possível escolher a função de processamento lá em cima com base na condição e fazer o laço abaixo chamar a função escolhida a cada vez
Se a decisão for tomada o mais cedo possível, não será preciso espalhá-la pelo código inteiro. O interior do código executa sempre a mesma tarefa, sem ramificações, e a saída só varia por meio do grafo de configuração
Claro que isso não é uma ideia nova; 15 anos atrás já era uma ideia antiga: https://www.youtube.com/watch?v=4F72VULWFvc
walrus, e ajuda a deixar esse fato explícitoSe aplicar novamente aqui o “mova o for para baixo”, a forma resultante será chamar
frobnicate_batch(walruses)outransmogrify_batch(walruses)dentro deif conditionAlguém colocou em palavras um problema que encontrei várias vezes, mas que eu não conseguia expressar direito, e parece um bom modelo para guardar na cabeça
Ao mesmo tempo, essa crítica também tem valor. Espero que desenvolvedores juniores dogmáticos leiam junto e passem a julgar com um pouco mais de nuance
Programadores, em especial, não devem copiar essas coisas e aplicá-las mecanicamente
Aplicar heurísticas e boas práticas às cegas pode não ser uma boa ideia e, no fim, tudo “depende do contexto”
Heurísticas como essa geralmente contêm um grão de sabedoria, mas, em geral, acabam criando algo que depois precisa ser desdogmatizado em programadores iniciantes
Sempre há muitos casos em que as coisas pioram quando se tenta segui-las com rigidez demais, e “saber quando não ouvir esse conselho” é, na prática, a parte difícil central
Acho que essas regras são úteis para brincar com elas. Experimente adotá-las, leve-as até o fim, inverta-as por um dia ou por um ano, e veja até onde chegam
Aprender seus limites também é mais um material acumulado sobre o pergaminho que continua recebendo camadas
Alguns desses koans devem se contradizer