3 points par GN⁺ 2025-05-18 | 1 commentaires | Partager sur WhatsApp
  • 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 Walrus est plus simple qu’une fonction qui reçoit un Option<Walrus> et retourne simplement en cas de None
    • 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
  • 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 if ont 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 match répètent le même branchement
    • Si f() crée E::Foo ou E::Bar selon une condition, puis que g() refait un match pour appeler foo() ou bar(), le branchement apparaît deux fois
    • En remontant la condition, on obtient une forme où main() appelle directement foo(x) ou bar(y) selon cette même condition

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 que for 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 if et les for peuvent 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
  • 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 for est la performance, mais elle peut aussi améliorer l’expressivité
    • jQuery opé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

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

    • Cette perspective correspond bien à ce qu’on voit en théorie des langages de programmation, ou dans la sémantique à petits pas du lambda-calcul.
      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) + 4 devient 3 + 4, puis 7.
      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
    • Mon modèle mental consiste à l’adapter au monde concret dans lequel se trouve le code que je suis en train d’écrire.
      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 g est le seul appelant de h et le restera.
      Dans une vraie base de code, il y a généralement une raison pour laquelle g et h n’ont pas été fusionnées dès le départ.
    • Pour proposer un modèle voisin : les classes sont des noms et les fonctions sont des verbes.
    • Ce modèle n’est pas si étrange ; si on le pousse jusqu’au bout, il se rapproche en pratique du modèle d’exécution de Prolog.
  • Une règle plus générale consiste à placer le if au 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.

    • C’est presque la même idée que parse, don’t validate : https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
    • Mais alors, les hypothèses que l’on peut faire en comprenant la logique cœur ne deviennent-elles pas plus floues ? Préférez-vous devoir suivre et vérifier chaque chaîne d’appels une par une ?
  • Une supposition vague du type « s’il y a une condition if dans 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 if partout ?
    Si cette fonction est getaddrinfo ou EnterCriticalSection, faut-il pousser le if vers 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 if et 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 le if.
    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 if dans 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.

    • Si une fonction est appelée à 37 endroits, cela veut plutôt dire que, dans ce cas, on peut la scinder en deux fonctions implémentant les branches vrai/faux, appelées respectivement depuis 21 et 16 endroits.
    • Le mot clé ici est 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.
    • Si une fonction est appelée à 37 endroits, il faut refactoriser le code, mais la réponse reste « ça dépend ».
      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.
      EnterCriticalSection suggère un chemin de code où une validation forte à l’entrée, y compris une condition if, 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 if vers 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 match par un appel de méthode polymorphe sur l’énumération
    Le 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 ensuite foo ou bar
    La 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 if vers le bas. L’article recommande l’inverse
    Quand 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...

    • La solution consiste à séparer la décision de l’exécution, un concept que je tiens de Bertrand Meyer
      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
    • Il ne faut pas prendre les rapports des scanners de code pour parole d’évangile, mais les examiner avec scepticisme
      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
    • D’après mon expérience, c’est souvent un optimum local
      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
    • Depuis que j’ai vu ce type d’outil se plaindre d’une grande fonction parfaitement lisible, j’ai toujours détesté les scanners de complexité du code
      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
    • Hier, dans un fil sur les LLM, quelqu’un demandait « quel autre outil peu fiable les gens acceptent-ils en programmation ? » ; maintenant, j’ai la réponse
  • 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 ?

    • On peut peut-être écrire une fonction sans vérifications, puis ajouter une fonction wrapper qui ne fait que les vérifications avant d’appeler la fonction interne
    • En réalité, la réponse est dans la question
      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 PUT dans laquelle les données d’entrée contiennent tout l’état à enregistrer
      La 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 if devraient être poussés vers le bas au nom du DRY
    Si les performances le permettent, il faut envisager de remonter les for. On peut alors utiliser filter/map/reduce et la composition de fonctions pour choisir quel comportement appliquer à quels objets, et en pratique vectoriser le code

    • Il semble que les noms aient été inversés, ou que les raisons avancées ne soutiennent pas la conclusion
      Pousser les if vers le bas empêche généralement la vectorisation
      Les 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

    • Cela m’a immédiatement fait penser à The Wrong Abstraction, de la même autrice
      Mettre une branche dans une boucle for, c’est une abstraction qui dit : « la boucle for est 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

    • La fonction printInvoice est censée imprimer une facture
      Que 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
    • Dire qu’on « pousse tout vers le bas pour améliorer la lisibilité » tout en montrant l’antipattern de la flèche, ce n’est pas terrible
      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
    • Cela pourrait être le pilote d’imprimante du PC, ou bien les circuits internes de l’imprimante, donc les avis peuvent varier selon les personnes
      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
    • Côté Elixir, j’appellerais probablement cette fonction quelque chose comme maybe_print_invoice, et cette option me plaît beaucoup plus