3 points par GN⁺ 2025-05-18 | 1 commentaires | Partager sur WhatsApp
  • Faire remonter les instructions if vers le site d’appel à l’intérieur d’une fonction aide à réduire la complexité du code
  • En centralisant les tests de condition et le traitement des branches à un seul endroit, il devient plus facile de repérer les duplications et les vérifications de branchement inutiles
  • Le refactoring de dissolution d’enum permet d’éviter que la même condition se disperse à plusieurs endroits du code
  • Les boucles for fondées sur des opérations par lot sont efficaces pour améliorer les performances et optimiser les traitements répétitifs
  • La combinaison du modèle les if vers le haut, les for vers le bas permet d’améliorer à la fois la lisibilité et l’efficacité du code

Brève note sur deux règles liées

  • Lorsqu’une fonction contient une condition if, il est recommandé de se demander si elle peut être déplacée vers le site d’appel
  • Comme dans l’exemple, plutôt que de vérifier une precondition à l’intérieur de la fonction, il est préférable de confier cette vérification à l’appelant, ou de faire en sorte que le type (ou un assert) garantisse la precondition
  • Cette approche de remontée des vérifications de precondition a un effet global sur le code et permet, dans l’ensemble, de réduire le nombre de tests de condition inutiles

Concentrer le flux de contrôle et les conditions

  • Le flux de contrôle et les instructions if sont des causes majeures de complexité et de bugs dans le code
  • Il est utile de concentrer les branchements plus haut, par exemple au niveau de l’appelant, de sorte qu’une seule fonction gère la logique de branchement, tandis que le vrai travail est confié à des sous-routines linéaires
  • Quand les branchements et le flux de contrôle sont regroupés à un seul endroit, il devient plus facile d’identifier les branches dupliquées et les conditions inutiles

Exemple :

  • Lorsqu’il y a des if imbriqués dans une fonction f, il est plus facile de repérer du code mort (Dead Branch)
  • Si les branchements sont dispersés entre plusieurs fonctions (g, h), cela devient plus difficile à voir

Refactoring de dissolution d’enum (Dissolving enum Refactor)

  • Quand le code encapsule les mêmes branchements conditionnels dans un enum ou équivalent, on peut faire remonter la condition vers un niveau supérieur pour séparer plus clairement la logique de branchement du travail lui-même
  • Cette approche évite que la même condition apparaisse plusieurs fois dans le code

Exemple :

  • Une situation où la même condition de branchement est exprimée à la fois dans les fonctions f, g et dans l’enum E
  • Peut être simplifiée dans l’ensemble du code avec un unique branchement conditionnel de niveau supérieur

Pensée orientée données (Data Oriented Thinking) et opérations par lot

  • La plupart des programmes fonctionnent avec de nombreux objets (entités). Sur le chemin critique (Hot Path), les performances dépendent du traitement d’un grand nombre d’objets
  • Il est souhaitable d’introduire la notion de lot (batch) et de faire des opérations sur des ensembles d’objets le cas général, tandis que l’opération sur un seul objet devient un cas particulier

Exemple :

  • Prendre comme base une fonction de traitement par lot comme frobnicate_batch(walruses)

  • Et convertir le traitement d’un objet individuel en cas particulier via une boucle for

  • Cette approche joue un rôle important pour l’optimisation des performances : sur des traitements massifs, elle réduit le coût de démarrage et augmente la flexibilité d’ordonnancement

  • Elle permet aussi d’exploiter des opérations SIMD (struct-of-array, etc.), par exemple en traitant certains champs en bloc avant l’opération globale

Cas pratiques et modèles recommandés

  • Comme pour la multiplication de polynômes basée sur la FFT, le fait de pouvoir calculer simultanément en plusieurs points permet de maximiser les performances
  • La règle qui consiste à faire remonter les conditions et descendre les boucles peut être appliquée conjointement

Exemple :

  • Plutôt que de retester la même condition à chaque itération dans une boucle, sortir la condition de la boucle réduit les branchements dans la boucle et facilite l’optimisation et la vectorisation
  • Cette approche garantit aussi une grande efficacité dans les plans de données de systèmes à grande échelle, comme dans la conception de TigerBeetle

