Refactoring von Spaghetticode

Refactoring von Spaghetticode ist grundsätzlich anders, als Refactoring im Rahmen testgetriebener Entwicklung: Letzteres ist gut verstanden und bedarf keiner besonderen Beachtung während der Planung, die einzelnen Refactoringschritte dauern immer nur ein paar Minuten und sind in der Gesamtschätzung enthalten.

Wenn man aber versucht, alten Spaghetticode durch Refactoring wieder in einen wartbaren Zustand zu bringen, sieht die Sache ganz anders aus. Dies ist ein kaum planbares Unterfangen mit hohem Zeit- und Aufwandsrisiko. Der Nutzen kommt dabei häufig erst, wenn die Arbeiten deutlich voran geschritten sind; oft wird also Monate lang ohne greifbaren Nutzen entwickelt, bis sich der „Knoten“ plötzlich auflöst und ein deutlich besser wartbares Design entsteht, das exakt der alten Funktionalität entspricht – modulo der Fehler, die man üblicherweise während dieser Arbeit noch entdeckt und behebt.

Um die Situation etwas zu entschärfen, gehen wir bei solchen Vorhaben oft in mehreren Phasen vor, die sich bisher ganz gut bewährt haben (ich spreche hier von „wir“, weil ein solcher Umbau immer im Team oder mindestens von einem Paar durchgeführt werden muss, niemals von einem „Einzelkämpfer“):

  1. Absichern: Refactoring ohne Absicherung durch automatisierte Tests ist so sinnlos und gefährlich, wie ein Salto Mortale ohne Netz und doppelten Boden. Unit-Tests setzen die Existenz testbarer Einheiten voraus: gerade bei Spaghetticode möchte man solche Einheiten aber erst einmal schaffen. Als Abhilfe setze ich daher zunächst auf Akzeptanztests, meistens in Form von Fit-Tests oder Fitnesse-Tests. Diese Tests müssen eine ausreichende fachliche Abdeckung erzielen, um einigermaßen sicherstellen zu können, dass das Refactoring das System nicht zerstört. Solche Testsuiten aufzubauen kann für ein Team aus zwei bis vier Personen (jeweils Paare aus Fachexperten und Entwicklern) leicht zwei bis drei Monate dauern.
    Nach dieser Rüstzeit kann man sich zwar an den eigentlichen Umbau wagen, die Testteams sind aber noch lange nicht fertig: Während der Umbauarbeiten benötigen wir normalerweise immer wieder neue Testszenarien, um spezielle Aspekte abzudecken, die gerade umgebaut werden. Da sich das nur sehr schwer planen lässt, ist ein kurzer Weg zwischen Testern und Umbauern erfolgsentscheidend.
  2. Einkapseln: Kann man sich mit einigem Mut auf die Testfälle einigermaßen verlassen, startet der eigentliche Umbau. Meist liegt bei solchen Systemen die Funktionalität in wenigen, riesigen Klassen vor. Wir beginnen mit einer Klasse, die im wesentlichen eine fachlich gut herauslösbare Komponente beschreibt. Es ist normal, dass der Code dieser Klasse nicht ebenso herauslösbar ist. Schließlich ist das gerade das Problem, das wir lösen wollen. Im ersten Schritt kapseln wir alle externen Zugriffe auf Instanzvariablen der Klasse mit Getter- und Settermethoden bzw. Properties ein. In Java oder C# stehen dafür sehr brauchbare Werkzeuge zur Verfügung; in C++ ist dafür viel Handarbeit notwendig, daher beschränke ich mich oft zunächst auf die Variablen, die von fremden Klassen aus verwendet werden (Tipp: Der Einsatz von „Tagged Regular Expressions“ kann auch schon eine ganze Menge Handarbeit abnehmen). Oft erkennt man dabei, dass Attribute in der einen Klasse definiert sind, aber (fast) ausschließlich in einer anderen Klasse verwendet werden. Wir nehmen das schon jetzt zum Anlass, die Instanzvariable zur verwendenden Klasse zu schieben – auch wenn sie vermutlich dort kaum auf Dauer bleiben wird. Ich habe schon oft erlebt, dass Methoden mit zehn oder fünfzehn Parametern durch diese Umbauten plötzlich auf zwei oder drei Parameter geschrumpft sind und auch noch 90% ihres Codes verloren haben.
  3. Interfaces einziehen: Im nächste Schritt ziehen wir Interfaces ein, um die Klassen, die zur Komponente gehören von denen zu trennen, die nicht zur Komponente gehören. Diese Interfaces sind in der Regel weder schön, noch minimal. Sie spiegeln stattdessen das bisherige Design wieder, machen also Designschwächen sichtbar – und isolieren Änderungen voneinander. Ich verwende daher immer auch ein paar Tage Arbeit darauf, diese Interfaces zumindest von den bösesten Schmutzstellen zu befreien. Als Ergebnis haben wir nun eine einigermaßen gut isolierte Komponente, auf die wir die weitere Arbeit konzentrieren können (Der Code außerhalb dieser Komponente kommt später dran).
  4. Redundanz eliminieren: Ein wichtiges Hindernis für gutes Codeverständnis sind redundante Instanzvariablen, Methoden und Codeteile. Taucht das gleiche Konzept immer wieder in leicht variierter Form auf, bedarf es zeitraubender Analyse, um festzustellen, ob das nur äquivalente Formulierungen der gleichen Logik sind, ob es geringe fachliche Differenzen sind, die sich in fast identischem Code irgendwo verbergen, oder ob hier sogar Fehler verborgen sind, weil Codeteile eigentlich identisch sein sollten, aber Fehler hineingepflegt wurden.
    Wir versuchen daher, identisch aussehende Codeteile so umzuformen, dass sie in absolut identische Teile und grundsätzlich unterschiedliche Teile zerfallen, die auch fachlich sinnvoll sind. Oft sind dafür komplexe bool’sche Umformungen von Bedingungsausdrücken und andere arithmetische Umformungen notwendig. Hat man die identischen Teile isoliert, werden sie in eigene Methoden ausgelagert, für die sich oft schnell weitere Aufrufer finden. Bei dieser Arbeit schrumpft der Code nicht selten um 50% und mehr.
  5. Fachlichkeit durch Umbenennen entdecken: Wenn wir redundanten Code herausziehen, wird die Namensgebung zu einem spannenden Akt. Gelingt es uns, einen fachlich sprechenden Namen zu finden, sind wir auf einem guten Weg, mehr Fachlichkeit in das Design zu bringen. Oft genug führt das dazu, dass nochmals einzelne Codezeilen aus einer Methode heraus oder in sie hinein geschoben werden, weil das der Fachlichkeit besser entspricht.
  6. Fallunterscheidungen durch Polymorphie ersetzen: Dies ist einer der wichtigsten Schritte, um das Design zu verbessern: Wir durchpflügen das Programm nach häufig wiederkehrenden Fallunterscheidungen, die sich in der Regeln als switch-Anweisungen oder verkettete Fallunterscheidungen tarnen. Meine ersten Ziele sind Fallunterscheidungen, die sich auf fachliche Varianten oder Status beziehen: Querbeziehungen zwischen den einzelnen Zweigen werden aufgelöst und der gesamte Verteiler in eine Vererbungshierarchie überführt, in der jede Subklasse einen Zweig aufnimmt. Zwar haben diese Klasse zunächst nur eine Methode, das ändert sich aber in der Regel schnell.
    Je nach Fachlichkeit, kann so zum Beispiel ein Strategy, State oder Template Method Designmuster entstehen. Wandern im weiteren Umbau auch noch Instanzvariablen in diese Klassen, ist man auf dem besten Weg zu einer fachlich tragfähigen Objektstruktur.
  7. Schleifen durch Strukturrekursion ersetzen: Dieser Schritt ergibt sich häufig, wenn man mit Hilfe der vorigen Schritte ein wenig Überblick über das Chaos gewonnen hat. Prozeduraler Code zeigt oft Schleifen über Arrays oder Datenbankcursor, die meist mit mehr oder weniger komplexen Filtern und Fallunterscheidungen zu Methoden von mehreren tausend Zeilen Code führen. Durch die tiefe Schachtelung von Schleifen ist solcher Code schwer zu überblicken, die vielfältigen Zugriffe auf Variablen machen ihn sehr fehleranfällig. Diese Monster können meistens durch Strukturrekursion zerschlagen und in handhabbare Teile zerlegt werden.
    Als ersten Schritt extrahieren wir dafür das Innere der Schleife in eine eigene Methode. Diese verschieben wir dann in eine separate Klasse. Für jeden Schleifendurchlauf wird eine eigene Instanz dieser Klasse erzeugt. Alle Variablen, die nur innerhalb der Schleife verwendet werden, wandern ebenfalls als Instanzvariablen in diese Klasse. Falls noch nicht im vorigen Schritt geschehen, kann man jetzt noch einmal kritisch prüfen, ob es nicht sinnvoll ist, die verschiedenen Fallunterscheidungen in Subklassen auszulagern. Die Schleife sollte nun nur noch aus dem Kontrollkonstrukt (while oder for), der Instanziierung unserer neuen Klasse und dem Aufruf einer Methode bestehen.
    Nun trennen wir die Instanziierung von der Bearbeitung: Zunächst werden weiterhin in einer Schleife die Instanzen erzeugt und in einer geeigneten Collection abgelegt (möglicht keine Arrays, sondern Collectionklassen, die sich selbst verwalten). Dann iterieren wir über diese Collection, um die eigentliche Arbeit zu erledigen. Je nach Aufgabe kann man die Instanzen auch verketten und eine Chain of Responsibility aufbauen.
    Nun verschieben wir den Aufbau der Collection innerhalb der Klasse an eine geeignete Stelle, zum Beispiel dorthin, wo die Daten aus der Datenbank geladen werden, und speichern sie in einer Instanzvariablen. So wird aus einer Monstermethode die einfache Interation über eine Instanzvariable. Ist diese Arbeit einmal erledigt, finden sich üblicherweise weitere Stellen, wo Schleifen durch Iterationen über diese Collection ersetzt werden können.
  8. Zieldesign herausarbeiten: Nun schält sich langsam ein Design heraus, das auch fachlich sinnvoll ist. Wenn ich den Eindruck habe, wir haben die wichtigsten Konstrukte gefunden und durch leistungsfähige Klassenstrukturen abgedeckt, greife ich persönlich gerne zu Zettel und Bleistift und beginne, das entstandene Design aufzumalen. Das gibt nochmals einen guten Überblick über die entstandene Struktur und zeigt auf, wo Redundanzen entstanden oder noch verblieben sind. Alternativ kann man auch Werkzeuge einsetzen, die UML Diagramme aus dem Code erzeugen. Diese Diagramme werden dann mit einem Zeichenwerkzeug wie Visio „gerade gezogen“.
    Wichtig ist in beiden Fällen nicht das Ergebnis, sondern der Schritt zurück. Man beschäftigt sich mit dem Design auf einer „höheren Ebene“ und nicht nur auf der Ebene einzelner Codezeilen. Daraus ergeben sich in der Regeln nochmals Korrekturen am Design, bei denen man im Einzelfall entscheiden muss, ob sich Aufwand, Risiko und Nutzen noch in der Waage halten.
  9. Nächste Komponente angehen: Ist eine Komponente in eine wartbarere Form gebracht, wird es Zeit, die nächste anzugehen. Die Arbeit ist diesmal ein wenig leichter, weil zum einen die Querbeziehungen zu der bereits umgebauten Komponente bereits aufgelöst sind, zum anderen hat das Team nun mehr Erfahrung mit typischen Konstrukten im Code und ihrem Umbau. Aber hüten Sie sich vor zu optimistischen Schätzungen: Auch hier sollte man „Yesterday’s Weather“ einsetzen: Ist die nächste Komponenten ungefähr so umfangreich, wie die vorige, plant man auch so viel Zeit ein, wie man für die vorige benötigt hat. Ist man wirklich schneller, bekommt man die Belohnung dafür bei der übernächsten Komponente.

Wie immer gelten auch bei diesen Umbauten die beiden ehernen Gesetze des Refactoring: Kleinste, möglichst triviale Einzelschritte und nur gegen grüne Balken, also alle Testfälle müssen immer laufen. Ebenfalls wie immer sollten funktionale Änderungen aufgrund von Fehlern, die man beim Umbau entdeckt, nur gegen neue, scheiternde Tests erfolgen. Das erlaubt es auch, wie hoffentlich gewohnt, mehrmals täglich einzuchecken und damit das Risiko klein zu halten, dass man das System durch die Umbauten zerstört.

Natürlich sind diese Punkte kein Ablaufplan, der Schritt für Schritt einmal geplant und abgearbeitet wird. Ich sehe die einzelnen Punkte eher als typische Phasen, die sich überlappen und auf verschiedener Ebene immer wieder durchlaufen werden. Für einen sehr kleinen und relativ übersichtlichten Ausschnitt ist es durchaus denkbar, alle Phasen innerhalb eines Tages zu durchlaufen. An typischen fachlichen Komponenten kann man durchaus drei bis sechs Monate arbeiten, bevor sie in einem annehmbaren Zustand sind.

Mir kommen solche Umbauten immer vor, wie das Entwirren eines Wollknäuels: Oft zupft man an verschiedenen Ecken, ohne dass echter Fortschritt sichtbar würde. Durch das Zupfen wird aber der Knoten so weit gelockert, dass er irgendwann unvermittelt aufgeht: nennenswerte Teile können plötzlich sehr schnell umgebaut werden. Es ist nicht vorhersehbar, wann ein solcher Moment eintritt. Um das ganze Knäuel zu entwirren, muss man viele solcher Knoten entwirren – je mehr man schon geschafft hat, um so schneller geht es oft – aber Ausnahmen bestätigen auch hier die Regel.

Wenn Sie noch wenig Erfahrung mit Refactoring haben, ist es nicht besonders ratsam, mit einer so komplexen Aufgabe zu beginnen. Auch beim Klettern beginnt man in der Halle an der Boulderwand und nicht an der Eiger Nordwand. Hier braucht man erfahrene Unterstützung. Da solche Aufgaben ohnehin im Pärchen gemacht werden, sind sie unter kundiger Anteilung aber gut zum Eintrainieren von Refactoringtechniken für Fortgeschrittene geeignet.