Préférez la duplication à la mauvaise abstraction (2016)
(sandimetz.com)- La duplication de code coûte bien moins cher qu’une mauvaise abstraction, et une généralisation prématurée augmente les coûts de maintenance à long terme
- Même une extraction raisonnable au départ finit, lorsque les besoins divergent peu à peu, par se charger de paramètres et de conditions, ce qui brouille l’intention initiale
- Quand une abstraction commune commence à porter plusieurs idées à la fois, le code se transforme en procédure centrée sur les conditions, et plus on ajoute de fonctionnalités, plus il devient fragile
- Il faut se méfier du biais des coûts irrécupérables, qui pousse à protéger les efforts déjà investis dans le code, et si nécessaire réintégrer l’abstraction dans les sites d’appel pour ne conserver que le code réellement utile
- Lorsqu’une mauvaise abstraction est mise en évidence, il est souvent plus rapide de réintroduire la duplication, d’observer à nouveau les points communs des besoins actuels, puis d’extraire ensuite une nouvelle abstraction
Comment se forme une mauvaise abstraction
- La phrase « duplication is far cheaper than the wrong abstraction » faisait partie d’une présentation à RailsConf 2014, mais elle continue d’être souvent citée depuis
- Voici un chemin d’échec fréquent
- Le développeur A repère une duplication
- Il extrait cette duplication dans une méthode ou une classe, lui donne un nom et crée une nouvelle abstraction
- Il remplace le code répétitif des sites d’appel par des appels à cette nouvelle abstraction
- Avec le temps, une nouvelle exigence apparaît, presque identique mais pas tout à fait
- Le développeur B essaie de conserver l’abstraction existante, ajoute des paramètres et introduit des conditions qui empruntent des chemins différents selon les valeurs
- Ensuite, à chaque nouvelle exigence, les paramètres et les conditions se multiplient, et le code devient de plus en plus difficile à comprendre
- Une fois créé, le code donne facilement l’impression d’un investissement à préserver
- On est psychologiquement réticent à abandonner l’effort déjà fourni
- Plus le code est complexe et difficile à comprendre, plus il semble important et long à produire, donc difficile à jeter
- Cela rejoint le biais des coûts irrécupérables
Revenir à la duplication puis extraire à nouveau
- Si l’on continue à implémenter de nouvelles exigences sur une mauvaise abstraction, le code partagé finit par être dominé par les conditions et devient plus instable à mesure qu’on ajoute des fonctionnalités
- Dans ce cas, la voie rapide n’est pas de forcer davantage, mais de revenir en arrière
- Réintégrer le code abstrait dans chaque site d’appel pour réintroduire la duplication
- Vérifier, à partir des paramètres transmis dans chaque site d’appel, quel code est réellement exécuté
- Supprimer le code inutile pour ce site d’appel
- Le processus de réintégration élimine à la fois l’abstraction et les conditions, et ramène chaque site d’appel à un état où il ne contient que le code dont il a besoin
- Un code qui semblait appeler la même abstraction pouvait en réalité exécuter des chemins de code très spécifiques selon les sites d’appel
- Ce n’est qu’après avoir complètement supprimé l’ancienne abstraction qu’on peut réobserver la duplication et extraire une nouvelle abstraction adaptée aux besoins actuels
- Si l’on continue d’ajouter des paramètres et des chemins conditionnels dans le code partagé, il y a de fortes chances que cette abstraction ne soit plus adaptée
- Elle pouvait être pertinente au départ
- Mais l’évolution des besoins a pu la rendre difficile à maintenir sous la même forme
- Face à une mauvaise abstraction, réintroduire la duplication n’est pas un recul, mais une meilleure façon d’avancer
5 commentaires
Je ne sais pas vraiment si c’est un sujet qui nécessite une interprétation aussi binaire.
Oh, je me reconnais tout à fait là-dedans.
Ce qui n’est pas encore structuré, on peut l’organiser,
mais quand c’est déjà structuré, j’ai l’impression que le coût pour tout remettre à plat est bien plus élevé.
Ponytail l’a posté, et c’est exactement le genre d’article, haha.
C’est toujours aussi conflictuel.
Commentaires Hacker News
Je pense que le principe de la source unique de vérité (single source of truth) doit toujours être respecté
Si du code dupliqué devient un bug dès qu’il diverge, il faut le refactoriser. Sinon, on crée un couplage à distance que les futurs développeurs auront du mal à remarquer avant qu’un bug n’explose
En revanche, tant que ce principe n’est pas violé, l’abstraction n’est qu’une commodité, et si elle commence à devenir pénible, c’est qu’elle ne remplit plus son rôle et qu’il n’y a pas de raison de l’utiliser. Si une fonction a besoin de plusieurs flags pour fournir un comportement sur mesure, il y a de fortes chances que ce soit une mauvaise abstraction ou une violation du principe de responsabilité unique
S’il faut vraiment beaucoup de personnalisation, il est souvent préférable de passer une fonction/un foncteur en argument. Par exemple, au lieu de
solve(f:double -> double, max_iters = 99, x_abs_tol = 1e-15, x_rel_tol = 1e-15, ...), on peut fairesolve(f:double -> double, stopping_criteria: StoppingCriteriaClass)On ne sait pas si deux endroits du code utilisent le même algorithme, ou une version légèrement différente, et surtout s’ils changeront pour les mêmes raisons
La maxime du titre dit qu’il est plus douloureux de forcer des choses différentes à être identiques que de dupliquer des choses identiques puis de les différencier plus tard, et je suis d’accord. Dans le second cas, il suffit d’appliquer le même changement deux fois ou de faire un refactoring qui introduit une abstraction, alors que dans le premier il faut continuer à empiler des rustines sur l’abstraction ou la défaire
En particulier, cela casse la localité (locality), alors que c’est la propriété la plus importante quand on modifie quelque chose. J’ai juste envie de faire ce changement sans m’inquiéter d’effets de bord dans des parties du système sans rapport
mainsi les deux sources ne correspondent pasL’exemple classique, c’est la synchronisation entre pyproject.toml et requirements.txt, qui peut parfois être réellement la meilleure option, et l’idée semble plus largement applicable. Le présupposé, c’est que la situation s’est déjà tellement dégradée qu’une source unique de vérité est impossible ; on est plus dans la réduction des dégâts que dans le traitement
J’ai souvent vu deux morceaux de code paraître similaires à un moment donné, être sur-abstraits, puis finir par diverger plus tard
En particulier, les développeurs juniors traitent parfois la duplication comme si c’était la racine de tous les maux
Je pense parfois à ce problème. Je l’ai rencontré récemment dans un projet perso en manipulant des sprites 2D pour des unités de RTS : les sprites d’unités étaient rangés dans une sprite sheet de manière cohérente, avec 5 sprites pour 8 directions, dont 3 obtenues par miroir, et dans l’ordre stand, move, attack, die
J’ai donc créé un loader qui prend action + direction et renvoie le tableau de sprites à jouer
Puis sont arrivés des sprites d’explosion sans direction, des sprites de cadavre avec 4 directions et seulement 2 miroirs, et même le fait que les orcs et les humains partagent en grande partie les mêmes sprites sauf les quatre premiers
Je me suis brièvement demandé quelle pouvait bien être l’abstraction commune à tout cela, puis j’ai simplement extrait une partie du code de chargement, créé
UnitLoader,CorpseLoaderetEffectLoader, et je suis passé à autre chose. Il existe peut-être une meilleure abstraction puisque les trois loaders traitent des choses un peu similaires, mais je la découvrirai plus tard si besoin. Il est plus simple d’éliminer cette duplication plus tard que d’essayer maintenant de créer unEverythingLoadercomplexe qui gère tous les casEn programmation, on a l’instinct de simplifier le code par la généralisation, mais la réalité est désordonnée, donc on simplifie souvent trop. Comme dans le texte, avec le temps et l’arrivée de nouvelles exigences, on découvre que c’était une simplification trop précoce
« L’abstraction prématurée est la source de beaucoup de code pourri » ferait une bonne maxime
À l’étage au-dessus, l’interprétation de la disposition de la sprite sheet et la gestion des modes de lecture ont plusieurs variantes, et il se peut qu’il n’existe pas d’abstraction commune valable pour tous les cas
Je préfère la solution actuelle plutôt que de forcer une abstraction invisible ou d’essayer de faire entrer les choses dans une abstraction incomplète. Attendre qu’une abstraction soit totalement claire et que le besoin soit évident est une bonne chose
Il existe un antidote opposé à DRY : WET. L’idée est d’écrire les choses deux ou trois fois. Plus important encore, je pense qu’il ne faut abstraire que des cas d’usage réellement prouvés, généralement ceux qui se révèlent d’abord sous forme de duplication. Le code écrit pour de futurs cas d’usage qui n’existent pas encore gêne souvent l’abstraction de ce qu’on a réellement, et c’est assez drôle à chaque fois que ça arrive
Le travail difficile et ennuyeux peut attendre qu’on atteigne les 10 % finaux du projet
En plus, les « bugs » créés par la duplication deviennent parfois des fonctionnalités amusantes que les joueurs adorent
À l’époque où j’utilisais la POO, j’ai souffert à cause des abstractions, mais depuis que je suis passé à une approche presque purement fonctionnelle, la duplication de code est devenue rare
Il suffit de créer une fonction et de l’appeler à deux endroits. Le principal problème d’abstraction concerne les structures de données, mais les interfaces TypeScript relèvent essentiellement du duck typing, donc là aussi ce n’est pas vraiment un gros problème
Du coup, la duplication de code causée par des problèmes d’abstraction est rare. La duplication de code causée par le travail en silo des développeurs est bien plus fréquente
La plupart des langages modernes peuvent assez facilement s’appuyer sur la théorie de la programmation fonctionnelle, et il n’est pas nécessaire de connaître Haskell. Chaque cerveau fonctionne différemment, bien sûr, mais l’idée que l’ensemble se construit à partir de petites pièces simples, parfois souples, me convient bien
C’est l’opposé des grosses machines de transformation complexes qui font tout
Dès qu’une équipe dépasse une certaine taille, au point que chacun ne peut plus savoir ce que font tous les autres, la duplication de code devient assez inévitable. Et ce serait pareil même si tout le monde écrivait dans un style fonctionnel
C’est d’ailleurs arrivé dans mon entreprise le mois dernier. J’avais écrit une nouvelle fonction helper pure et l’avais placée au début du fichier ; une semaine plus tard, un collègue m’a signalé qu’une helper pratiquement équivalente, mais avec une signature différente, existait déjà à la fin du même fichier
Dans le même esprit que l’article, quiconque a connu les deux sera d’accord. Une base de code sous-conçue est bien plus facile à gérer qu’une base de code surconçue.
Le pire code que j’ai eu à maintenir était du code qui essayait d’appliquer DRY, sans chercher à comprendre l’intention d’origine de ce principe.
Le seul moyen de sortir de ce chaos a été de réintroduire une duplication de code à grande échelle.
Cela me rappelle deux présentations : Data-Oriented Design and C++ de Mike Acton [1] et The Complexity of Simplicity de Brian Cantrill [2].
La présentation de Mike explique qu’une solution logicielle n’a pas besoin de modéliser le monde réel, que des données différentes créent des problèmes différents et nécessitent donc des solutions différentes. Il est difficile de rendre justice à cette présentation, mais elle m’a profondément influencé.
La présentation de Brian traite de l’abstraction en général et de la difficulté qu’il y a à trouver la « bonne » abstraction.
Il y a quelques années, peu après être sorti de l’école, j’implémentais un pool de connexions en Rust, et l’implémentation la plus raisonnable consistait à faire en sorte que l’objet connexion conserve une référence faible vers le pool pour y être automatiquement renvoyé lors du
drop.Mon manager, qui était très expérimenté, n’aimait pas cette idée parce que « c’est la bibliothèque qui possède les livres, pas les livres qui possèdent la bibliothèque ». Je ne trouvais pas cela suffisamment convaincant pour changer la conception, mais il refusait d’aborder le problème autrement qu’à travers le prisme de cette métaphore.
Finalement, un autre manager a débloqué la situation en suggérant que « les livres de bibliothèque ne contiennent pas la bibliothèque, mais ils ont bien au dos un tampon avec le nom de la bibliothèque à laquelle les rendre ». Ce manager semblait juger cette extension de la métaphore raisonnable.
Si j’avais eu plus d’expérience, j’aurais peut-être trouvé comment discuter à l’intérieur de cette métaphore sans céder sur le fond, mais encore aujourd’hui je trouve totalement bizarre qu’on ait imposé cette métaphore comme cadre de référence standard au lieu d’examiner les abstractions du code et les conséquences sur l’expérience d’utilisation de la bibliothèque.
Personne n’écoute. Absolument personne n’écoute. Dans 90 % des entreprises, il y a ce qu’on appelle des développeurs seniors qui s’extasient dès qu’ils peuvent créer une nouvelle abstraction.
Surconception, abstraction et optimisation prématurée sont les trois grandes calamités de l’ingénierie.
En même temps, je m’en réjouis un peu, parce que grâce à elles il y aura toujours du travail.
Dans le même ordre d’idées, certains développeurs semblent considérer que les chaînes inline ou les constantes numériques sont intrinsèquement mauvaises. J’ai vu ça dans une PR :
HTTPS_SCHEME = 'https'DOMAIN = 'www.example.com'url = HTTPS_SCHEME + '://' + DOMAINJe ne vois pas ce que cela apporte, à part suivre comme un cargo cult l’idée qu’il ne faut pas « hardcoder » les constantes. En plus, les définitions des constantes étaient tout en haut du fichier, alors que le code qui construisait l’URL se trouvait des centaines de lignes plus bas.
Les regex aussi, pas besoin de les mettre en haut du fichier ; elles peuvent être placées là où on les utilise. Le langage est assez intelligent pour probablement comprendre qu’il s’agit de constantes.
Si c’est une toute petite fonction, utilisez simplement une lambda. J’aimerais qu’on évite de définir très loin un one-liner qu’on n’utilise qu’une ou deux fois.
S’il faut remplacer
httpsparhttpen test ou en staging, il est logique de séparer le schéma et le domaine, et de mettre les constantes en haut ou dans un fichier distinct. Le fait queurlsoit construite à plusieurs endroits ou à un seul compte aussi.Mettre des constantes nommées en haut d’un fichier est un style très courant, et parfois même une partie des standards de code d’une équipe.
Il peut aussi y avoir d’autres raisons, donc il est bon de penser à Chesterton’s Fence. En tout cas, conclure d’emblée à un cargo cult n’est pas une bonne idée. Quelqu’un pourrait tout aussi bien dire que l’usage de littéraux inline relève, lui aussi, d’un cargo cult. Si cela vous semble étrange, demandez : il y a peut-être une bonne raison, ou bien personne n’y a vraiment réfléchi et tout le monde sera content que vous refactoriez pour réintégrer les constantes inline.
Si on l’extrait dans une constante, il faut alors rouvrir les projets un par un et utiliser Find Usages.
Avec les microservices, on peut faire les deux.
Si vous maintenez un service, vous n’avez aucune raison de vous soucier du code d’un autre service. Pourquoi vous en soucieriez-vous, puisque c’est le code d’une autre équipe ? Vous n’avez même pas besoin de savoir que cette équipe existe. Dans les grands systèmes, il est parfois irréaliste de connaître l’existence de toutes les applications.
Pour seulement 19,95 $, nous transformons votre point unique de défaillance en plusieurs points uniques de défaillance !
Mieux vaut utiliser une architecture orientée services tout en déployant simplement un monolithe. C’est plus facile à tester et cela évite la couche supplémentaire de sérialisation/désérialisation.
La plupart des seniors savent, je pense, qu’il ne faut pas suivre DRY aveuglément. Malgré cela, beaucoup d’entre nous restent mal à l’aise à l’idée de devoir maintenir plusieurs sources de code dupliqué
Pour traiter ce sujet, il faut examiner de près le modèle simple où deux appelants dépendent d’un code commun. Si ce code commun doit être modifié uniquement pour répondre aux besoins d’un seul appelant, alors ce code n’appartient pas au commun
Le mauvais objectif de DRY consiste à vouloir résoudre cela par l’encapsulation. L’encapsulation déplace le travail de refactoring des appelants vers le code commun. Mais comme l’impact d’une mise à jour du code commun est bien plus large que celui d’une mise à jour des appelants, ce n’est pas la direction souhaitable
On peut respecter DRY tout en évitant l’encapsulation. Il vaut mieux avoir plusieurs abstractions fines dont les appelants doivent être conscients. En POO, cela passe par SRP et IoC ; en programmation procédurale, cela apparaît naturellement sous la forme d’une série de fonctions utilitaires à appeler