La conception agile et les symptômes de pourriture
Ce qu'est une conception agile, et les sept symptômes d'une conception qui pourrit — rigidité, fragilité, immobilité, viscosité…
En 1992, Jack Reeves signait dans le C++ Journal un article devenu fondateur, « Qu'est-ce que la conception logicielle ? » (What is Software Design?). Sa thèse : la conception d'un système logiciel est documentée avant tout par son code source. Les diagrammes qui le représentent sont accessoires ; ils ne sont pas la conception. Lorsque, dans ce livre, Martin parle de « la conception », il ne faut donc pas entendre un jeu de diagrammes UML séparé du code. Une conception est un concept abstrait : la forme et la structure d'ensemble du programme, mais aussi la forme et la structure détaillées de chaque module, classe et méthode. Elle peut s'exprimer par de nombreux médias, mais son incarnation finale, c'est le code source. Au bout du compte, le code source est la conception.
Cette idée est annonciatrice du développement agile. Et c'est sur ce socle que repose le chapitre 7 du PPP, qui pose la question fondatrice de la deuxième partie du livre : qu'est-ce qu'une conception agile (agile design) ? La réponse, on le verra, n'est pas un artefact mais un processus. Avant d'y arriver, il faut savoir reconnaître l'ennemi : les symptômes de la pourriture (design smells), ces odeurs caractéristiques que dégage un logiciel qui se décompose.
Ce qui arrive au logiciel
Avec un peu de chance, on démarre un projet avec une image claire de ce que le système doit être. Cette conception est vivante dans notre esprit. Avec plus de chance encore, sa clarté survit jusqu'à la première livraison. Puis quelque chose se dérègle. Le logiciel commence à pourrir comme un morceau de viande avariée. Le temps passant, la pourriture s'étend et grossit ; des plaies suppurantes s'accumulent dans le code, rendant la maintenance de plus en plus pénible.
Vient un moment où le simple effort d'apporter le plus anodin des changements devient si onéreux que les développeurs et les chefs de projet réclament une refonte (redesign). De telles refontes réussissent rarement. Les concepteurs partent avec de bonnes intentions, mais ils découvrent qu'ils tirent sur une cible mouvante : l'ancien système continue d'évoluer pendant que la nouvelle conception tente de le rattraper. Les verrues et les ulcères s'accumulent dans le nouveau code avant même qu'il n'atteigne sa première livraison.
Note
Un symptôme (smell) n'est pas un bug. C'est un signe subjectivement mesurable que la conception se dégrade. Il provient le plus souvent de la violation d'un ou plusieurs des principes que nous étudierons dans les chapitres suivants. Par exemple, l'odeur de la rigidité résulte fréquemment d'un manque d'attention au principe ouvert/fermé (Open-Closed Principle, OCP).
Les sept odeurs d'un logiciel qui pourrit
On sait que le logiciel pourrit lorsqu'il commence à dégager l'une des sept odeurs suivantes. Ce vocabulaire, propre à ce livre, est devenu un véritable langage commun pour diagnostiquer une conception malade.
Rigidité
La rigidité (rigidity) est la tendance d'un logiciel à être difficile à modifier, même pour des changements simples. Une conception est rigide quand un seul changement provoque une cascade de modifications subséquentes dans les modules dépendants. Plus le nombre de modules à toucher est grand, plus la conception est rigide.
Le scénario est universel. On demande au développeur ce qui ressemble à une retouche triviale. Il l'examine, fournit une estimation raisonnable. Mais à mesure qu'il avance, il découvre des répercussions qu'il n'avait pas anticipées. Le voilà à poursuivre son changement à travers d'immenses portions du code, à modifier bien plus de modules que prévu. Au final, le travail prend infiniment plus longtemps que l'estimation initiale. Et lorsqu'on l'interroge sur la médiocrité de son estimation, il répond par la complainte traditionnelle du développeur : « C'était beaucoup plus compliqué que je ne le pensais ! »
Fragilité
La fragilité (fragility) est la tendance d'un programme à casser à de nombreux endroits quand on lui apporte un seul changement. Souvent, les nouveaux problèmes surgissent dans des zones qui n'ont aucun rapport conceptuel avec la partie modifiée. Réparer ces problèmes en engendre d'autres, et l'équipe finit par ressembler à un chien qui court après sa propre queue.
À mesure que la fragilité d'un module augmente, la probabilité qu'un changement y introduise un problème inattendu approche la certitude. Cela semble absurde, mais ces modules ne sont pas rares : ce sont ceux qui ont constamment besoin de réparations, ceux qui ne quittent jamais la liste des bugs, ceux que tout le monde sait devoir refondre mais que personne n'ose affronter, ceux qui empirent à chaque correction.
Immobilité
Une conception est immobile (immobility) lorsqu'elle contient des parties qui pourraient être utiles à d'autres systèmes, mais que l'effort et le risque nécessaires pour les détacher du système d'origine sont trop élevés. On voudrait réutiliser un composant, mais il est si enchevêtré dans son contexte qu'on renonce et on réécrit le tout de zéro. Situation aussi regrettable que répandue.
Viscosité
La viscosité (viscosity) se présente sous deux formes : la viscosité du logiciel et celle de l'environnement.
Face à un changement, le développeur trouve d'ordinaire plus d'une façon de le réaliser. Certaines préservent la conception ; d'autres non — ce sont des bidouilles (hacks). Quand les méthodes respectueuses de la conception sont plus difficiles à employer que les bidouilles, la viscosité du logiciel est élevée. Il est facile de faire la mauvaise chose et difficile de faire la bonne. Or l'objectif inverse devrait nous guider : concevoir le logiciel pour que les changements qui préservent la conception soient les plus faciles à réaliser.
La viscosité de l'environnement apparaît quand l'environnement de développement est lent et inefficace. Si les temps de compilation sont très longs, les développeurs seront tentés de faire des changements qui ne déclenchent pas de grosses recompilations, même au prix de la conception. Si le système de gestion de versions exige des heures pour archiver quelques fichiers, on cherchera à minimiser le nombre d'archivages, peu importe que la conception en souffre. Dans les deux cas, un projet visqueux est un projet où il est difficile de préserver la conception.
Astuce
La viscosité est sournoise car elle ne se voit pas dans le code : c'est un défaut de l'outillage et de l'ergonomie du projet. Des builds rapides, des tests rapides, un contrôle de version fluide ne sont pas du confort superflu — ce sont des garde-fous qui rendent la bonne conception plus facile que la mauvaise.
Complexité inutile
Une conception contient de la complexité inutile (needless complexity) quand elle abrite des éléments qui ne servent à rien pour l'instant. Cela arrive typiquement lorsque les développeurs anticipent des changements d'exigences et garnissent le logiciel de dispositifs censés les absorber. Sur le moment, cela paraît sage : se préparer aux évolutions futures devrait garder le code flexible et éviter des cauchemars plus tard.
L'effet est hélas souvent l'inverse. À force de se préparer à trop d'éventualités, la conception se retrouve jonchée de constructions qui ne servent jamais. Quelques-unes paieront peut-être ; bien d'autres non. Pendant ce temps, la conception porte le poids de tous ces éléments inutilisés, ce qui rend le logiciel complexe et difficile à comprendre. C'est le péché de la conception spéculative — l'exact opposé de la sobriété agile.
Répétition inutile
Le copier-coller est une opération d'édition de texte parfois commode, mais une opération d'édition de code potentiellement désastreuse. Trop souvent, les systèmes sont bâtis sur des dizaines, voire des centaines de fragments de code répétés. Le mécanisme est toujours le même : un développeur a besoin d'un traitement, en repère un similaire ailleurs, le copie dans son module et l'adapte. Sauf que le fragment qu'il a récupéré avait lui-même été copié par quelqu'un d'autre, qui l'avait lui-même pris ailleurs.
Quand le même code réapparaît encore et encore, sous des formes légèrement différentes, c'est qu'une abstraction manque. Trouver ces répétitions et les unifier sous une abstraction appropriée n'est peut-être pas en tête des priorités, mais cela ferait beaucoup pour rendre le système plus compréhensible et maintenable. Car avec du code redondant, modifier le système devient ardu : un bug trouvé dans une unité répétée doit être corrigé dans chaque répétition. Et comme chacune diffère légèrement des autres, la correction n'est jamais tout à fait la même.
Opacité
L'opacité (opacity) est la tendance d'un module à être difficile à comprendre. Le code peut être écrit de manière claire et expressive, ou bien opaque et alambiquée. Le code qui évolue dans le temps tend à devenir de plus en plus opaque avec l'âge ; un effort constant est nécessaire pour le maintenir limpide.
Quand un développeur écrit un module, le code lui semble clair — parce qu'il y est immergé et le comprend intimement. Plus tard, l'intimité dissipée, il y revient et se demande comment il a pu écrire quelque chose d'aussi affreux. Pour éviter cela, il faut se mettre à la place de ses lecteurs et faire l'effort délibéré de remanier (refactor) son code afin qu'ils le comprennent — et faire relire son code par d'autres.
Tableau de synthèse
Les sept symptômes, leur définition et leur remède de principe :
| Symptôme | Définition | Remède |
|---|---|---|
| Rigidité (rigidity) | Un changement en force beaucoup d'autres en cascade. | Inverser les dépendances ; appliquer OCP et DIP. |
| Fragilité (fragility) | Le système casse à des endroits sans rapport avec le changement. | Isoler les responsabilités (SRP) ; couvrir de tests. |
| Immobilité (immobility) | Impossible de réutiliser un composant tant il est couplé. | Découpler via des interfaces ; respecter ISP et DIP. |
| Viscosité (viscosity) | La mauvaise solution est plus facile que la bonne (logiciel) ; builds et tests lents (environnement). | Rendre la bonne voie la plus facile ; accélérer build et tests. |
| Complexité inutile (needless complexity) | Une infrastructure qui n'apporte aucun bénéfice actuel. | Ne rien anticiper ; supprimer le code spéculatif (YAGNI). |
| Répétition inutile (needless repetition) | Des structures répétées qu'une abstraction unifierait. | Factoriser ; ne pas se répéter (DRY). |
| Opacité (opacity) | Code difficile à lire, qui n'exprime pas son intention. | Remanier sans relâche ; faire relire son code. |
Ce qui pousse le logiciel à pourrir
Dans les environnements non agiles, les conceptions se dégradent parce que les exigences évoluent d'une manière que la conception initiale n'avait pas anticipée. Ces changements doivent souvent être faits vite, parfois par des développeurs qui ne connaissent pas la philosophie de conception d'origine. Si bien que, même quand le changement fonctionne, il viole d'une façon ou d'une autre la conception initiale. Petit à petit, à mesure que les changements s'accumulent, ces violations s'additionnent — et la conception commence à sentir.
Mais on ne peut pas accuser la dérive des exigences de cette dégradation. Nous, développeurs, savons pertinemment que les exigences changent. Mieux : la plupart d'entre nous savons que les exigences sont l'élément le plus volatil d'un projet. Si nos conceptions échouent sous la pluie constante des exigences qui changent, alors ce sont nos conceptions et nos pratiques qui sont fautives. Il nous faut trouver le moyen de rendre nos conceptions résilientes à ces changements, et adopter des pratiques qui les protègent de la pourriture.
À retenir
La cause profonde des sept symptômes est toujours la même : des dépendances mal gérées face à des exigences qui changent. Le code se dégrade non pas parce que les exigences bougent, mais parce que la structure des dépendances n'a pas été conçue pour absorber ce mouvement. C'est précisément ce que les principes SOLID et les principes de packaging s'emploieront à corriger.
L'exemple du programme « Copy »
Pour rendre la pourriture tangible, le livre déroule une petite parabole. Lundi matin, le chef demande un programme qui copie des caractères du clavier vers l'imprimante. Quelques calculs mentaux : moins de dix lignes, conception et codage en moins d'une heure. La conception structurée donne trois modules — Copy appelle LireClavier et EcrireImprimante — et le code initial est limpide :
// Version 1 : simple et élégante. Pour le besoin du jour.
function copier(): void {
let c: number;
while ((c = lireClavier()) !== EOF) {
ecrireImprimante(c);
}
} Quelques mois plus tard, le chef revient : il faudrait parfois lire depuis le lecteur de bande perforée. Le programme n'a pas été conçu pour ça. On voudrait bien ajouter un argument booléen à copier, mais tant d'autres programmes l'utilisent désormais qu'on ne peut plus changer l'interface — ce serait des semaines de recompilation et de tests. La solution de facilité : un drapeau global et l'opérateur ternaire.
// Version 2 : la pourriture s'installe. Un drapeau global,
// que l'appelant doit penser à réinitialiser. Un commentaire
// pour s'en souvenir... ce qui en dit long.
let drapeauBande = false;
// penser à réinitialiser ce drapeau
function copier(): void {
let c: number;
while ((c = (drapeauBande ? lireBande() : lireClavier())) !== EOF) {
ecrireImprimante(c);
}
} Puis le chef demande de pouvoir aussi écrire vers la perforatrice de bande. Même remède : un second drapeau global, un second ternaire.
// Version 3 : la structure commence à s'effondrer. Tout
// nouveau périphérique forcera à restructurer la boucle.
let drapeauBande = false;
let drapeauPerforatrice = false;
// penser à réinitialiser ces drapeaux
function copier(): void {
let c: number;
while ((c = (drapeauBande ? lireBande() : lireClavier())) !== EOF) {
drapeauPerforatrice ? ecrirePerforatrice(c) : ecrireImprimante(c);
}
} Piège courant
Après seulement deux changements, le programme Copy, initialement simple et élégant, présente déjà les signes de la rigidité, de la fragilité, de l'immobilité, de la complexité, de la redondance et de l'opacité. Et la tendance va se poursuivre : le programme deviendra une bouillie. On pourrait blâmer les changements — mais ce serait ignorer le fait le plus saillant du développement : les exigences changent toujours.
La version agile
Une équipe agile aurait pu démarrer exactement avec la version 1. Mais quand le chef demande de lire depuis la bande, au lieu de rapiécer la conception pour faire passer le nouveau besoin, elle saisit l'occasion de rendre la conception résiliente à ce type de changement. Elle introduit une abstraction Lecteur et applique le patron stratégie (Strategy).
// Version agile : la dépendance est inversée. Copy ne dépend
// plus d'un périphérique concret, mais d'une abstraction.
interface Lecteur {
lire(): number;
}
class LecteurClavier implements Lecteur {
lire(): number {
return lireClavier();
}
}
// Désormais, tout nouveau périphérique d'entrée s'ajoute SANS
// modifier copier : il suffit d'une nouvelle classe Lecteur.
function copier(lecteur: Lecteur): void {
let c: number;
while ((c = lecteur.lire()) !== EOF) {
ecrireImprimante(c);
}
} Ce faisant, l'équipe a suivi le principe ouvert/fermé (Open-Closed Principle, OCP), que nous étudierons au chapitre 9. Ce principe nous enjoint de concevoir nos modules de telle sorte qu'ils puissent être étendus sans être modifiés — et c'est exactement ce que l'équipe a réalisé : chaque nouveau périphérique d'entrée que le chef réclamera pourra être fourni sans toucher au module copier. Le même remède peut donc se lire de deux façons complémentaires : comme l'application de l'OCP — on ouvre le module à l'extension tout en le fermant à la modification — et comme une inversion de dépendance réalisée par le patron stratégie, ainsi qu'on le détaillera plus loin.
Deux points méritent d'être soulignés. D'abord, l'équipe n'a pas tenté de deviner comment le programme allait changer lors de la conception initiale : elle l'a écrit le plus simplement possible. Ce n'est que lorsque l'exigence a effectivement changé qu'elle a rendu le module résilient à ce type de changement. Ensuite, l'équipe a protégé le programme contre les changements de périphérique d'entrée, mais pas de sortie — car rien n'indiquait que la sortie changerait un jour, et ajouter cette protection maintenant aurait été du travail sans utilité présente. Si le besoin survient, il sera facile de l'ajouter alors. Ainsi naît la conception agile : minimaliste, mais prête à s'ouvrir là où le réel l'exige.
Comment les développeurs agiles ont-ils su quoi faire ?
La conception initiale du Copy est inflexible à cause de la direction de ses dépendances. Le module Copy est un module de haut niveau : il porte la politique de l'application, il sait comment copier des caractères. Mais il a été rendu dépendant des détails de bas niveau du clavier et de l'imprimante. Du coup, quand les détails de bas niveau changent, la politique de haut niveau en est affectée. Une fois cette inflexibilité exposée, les développeurs ont su qu'il fallait inverser la dépendance allant de Copy vers le périphérique d'entrée — c'est le principe d'inversion des dépendances (Dependency-Inversion Principle, DIP), traité plus loin — puis employer le patron stratégie pour réaliser cette inversion. C'est l'autre face d'un même geste : ce que l'on vient de décrire comme le respect du principe ouvert/fermé (OCP) se réalise concrètement par l'inversion d'une dépendance, le patron stratégie servant de mécanisme à cette inversion.
En somme, les développeurs agiles ont su quoi faire en suivant un cycle en trois temps :
- ils ont détecté le problème en suivant des pratiques agiles ;
- ils l'ont diagnostiqué en appliquant des principes de conception ;
- ils l'ont résolu en appliquant le patron de conception approprié.
Astuce
C'est l'interaction entre ces trois aspects — pratiques, principes, patterns — qui constitue l'acte de concevoir. Aucun ne suffit seul : sans pratiques, on ne voit pas le mal ; sans principes, on ne le nomme pas ; sans patterns, on ne le soigne pas.
Garder la conception aussi bonne que possible
Les développeurs agiles se dédient à maintenir la conception aussi appropriée et propre que possible. Ce n'est pas un engagement nonchalant ou intermittent : ils ne « nettoient » pas la conception toutes les quelques semaines. Ils gardent le logiciel aussi propre, simple et expressif qu'ils le peuvent, chaque jour, chaque heure, chaque minute. Ils ne disent jamais « on reviendra arranger ça plus tard ». Ils ne laissent jamais la pourriture commencer.
Une équipe agile prospère sur le changement. Elle investit peu en amont ; aussi n'est-elle pas captive d'une conception initiale qui vieillit. Elle garde plutôt la conception du système aussi propre et simple que possible, et l'étaye par quantité de tests unitaires et de tests d'acceptation. C'est ce couple de tests qui maintient la conception flexible et facile à modifier : l'équipe peut alors s'appuyer sur cette flexibilité pour améliorer la conception en continu, de sorte que chaque itération s'achève sur un système dont la conception est aussi appropriée que possible pour les exigences de cette itération.
L'attitude du développeur agile envers sa conception est celle du chirurgien envers la procédure stérile. La stérilité est ce qui rend la chirurgie possible : sans elle, le risque d'infection serait intolérable. Le développeur agile ressent la même chose : le risque de laisser le moindre germe de pourriture s'installer est trop élevé pour être toléré. La conception doit rester propre — et puisque le code source en est l'expression la plus importante, lui aussi doit rester propre.
Qu'est-ce donc que la conception agile ?
La conception agile n'est pas un événement, c'est un processus. Ce n'est pas un grand acte de conception en amont (Big Design Up Front), figé dans des diagrammes avant la première ligne de code. C'est l'application continue de principes, de patterns et de pratiques pour améliorer la structure et la lisibilité du logiciel. C'est l'engagement de garder la conception du système aussi simple, propre et expressive que possible, à tout instant.
À retenir
La conception est un processus, pas un artefact. Les principes SOLID et les principes de packaging qui suivent dans ce livre ne s'appliquent pas à une grande conception initiale figée. Ils s'appliquent d'itération en itération, à chaque changement, pour garder le code — et la conception qu'il incarne — propre. C'est la différence essentielle entre une conception qui pourrit et une conception qui vit.
Les chapitres qui suivent vont explorer ces principes et ces patterns un à un : le principe de responsabilité unique (Single-Responsibility Principle, SRP), le principe ouvert/fermé (OCP), le principe de substitution de Liskov (Liskov Substitution Principle, LSP), le principe d'inversion des dépendances (DIP) et le principe de ségrégation des interfaces (Interface Segregation Principle, ISP) — les cinq principes que l'on regroupe sous l'acronyme SOLID —, puis les principes de cohésion et de couplage des paquets (REP, CCP, CRP, ADP, SDP, SAP). Tous ont un même but : éliminer les symptômes de pourriture et bâtir la meilleure conception possible pour le jeu d'exigences du moment.
Attention
On n'applique les principes que lorsqu'il y a un symptôme. Se conformer à un principe inconditionnellement, juste parce que c'est un principe, est une erreur : les principes ne sont pas un parfum à répandre partout dans le système. La surconformité aux principes mène droit au symptôme de la complexité inutile. Le bon réflexe agile : sentir l'odeur d'abord, appliquer le principe ensuite.
À retenir
- Le code source est la conception : les diagrammes ne sont qu'accessoires, et la conception n'est pas un jeu d'UML figé en amont mais la forme du programme telle que le code l'incarne.
- Une conception qui pourrit dégage sept odeurs : rigidité, fragilité, immobilité, viscosité, complexité inutile, répétition inutile, opacité.
- Rigidité = un changement en force beaucoup d'autres ; fragilité = ça casse à des endroits sans rapport ; immobilité = impossible de réutiliser tant c'est couplé.
- La viscosité est double : celle du logiciel (la bidouille est plus facile que la bonne solution) et celle de l'environnement (builds et tests lents). Rendez la bonne voie la plus facile.
- La cause de tous les symptômes est la même : des dépendances mal gérées face à des exigences qui changent — et les exigences changent toujours.
- L'acte de concevoir tient en trois temps : détecter (pratiques), diagnostiquer (principes), résoudre (patterns).
- La conception agile est un processus, pas un événement : on garde le code propre en continu, et l'on n'applique un principe que face à un symptôme, sous peine de complexité inutile.