Comment puis-je éviter les refactorisations en cascade?

52

J'ai un projet. Dans ce projet, je souhaitais le refactoriser pour ajouter une fonctionnalité et le refactorer pour ajouter la fonctionnalité.

Le problème est que quand j'ai eu fini, il s'est avéré que je devais faire un changement d'interface mineur pour l'adapter. Alors j'ai fait le changement. Et puis, la classe consommatrice ne peut pas être implémentée avec son interface actuelle par rapport à la nouvelle, elle a donc également besoin d'une nouvelle interface. Maintenant, trois mois plus tard, j'ai dû résoudre d'innombrables problèmes pratiquement sans rapport. Je cherche à résoudre les problèmes qui étaient planifiés depuis un an ou simplement répertoriés comme ne seront pas réglés en raison de difficultés avant que la chose ne soit compilée encore.

Comment puis-je éviter ce genre de refactorisation en cascade à l'avenir? Est-ce juste un symptôme de mes classes précédentes dépendantes trop étroitement les unes des autres?

Brève édition: 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 souhaitais proposer. Ainsi, le refactor 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 avec une interface, mais lors de l'implémentation, j'ai simplement passé en revue dynamic_casttoutes les implémentations possibles fournies. Cela signifiait évidemment que vous ne pouviez pas simplement hériter de l'interface, d'une part, et qu'il serait impossible à quiconque sans accès à la mise en oeuvre d'implémenter cette interface. J'ai donc décidé que je voulais résoudre ce problème et ouvrir l'interface à la consommation publique afin que tout le monde puisse l'implémenter et que l'implémentation de l'interface nécessitait la totalité du contrat - é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 classes dérivées et des fonctionnalités dupliquées déjà implémentées, mais améliorées ailleurs. Il aurait pu être implémenté en termes d’interface publique 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. Grosso modo, l'implémentation précédente de l'appelant ressemblait un peu à

for(auto&& a : as) {
     f(a);
}

Cependant, pour obtenir ce contexte, je devais le changer en quelque chose de plus semblable à

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 auparavant partie f, certaines d’entre elles doivent faire partie de la nouvelle fonction gqui fonctionne sans contexte, et certaines d’entre elles doivent faire partie d’une partie de la fonction actuellement différée f. Mais toutes les méthodes fn’appellent pas nécessairement le besoin ou le désir de ce contexte - certaines d’entre elles ont besoin d’un contexte distinct qu’elles obtiennent par des moyens distincts. Donc, pour tout ce qui ffinit par s’appeler (c’est-à-dire grosso modo tout ), je devais déterminer le contexte, le cas échéant, dont ils avaient besoin, de quelle manière, et comment les scinder de l’ancien fau nouveau f. g.

Et c'est comme ça que j'ai fini où je suis maintenant. La seule raison pour laquelle j'ai persévéré, c'est que j'avais besoin de cette refactorisation pour d'autres raisons.

DeadMG
la source
67
Quand vous dites que vous avez "refactoré le projet pour ajouter une fonctionnalité", que voulez-vous dire exactement? Le refactoring ne change pas le comportement des programmes, par définition, ce qui rend cette déclaration confuse.
Jules
5
@Jules: À proprement parler, la fonctionnalité visait à autoriser d'autres développeurs à ajouter un type d'extension particulier. Il s'agissait donc du refactor, ce qui rendait la structure de classe plus ouverte.
DeadMG
5
Je pensais que cela était abordé dans tous les livres et articles traitant de la refactorisation? Le contrôle de source vient à la rescousse; si vous constatez que pour effectuer l'étape A, vous devez d'abord effectuer l'étape B, puis supprimer A et effectuer B en premier.
Rwong
4
@DeadMG: voici le livre que je voulais citer dans mon premier commentaire: "Le jeu" pick-up sticks "est une bonne métaphore de la méthode Mikado. Vous éliminez la" dette technique ", les problèmes hérités inhérents à presque tous les logiciels. système - en suivant un ensemble de règles faciles à mettre en œuvre. Vous extrayez avec précaution chaque dépendance entrelacée jusqu'à ce que vous exposiez le problème central, sans pour autant réduire le projet. "
Rwong
2
Pouvez-vous préciser de quel langage de programmation nous parlons? Après avoir lu tous vos commentaires, je conclus que vous le faites à la main au lieu d’utiliser un IDE pour vous aider. Je voudrais donc savoir si je peux vous donner quelques conseils pratiques.
thepacker

