J'ai un projet. Dans ce projet, je souhaitais le refactoriser pour ajouter une fonctionnalité, et j'ai refactorisé le projet pour ajouter la fonctionnalité.
Le problème est que lorsque j'ai terminé, il s'est avéré que je devais apporter une modification mineure à l'interface pour l'adapter. J'ai donc fait le changement. Et puis la classe consommatrice ne peut pas être implémentée avec son interface actuelle en termes de nouvelle, donc elle a également besoin d'une nouvelle interface. Maintenant, c'est trois mois plus tard, et j'ai dû résoudre d'innombrables problèmes pratiquement indépendants, et je cherche à résoudre des problèmes qui ont été planifiés depuis un an ou simplement répertoriés comme ne pouvant pas être résolus en raison de difficultés avant que la chose ne se compile. encore.
Comment puis-je éviter à l'avenir ce type de refactoring en cascade? Est-ce juste un symptôme de mes cours précédents qui dépendent trop étroitement les uns des autres?
Brève modification: dans ce cas, le refactor était la fonctionnalité, car le refactor augmentait l'extensibilité d'un morceau de code particulier et diminuait le couplage. Cela signifiait que les développeurs externes pouvaient faire plus, ce qui était la fonctionnalité que je voulais offrir. Ainsi, le refactoriseur d'origine lui-même n'aurait pas dû être un changement fonctionnel.
Plus gros montage que j'ai promis il y a cinq jours:
Avant de commencer ce refactor, j'avais un système où j'avais une interface, mais dans l'implémentation, j'ai simplement dynamic_cast
à travers toutes les implémentations possibles que j'ai expédiées. Cela signifiait évidemment que vous ne pouviez pas simplement hériter de l'interface, d'une part, et d'autre part, qu'il serait impossible pour quiconque sans accès à l'implémentation d'implémenter cette interface. J'ai donc décidé que je voulais résoudre ce problème et ouvrir l'interface pour la consommation publique afin que n'importe qui puisse l'implémenter et que la mise en œuvre de l'interface soit tout le contrat requis - évidemment une amélioration.
Quand j'ai trouvé et tué avec le feu tous les endroits où j'avais fait cela, j'ai trouvé un endroit qui s'est avéré être un problème particulier. Cela dépendait des détails d'implémentation de toutes les différentes classes dérivées et des fonctionnalités dupliquées qui étaient déjà implémentées mais mieux ailleurs. Il aurait pu être implémenté en termes d'interface publique à la place et réutiliser l'implémentation existante de cette fonctionnalité. J'ai découvert qu'il fallait un élément de contexte particulier pour fonctionner correctement. En gros, l'implémentation précédente appelante ressemblait un peu à
for(auto&& a : as) {
f(a);
}
Cependant, pour obtenir ce contexte, je devais le changer en quelque chose de plus comme
std::vector<Context> contexts;
for(auto&& a : as)
contexts.Push_back(g(a));
do_thing_now_we_have_contexts();
for(auto&& con : contexts)
f(con);
Cela signifie que pour toutes les opérations qui faisaient partie de f
, certaines d'entre elles doivent faire partie de la nouvelle fonction g
qui fonctionne sans contexte, et certaines d'entre elles ont besoin doit être constitué d'une partie du f
désormais différé. Mais toutes les méthodes appelant f
n'ont pas besoin ou ne veulent pas de ce contexte - certaines d'entre elles ont besoin d'un contexte distinct qu'elles obtiennent par des moyens séparés. Donc, pour tout ce que f
finit par appeler (ce qui est, grosso modo, à peu près tout), j'ai dû déterminer quel contexte, le cas échéant, ils avaient besoin, où ils devraient l'obtenir à partir de, et comment les séparer de l'ancien f
en un nouveau f
et un nouveau g
.
Et c'est comme ça que je me suis retrouvé là où je suis maintenant. La seule raison pour laquelle j'ai continué c'est parce que j'avais besoin de ce refactoring pour d'autres raisons de toute façon.
La dernière fois que j'ai essayé de commencer une refactorisation avec des conséquences imprévues, et je n'ai pas pu stabiliser la construction et/ou les tests après n jour, j'ai abandonné et j'ai rétabli la base de code au point avant la refactorisation.
Ensuite, j'ai commencé à analyser ce qui n'allait pas et j'ai développé un meilleur plan pour faire le refactoring en plus petites étapes. Donc, mon conseil pour éviter les refactorings en cascade est juste: savoir quand s'arrêter, ne laissez pas les choses échapper à votre contrôle!
Parfois, vous devez mordre la balle et jeter une journée complète de travail - certainement plus facile que de jeter trois mois de travail. Le jour que vous perdez n'est pas complètement vain, au moins vous avez appris comment ne pas aborder le problème. Et d'après mon expérience, il existe toujours des possibilités de faire de plus petites étapes de refactoring.
Note complémentaire : vous semblez être dans une situation où vous devez décider si vous êtes prêt à sacrifier trois mois complets de travail et recommencer avec un nouveau plan de refactorisation (et, espérons-le, plus efficace). Je peux imaginer que ce n'est pas une décision facile, mais demandez-vous quel est le risque dont vous avez besoin pendant trois mois non seulement pour stabiliser la construction, mais aussi pour corriger tous les bugs imprévus que vous avez probablement introduits lors de votre réécriture = vous avez fait les trois derniers mois? J'ai écrit "réécrire", parce que je suppose que c'est vraiment ce que vous avez fait, pas un "refactoring". Il n'est pas improbable que vous puissiez résoudre votre problème actuel plus rapidement en revenant à la dernière révision où votre projet se compile et commencez par un réel refactoring (par opposition à "réécrire") à nouveau.
Est-ce juste un symptôme de mes cours précédents qui dépendent trop étroitement les uns des autres?
Sûr. Un changement provoquant une myriade d'autres changements est à peu près la définition du couplage.
Comment éviter les refacteurs en cascade?
Dans les pires types de bases de code, un seul changement continuera de se répercuter en cascade, ce qui vous amènera éventuellement à (presque) tout changer. Une partie de tout refactor où le couplage est répandu consiste à isoler la pièce sur laquelle vous travaillez. Vous devez refactoriser non seulement où votre nouvelle fonctionnalité touche ce code, mais où tout le reste touche ce code.
Habituellement, cela signifie faire des adaptateurs pour aider l'ancien code à fonctionner avec quelque chose qui ressemble et se comporte comme l'ancien code, mais utilise la nouvelle implémentation/interface. Après tout, si tout ce que vous faites est de changer l'interface/l'implémentation mais en laissant le couplage, vous ne gagnez rien. C'est du rouge à lèvres sur un cochon.
On dirait que votre refactoring était trop ambitieux. Un refactoring doit être appliqué par petites étapes, chacune pouvant être achevée en (disons) 30 minutes - ou, dans le pire des cas, au plus une journée - et laisse le projet constructible et tous les tests réussissent toujours.
Si vous maintenez chaque modification individuelle minimale, il ne devrait vraiment pas être possible pour un refactoring de casser votre build pendant longtemps. Le pire des cas est probablement de changer les paramètres en une méthode dans une interface largement utilisée, par ex. pour ajouter un nouveau paramètre. Mais les changements qui en découlent sont mécaniques: ajouter (et ignorer) le paramètre dans chaque implémentation, et ajouter une valeur par défaut dans chaque appel. Même s'il existe des centaines de références, cela ne devrait pas prendre même un jour pour effectuer un tel refactoring.
Comment puis-je éviter ce genre de refactoring en cascade à l'avenir?
L'objectif est excellent OO conception et implémentation de la nouvelle fonctionnalité. Éviter le refactoring est également un objectif.
Commencez à zéro et créez un design pour la nouvelle fonctionnalité c'est ce que vous souhaitez avoir. Prenez le temps de bien le faire.
Notez cependant que la clé ici est "ajouter une fonction". Les nouveautés ont tendance à nous faire largement ignorer la structure actuelle de la base de code. Notre conception de vœux pieux est indépendante. Mais nous avons alors besoin de deux choses supplémentaires:
La refactorisation a été aussi simple que l'ajout d'un paramètre par défaut à un appel de méthode existant; ou un seul appel à une méthode de classe statique.
Les méthodes d'extension sur les classes existantes peuvent aider à maintenir la qualité du nouveau design avec un risque minimal absolu.
La "structure" est tout. La structure est la réalisation du principe de responsabilité unique; conception qui facilite la fonctionnalité. Le code restera court et simple tout au long de la hiérarchie des classes. Le temps pour une nouvelle conception est pris en compte lors des tests, des retouches et en évitant le piratage dans la jungle de code héritée.
Les cours de vœux pieux se concentrent sur la tâche à accomplir. Généralement, oubliez d'étendre une classe existante - vous induisez simplement la cascade de refactorisation à nouveau et vous devez gérer les frais généraux de la classe "plus lourde".
Purgez tous les restes de cette nouvelle fonctionnalité du code existant. Ici, une nouvelle fonctionnalité complète et bien encapsulée est plus importante que d'éviter la refactorisation.
De (le merveilleux) livre Travailler efficacement avec Legacy Code par Michael Feathers :
Lorsque vous cassez des dépendances dans du code hérité, vous devez souvent suspendre un peu votre sens de l'esthétique. Certaines dépendances se cassent proprement; d'autres finissent par ne pas être idéaux du point de vue du design. Ils sont comme les points d'incision en chirurgie: il peut y avoir une cicatrice dans votre code après votre travail, mais tout en dessous peut s'améliorer.
Si plus tard, vous pouvez couvrir le code autour du point où vous avez brisé les dépendances, vous pouvez également guérir cette cicatrice.
Il semble que (en particulier à partir des discussions dans les commentaires), vous vous êtes enfermé avec des règles auto-imposées qui signifient que ce changement "mineur" représente la même quantité de travail qu'une réécriture complète du logiciel.
La solution doit être "ne faites pas ça, alors". C'est ce qui se passe dans les vrais projets. De nombreuses anciennes API ont des interfaces laides ou des paramètres abandonnés (toujours nuls), ou des fonctions nommées DoThisThing2 () qui font la même chose que DoThisThing () avec une liste de paramètres entièrement différente. D'autres astuces courantes incluent le stockage d'informations dans des globaux ou des pointeurs étiquetés afin de les faire passer en contrebande sur une grande partie du cadre. (Par exemple, j'ai un projet où la moitié des tampons audio ne contiennent qu'une valeur magique de 4 octets, car cela était beaucoup plus facile que de changer la façon dont une bibliothèque invoquait ses codecs audio.)
Il est difficile de donner des conseils spécifiques sans code spécifique.
Tests automatisés. Vous n'avez pas besoin d'être un fanatique TDD, ni une couverture à 100%, mais les tests automatisés vous permettent d'apporter des modifications en toute confiance. De plus, il semble que vous ayez un design avec un couplage très élevé; vous devriez lire les principes SOLID, qui sont formulés spécifiquement pour résoudre ce type de problème dans la conception de logiciels.
Je recommanderais également ces livres.
Est-ce juste un symptôme de mes cours précédents qui dépendent trop étroitement les uns des autres?
Très probablement oui. Bien que vous puissiez obtenir des effets similaires avec une base de code plutôt agréable et propre lorsque les exigences changent suffisamment
Comment puis-je éviter à l'avenir ce type de refactoring en cascade?
En plus d'arrêter de travailler sur le code hérité, vous ne pouvez pas, je le crains. Mais ce que vous pouvez utiliser est une méthode qui évite de ne pas avoir de base de code de travail pendant des jours, des semaines ou même des mois.
Cette méthode est nommée "méthode Mikado" et fonctionne comme ceci:
notez l'objectif que vous souhaitez atteindre sur une feuille de papier
faites le changement le plus simple qui vous amène dans cette direction.
vérifiez si cela fonctionne en utilisant le compilateur et votre suite de tests. S'il continue avec l'étape 7. sinon, passez à l'étape 4.
sur votre papier, notez les choses qui doivent changer pour que votre changement actuel fonctionne. Dessinez des flèches, de votre tâche actuelle, vers les nouvelles.
Annuler vos modifications Ceci est l'étape importante. C'est contre-intuitif et ça fait mal physiquement au début, mais comme vous venez d'essayer une chose simple, ce n'est pas si mal.
choisissez l'une des tâches, qui n'a pas d'erreurs sortantes (pas de dépendances connues) et revenez à 2.
valider la modification, barrer la tâche sur le papier, choisir une tâche qui n'a pas d'erreur sortante (pas de dépendances connues) et revenir à 2.
De cette façon, vous aurez une base de code de travail à de courts intervalles. Où vous pouvez également fusionner les modifications du reste de l'équipe. Et vous avez une représentation visuelle de ce que vous savez que vous avez encore à faire, cela aide à décider si vous voulez continuer avec la fin ou si vous devez l'arrêter.
... J'ai refactorisé le projet pour ajouter la fonctionnalité.
Comme l'a dit @Jules, la refactorisation et l'ajout de fonctionnalités sont deux choses très différentes.
... mais en effet, parfois, vous devez changer le fonctionnement interne pour ajouter vos trucs, mais je préfère appeler cela la modification plutôt que la refactorisation.
Je devais apporter une modification mineure à l'interface pour l'adapter
C'est là que les choses deviennent désordonnées. Les interfaces sont conçues comme des limites pour isoler l'implémentation de la façon dont elle est utilisée. Dès que vous touchez des interfaces, tout de chaque côté (l'implémenter ou l'utiliser) devra également être modifié. Cela peut s'étendre autant que vous l'avez vécu.
alors la classe consommatrice ne peut pas être implémentée avec son interface actuelle en termes de nouvelle, donc elle a également besoin d'une nouvelle interface.
Qu'une interface nécessite un changement sonne bien ... qu'elle se propage à une autre implique des changements encore plus étendus. Cela ressemble à une forme d'entrée/de données nécessaire pour descendre dans la chaîne. Est-ce le cas?
Votre exposé est très abstrait, il est donc difficile de comprendre. Un exemple serait très utile. Habituellement, les interfaces doivent être assez stables et indépendantes les unes des autres, permettant de modifier une partie du système sans nuire au reste ... grâce aux interfaces.
... en fait, la meilleure façon d'éviter les modifications de code en cascade est précisément de bonnes bonnes interfaces.;)
Refactoring est une discipline structurée, distincte du nettoyage du code comme bon vous semble. Vous devez avoir des tests unitaires écrits avant de commencer, et chaque étape doit consister en une transformation spécifique qui, vous le savez, ne devrait pas modifier les fonctionnalités. Les tests unitaires doivent réussir après chaque changement.
Bien sûr, au cours du processus de refactorisation, vous découvrirez naturellement les changements qui doivent être appliqués et qui peuvent provoquer une rupture. Dans ce cas, faites de votre mieux pour implémenter un module d'interface de compatibilité pour l'ancienne interface qui utilise le nouveau cadre. En théorie, le système devrait toujours fonctionner comme avant et les tests unitaires devraient réussir. Vous pouvez marquer le module d'interface de compatibilité en tant qu'interface obsolète et le nettoyer à un moment plus approprié.