Remonter les `if`, descendre les `for`
(matklad.github.io)- Si l’on remonte les branchements conditionnels à l’appelant et que l’on abaisse les opérations répétées vers des opérations par lot plutôt que des appels individuels, on peut simplifier à la fois le flux du code et les performances
- En remontant les
if, il devient plus facile de garantir les préconditions via les types ou des assertions, et l’on réduit les vérifications redondantes ainsi que les branchements inutiles à l’intérieur des fonctions - Quand la logique de branchement est dispersée dans plusieurs fonctions, il devient difficile de repérer les conditions mortes ou les jugements répétés ; une structure où l’on crée un enum puis on refait un match peut aussi revenir à répéter le même branchement dans la structure de données
- En descendant les
for, on peut traiter plusieurs objets en une seule fois pour amortir le coût de démarrage, et ouvrir la voie à des optimisations comme le réordonnancement du traitement ou la vectorisation - En appliquant ensemble ces deux règles, on réduit la réévaluation des conditions dans les boucles et les branchements dans les hot loops, ce qui rapproche l’architecture d’un modèle où le coût de décision du plan de contrôle est transféré vers un traitement par lot dans le plan de données
Remonter les if du côté de l’appelant
- Si une fonction contient une condition
if, on peut d’abord examiner s’il est possible de déplacer cette condition vers l’appelant- Une fonction qui reçoit déjà un
Walrusest plus simple qu’une fonction qui reçoit unOption<Walrus>et retourne simplement en cas deNone - Au lieu qu’une fonction vérifie ses préconditions et « ne fasse rien », l’appelant peut les vérifier puis les garantir via le type ou une assertion
- Une fonction qui reçoit déjà un
- Cette façon de remonter les vérifications de préconditions peut se propager à tout le chemin d’appel, ce qui devient au final une motivation pour réduire le nombre de vérifications
- Le flux de contrôle et les
ifont tendance à accroître la complexité du code et le risque de bugs- Il est plus lisible de regrouper une logique de branchement complexe dans une seule fonction de haut niveau, puis de déléguer le travail réel à des fonctions de niveau inférieur plus linéaires
- Quand un flux de contrôle complexe est concentré dans une seule fonction visible sur un écran, il est plus facile de repérer les conditions redondantes ou mortes que s’il est dispersé dans tout le fichier
- Le refactoring « dissolving enum » est utile quand la création d’un enum et son
matchrépètent le même branchement- Si
f()créeE::FooouE::Barselon une condition, puis queg()refait unmatchpour appelerfoo()oubar(), le branchement apparaît deux fois - En remontant la condition, on obtient une forme où
main()appelle directementfoo(x)oubar(y)selon cette même condition
- Si
Descendre les for vers les opérations par lot
- Dans une approche orientée données, les programmes manipulent souvent des ensembles d’objets, et les hot paths deviennent fréquemment chauds parce qu’ils traitent de nombreuses entités
- Il est utile d’introduire la notion de lot d’objets, et de considérer la version scalaire comme un cas particulier de la version par lot
frobnicate_batch(walruses)est une meilleure forme quefor walrus in walruses { frobnicate(walrus) }
- Le principal avantage du traitement par lot est la performance
- Traiter plusieurs cibles à la fois permet d’amortir le coût de démarrage
- On gagne en flexibilité pour réorganiser l’ordre de traitement, et il devient moins nécessaire de traiter les entités dans un ordre précis
- On peut utiliser des techniques comme la vectorisation ou les struct-of-arrays, en traitant d’abord un champ de toutes les entités, puis un autre
- Comme cas extrême de performance, l’article renvoie vers Vectorized Interpreters Talk
- Un exemple intéressant 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 d’évaluer séparément chaque point à plusieurs reprises
- Les règles sur les
ifet lesforpeuvent s’appliquer ensemble- Si la condition est en dehors de la boucle, on ne réévalue pas
conditionà répétition - Les branchements sont supprimés des hot loops
- La possibilité de vectorisation s’ouvre
- Si la condition est en dehors de la boucle, on ne réévalue pas
- Le même schéma fonctionne aussi bien au niveau micro qu’au niveau macro
- L’architecture de TigerBeetle traite simultanément des lots d’objets dans le plan de données afin d’amortir le coût de décision du plan de contrôle
- La motivation principale de la règle sur les
forest la performance, mais elle peut aussi améliorer l’expressivitéjQueryopérait sur des collections d’éléments et a connu du succès par le passé- Le langage des espaces vectoriels abstraits peut parfois être un meilleur outil de pensée qu’un ensemble d’équations coordonnées par coordonnées
1 commentaires
Avis de Hacker News
Mon modèle mental un peu étrange est qu’il existe un arbre des états possibles et du flux du programme, et que les conditionnelles élaguent cet arbre.
Il vaut mieux élaguer le plus tôt possible, afin de ne travailler que sur moins de branches.
Au fond, j’aimerais qu’une fonction se concentre soit sur le parcours de l’arbre du programme, soit sur le travail réel.
Les expressions sont évaluées en étant « réécrites » de façon répétée selon des règles de réduction. Par exemple,
(1 + 2) + 4devient3 + 4, puis7.Il y a là des règles de congruence, qui déterminent quelle sous-expression évaluer ensuite, et des règles de calcul, qui modifient effectivement l’expression et changent l’état du programme.
Les langages stricts, c’est-à-dire non paresseux, font pour la plupart évaluer toutes les sous-expressions avant leur expression parente, mais les constructions spéciales comme les conditionnelles et les boucles infinies font exception.
Dans une conditionnelle, la règle de calcul s’applique avant que les règles de congruence n’ordonnent d’évaluer toutes les sous-expressions, ce qui élague littéralement l’arbre d’expressions.
[1] : je recommande Benjamin C. Pierce, Types and Programming Languages
Il faut regarder les caractéristiques du domaine, les patterns de la base de code existante, l’étape du pipeline de données où l’on se trouve, les caractéristiques de performance, etc.
Avant, j’essayais de formuler ce genre de règles d’organisation du code et d’heuristiques, mais à force d’écrire suffisamment de code, on finit par accepter que le niveau d’abstraction est mauvais et que cela ne vaut pas la peine de s’y accrocher longtemps.
Le fait que ce genre de discussion s’appuie sur de faux noms de fonctions ou des variables d’une seule lettre est parlant. Dans une « île de code » sans contexte externe, presque n’importe quelle règle peut sembler plausible.
On ne peut dire que cette règle a révélé une branche morte qu’en faisant l’hypothèse commode que
gest le seul appelant dehet le restera.Dans une vraie base de code, il y a généralement une raison pour laquelle
gethn’ont pas été fusionnées dès le départ.Une règle plus générale consiste à placer le
ifau plus près de la source des entrées : https://gieseanw.wordpress.com/2024/06/24/dont-push-ifs-up-p...L’idée centrale est de trouver ce point, y compris les points d’entrée par lesquels l’extérieur arrive dans le programme et les données récupérées auprès d’autres services, puis de façonner le code pour établir autant de garanties que possible avant d’atteindre la logique cœur, surtout les parties coûteuses en ressources.
Quand c’est possible, il vaut mieux encoder ces garanties dans les types.
Une supposition vague du type « s’il y a une condition
ifdans une fonction, demandez-vous si elle peut être déplacée côté appelant » a beaucoup trop de contre-exemples.Si la fonction est appelée à 37 endroits, faut-il répéter le
ifpartout ?Si cette fonction est
getaddrinfoouEnterCriticalSection, faut-il pousser leifvers l’extérieur, du côté des utilisateurs de l’API ?À mon avis, cette transformation ne peut s’envisager que pour une fonction interne appelée à deux endroits au maximum, et lorsque cette décision se situe en dehors de la responsabilité de la fonction.
Une autre approche consiste à faire en sorte que la fonction ne fasse que le
ifet appelle deux fonctions auxiliaires.Si l’appelant doit sortir la condition d’une boucle, il peut utiliser un « helper d’interprétation de condition » de bas niveau. Les appelants qui n’ont besoin que d’un seul
if, pas à l’intérieur ou à l’extérieur d’une boucle, peuvent utiliser une fonction de commodité qui masque leif.Mais il s’agit alors d’optimisation, et l’optimisation entre souvent en conflit avec une bonne structure de programme.
En orienté objet, le choix effectué par un
ifdans l’appelé apparaît comme un dispatch de méthode, c’est-à-dire le choix de la méthode à appeler.Extraire le dispatch de méthode hors d’une boucle peut aussi aller à l’encontre du flux de conception.
Par exemple, pour remplir un objet canvas avec une image raster, on ne veut pas parcourir les pixels de l’image en appelant
canvas.putpixel(x, y, color). Il devrait exister une méthode pour blitter l’image sur le canvas, ou sur une région rectangulaire de celui-ci.consider.L’article vise un problème de conception assez spécifique, qui apparaît notamment quand on utilise des choses comme des unions taguées.
DRY peut sembler être la bonne réponse, mais il faut voir un exemple de code réel pour en juger.
Pour une fonction de bibliothèque, la position est particulière. Elle se trouve à une frontière de propriété, les données traversent des domaines et, pour le dire à la manière du DDD, franchissent un contexte borné. Elle doit donc protéger elle-même son propre territoire.
EnterCriticalSectionsuggère un chemin de code où une validation forte à l’entrée, y compris une conditionif, est légitime, et il faut y voir une frontière de domaine.En revanche, lorsqu’on écrit une application et qu’une fonction applicative ordinaire contient un
if, on peut sans risque le pousser vers l’extérieur.Même dans une bibliothèque ou une portion de code critique, il est sûr de remonter le
ifvers les bords plutôt que de le laisser profondément à l’intérieur.Le conseil consistant à gérer son propre domaine, à ne pas imposer d’exigences au domaine des autres, et à déplacer le flux de contrôle vers les bords à l’intérieur de ce domaine, paraît raisonnable.
Bien sûr, un idiome reste un idiome ; dans le monde réel, l’évaluation doit être faite par quelqu’un qui comprend le contexte et sait juger raisonnablement.
L’exemple de « refactoring par décomposition d’énumération » relève essentiellement du polymorphisme
On peut remplacer le
matchpar un appel de méthode polymorphe sur l’énumérationLe but est de séparer l’endroit où la distinction entre les cas est créée, c’est-à-dire le premier
if, de l’endroit où l’on exécute ensuitefoooubarLa distinction entre les cas est portée par l’objet, ici la valeur d’énumération ou la closure, et il n’est pas nécessaire de la répéter au point d’appel
Autrement dit, si la distinction change, il suffit de modifier l’endroit où elle est créée, sans toucher aux endroits qui déclenchent le comportement propre à chaque cas
Il y a toutefois un compromis. Il peut être utile de voir directement, à l’endroit où le comportement est exécuté, les cas individuels à prendre en compte, mais cela ajoute en contrepartie une dépendance au niveau du code à la liste de ces cas
Les scanners de complexité du code finissent par imposer de pousser les
ifvers le bas. L’article recommande l’inverseQuand on remonte les
if, le flux de contrôle est souvent centralisé dans une seule fonction, qui contient une logique de branchement complexe, mais délègue le travail réel à des sous-routines linéaires⁰ https://docs.sonarsource.com/sonarqube-server/latest/user-gu...
if (weShouldDoThis()) { doThis(); }Cela complète, ou fait partie de, l’approche noyau fonctionnel et shell impératif
En isolant les vérifications, elles deviennent plus faciles à tester ; et si la complexité pose problème, on peut extraire chaque clause de la vérification dans une fonction
Sonar, en particulier, signale des « code smells » plutôt que de vrais bugs
En corrigeant ce genre d’éléments « qui ne sont pas des bugs », le risque d’introduire une nouvelle erreur passe de zéro à une valeur supérieure à zéro, et on peut gaspiller du temps de développeur qui aurait servi à traiter de vrais problèmes en production
Par « local », j’entends que cela reste vrai seulement jusqu’à ce que les exigences changent, ou qu’un cas exceptionnel soit découvert, et qu’il faille aussi brancher en dehors de la boucle
Quand on se retrouve avec des branches à la fois dans et hors de la boucle, le raisonnement devient plus difficile
Si l’on est assez sûr que la condition n’affectera que l’intérieur de la boucle, on peut la laisser là
Mais s’il n’est pas difficile d’imaginer une exigence qui nécessitera aussi un branchement en dehors de la boucle, il peut être préférable de concevoir cette structure à l’avance
Le code peut devenir plus verbeux, mais il sera plus facile à suivre et aura moins de chances de se transformer en spaghetti plus tard
C’est pour cela que j’ai arrêté d’utiliser Haskell. Haskell donne envie d’écrire la logique la plus concise et la plus « localement optimale », mais cela privilégie l’expression de la logique elle-même plutôt que son intention. Un changement d’exigence mineur pouvait obliger à tout détricoter de façon épouvantable
Il est bien plus lisible d’avoir la logique au même endroit, et il ne faut chercher à la découper que lorsque les détails commencent à faire perdre de vue la vue d’ensemble
Parfois, je préfère placer la logique conditionnelle dans l’appelé, parce que cela empêche l’appelant de faire les choses dans le mauvais ordre par erreur
Par exemple, si l’on veut créer une opération idempotente, on peut d’abord vérifier si cette tâche a déjà été effectuée, puis l’exécuter si ce n’est pas le cas
Si l’on pousse cette condition hors de l’appelant, chaque appelant de la fonction doit vérifier de son côté qu’il l’appelle de la bonne manière pour obtenir la garantie d’idempotence, et on ne peut plus abstraire cette garantie
Il y a aussi des cas où il faut exécuter une série de vérifications avant de faire une opération dans une transaction de base de données. Comment appliquer cette philosophie tout en gardant les vérifications à l’intérieur de la frontière transactionnelle ?
Si l’on pousse la condition hors de l’appelant, cette fonction n’est plus idempotente, donc elle ne peut évidemment plus fournir cette garantie
Cela dit, si une fonction individuelle doit implémenter de la gestion d’état pour fournir l’idempotence, la structure est probablement assez étrange, et il semble se passer trop de logique dans une seule fonction
Le code idempotent se divise généralement en deux catégories
La première est celle où le modèle de données et l’opération effectuée sont intrinsèquement idempotents. Il peut s’agir d’une opération sans état, ou d’une opération de style
PUTdans laquelle les données d’entrée contiennent tout l’état à enregistrerLa seconde concerne des opérations métier plus complexes, qui créent une abstraction idempotente en fournissant une abstraction d’application atomique capable d’effectuer un rollback ou de garantir qu’un échec partiel ne corrompt pas l’état
Dans le premier cas, il n’est pas nécessaire de vérifier l’ordre des opérations. Comme c’est intrinsèquement idempotent, il suffit de la relancer
Dans le second cas, on ne peut pas appliquer une abstraction simple. Il faut enregistrer ce que l’on veut faire, garantir que cela se termine ou échoue, puis faire en sorte que cette réussite ou cet échec soit durable
Ce genre de logique n’est pas quelque chose que l’on met dans une seule fonction pour ensuite la composer avec d’autres opérations
Ces conseils sont très tranchés et ne devraient pas être traités comme des règles empiriques
À mon avis, il n’y a même pas de règle empirique ici. Si je devais en formuler une, je dirais probablement l’inverse
Les
ifdevraient être poussés vers le bas au nom du DRYSi les performances le permettent, il faut envisager de remonter les
for. On peut alors utiliserfilter/map/reduceet la composition de fonctions pour choisir quel comportement appliquer à quels objets, et en pratique vectoriser le codePousser les
ifvers le bas empêche généralement la vectorisationLes cas évoqués dans l’article sont ceux où le code n’est pas DRY, en particulier quand le type est tagué en interne et que des branches similaires doivent proliférer dans de nombreuses fonctions plus bas dans la pile
Je ne suis pas convaincu que ce soit une « bonne » règle à suivre
Cela peut être vrai dans certains cas, mais c’est trop dépendant du contexte pour en tirer une conclusion
Cela me fait penser à une règle du type « i vient avant e, sauf après c ». Il y a tellement d’exceptions que c’est pratiquement comme s’il n’y avait pas de règle
J’ai retrouvé une version assez proche dans 99 Bottles of OOP de Sandi Metz
Globalement, ce n’est pas vraiment mon style, mais quand on travaille dans une base de code qui fait descendre beaucoup de flags sur plusieurs couches, l’idée de remonter les bifurcations de logique plus haut dans la pile d’appels est très convaincante
https://sandimetz.com/99bottles
Mettre une branche dans une boucle
for, c’est une abstraction qui dit : « la boucleforest la règle, la branche est le comportement »Mais il est très fréquent qu’une nouvelle exigence casse cette abstraction
Il faut alors la contourner, et le code obtenu se retrouve avec une abstraction qui s’applique dans certains cas mais pas dans d’autres, ou bien on bourre l’abstraction de paramètres pour essayer de la faire s’appliquer partout, ce qui la rend difficile à suivre
Si l’on n’avait pas créé cette abstraction au départ, le code obtenu aurait peut-être été plus facile à modifier et à comprendre
https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction
Pour la lisibilité du code, il vaut mieux pousser tout vers le bas
printInvoice(invoice, options)est bien préférable àif(printerReady){ if(printerHasInk){ if(printerHasPaper){ if(invoiceFormatIsPortrait){ ... }}}}De même pour les boucles,
printInvoices(invoices)est préférable àfor(invoice of invoices){ printInvoice(invoice) }Au final, la lisibilité du code est très importante, mais l’encapsulation l’est encore plus, donc il faut trouver le bon mélange des deux
printInvoiceest censée imprimer une factureQue se passe-t-il si l’une des conditions suggérées par son nom est fausse et qu’il est impossible d’imprimer la facture ?
On peut lancer une exception, ou renvoyer une valeur sentinelle ou un type d’erreur, mais dans ce cas il n’est pas immédiatement clair quoi faire
En particulier dans des langages comme Java ou C++, où l’on hésite à utiliser les exceptions pour le flux de contrôle normal et où les erreurs de style monadique ne sont pas courantes, il peut être préférable de structurer le code plus près du second style
En revanche, si le format vertical ne signale pas une erreur, il devrait être géré par l’imprimante de factures
L’encapsulation me semble surtout être un outil au service de la lisibilité du code à long terme, des refactorings et changements locaux, et de la capacité à raisonner sur le comportement global en ne se préoccupant que d’objets locaux
Du coup, comparer lisibilité et encapsulation pour dire que l’une est plus importante que l’autre ressemble à une erreur de catégorie
Il faudrait plutôt faire ainsi
if(!printerReady){ return; }if(!printerHasInk){ return; }if(!printerHasPaper){ return; }if(!invoiceFormatIsPortrait){ return; }C’est bien plus lisible qu’une flèche qui s’élargit
Cela dit, mettre la boucle dans sa propre fonction et avoir déjà traité les autres hypothèses, c’est une bonne chose
Quand il n’y a pas de papier, l’imprimante elle-même ne doit absolument pas faire tourner ses rouleaux dans le vide. Je mettrais cette vérification dans la fonction
maybe_print_invoice, et cette option me plaît beaucoup plus