Réponses:

69

La dernière fois que j'ai essayé de lancer un refactoring avec des conséquences imprévues, et que je ne pouvais pas stabiliser la construction et / ou les tests après une journée , j'ai abandonné et j'ai retourné la base de code au point précédant le refactoring.

Ensuite, j'ai commencé à analyser ce qui n'allait pas et à élaborer un meilleur plan pour procéder à la refactorisation en plusieurs étapes. Mon conseil pour éviter les refactorisations en cascade est donc simple: sachez quand arrêter , ne laissez pas les choses vous échapper!

Parfois, il faut mordre la balle et jeter une journée complète de travail, ce qui est certainement plus facile que de jeter trois mois de travail. Le jour où vous perdez n’est pas complètement vain, vous avez au moins appris à ne pas aborder le problème. Et à mon expérience, il y a toujours des possibilités pour faire de plus petites étapes dans la refactorisation.

Remarque secondaire : vous semblez être dans une situation où vous devez décider si vous êtes prêt à sacrifier trois mois de travail et à recommencer avec un nouveau plan de refactoring (et, espérons-le, plus réussi). J'imagine que ce n'est pas une décision facile à prendre, mais demandez-vous quel est le risque qu'il vous faut encore trois mois, non seulement pour stabiliser la construction, mais également pour corriger tous les bugs imprévus que vous avez probablement introduits lors de votre réécriture des trois derniers mois ? J'ai écrit "réécrire", parce que je suppose que c'est ce que vous avez vraiment 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 est compilé et commencez à nouveau par une refactorisation réelle (par opposition à une "réécriture").

Doc Brown
la source
53

Est-ce juste un symptôme de mes classes précédentes dépendantes trop étroitement les unes des autres?

Sûr. Un changement causant une multitude d'autres changements est à peu près la définition du couplage.

Comment puis-je éviter les refactors en cascade?

Dans les pires types de bases de code, un seul changement continuera à se propager en cascade, ce qui finira par vous amener à tout (presque) changer. Une partie de tout refactor où il y a un couplage généralisé consiste à isoler la partie sur laquelle vous travaillez. Vous devez refactoriser non seulement l'endroit où votre nouvelle fonctionnalité touche ce code, mais également tout ce qui touche à ce code.

Cela signifie généralement que certains adaptateurs permettant à l’ancien code de fonctionner avec quelque chose qui ressemble à l’ancien code, mais qui utilise la nouvelle implémentation / interface. Après tout, si vous ne faites que modifier l’interface / l’implémentation tout en laissant le couplage, vous ne gagnez rien. C'est du rouge à lèvres sur un cochon.

Telastyn
la source
33
+1 Plus une refactorisation est nécessaire, plus large sera la refactorisation. C'est la nature même de la chose.
Paul Draper
4
Si vous êtes vraiment en train de refactoriser , cependant, un autre code ne devrait pas avoir à se préoccuper des modifications immédiatement. (Bien sûr, vous voudrez éventuellement nettoyer les autres parties ... mais cela ne devrait pas être nécessaire immédiatement.) Un changement qui "cascade" dans le reste de l'application, est plus important que la refactorisation - à ce stade, il est fondamentalement une refonte ou une réécriture.
cHao
+1 Un adaptateur est le moyen idéal pour isoler le code que vous souhaitez modifier en premier.
clin d'oeil du
17

On dirait que votre refactoring était trop ambitieux. Une refactorisation doit être appliquée par petites étapes, chacune pouvant être complété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éussis.

Si vous minimisez chaque modification individuelle, il ne devrait pas être possible pour un refactoring de casser votre construction pendant longtemps. Le pire des cas est probablement de remplacer les paramètres par une méthode dans une interface largement utilisée, par exemple 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 à chaque appel. Même s'il y a des centaines de références, cela ne devrait pas prendre une journée pour effectuer une telle refactorisation.

Jules
la source
4
Je ne vois pas comment une telle situation peut survenir. Pour tout refactoring raisonnable de l'interface d'une méthode, il doit exister un nouvel ensemble de paramètres pouvant être transmis qui permettra au comportement de l'appel d'être identique à celui qu'il avait avant le changement.
Jules
3
Je n'ai jamais été dans une situation où je voulais effectuer une telle refactorisation, mais je dois dire que cela me semble assez inhabituel. Voulez-vous dire que vous avez supprimé la fonctionnalité de l'interface? Si oui, où est-il allé? Dans une autre interface? Ou ailleurs?
Jules
5
Ensuite, la solution consiste à supprimer toutes les utilisations de la fonctionnalité à supprimer avant le refactoring pour la supprimer, plutôt qu'après. Cela vous permet de garder le code en construction pendant que vous y travaillez.
Jules
11
@DeadMG: cela semble étrange: vous supprimez une fonctionnalité dont vous n'avez plus besoin, comme vous dites. Mais d’autre part, vous écrivez "le projet devient complètement non fonctionnel" - cela semble en fait que la fonctionnalité est absolument nécessaire. Précisez s'il vous plaît.
Doc Brown
26
@DeadMG Dans de tels cas, vous développerez normalement la nouvelle fonctionnalité, ajoutez des tests pour vous assurer qu'il fonctionne, faites la transition du code existant pour utiliser la nouvelle interface, puis supprimez l'ancienne fonctionnalité (désormais) superflue. De cette façon, il ne devrait pas y avoir un point où les choses se cassent.
sapi
12

Comment puis-je éviter ce genre de refactor en cascade à l'avenir?

Conception de rêve

L'objectif est une excellente conception et implémentation de OO pour la nouvelle fonctionnalité. Éviter la refactorisation est également un objectif.

Commencez à partir de zéro et créez un design pour la nouvelle fonctionnalité que vous souhaiteriez avoir. Prenez le temps de bien le faire.

Notez cependant que la clé est "ajouter une fonctionnalité". Les nouveautés tendent à nous faire oublier en grande partie 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:

  • Refacturer juste assez pour faire une couture nécessaire pour injecter / implémenter le code de la nouvelle fonctionnalité.
    • La résistance à la refactorisation ne devrait pas conduire à la nouvelle conception.
  • Ecrivez une classe faisant face à un client avec une API qui garde la nouvelle fonctionnalité et le codez existant parfaitement ignorants les uns des autres.
    • Il translittère pour obtenir des objets, des données et des résultats dans les deux sens. Le moindre principe de connaissance soit damné. Nous ne ferons rien de pire que ce que le code existant fait déjà.

Heuristique, leçons apprises, etc.

Le refactoring a été aussi simple que d’ajouter 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 à conserver la qualité de la nouvelle conception avec un risque minimal absolu.

"Structure" est tout. La structure est la concrétisation du principe de responsabilité unique. conception qui facilite la fonctionnalité. Le code restera court et simple tout au long de la hiérarchie de classe. Le temps nécessaire pour un nouveau design est pris en charge lors des tests, des modifications, et en évitant de pirater la jungle de codes héritée.

Les classes de rêve se concentrent sur la tâche à accomplir. Généralement, oubliez l’extension d’une classe existante, vous indiquez simplement à nouveau la cascade du refactor et 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 le refactoring.

radarbob
la source
9

Du (merveilleux) livre Travailler efficacement avec Legacy Code de Michael Feathers :

Lorsque vous supprimez 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 avoir une apparence moins qu'idéale du point de vue de la conception. Ils ressemblent aux points d'incision de la chirurgie: il se peut qu'il reste une cicatrice dans votre code après votre travail, mais tout ce qui se trouve 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.

Kenny Evitt
la source
6

