Faire remonter les `if`, faire descendre les `for`
(matklad.github.io)- Deux règles de structuration du code sont au cœur de l’idée : faire remonter les branchements conditionnels vers l’appelant, et pousser le traitement itératif vers les opérations par lot
- Faire remonter les
iffacilite l’imposition des préconditions via des types ou des assertions, et permet de regrouper les flux de contrôle complexes en un seul endroit pour repérer plus facilement les conditions dupliquées et les branches mortes - Une structure qui crée un
enumpour ensuite refaire immédiatement unmatchrépète la même condition sous forme de branchement, de structure de données puis de rebranchement ; on peut alors souvent la simplifier en appels àfoo(x)etbar(y) - Faire descendre les
forfait des opérations par lot l’unité de base plutôt que l’itération sur des objets individuels, ce qui permet d’amortir les coûts de démarrage, de réorganiser l’ordre de traitement, et d’ouvrir la voie à la vectorisation et aux optimisations en struct-of-arrays - Sortir les conditions de la boucle permet de réduire les branchements dans les boucles chaudes, et rejoint des architectures comme TigerBeetle, qui amortissent le coût décisionnel du plan de contrôle par un traitement par lots dans le plan de données
Faire remonter les if
- Les conditions
ifà l’intérieur d’une fonction sont d’abord des candidates à déplacer vers l’appelant- L’exemple présenté considère qu’une fonction qui reçoit directement un
Walrusest préférable à une fonction qui reçoit unOption<Walrus>et retourne si la valeur estNone - Plutôt que de vérifier la précondition dans la fonction puis de « ne rien faire », il est plus clair que l’appelant fasse la vérification et que le type ou une assertion garantisse sa validité
- L’exemple présenté considère qu’une fonction qui reçoit directement un
- Ce refactoring, qui consiste à faire remonter la vérification des préconditions, peut se propager le long du chemin d’appel et réduire au final le nombre total de vérifications
- Le flux de contrôle et les
ifaugmentent la complexité et sont souvent source de bugs- Faire remonter les
ifconcentre la logique de branchement complexe dans une seule fonction, tandis que le travail effectif descend dans des sous-routines linéaires - Un flux de contrôle complexe est plus facile à analyser pour détecter duplications et conditions mortes lorsqu’il tient dans une seule fonction à l’écran plutôt que d’être dispersé dans tout le fichier
- Faire remonter les
- Le refactoring « dissolving enum » consiste à éliminer le motif où un branchement est transformé en structure de données, puis immédiatement retransformé en branchement
- Si
f()créeE::Foo(x)ouE::Bar(y)selon une condition, puisg(e)refait unmatchpour appelerfoo(x)oubar(y), la même condition se répète sous plusieurs formes - En faisant remonter la condition dans
main(), on obtient une forme plus simple :if condition { foo(x) } else { bar(y) }
- Si
Faire descendre les for
- Dans une perspective orientée données, les programmes et les hot paths manipulent généralement de nombreux objets ; on introduit donc des lots d’objets et on prend les opérations par lot comme cas de base
- La version scalaire pour un objet individuel devient un cas particulier de l’opération par lot
for walrus in walruses { frobnicate(walrus) }est une moins bonne forme quefrobnicate_batch(walruses)
- Le principal avantage est la performance
- Traiter tout un lot d’un coup permet d’amortir les coûts de démarrage
- On peut réorganiser plus librement l’ordre du traitement, et adopter une vectorisation ou une approche struct-of-arrays où l’on traite d’abord un même champ pour toutes les entités
- Comme cas extrême, l’article renvoie à Vectorized Interpreters Talk
- Un exemple intéressant cité est la multiplication de polynômes basée sur la FFT
- Évaluer un polynôme simultanément en plusieurs points peut être plus rapide que de refaire plusieurs évaluations point par point
- Les règles sur les
ifet lesforpeuvent s’appliquer ensemble- La bonne forme consiste à brancher en dehors du
if condition, puis à exécuterfor walrus in walrusesdans chaque branche - La mauvaise forme consiste à réévaluer
if conditionà chaque itération à l’intérieur de la bouclefor - Cela évite les réévaluations inutiles de la condition, supprime les branchements dans les boucles chaudes et peut rendre la vectorisation possible
- La bonne forme consiste à brancher en dehors du
- Ce motif s’applique aussi bien au niveau micro qu’au niveau macro
- L’architecture de TigerBeetle amortit le coût décisionnel du plan de contrôle en traitant simultanément des lots d’objets dans le plan de données
- Si la motivation première du conseil sur les
forest la performance, une approche par collections comme avec jQuery peut aussi améliorer l’expressivité
1 commentaires
Avis sur Hacker News
Au début, j’ai été surpris que cet article suscite autant de réactions hostiles, mais en me rappelant qu’il s’agit de conseils de conception orientée données, ça devient compréhensible
Comme beaucoup de gens sur ce forum, je développe surtout des applications web professionnelles, et dans ce contexte ce conseil peut passer pour n’importe quoi
Si, dans votre travail quotidien, vous n’avez pas à vous soucier du cache d’instructions, l’heuristique selon laquelle on peut globalement ignorer ce conseil semble correcte
Si vous voulez sentir dans quels cas ce conseil devient important, cherchez “Typical C++ Bullshit” de Mike Acton ; cet article donne l’impression d’en proposer une synthèse plus facile à comprendre
Je partage assez largement les préoccupations de Casey Muratori, mais la plupart des logiciels professionnels doivent optimiser la facilité de modification et la correction plutôt que les performances, autrement dit la “programmation dans la durée”
Les développeurs essaient souvent de rendre du code métier complexe DRY en le découpant en petites méthodes private dans des classes ; “remonter les if” permet d’éviter que la logique de branchement soit éparpillée entre plusieurs méthodes
“Descendre les for” est tout aussi important. De nombreux flux d’appels finissent par une requête de base de données coûteuse, et il est fréquent qu’une boucle située plus haut provoque plusieurs appels à la DB plus bas
Cette itération peut souvent être remplacée par une clause
whereou unjoinSQL, et pour les agrégations ou le filtrage, il vaut mieux les pousser au plus près du DAO plutôt que de sortir une foule d’objets pour itérer dessus, car cela se prête mieux à l’optimisation près de la base de donnéesCela dit, comme pour tout principe de conception, il ne faut pas l’appliquer comme un dogme, mais juger consciemment
Je trouve regrettable d’opposer facilité de modification et performances. En pratique, je n’ai pas encore vu de preuve convaincante qu’elles entrent réellement en conflit
L’un des aspects les plus importants des performances est de faire petit. Du code compact, de petites structures de données, peu d’instructions exécutées : c’est essentiel, et “penser au cache d’instructions” revient fondamentalement à écrire du petit code
Plus le code est petit, moins il y a de place pour les bugs, plus il est facile à comprendre d’un seul coup, et plus il est facile d’obtenir une bonne couverture de tests, ce qui favorise la correction. La facilité de modification y gagne aussi, car plus le code est petit, plus les changements sont petits
Bien sûr, certaines optimisations rendent le code plus complexe, comme la parallélisation, le caching ou les optimisations bas niveau, mais ce n’est qu’une partie de l’optimisation des performances ; un programmeur qui se soucie sérieusement des performances ne ferait pas cela sans profiling ni analyse
if/foretfor/ifpeuvent être très proches fonctionnellement tout en ayant des performances différentes ; si l’on peut choisir correctement sans coût supplémentaire, il n’y a pas de raison de choisir délibérément la version plus lenteLes programmes simples ont une pile d’appels courte et évitent les abstractions complexes et coûteuses ; ils ont donc tendance à utiliser moins de mémoire et à tourner plus vite
Chercher des performances de tout premier niveau peut rendre un programme complexe, mais la vraie simplicité apporte le plus souvent des performances raisonnables
“Descendre les for” relève surtout d’un conseil orienté données, tandis que “remonter les if” se rapproche davantage d’une façon de rendre le programme plus simple. Plus précisément, il s’agit d’améliorer la localité du code source, et l’idée centrale est de regrouper la logique de branchement au même endroit : https://loup-vaillant.fr/articles/source-of-readability
En particulier, “descendre les boucles” est puissant dans les applications CRUD. Traiter les créations et les mises à jour par lots autant que possible fait souvent gagner bien plus de temps que les goulots d’étranglement CPU
La différence entre
items.map(insertToDb/postToServer)etinsertToDb/postToServer(items)se mesure presque toujours en plusieurs ordres de grandeurJ’ai vu ce type d’optimisation faire passer des traitements de l’ordre de la seconde ou de la minute à la milliseconde, et souvent rendre aussi l’API plus propre et les logs plus faciles à lire
Avec l’expérience, j’ai l’impression que beaucoup trop de programmeurs se soucient des petits morceaux de « joli code », mais pas assez de la conception de l’ensemble de la codebase.
Des fonctions concises et bien polies, c’est très bien, mais ce genre d’article peut dégénérer, dans les PR ou les discussions, en débats stériles de type « abri à vélos ».
Qu’une fonction soit un peu désordonnée, où l’on place les
ifet lesfor, ou qu’on utilisemapetfilter, tout cela m’importe assez peu si le nom de la fonction est approprié, si l’interface et les types sont expressifs, si l’objectif est clair, si c’est documenté et si les effets de bord ne sont pas utilisés à l’excès.Si l’on réfléchit à l’endroit où valider une entrée, cette heuristique dit qu’il faut le faire au niveau le plus haut où l’entrée est reçue.
Cela aide aussi à comprendre le code et, du point de vue de la preuve, c’est techniquement nécessaire, car les préconditions doivent être propagées vers le haut.
Le premier conseil n’est clairement pas un débat anodin ; le second est un peu plus ambigu.
Elles essaient d’éviter que les juniors ne se tirent une balle dans le pied, mais n’ont souvent pas encore une intuition suffisante du coût de la complexité ni de la façon dont le code évolue à long terme.
À l’inverse, après plus de 20 ans de pratique, on finit par accorder plus de valeur à la simplicité qu’à la plupart des aspects techniques.
Des questions comme « à quel moment bifurquer ? » ou « que doit réellement faire ce code ? » ont tendance à donner les réponses les plus précieuses sur le long terme.
Les questions d’abstraction et d’encapsulation mènent plutôt au type de débat évoqué plus haut, et si l’on trouve le plus de problèmes de sécurité dans le code des gens qui ne voient que la « vue d’ensemble », c’est aussi parce qu’ils ne comprennent pas bien ce que fait réellement cette codebase pourtant bien conçue.
Dans les logiciels sensibles aux performances où la conception orientée données convient bien, il faut prêter davantage attention à ces détails de petite échelle, parce que les optimisations du compilateur fonctionnent de cette manière.
Les règles du jeu changent alors : le sens des instructions devient important, le code auto-explicatif gagne en valeur, les commentaires de code ne restent utiles que pour le raisonnement, et la « documentation » se rapproche davantage d’une spécification et d’un manuel utilisateur.
Au mieux, on peut regrouper des fonctions, puis il faut se débrouiller.
L’inconvénient de « remonter les if » est que, dans la définition de la fonction, les préconditions et postconditions ne sont pas visibles directement, et qu’il faut les vérifier à chaque point d’appel.
Dans un grand projet auquel participent plusieurs personnes, ce type de fonction peut être réutilisé hors de son contexte prévu et entraîner des bugs.
On peut résoudre cela avec un framework de contrats, mais on finit par écrire les conditions deux fois, dans le contrat et dans le code ; les types dépendants posent le même problème.
Une approche consistant à étiqueter des zones de code appartenant à un contexte donné, puis à définir des fonctions appelables uniquement dans ce contexte, est intéressante.
En Python, on pourrait utiliser un décorateur comme
@requires_context("VALIDATED_XY")et un context managervalidated_xypour n’autoriser l’appel de la fonction que dans une zone où la validation a été effectuée.Le runtime ne connaît pas la signification de ce contexte, mais avec l’outillage et les tests appropriés, on peut concevoir le système de manière à n’établir le contexte voulu que lorsque certaines conditions sont satisfaites.
Dans des langages comme Haskell, on peut l’imposer au niveau des types avec quelque chose comme la monade identité ; et même sans contrainte au niveau des types, cela peut être une façon intéressante de protéger les zones de code « unsafe ».
Les préconditions restent donc visibles directement dans la définition de la fonction, mais elles apparaissent dans la signature de type plutôt que sous forme d’instruction
if.C’est un pattern courant en Rust, utilisé dans l’article, et contrairement à une vérification par
if, c’est une précondition stricte vérifiée à la compilation plutôt qu’à l’exécution : si elle n’est pas satisfaite, le programme ne compile pas.Par exemple, on peut accepter
do_something(position: ValidatedPosition), puis valider unPositionordinaire avant de le convertir enValidatedPositionet de le passer à la fonction.En pratique, on encapsulerait probablement la validation dans le constructeur de
ValidatedPosition, mais l’idée clé est que si l’on tente de passer directement unPosition, mypy signalera qu’un mauvais type a été fourni.Le typage de Python n’est pas aussi complet que celui de Rust, mais il devient de plus en plus utile lorsqu’on veut garantir que les données transmises ont bien été traitées comme il faut.
Si une fonction a des préconditions, on peut évidemment les affirmer au début de la fonction. Par exemple, le système de types de Java autorise
null, donc une fonction qui a besoin d’un objet doit lever une exception si elle reçoitnull.Chaque point d’appel a la responsabilité de n’appeler la fonction que lorsque les préconditions sont vraies. C’est inhérent à la définition d’une précondition.
Appeler une fonction en violant ses préconditions est un bug de l’appelant. Il peut être nécessaire d’ajouter dans la fonction du code qui vérifie cela pour éviter un comportement indéfini, mais il faut distinguer ces assertions du véritable flux de contrôle du programme ; l’article parle du second.
fn frobnicate(walrus: Walrus), si l’on passe autre chose qu’unWalruspossédé, le programme ne compile pas.Même avec des génériques, les arguments doivent satisfaire les bornes de traits, et selon la manière dont les arguments sont utilisés dans la fonction, le compilateur impose les bornes nécessaires à la définition de la fonction.
publicetprivateétait en partie de jouer ce rôle d’étiquetage du code selon un contexte donné.Ou alors il faudrait peut-être une sémantique plus précise, qui traverse les domaines couverts par
public,privateetprotecteddans l’écosystème .NET.Le premier exemple n’est pas mauvais à cause du
ifet dufor, mais pour une autre raisonEn général, quand on a un « conteneur » de quelque chose, il vaut mieux écrire les fonctions sur « l’objet » de niveau domaine qu’il contient, plutôt que sur le conteneur lui-même
En Clojure, quand on utilise un agent, on n’écrit pas des fonctions pour l’agent, mais des fonctions pour l’objet que l’agent peut contenir
En Elixir aussi, les fonctions cœur du domaine opèrent sur la structure de données métier interne, pas sur le PID, et les appels GenServer leur sont délégués si nécessaire
Cela rend le code plus flexible et permet de séparer plus proprement le cœur du domaine — « frobnicate un Walrus » — de la préoccupation applicative — « il peut y avoir ou non un Walrus, et s’il y en a un, le frobnicate »
Valider tôt est une bonne chose, mais il est aussi important d’émettre clairement une erreur de validation, plutôt que de laisser une fonction exploser soudainement avec une erreur bizarre
Haskell résout ce problème avec
newtype, en fournissant une sorte de « conteneur transparent certifiant qu’une validation appropriée a déjà été faite »Le conseil que j’aimerais vraiment faire passer aux gens est de préférer les « if tristes ». Plutôt que d’imbriquer le chemin normal sur plusieurs niveaux, un code qui vérifie les conditions invalides une par une en haut, puis les corrige ou s’arrête immédiatement, est presque toujours plus lisible et plus maintenable
Les humains ont naturellement tendance à se concentrer sur le cas attendu, mais le code, lui, demande au contraire de se concentrer d’abord sur les cas exceptionnels. Chaque
ifcrée une charge mentale, et si l’on doit récupérer des informations auprès d’un système externe ou sortir tôt en erreur, on peut se débarrasser immédiatement de cette charge lorsque la détection et le traitement sont côte à côtewalrus.frobnicate(), j’ai l’impression que c’est davantage une question de préférence syntaxique que le point central que l’auteur veut défendreJe n’irais pas jusqu’à dire que c’est un mauvais conseil, mais ce n’est pas forcément un bon conseil non plus
Le fait que le langage choisi soit Rust en dit assez long. Un système de types fort évite une grande partie de la programmation défensive nécessaire dans d’autres langages
Si un programmeur C ne vérifie pas la validité d’un pointeur passé à une fonction et provoque un déréférencement de NULL, je n’ai pas envie de travailler dans la même équipe que lui
Donc, au moins certains
ifdoivent clairement se trouver plus bas, et il vaut mieux que les erreurs se propagent correctement vers le hautJe suis moins catégorique pour les
for, mais en C, comme les arguments tableau se dégradent en pointeurs, je pense que les boucles doivent elles aussi être en haut plutôt qu’en bas. Dans la fonction où le tableau est créé, on peut faire confiance à sa longueur ; dans une fonction à laquelle il est passé en argument, nonLa documentation doit remplacer un système de types fort ; les vérifications à l’exécution ne doivent pas le faire
Un code rempli de vérifications de NULL et d’autres mesures défensives est beaucoup plus difficile à lire
On peut dire qu’il faut des vérifications plus défensives aux frontières des bibliothèques, et c’est exactement ce que cet article dit de faire remonter
Le code critique pour la sécurité peut être différent, mais dans la plupart des cas, il suffit que les déréférencements accidentels de NULL soient détectés par les tests, les sanitizers et le fuzzing
Pour vérifier les valeurs de pointeur invalides, il faudrait tester l’ensemble des innombrables mauvaises valeurs possibles ; si l’on ne vérifie que NULL, on ne vérifie pas vraiment les valeurs invalides
Si la précondition d’une fonction est « le paramètre
pne peut pas être NULL », alors on peut la vérifier. Mais si la précondition est «pdoit être un pointeur valide », bonne chance pour trouver l’assertion appropriéeJ’utilise beaucoup T-SQL en ce moment, et on ne peut pas déclarer les paramètres ou les variables en
NOT NULLIl vaut donc mieux vérifier NULL le plus tôt raisonnablement possible, généralement tout en haut de la procédure stockée
Sinon, NULL peut se propager de façon inattendue profondément dans la hiérarchie d’appels et créer un problème moins clair
Heureusement, les données de table peuvent être déclarées
NOT NULL, donc ce genre de bug ne corrompt généralement pas les données, mais il est bien plus facile de l’attraper tôtCela dit, s’il existe une logique qui écrit en base selon la valeur d’un paramètre, et que cette valeur est inopinément NULL, une mauvaise valeur peut être écrite, ou une valeur nécessaire peut ne pas l’être du tout, ce qui revient en pratique à corrompre les données
La programmation défensive est donc la réponse
Sans contexte approprié, ce conseil paraît assez étrange, voire peut-être mauvais
Les boucles et les instructions
ifsont toutes deux des opérations de flux de contrôle, donc une partie du raisonnement de l’article n’a pas beaucoup de sensL’argument le plus fort semble être la performance, mais la performance devrait généralement être la dernière chose dont on se préoccupe, surtout lorsqu’il s’agit d’un conseil heuristique
Malheureusement, l’auteur en a fait un slogan, et j’espère qu’il ne se diffusera pas
Si l’on peut écrire le code amélioré comme dans l’exemple de l’auteur, la condition est constante pendant l’exécution de la boucle. Tant qu’on n’évalue pas à chaque fois une condition coûteuse, la prédiction de branchement s’en charge en grande partie
Si la condition est une expression booléenne composée de valeurs
const, le compilateur a aussi des chances de s’en rendre compteJe pensais que tout l’article allait enchaîner sur un exemple de ce genre : répartir les
walrusselon la condition, puis appelerfrobnicate_batch(fwalrus)ettransmogrify_batch(twalrus)Mais en réalité, l’exemple est parti sur un seul
ifqui enveloppe toute la boucle en deux branchesJe suis surpris que les programmeurs issus du génie logiciel se trompent si souvent là-dessus
J’ai commencé à programmer dans le domaine scientifique, où il est absolument indispensable de réfléchir à ce genre de choses
Il suffit de se tromper dans l’ordre des boucles pour qu’une simulation prenne une semaine au lieu d’une heure
Grâce à ce bagage, j’ajuste instinctivement l’ordre des
foret desifpour obtenir de petites optimisations, et le code qui ne le fait pas me semble tout simplement incorrectJe ne suis pas sûr que ce genre de règle générale puisse vraiment s’appliquer au code réel.
Ces règles ressemblent souvent à des dogmes mal placés, et même si l’article de blog commence par dire qu’il s’agit d’une heuristique, les jeunes programmeurs ne le prennent pas toujours ainsi.
Il y a quelques semaines, YouTube n’arrêtait pas de me recommander une vidéo intitulée « I’m a never-nester », qui semblait défendre l’idée de ne jamais imbriquer de
if, et c’était assez ridicule.Comme dans l’exemple de l’article, du code avec un
if conditiondans unforétait marqué comme « mauvais », mais dans la plupart des codes réellement écrits ainsi,conditiondépend dewalrus, ce qui empêche de remonter leif.Si c’était possible, le fait de réévaluer la même condition à chaque itération serait tellement évident que les programmeurs auraient naturellement tendance à l’éviter.
Mais si un junior ou un étudiant lit un conseil qui sonne comme un dogme, il peut essayer de le suivre strictement et produire un code encore pire.
match (condition_a, condition_b)serait moins bien que desifimbriqués.ifqu’il faudrait « remonter ».Le but du code est d’effectuer une opération donnée sur
walrusselon une condition, mais en réalité leifest utilisé à la place du polymorphisme et du système de types.Pourquoi
walrusdevrait-il avoir deux fonctions à appeler dans des scénarios différents ? Pourquoi ne pas avoir une seule fonction et deux types, puis passer le bon type ?Même avec la structure actuelle, on pourrait choisir la fonction de traitement plus haut selon la condition, puis faire en sorte que la boucle plus bas appelle à chaque fois la fonction sélectionnée.
Si l’on prend la décision le plus tôt possible, on n’a pas besoin de la disséminer dans tout le code. L’intérieur du code effectue la même opération à chaque fois, sans branchement, et seule la sortie varie via le graphe de composition.
Bien sûr, ce n’est pas une idée nouvelle, et elle était déjà ancienne il y a 15 ans : https://www.youtube.com/watch?v=4F72VULWFvc
walrus, et il aide à rendre ce fait explicite.Si l’on réapplique ici « descendre le
for», cela donne une forme où l’on appellefrobnicate_batch(walruses)outransmogrify_batch(walruses)à l’intérieur deif condition.Quelqu’un a mis des mots sur un problème que j’avais rencontré plusieurs fois sans réussir à bien l’exprimer, et cela ressemble à un bon modèle à garder en tête.
En même temps, ce genre de critique a aussi de la valeur. J’espère qu’un développeur junior dogmatique lira les deux et portera un jugement un peu plus nuancé.
Les programmeurs, en particulier, ne devraient pas les copier et les appliquer mécaniquement.
Appliquer aveuglément des heuristiques et des bonnes pratiques peut être une mauvaise idée, et au bout du compte, tout « dépend du contexte ».
Ce genre d’heuristique contient généralement une parcelle de sagesse, mais, le plus souvent, cela crée ensuite chez les programmeurs débutants quelque chose qu’il faudra désendoctriner.
Il y a toujours beaucoup de cas où chercher à les suivre trop strictement aggrave les choses, et « savoir quand ne pas écouter ce conseil » est en fait la principale difficulté.
Je pense que ces règles sont utiles pour expérimenter. On peut les adopter, les pousser jusqu’au bout, les retourner pendant une journée ou un an, et voir jusqu’où cela mène.
Apprendre leurs limites est aussi une couche de plus ajoutée au palimpseste qui ne cesse de se réécrire.
Certains de ces koans devraient se contredire les uns les autres.