Le code linéaire est plus facile à lire
(blog.separateconcerns.com)- Plutôt que de séparer les niveaux d’abstraction en découpant en petites fonctions, l’idée défendue ici est qu’un code linéaire qui se déroule de haut en bas permet de suivre plus facilement le flux global
- Quand on extrait des fonctions pour créer une structure top-down, on peut devoir faire des allers-retours entre des fonctions aux noms proches comme
bakeetbakePizza - Comme pour l’emplacement du préchauffage du four ou le résultat quand on retourne une pizza deux fois, les petites fonctions peuvent révéler l’intention tout en cachant le comportement réel
- En ajoutant des commentaires étape par étape à un code linéaire, on peut expliquer l’intention du traitement sans multiplier les références indirectes, ce qui peut être plus lisible qu’une abstraction supplémentaire
- Extraire une petite fonction utilisée une seule fois entraîne une perte de linéarité et, comme dans l’exemple de création du four, peut même révéler des problèmes de performance dans du vrai code
Quand la linéarité compte plus que l’extraction de fonctions
- L’exemple du Google Testing Blog compare deux implémentations de
createPizzaet considère que celle de droite est plus facile à lire, car elle ne mélange pas les niveaux d’abstraction et suit une approche top-down - Le point de vue opposé estime que l’implémentation de gauche est plus importante parce qu’elle reste un code lisible de façon linéaire, de haut en bas à l’écran
- Dans l’implémentation de droite, il faut naviguer entre plusieurs petites définitions de fonctions pour comprendre le comportement global
- Même dans la présentation, une partie du code de droite est omise, ce qui donne l’impression que les deux versions ont une taille comparable, alors qu’en réalité celle de droite est plus longue
- L’extraction de fonctions peut rendre le comportement difficile à comprendre à partir du seul nom
- S’il existe à la fois
bakeetbakePizza, il n’est pas évident de savoir immédiatement quelle fonction chauffe le four - Pour vérifier si retourner deux fois la même pizza est idempotent ou si cela ruine le résultat, il faut regarder l’implémentation interne
- S’il existe à la fois
Code linéaire commenté et exemple du four
- La version la plus lisible est jugée être celle qui reprend le code linéaire de gauche en y ajoutant, sous forme de commentaires, les noms de fonctions utilisés à droite
- Des commentaires comme
Prepare pizza,Add toppings,Heat oven,Bake pizza,Box and slicerendent explicite l’intention de chaque étape - La lisibilité ne vient pas d’une couche d’abstraction supplémentaire ni des références indirectes, mais du fait d’expliquer correctement ce qui est en train d’être fait
- Des commentaires comme
- La conclusion va donc plutôt dans le sens de ne pas extraire hors d’un code linéaire de petites fonctions qui ne servent qu’une seule fois
- Les bénéfices de l’extraction de petites fonctions ne compensent pas, selon cette analyse, la perte de linéarité
- Le traitement du four dans l’exemple paraît lui aussi maladroit sur le plan de la structure
- Le préchauffage du four est une opération autonome, et il serait donc plus logique qu’il s’agisse d’une méthode du four
- Créer et préchauffer un nouveau four à chaque fois qu’on prépare une pizza ne correspond pas à un usage réaliste
- On retrouve ce genre de structure dans du vrai code, et cela peut parfois créer des problèmes de performance
- Il est probable que le four devrait être passé en paramètre plutôt que créé à l’intérieur de
createPizza- Fournir le four relève davantage de la responsabilité de l’appelant
- Si le flux consiste à mettre la pizza en boîte, une interface qui renvoie la boîte plutôt que la pizza pourrait sembler plus naturelle
1 commentaires
Commentaires sur Hacker News
C’est une question de style et, comme en cuisine, trop ou trop peu de sel gâche le plat.
J’espère que personne ici ne propose une fonction divine de 1 000 lignes, et une limite maximale de 5 lignes par fonction n’est pas agréable à lire non plus. Décider où découper demande du jugement, un bon sens pratique et des itérations. Ce n’est pas parce que la première abstraction tentée était médiocre qu’il faut renoncer aux abstractions ; après quelques refactorings, on peut aboutir à des classes et des API qui correspondent bien au domaine métier.
En même temps, il ne faut pas se précipiter sur l’abstraction ni réagir à quelques lignes dupliquées comme si c’était une blessure mortelle. Une abstraction prématurée a vite fait de lier du code qui n’a pas besoin d’évoluer ensemble. Extraire une fonction appelée depuis un seul endroit pour masquer une unité de travail peut rendre un algorithme plus propre, surtout quand il s’agit de cacher du boilerplate ou le mélange entre logique métier et préoccupations d’infrastructure comme la gestion de la connexion à la DB. Mais il faut l’utiliser avec prudence, et il vaut mieux éviter de découper des étapes qui devraient rester au même niveau d’abstraction.
Quelqu’un avec qui j’ai travaillé extrayait toutes les conditions booléennes en fonctions au motif que c’était « plus lisible », et n’écrivait aucun commentaire parce que « les commentaires, c’est mal ». Je n’aime pas ce livre parce qu’il fabrique des fanatiques qui suivent aveuglément ce genre de mauvais conseils.
Parfois, le domaine peut exiger une fonction divine de 1 000 lignes, et le fait d’avoir la logique et le travail réunis au même endroit peut être bien plus lisible que 20 fonctions de 50 lignes. De toute façon, pour comprendre l’ensemble, il faudra lire les 20, et quelqu’un risque de vouloir en réutiliser certaines en les ajustant à 2 ou 3 exigences qui n’existaient pas dans la tâche d’origine, puis de coupler une logique précise à des cas d’usage sans rapport.
Si cette fonction est une fonction pure, qu’elle fasse 1 000 ou 10 000 lignes ne me dérange pas ; je considère que cela reste acceptable.
Les recettes de cuisine sont déjà très abstraites. Quand on dit « faire revenir doucement les oignons », on suppose que vous savez déjà couper des oignons et appliquer l’algorithme qui consiste à les faire revenir doucement. Si l’on mettait tout en ligne, ce serait illisible.
Le code est similaire. Si l’on exclut strictement les abstractions, on descend jusqu’au niveau le plus bas permis par le langage, et ce n’est certainement pas du code lisible. Par exemple, si l’on essayait de faire soi-même le décodage Unicode au lieu d’utiliser la méthode
decodede Python, il deviendrait très difficile de comprendre ce que fait réellement le programme. Personne ne le fait simplement parce que le langage fournit une abstraction simple et bien éprouvée ; en quoi est-ce différent de créer soi-même des abstractions simples et bien éprouvées et de les utiliser dans toute la logique métier ?Le plus difficile est de créer des abstractions suffisamment bien choisies pour que personne n’ait besoin d’y retoucher.
Ce n’est pas parce que des lignes de code se ressemblent aujourd’hui qu’elles devront rester identiques, ni être maintenues identiques à l’avenir. Si l’on force la fusion de deux cas d’usage différents au seul motif que « le code est presque répété », on obtient facilement, avec le temps, une abstraction qui n’abstrait plus rien.
Si les cas d’usage divergent trop, l’implémentation finit soit par repousser beaucoup de logique vers l’appelant, soit par exposer les différences sous forme de flags et par garder deux implémentations différentes côte à côte en interne. La première option est une abstraction superficielle, donc de faible valeur ; la seconde est moins claire que deux implémentations indépendantes.
Le code d’exemple est tellement simple qu’il est évident qu’un code linéaire est plus agréable à lire, mais l’idée ne passe pas bien à l’échelle.
Il faut aussi tenir compte de la réutilisabilité et de la facilité à écrire des tests unitaires ; si l’on met tout le code dans une seule fonction, toutes les variables locales, qu’elles aient ou non un rapport avec le bloc de code qu’on est en train de lire, se retrouvent dans le scope, ce qui peut rendre le raisonnement plus difficile.
Cela dit, quand je repense à mes années de moindre expérience, il m’est souvent arrivé de trop modulariser du code linéaire parfaitement correct, au point d’en faire un code moins maintenable où il fallait sauter d’un endroit à l’autre. La forme initiale est plus proche du fil de pensée que j’avais en tête au moment de l’écriture, et le lecteur a probablement plus de chances de l’interpréter ainsi. Un refactoring excessif peut faire disparaître cet avantage.
Au final, la programmation tient davantage de l’artisanat, et l’expérience aide à faire le bon choix selon le contexte.
Elle n’avait qu’un seul objectif : transformer des pages HTML individuelles utilisées dans un coin de l’app, sur une plateforme donnée, en un carrousel imitant l’apparence native d’une autre plateforme. Elle était extrêmement spécifique à cette plateforme et à cette zone de l’app.
On aurait pu faire de chacun des 9 scopes une fonction, mais cela aurait donné envie aux développeurs de les réutiliser. Chaque étape contenait des hypothèses subtiles sur ce qui s’était passé à l’étape précédente, et les extraire en fonctions séparées aurait obligé à réexaminer ces hypothèses, les généraliser et vérifier que chaque méthode fonctionne de manière indépendante. Il n’y avait aucune raison de payer ce coût pour du code qui ne servirait quasiment nulle part ailleurs.
Le débogage n’était pas plus difficile, il y avait des tests end-to-end, et l’état des étapes intermédiaires ne fuyait pas hors de la fonction. En pratique, deux autres développeurs ont contribué à la modifier au fil du temps, cela a bien fonctionné, et elle a été rapide à écrire.
Le code linéaire passe bien à l’échelle et résout des problèmes. Ce n’est pas toujours la forme souhaitée, mais dans bien plus de situations qu’on ne le croit, il simplifie énormément la vie.
La première réaction face au monstre de 2 000 lignes n’était pas bonne, mais après 5 minutes à l’examiner, il était difficile d’y trouver de vrais défauts ; avec quelques tests, il ne restait que des craintes qui ne se matérialisaient pas.
À un moment, on finit par comprendre que ces dizaines de fonctions doivent être appelées dans un ordre précis, et que chacune n’est utilisée qu’une seule fois. Au final, cela revient à obliger quiconque veut les utiliser utilement à connaître un ordre de combinaison quasi magique.
La raison essentielle pour laquelle une grosse fonction linéaire est souvent plus lisible et souhaitable, c’est qu’elle permet de garder simultanément en tête plusieurs concepts et relations en un seul bloc, sans changement de contexte, ce qui facilite la compréhension. Un partisan extrême est Arthur Whitney, l’inventeur du langage K, qui écrit du code très concis, presque incompréhensible pour les autres, afin d’en faire tenir un maximum sur un seul écran.
À titre personnel, il m’a été beaucoup plus facile de lire, comprendre et déboguer une énorme fonction de traitement de messages Windows, c’est-à-dire
WndProc, avec la logique métier dans un grosswitch, que la version Visual C++ où les gestionnaires de messages étaient découpés en fonctions séparées.Autre exemple : dans du code d’exemple pour microcontrôleur, il existait une version où l’exemple d’utilisation de l’ADC tenait entièrement dans un seul fichier, et une autre répartie entre
main.c,config.c,interrupts.c,timer.c, etc. Elle faisait moins de 200 lignes, mais la seconde était difficile à comprendre à cause des changements de contexte.Ces morceaux de code deviennent généralement des fonctions
privated’une classe, et portent de l’état. Comme ce sont des fonctionsprivate, elles sont en réalité difficiles à tester.On se retrouve alors avec tout un tas de fonctions
privateappelées une seule fois, qui modifient généralement un état à effet de bord. Si elles restent juste à côté de l’appelant, cela reste lisible dans les cas simples, mais avec le temps quelqu’un ajoute une autre fonction entre la fonction appelante et la fonction extraite.Dès lors, à moins de consulter le graphe d’appels ou de chercher dans le fichier de la classe, des morceaux de code dont on ne sait pas où ils sont appelés modifient différents états à effets de bord.
Si l’on veut rendre le code non linéaire, j’aimerais qu’on envisage au moins, lorsque le langage le permet, de faire des fonctions
privateextraites des fonctions internes de la fonction appelante. Il devient alors clair qu’elles ne sont pas appelées ailleurs.Dans une vraie codebase, ce n’est pas non plus un choix binaire : c’est plutôt un art consistant à combiner les deux pour produire une forme lisible et maintenable.
Les gens testeront-ils toutes ces branches ? Ou bien écriront-ils seulement un test qui ajoute une pizza, histoire de voir si ça fonctionne à peu près ? Tester plusieurs branches depuis l’extérieur est généralement fastidieux, plus pénible que de tester de petites fonctions spécialisées ; il me semble donc plus probable qu’ils fassent la seconde chose.
Dire que « le code linéaire ne passe pas à l’échelle » est plutôt l’inverse. Dans une grande base de code, le vrai cauchemar, ce sont les petites fonctions concises avec des piles d’appels profondément imbriquées
On ne sait pas clairement où ajouter le nouveau code, il faut suivre tous les chemins par lesquels le code peut être appelé, ce qui fait croître de façon exponentielle la difficulté d’évaluer l’impact d’un changement, et des sous-routines dupliquées apparaissent aussi
Dans 99 % des cas, vous n’avez pas créé une bonne abstraction, donc mieux vaut simplement écrire du code linéaire. Je préfère le copier/coller à une sémantique de fonction douteuse
print_table(), quelqu’un va la trouver, l’utiliser dans son propre code, puis y ajouter un petit flag pour ajuster la sortie à son cas d’usageDouze mois plus tard, ça ressemble à ça :
print_table(rows,headers = None,is_unicode = False,left_align = False,align = [],remove_emoji = None,max_width = 80,potato_mode = 7,_debug_frontend = not FLAGS.dont_debug,ellipsis_for = 0,no_print = False,)Si l’on considère les deux concepts comme orthogonaux, hormis le fait que la lisibilité peut influer sur la scalabilité, le code linéaire ne passe pas aussi bien à l’échelle que le code modulaire. Cette dichotomie vaut la peine d’être connue et prise en compte selon le contexte
Pour autant, je ne suis toujours pas d’accord. Si une petite fonction est une fonction pure, elle ne pose pas de problème de lisibilité. Cela signifie qu’elle ne touche pas à l’état, qu’elle n’injecte pas de logique dans le code, et qu’il faut explicitement minimiser l’injection de dépendances ainsi que le passage de fonctions à d’autres fonctions
Si vous construisez un pipeline de fonctions pures qui ne transmettent que des données, le résultat devient lisible et extensible. Il y a beaucoup moins de cas où il faut réécrire de la logique à cause d’un défaut de conception, et en composant des fonctions pures, le code devient comme des Lego. Le refactoring se rapproche alors d’un travail de réorganisation et de recombinaison d’éléments primitifs existants
L’exemple de code aurait été moins distrayant s’il avait au moins essayé de garder la métaphore de la pizza pertinente, ou s’il n’avait pas été du code Go de bas niveau
prepareest un nom de fonction horrible. Un Gopher expérimenté aurait probablement choisi un nom commeNewPizzaFromOrderJe ne vois pas de raison de mettre
addToppingsdans une fonction séparée. Si c’était absolument nécessaire, personnellement j’en ferais une méthode dePizza, du genrefunc (p *Pizza) WithToppings(topping ...Topping) *Pizza { /* ... */ }. Une vraie pizza étant mutable, la méthode modifierait le receveurJe ne comprends pas non plus pourquoi instancier un nouveau four à chaque fois qu’on cuit une pizza. Il faudrait partir d’un four déjà existant, appeler
oven.Preheat(), puisoven.Bake(pizza). On pourrait même aller plus loin et faire en sorte queoven.Preheat()renvoie un nouveau type d’Ovenqui expose.Bake(), afin d’empêcher à la compilation l’erreur de cuire sans préchauffage. Ailleurs, il pourrait y avoir une interfaceBaker, et une implémentationToasterOvenpour laquelle le préchauffage n’est pas si important et donc inutileMême sans changer le code, j’aurais réorganisé l’ordre des déclarations pour qu’il suive le flux attendu. Ainsi, quand on parcourt des fonctions qui s’appellent entre elles, on n’a pas besoin de sauter de haut en bas dans la page
Je m’arrête là faute de temps, mais ce code est déjà un trop mauvais exemple pour lancer un débat sur « lequel est le plus lisible »
John Carmack a dit à peu près la même chose, et je m’y tiens depuis. Le code linéaire suit l’ordre d’exécution, il est donc naturellement facile à lire et minimise les sauts du regard
Certains codes doivent être non linéaires pour être réutilisables, et dans ce cas l’exécution devient un graphe. Si le code ne tire pas parti d’une réutilisation en structure de graphe, inutile d’introduire un sommet là où une seule arête suffit
http://number-none.com/blow/blog/programming/2014/09/26/carm...
Dans ce cas, pour le code de gauche, quelque chose comme
pizza.Toppings = get_pizza_toppings(order.kind)aurait sans doute été mieux, car la modification de la pizza serait restée au centre de la fonction principaleJe suis assez d’accord pour dire que le code linéaire est plus lisible, mais cela ne suffit pas à en faire une bonne pratique de code.
Je pense qu’un bon code linéaire est plus facile à lire, mais sa maintenabilité et sa testabilité sont bien moindres. J’ai des décennies d’expérience et je suis aussi examinateur externe pour des étudiants en informatique ; au fil des années, parmi les bonnes pratiques que j’ai vues dans le monde réel, la seule chose vraiment certaine était de garder les fonctions petites.
Je n’aime pas particulièrement les abstractions, et je ne pense pas non plus qu’il faille éviter à tout prix la duplication de code, mais si vous créez des fonctions aussi proches que possible d’un objectif unique, votre vous du futur vous en remerciera.
Si du code comme celui de l’exemple tourne en production pendant 10 ans, chaque section finira par changer. Avec un peu de chance, les commentaires seront aussi mis à jour, mais la plupart du temps ce ne sera pas le cas. Les tests unitaires deviendront eux aussi gros et difficiles à manipuler, de plus en plus approximatifs, et quelqu’un pourra oublier de modifier une partie d’un test qui ne semble pas clairement liée au changement. Le code aussi deviendra probablement moins lisible avec le temps. Pas par intention ou par incompétence, mais pour des raisons humaines comme la pression du temps.
Dans un monde parfait, nous n’aurions pas besoin de séparer les préoccupations, mais nous vivons dans un monde imparfait, et plus les fonctions sont petites et ont peu de responsabilités, plus il est facile de gérer cette imperfection au fil du temps.
Si vous faites passer un objet par un flux d’états précis, je pense qu’il vaut mieux soit le découper et représenter les transitions par des types, soit l’écrire comme une grande fonction. Par exemple, si
bakePizzaprend uneRawPizzaet renvoie uneBakedPizza, l’ordre d’appel peut être imposé à la compilation.Pour la lisibilité, la correction et la testabilité, je préfère la première option, mais dans la plupart des langages de programmation, changer le type d’un objet nécessite de créer un nouvel objet, avec un coût à l’exécution. Si c’est un chemin de code critique, une modification en place est justifiée, et dans ce cas il vaut mieux garder cela dans une seule fonction linéaire.
https://mitpress.mit.edu/9780262045490/
E-mail connexe de John Carmack : http://number-none.com/blow/blog/programming/2014/09/26/carm...
Discussion : https://news.ycombinator.com/item?id=12120752
Tout à fait d’accord. Avant, j’étais dans le camp opposé.
La tension fondamentale ici est entre, d’un côté, la localité du comportement, et de l’autre, le désir de rendre explicite une vue de haut niveau en « table des matières ». Pour un code lisible, la localité est plus importante. Comme le dit l’article, la perspective de table des matières peut être rendue suffisamment claire avec des commentaires de section.
Il y a aussi une raison plus importante de préférer le code linéaire. Quand on parcourt toute une base de code, c’est beaucoup plus facile si les « blocs » — fonctions, classes ou unités imposées par le langage — correspondent à peu près à des cas d’usage métier. Sinon, l’espace de recherche devient trop grand, et il faut reconstruire soi-même le tout à partir des morceaux. La structure du code devrait faire ce travail à votre place.
Si plusieurs « choses » relèvent toutes d’une même tâche, par exemple l’inscription ou l’achat, il vaut mieux les garder ensemble dans le code. C’est beaucoup plus facile à trouver et à modifier. Il ne faut les extraire en sous-fonctions que lorsque la réutilisation est nécessaire, pas seulement pour organiser.
[0] https://htmx.org/essays/locality-of-behaviour/
La raison principale, c’est l’état. Plus une fonction est longue, plus la portée des variables locales est large. N’importe quelle variable peut être modifiée n’importe où dans la fonction, et le flux de données n’est pas immédiatement clair. Avec davantage de fonctions, la portée reste plus petite et le flux de données est plus explicite.
Effet secondaire : cela réduit aussi l’indentation.
En même temps, je n’aime pas les fonctions trop petites. Cela rend difficile de trouver où le vrai travail se fait.
Imaginons un traitement de clôture de journée avec 10 étapes non réutilisables à exécuter dans l’ordre, chacune faisant 100 lignes. Chaque étape utilise des données similaires à la précédente, mais pas identiques. Vous choisiriez vraiment une seule fonction de 1000 lignes ?
Les deux se lisent de façon linéaire. La version qui extrait de petites fonctions a une table des matières en haut de la page et résume le flux de données entre les étapes. Si l’on compte tout lire, cela semble offrir un ordre de lecture attrayant.
Cela dit, pour préserver cette lisibilité, il faut aussi déplacer l’emplacement des fonctions lorsque l’ordre des étapes change. Si ce sont des fonctions
privateappelées uniquement depuis la table des matières, ça va. Mais rien n’impose de conserver l’ordre, ni même de penser au flux de lecture global.Quand une fonction commence à être réutilisée, il arrive souvent qu’on ne puisse plus la linéariser. Parfois les gens abandonnent et trient par ordre alphabétique, ou bien cela devient simplement aléatoire.
D’après mon expérience, plus une personne est familière avec le code, plus elle pense que pousser le code dans de petites fonctions est la bonne voie
Comme elle s’est déjà construit le modèle mental de ce code, l’implémentation la plus propre pour elle est celle qui comporte très peu de lignes
Mais quand la personne suivante arrive, elle doit faire des allers-retours partout et push/pop une pile dans sa tête pour construire le même modèle mental sans le contexte d’origine, et c’est bien plus difficile
Par exemple, à quelle fréquence lit-on le code source de la bibliothèque standard du langage qu’on utilise ? Presque jamais ; en général, on regarde la signature de la méthode et, si c’est un peu complexe ou nouveau, on lit la documentation
Le rôle essentiel d’une interface est de faire en sorte qu’on se soucie de ce que fait une méthode, pas de la façon dont elle est implémentée. Cela s’explique par une combinaison de contexte, de nommage et de documentation. Mais beaucoup de développeurs ne le comprennent pas ou ne s’en soucient pas, et écrivent du code qui n’a pas de sens, qu’il soit linéaire ou modulaire
Par exemple, si dans une classe de service il faut appeler une méthode pour obtenir certaines données, une autre pour obtenir d’autres données, puis une troisième pour obtenir des données à combiner avec les deux premières, quel est le sens de ce service ? Cela revient à exposer toute sa complexité interne à l’extérieur
Il ne s’agit pas d’imposer de petites méthodes. Vingt fonctions de 5 lignes appelées une seule fois, qui font quelque chose de très spécifique et doivent être appelées dans le bon ordre, n’ont pas de sens. Ce n’est pas du code propre, c’est plutôt de la programmation cargo cult
L’important est d’abstraire correctement, de façon à ce que le code ait du sens pour les nouveaux membres comme pour les membres expérimentés de l’équipe, qu’il soit facile à raisonner et que la complexité soit cachée au bon endroit. Ce n’est pas facile, mais c’est possible
Mon fils, bien qu’assez intelligent, a eu des difficultés à l’école, et l’un des nombreux spécialistes consultés a expliqué que l’école enseigne généralement de manière ascendante, alors que mon fils est un apprenant très descendant. Il a besoin d’abord d’une vue d’ensemble avant d’entrer dans les détails, tandis que d’autres doivent d’abord saisir les détails puis assembler la vue d’ensemble. L’école enseigne généralement pour ce second groupe
Il peut exister une différence similaire entre programmeurs
Si le développeur précédent a écrit une fonction
BakePizza, il suffit de supposer que la pizza sera correctement cuite et de passer à la ligne suivante. Si, en essayant de comprendre le fonctionnement du restaurant, on se perd dans des détails comme la température du four, on ne comprendra pas non plus comment le restaurant fonctionne et on oubliera même la température exacte du fourL’éditeur devrait avoir un bouton permettant d’inliner temporairement les fonctions. Plus besoin de faire des allers-retours