On dirait que (en particulier d'après les discussions dans les commentaires), vous vous êtes retrouvé avec des règles que vous vous êtes imposées, ce qui signifie que cette modification "mineure" représente la même quantité de travail qu'une réécriture complète du logiciel.

La solution doit être "ne fais pas ça, alors" . C'est ce qui se passe dans les projets réels. De nombreuses anciennes API ont par conséquent 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 totalement différente. D'autres astuces courantes consistent à stocker des informations dans des globaux ou des pointeurs étiquetés afin de les faire passer clandestinement au-delà d'une grande partie du cadre. (Par exemple, j'ai un projet dont la moitié des tampons audio ne contiennent qu'une valeur magique de 4 octets, car cela était beaucoup plus simple que de changer la façon dont une bibliothèque appelle ses codecs audio.)

Il est difficile de donner des conseils spécifiques sans code spécifique.

pjc50
la source
3

Tests automatisés. Vous n'avez pas besoin d'être un fanatique du 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.

  • Travailler efficacement avec le code hérité , les plumes
  • Refactoring , Fowler
  • Logiciels orientés objet en pleine croissance, guidés par des tests , Freeman et Pryce
  • Clean Code , Martin
asthasr
la source
3
Votre question est: "comment puis-je éviter cet [échec] à l'avenir?" La réponse est que, même si vous avez "actuellement" des CI et des tests, vous ne les appliquez pas correctement. Je n'ai pas eu d'erreur de compilation qui a duré plus de dix minutes depuis des années, car je considère que la compilation est "le premier test unitaire", et quand elle est cassée, je la répare, car je dois pouvoir voir les tests passer avec succès. Je travaille plus sur le code.
samedi
6
Si je refacture une interface très utilisée, j'ajoute une cale. Cette cale gère la valeur par défaut afin que les appels hérités continuent à fonctionner. Je travaille sur l'interface derrière la cale, puis, quand j'en ai terminé, je commence à changer de classe pour utiliser l'interface à la place de la cale.
asthasr
5
Continuer à refactoriser malgré l’échec de la construction est apparenté à un calcul à mort . C'est une technique de navigation de dernier recours . Lors de la refactorisation, il est possible qu’une direction de refactoring soit tout simplement fausse, et vous en avez déjà vu le signe révélateur (au moment où elle cesse de compiler, c’est-à-dire que vous ne volez pas avec des indicateurs de vitesse), mais vous avez décidé de poursuivre. Finalement, l'avion tombe du radar. Heureusement, nous n'avons pas besoin de blackbox ou d'enquêteurs pour le refactoring: nous pouvons toujours "restaurer au dernier bon état connu".
Rwong
4
@DeadMG: vous avez écrit "Dans mon cas, les appels précédents n'ont tout simplement plus de sens", mais dans votre question, "une modification mineure de l' interface pour l'adapter". Honnêtement, une seule de ces deux phrases peut être vraie. Et d'après votre description du problème, il semble assez clair que le changement d'interface n'était certainement pas mineur . Vous devriez vraiment réfléchir vraiment à la manière de rendre votre changement plus compatible avec les versions antérieures. D'après mon expérience, c'est toujours possible, mais vous devez d'abord élaborer un bon plan.
Doc Brown
3
@DeadMG Dans ce cas, je pense que ce que vous faites ne peut pas être raisonnablement appelé refactoring, l' objectif fondamental étant d'appliquer les modifications de conception sous forme d'une série d'étapes très simples.
Jules
3

Est-ce juste un symptôme de mes classes précédentes dépendantes trop étroitement les unes 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 ce genre de refactorisation en cascade à l'avenir?

À part de m'arrêter pour travailler sur le code hérité, vous ne pouvez pas craindre. Mais vous pouvez utiliser une méthode qui évite de ne pas avoir une base de code opérationnelle pendant des jours, des semaines voire des mois.

Cette méthode s'appelle "Méthode Mikado" et fonctionne comme suit:

  1. écrivez l'objectif que vous voulez atteindre sur une feuille de papier

  2. faire le changement le plus simple qui vous mène dans cette direction.

  3. vérifiez si cela fonctionne avec le compilateur et votre suite de tests. Si c'est le cas, passez à l'étape 7. Sinon, passez à l'étape 4.

  4. sur votre papier, notez les choses à changer pour que votre changement actuel fonctionne. Dessinez des flèches de votre tâche actuelle vers les nouvelles.

  5. Annulez vos modifications C'est l'étape importante. C'est contre-intuitif et physiquement douloureux au début, mais puisque vous venez d'essayer une chose simple, ce n'est pas si grave.

  6. choisissez l'une des tâches ne contenant pas d'erreurs sortantes (aucune dépendance connue) et retournez à 2.

  7. valider la modification, rayer la tâche sur le papier, choisir une tâche ne contenant aucune erreur sortante (aucune dépendance connue) et revenir à 2.

De cette façon, vous aurez une base de code opérationnelle à intervalles rapprochés. 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 devez encore faire. Cela vous aide à décider si vous voulez continuer avec le tournage ou si vous devez l'arrêter.

Jens Schauder
la source
2

Le refactoring est une discipline structurée, distincte du nettoyage du code comme bon vous semble. Vous devez avoir écrit des tests unitaires avant de commencer et chaque étape doit consister en une transformation spécifique qui, vous savez, ne devrait apporter aucune modification à ses fonctionnalités. Les tests unitaires doivent réussir après chaque modification.

Bien entendu, lors du processus de refactoring, vous découvrirez naturellement les modifications à appliquer susceptibles de provoquer des ruptures. Dans ce cas, faites de votre mieux pour implémenter un shim de compatibilité pour l'ancienne interface utilisant le nouveau framework. En théorie, le système devrait toujours fonctionner comme avant et les tests unitaires devaient réussir. Vous pouvez marquer la cale de compatibilité comme une interface obsolète et la nettoyer à un moment plus opportun.

200_success
la source
2

... j'ai refactored le projet pour ajouter la fonctionnalité.

Comme dit @Jules, la refactorisation et l'ajout de fonctionnalités sont deux choses très différentes.

  • Le refactoring consiste à changer la structure du programme sans modifier son comportement.
  • L'ajout d'une fonctionnalité, par contre, augmente son comportement.

... mais en effet, vous avez parfois besoin de modifier le fonctionnement interne pour ajouter vos éléments, mais je préférerais appeler cela modification plutôt que refactorisation.

J'avais besoin de faire un changement d'interface mineur pour l'adapter

C'est là que les choses se gâtent. Les interfaces sont conçues comme des limites pour isoler l'implémentation de la manière dont elle est utilisée. Dès que vous touchez les interfaces, tout ce qui se trouve de part et d’autre (l’implémentation ou l’utilisation) devra également être modifié. Cela peut se répandre autant que vous l'avez vécu.

alors la classe consommatrice ne peut pas être implémentée avec son interface actuelle par rapport à la nouvelle, elle a donc également besoin d'une nouvelle interface.

Qu'une interface nécessite un changement sonne bien ... qu'elle se propage à une autre implique que les changements se propagent encore plus loin. Cela ressemble à une certaine forme d’entrée / de données qui nécessite de s’écouler dans la chaîne. Est-ce le cas?


Votre conversation est très abstraite, donc difficile à comprendre. Un exemple serait très utile. Habituellement, les interfaces doivent être assez stables et indépendantes les unes des autres, ce qui permet de modifier une partie du système sans nuire au reste ... grâce aux interfaces.

En fait, le meilleur moyen d'éviter les modifications de code en cascade consiste précisément à utiliser de bonnes interfaces. ;)

les dagnelies
la source
-1

Je pense que vous ne pouvez généralement pas, sauf si vous êtes prêt à garder les choses comme elles sont. Cependant, dans des situations comme la vôtre, le mieux est d’informer l’équipe et de lui faire savoir pourquoi il faudrait procéder à une refactorisation afin de poursuivre un développement plus sain. Je n'irais pas simplement régler les problèmes moi-même. J'en parlerais lors des réunions Scrum (en supposant que vous en ayez), et je l'approcherais systématiquement avec d'autres développeurs.

Tarik
la source
1
cela ne semble rien offrir de substantiel sur les points évoqués et expliqués dans les 9 réponses précédentes
gnat
@gnat: Peut-être pas, mais en simplifiant les réponses.
Tarik