Conclusion

  • En combinant le modèle où les if (conditions) remontent vers le haut (site d’appel, logique de contrôle) et les for (boucles) descendent vers le bas (logique de calcul, traitement des données), on améliore à la fois la lisibilité, l’efficacité et les performances du code
  • Réfléchir en termes d’espace vectoriel abstrait, c’est-à-dire d’opérations sur des ensembles, est un meilleur outil de résolution de problèmes que de traiter sans cesse des branchements répétitifs
  • En résumé : les if vers le haut, les for vers le bas !

1 commentaires

 
GN⁺ 2025-05-18
Commentaires Hacker News
  • Mon modèle mental un peu particulier est que les différents états ou flux d’un programme forment une structure en arbre. Les conditions servent à élaguer cet arbre. Je veux élaguer le plus tôt possible pour réduire le nombre de branches à traiter ensuite. Je veux éviter la situation où l’on évalue et nettoie chaque branche une par une pour finir par devoir couper tout l’arbre d’un coup. Vu sous un angle un peu différent, une condition consiste à « découvrir du travail inutile », tandis qu’une boucle correspond au « vrai travail ». Au final, je veux que mes fonctions se concentrent soit sur l’exploration de l’arbre du programme, soit sur l’exécution du travail réel
    • Je voudrais proposer mon propre modèle : les classes sont des noms, les fonctions sont des verbes
    • Mon modèle mental s’adapte au monde concret dans lequel existe le code que j’écris. Cela dépend des particularités du domaine, des motifs présents dans le code existant, des étapes du pipeline de données, des profils de performance, etc. Au début, j’essayais d’établir ce genre de règles ou d’heuristiques, puis à force d’écrire du code, j’ai compris que ces règles abstraites ont en réalité peu de valeur. Très souvent, on finit par fixer un nom de fonction ou une seule lettre, puis la règle ne tient qu’à l’intérieur de cet « îlot de code », alors que dans une vraie codebase il y a généralement une raison pour laquelle on n’a pas fusionné ces fonctions. L’exemple sur la « duplication et les conditions mortes » en est une illustration : c’est une règle qui repose sur l’hypothèse confortable que cette fonction n’est appelée qu’à un seul endroit. En pratique, elles sont souvent séparées pour d’autres raisons
    • Je trouve que c’est un très bon modèle
  • Une règle plus générale consiste à placer les conditions aussi près que possible de la source des entrées. L’essentiel est d’identifier au plus tôt les points d’entrée dans le programme (y compris les données venant d’autres services) et d’établir autant de garanties que possible avant d’atteindre la logique cœur, surtout avant les parties qui consomment beaucoup de ressources. Le fait de l’exprimer explicitement dans les types est aussi une très bonne chose
    • Mais dans ce cas, quand on cherche à comprendre la logique cœur, n’est-ce pas plus difficile de savoir sur quelles hypothèses elle repose ? Ne faut-il pas examiner toute la chaîne d’appels du code ?
  • Le conseil « si une condition se trouve dans une fonction, demandez-vous si vous pouvez la déplacer vers l’appelant » a bien trop de contre-exemples. Si une fonction est appelée à 37 endroits, faut-il répéter le même if à chacun de ces appels ? Peut-on vraiment demander de déplacer ainsi le if pour des fonctions comme getaddrinfo ou EnterCriticalSection ? À mon avis, ce type de transformation n’est à envisager que pour des fonctions appelées à un ou deux endroits, et lorsque cette décision ne relève pas de la responsabilité de la fonction. Une approche consiste à écrire une fonction qui ne fait que la condition et délègue à une fonction helper. Et si l’on doit déplacer la condition hors de la boucle, on peut faire en sorte que l’appelant utilise directement un helper de plus bas niveau pour la condition. Mais au fond, toute cette réflexion tourne autour de « l’optimisation ». Or l’optimisation entre souvent en conflit avec une meilleure conception du programme. Il peut être préférable que l’appelant n’ait pas besoin de connaître la condition. On retrouve souvent ce dilemme en POO : la décision, représentée ici par if, est en réalité prise via le dispatch de méthode. Sortir ce dispatch de la boucle peut aussi entrer en tension avec les principes de conception. Par exemple, pour dessiner une image sur un canvas, mieux vaut utiliser une méthode comme blit que d’appeler putpixel en boucle
    • Si une fonction est appelée à 37 endroits, il y a probablement du refactoring à faire. Pour répondre à la question : ça dépend du contexte. DRY semble souvent être la bonne réponse, mais il faut décider à partir d’un vrai exemple de code. Dans une bibliothèque, on se situe à une frontière de responsabilité, donc chacun doit gérer ses propres données et responsabilités. Une fonction comme EnterCriticalSection doit faire une validation solide dès le point d’entrée, y compris via des conditions. En revanche, dans du code applicatif, on peut déplacer le if vers l’appelant. Dans une bibliothèque ou du code central, il est approprié de pousser le flux de contrôle vers les bords. Dans son propre domaine, il est bon de garder le flux de contrôle sur les bords. Mais ce genre de règle reste toujours idiomatique ; au final, il faut quelqu’un capable de juger raisonnablement selon le contexte
  • L’exemple du refactoring « dissolving enum » est en réalité un pattern de polymorphisme. On peut remplacer un match par un appel de méthode polymorphe. L’objectif est de séparer le moment où l’on décide de la branche initiale et le moment où l’action réelle est exécutée. La distinction entre les cas est portée par l’objet lui-même (ici la valeur de l’enum) ou par une closure, donc il n’est pas nécessaire de la répéter à chaque appel. Si la distinction entre les cas change, il suffit de modifier le point de branchement, sans toucher aux endroits où le comportement se produit réellement. L’inconvénient est un compromis entre la commodité de voir directement le comportement de chaque cas et le fait d’introduire au niveau du code une dépendance à la liste des cas
  • Il y a des moments où j’aime garder la condition à l’intérieur de la fonction. Cela permet d’éviter délibérément que l’appelant se trompe dans l’ordre d’appel. Par exemple, lorsqu’il faut garantir l’idempotence, on commence par vérifier si l’état a déjà été traité, sinon on exécute l’action. Si l’on déplace cette condition à l’appel, l’idempotence n’est garantie que si tous les appelants respectent correctement cette procédure ; l’abstraction ne fournit donc plus cette garantie. Je me demande comment appliquer cette philosophie dans ce genre de cas. Autre exemple : lorsqu’on veut effectuer une série de vérifications dans une transaction base de données avant d’exécuter l’opération, où faut-il placer ces vérifications ?
    • Vous avez déjà, je pense, répondu vous-même à la question. Si l’on déplace la condition vers l’appelant, la fonction n’est plus idempotente et ne peut évidemment plus le garantir. Si vous ajoutez une logique de gestion d’état à chaque fonction pour garantir l’idempotence, c’est peut-être le signe que vous écrivez du code assez problématique et que vous concentrez trop de logique métier dans une seule fonction. On peut grossièrement distinguer deux types de code idempotent. D’abord, le code dont le modèle de données ou l’opération elle-même est idempotent ; dans ce cas, il n’est pas vraiment nécessaire de se soucier de l’ordre d’exécution. Ensuite, le cas des opérations métier plus complexes, où l’on construit une abstraction idempotente. Cela demande une logique plus sophistiquée, comme un rollback ou une application atomique, et ce n’est pas quelque chose qui se résout simplement dans une seule fonction
    • Une autre façon de procéder consiste à créer une fonction interne sans vérifications, puis une fonction wrapper externe qui effectue les vérifications avant d’appeler la fonction interne
  • Les scanners de complexité du code sont, au bout du compte, des outils qui poussent à faire descendre les if. Pourtant, cet article recommande l’inverse : remonter les if, c’est-à-dire vers des fonctions de plus haut niveau. Cela permet de centraliser dans une seule fonction la logique de branchement complexe, tout en déléguant le travail concret à des sous-routines
    • La solution consiste à séparer la « décision » de l’« exécution ». C’est une idée que j’ai apprise de Bertrand Meyer. Par exemple : if (weShouldDoThis()) { doThis(); }. Si l’on extrait chaque vérification dans une fonction distincte, il devient plus facile de tester et de gérer la complexité
    • Les rapports des scanners de code doivent être accueillis avec un sérieux scepticisme. Des outils comme sonarqube signalent à tout-va des « code smells » qui ne sont pas de vrais bugs. À vouloir corriger ce type de « code qui ne pose pas de problème », on risque surtout d’introduire de nouveaux bugs et de gaspiller du temps qui devrait être consacré aux vrais problèmes
    • Ce genre d’optimisation n’est le plus souvent qu’un « optimum local ». Dès qu’une nouvelle exigence ou un cas particulier apparaît, la logique de branchement devient nécessaire à l’extérieur de la boucle. Et à partir du moment où il y a des branches à la fois dedans et dehors, le code devient difficile à comprendre. Si vous êtes certain que la condition n’est nécessaire qu’à l’intérieur de la boucle, laissez-la là ; sinon, je pense qu’il vaut mieux accepter une conception un peu plus développée et un code plus verbeux, mais plus facile à comprendre. J’ai souvent eu cette expérience en Haskell. Quand on pousse la logique vers la forme la plus concise et optimisée possible — un optimum local — il suffit que les exigences changent un tout petit peu pour que l’intention de conception disparaisse au profit de la seule logique, et de petites modifications provoquent alors un déroulage du code très important
    • Les scanners de complexité du code m’ont toujours agacé. Ils se plaignent même de grosses fonctions pourtant faciles à lire. Quand la logique est regroupée au même endroit, le contexte global est souvent plus facile à saisir ; quand on découpe en fonctions, il faut faire attention à ne pas perdre le vrai contexte
    • Il y avait hier un thread sur les LLM à propos des « outils non fiables que les développeurs acceptent tous ». Je crois que j’ai maintenant la réponse…
  • Dans certains cas, il faut au contraire prendre le problème à l’envers et exploiter le SIMD. Avec AVX-512 par exemple, on peut traiter du code avec branchements sous forme branchless en utilisant les registres de masque vectoriel. Par exemple, un if dans une boucle for peut être plus facile à gérer qu’un if à l’extérieur de la boucle, et offrir une meilleure efficacité d’accès mémoire. Pour prendre un exemple concret, s’il faut faire +1 pour les nombres impairs et -2 pour les nombres pairs, il faudrait normalement brancher à chaque itération, mais avec du SIMD vectoriel on peut traiter 16 int à la fois, sans branchement. Si le compilateur vectorise correctement, il transformera le code d’origine en une version optimisée sans branchement
    • Le code before proposé me semble un peu à côté du sujet de l’article ; au contraire, la version SIMD optimisée correspond précisément à son propos. Dans l’exemple, le if à l’intérieur de la boucle dépend des données et ne peut donc pas être facilement remonté. Si l’algorithme utilisait au contraire une condition extérieure à la boucle, comme if (length % 2 == 1) { ... } else { ... }, alors oui, ce genre de condition doit évidemment être déplacé au-dessus du for. Dans la version SIMD, le if a complètement disparu, et c’est le genre de pattern idéal que l’auteur de l’article apprécierait sans doute
    • Moi aussi, j’ai tout de suite pensé à du code qui branche selon la valeur des éléments dans une boucle for. Est-ce que quelqu’un sait à quel point il est difficile pour un compilateur d’auto-vectoriser ce type de code ? La frontière m’intéresse
  • Personnellement, je ne pense pas que ce soit une « bonne » règle. Il y a des cas où elle s’applique, mais cela varie tellement selon le contexte qu’il est difficile d’en tirer une conclusion tranchée. Comme les règles d’orthographe anglaise, il y a trop d’exceptions pour que cela ressemble vraiment à une règle
  • Lien vers la discussion de l’époque (2023) (662 points, 295 commentaires) https://news.ycombinator.com/item?id=38282950
  • J’ai rencontré une idée similaire dans 99 Bottles of OOP de Sandi Metz. Ce n’est pas mon style, mais je suis d’accord sur le fait qu’il peut être utile de faire remonter la logique de branchement tout en haut de la pile d’appels. Je l’ai particulièrement ressenti dans des codebases où l’on faisait passer des flags à travers plusieurs couches. https://sandimetz.com/99bottles
    • Cela m’a immédiatement fait penser à l’article du même auteur, « The Wrong Abstraction ». Une branche à l’intérieur d’une boucle for crée une abstraction du type « le for est la règle, la branche est le comportement ». Mais quand de nouvelles exigences apparaissent, cette abstraction se brise, et l’on finit par ajouter de force des paramètres ou des exceptions, ce qui rend le code difficile à comprendre. Si l’on avait écrit le code sans abstraction dès le départ, le résultat aurait sans doute été plus clair et plus facile à maintenir. https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction