Sie öffnen eine Funktion, um einen Ein-Zeilen-Bug zu beheben, und vierzig Minuten spĂ€ter verfolgen Sie immer noch, welcher Zweig welche Variable setzt. Drei voneinander unabhĂ€ngige Aufgaben sind in einem einzigen Block zusammengeschweiĂt, und keine von ihnen hat einen Namen. Sie ziehen an einem Faden und können nicht sagen, welche anderen sich mitbewegen.
Das ist Spaghetti-Code, und der Name hat nichts mit EinrĂŒckung oder Namensstil zu tun. Er beschreibt den Kontrollfluss: Die AusfĂŒhrung springt zwischen Bedingungen hin und her, so wie ein Nudelstrang jeden anderen in der SchĂŒssel kreuzt, und einem einzigen Pfad bis zum Ende zu folgen bedeutet, vier Ebenen von if gleichzeitig im Kopf zu behalten.
Was Code tatsÀchlich zu Spaghetti-Code macht
Vier Symptome treten fast immer gemeinsam auf, und jedes lÀsst sich einzeln beheben:
- Tiefe Verschachtelung. Drei oder vier Ebenen von
if/elseineinander verschachtelt. Jede Ebene fĂŒgt EinrĂŒckung und eine Bedingung hinzu, die Sie im Kopf als wahr behalten mĂŒssen, um zu wissen, welche Zeile Sie gerade lesen. - Funktionen, die mehrere Aufgaben erledigen. Eine Funktion validiert die Eingabe, berechnet ein Ergebnis, verĂ€ndert den Zustand und schreibt eine Log-Nachricht. Sie Ă€ndern die Berechnung und riskieren das Logging; Sie testen die Validierung und mĂŒssen den Zustand simulieren.
- Versteckter gemeinsamer Zustand. Eine globale Variable oder ein Dictionary auf Modulebene, verÀndert aus dem Inneren einer Funktion, deren Name keinen Hinweis darauf gibt. Zwei Aufrufer können sich gegenseitig ins Gehege kommen, ohne dass der Code von beiden zeigt, warum.
- Copy-Paste-Zweige. Die gleiche Form von Logik wiederholt sich mit einem geÀnderten Wert, statt einmal extrahiert zu werden. Sie korrigieren eine Kopie, vergessen die andere, und haben einen Bug, der nur bei der HÀlfte der Eingaben auftritt.
Keines davon erfordert eine Neuschreibung. Jedes hat ein benanntes Refactoring, das es beseitigt, ohne zu Ă€ndern, was der Code tut. Diese EinschrĂ€nkung zĂ€hlt mehr, als sie klingt: Wenn Sie beim AufrĂ€umen einen Bug entdecken, beheben Sie ihn nicht in derselben Ănderung. Machen Sie es getrennt, sonst können Sie nicht sagen, welche Ănderung welche Regression verursacht hat.
Eine echte Funktion mit jedem Spaghetti-Code-Symptom
Hier ist process_order aus dem Checkout-Ablauf eines kleinen Shops. Lesen Sie sie einmal durch und achten Sie darauf, wo Sie den Faden verlieren.
| |
Alle vier Symptome sind hier vorhanden. Die Zeile, die den Kunden belastet, liegt vier Ebenen tief. Die Funktion prĂŒft den Lagerbestand, berechnet Preise, wendet einen Treuerabatt an, verĂ€ndert INVENTORY, versendet (ĂŒber print, als Platzhalter fĂŒr einen echten Versandaufruf) und summiert die Bestellung. INVENTORY ist ein Dictionary auf Modulebene, das auch jede andere Funktion verĂ€ndern kann, und nichts in der Signatur warnt Sie davor. Die VIP- und Nicht-VIP-Zweige haben dieselbe Form mit unterschiedlichen Zahlen, sodass eine dritte Rabattstufe eine dritte Kopie bedeutet.
Wie man Spaghetti-Code refactort, ohne ihn zu zerstören
Eine kleine, mechanische Ănderung nach der anderen. Jede lĂ€sst das Verhalten unverĂ€ndert und bekommt ihren eigenen Commit. Wenn Schritt 3 etwas kaputt macht, kann git bisect herausfinden, welcher der fĂŒnf kleinen Commits es war. Ein einziger âalles refactorenâ-Commit gibt bisect nichts, womit es eingrenzen kann.
Verhalten mit einem Charakterisierungstest festhalten
Bevor Sie eine Zeile Ă€ndern, schreiben Sie einen Test, der festhĂ€lt, was die Funktion heute tut. Nicht, was sie tun sollte. Das ist ein Charakterisierungstest: Sie frieren das aktuelle Verhalten ein, um den Moment zu bemerken, in dem eine âsichereâ Ănderung es nicht ist.
| |
FĂŒhren Sie beide zuerst gegen die noch unverĂ€nderte Funktion aus, damit Sie wissen, dass die erwarteten Zahlen aus dem Code stammen und nicht aus einem Tippfehler. Setzen Sie INVENTORY vor jedem Test zurĂŒck. Jeder Schritt unten lĂ€uft gegen dasselbe Paar. Solange sie bestehen, hat sich das Verhalten nicht verschoben.
Funktionen fĂŒr Preis und Versand extrahieren
Ziehen Sie die Preis- und Versandlogik in Funktionen, deren Namen sagen, was sie tun. Die Logik selbst Àndert sich in diesem Schritt nicht.
| |
FĂŒhren Sie die Tests aus: dieselben zwei Zahlen, derselbe Lagerbestand. Committen Sie mit einer Nachricht, die genau sagt, was passiert ist: âextract price_line_item and calculate_shipping, no behavior changeâ.
Verschachtelte Bedingungen durch Guard Clauses ersetzen
Die BestandsprĂŒfung in process_order besteht aus drei Verschachtelungsebenen rund um die eine Zeile, die zĂ€hlt. Verwandeln Sie jede PrĂŒfung in einen frĂŒhen Ausstieg fĂŒr den Fall, der nicht fortfahren kann. Der Erfolgspfad steht danach nicht mehr unter allem anderen eingerĂŒckt.
| |
Gleiche Tests, gleiche Ergebnisse. Der Schleifenkörper liest sich jetzt von oben nach unten: ablehnen, ablehnen, die Arbeit erledigen. Es sind dieselben Zweige wie zuvor, nur eine statt drei Ebenen tief.
Das Flag-Argument durch eine Lookup-Tabelle ersetzen
is_vip schaltet um, was price_line_item intern tut. Jede Aufrufstelle muss wissen, dass der boolesche Wert existiert, und eine dritte Stufe bedeutet einen dritten kopierten Zweig. Verschieben Sie die Zahlen in Daten:
| |
process_order Ă€ndert sich auf dieselbe Weise: ihr Parameter wird von is_vip in tier umbenannt, und der eine Aufruf von price_line_item(item, is_vip) wird zu price_line_item(item, tier). Sonst Ă€ndert sich nichts im Funktionskörper. Ein Aufruf wie process_order(order, tier="vip") liest sich von selbst, und eine neue Stufe ist ein neuer Eintrag in DISCOUNT_RATES statt eines weiteren if/else-Zweigs. Das ist der einzige Schritt, der eine Signatur Ă€ndert, also brauchen die Tests eine entsprechende Anpassung: Ăbergeben Sie tier="vip" statt is_vip=True. Die Assertions bleiben, wie sie sind, weil sich die Ausgabe fĂŒr dieselbe Bestellung nicht geĂ€ndert hat.
Seiteneffekte an die RĂ€nder verschieben
price_line_item ist jetzt rein: gleiche Eingaben, gleiche Ausgabe, nichts wird verĂ€ndert. process_order verĂ€ndert weiterhin INVENTORY und ruft mitten in einer Schleife print auf, weshalb das Testen einen globalen Reset statt eines einfachen Funktionsaufrufs braucht. Trennen Sie das Entscheiden vom Tun. Berechnen Sie, was passieren soll, geben Sie es zurĂŒck, und lassen Sie den Aufrufer entsprechend handeln.
| |
Zwei Details sorgen dafĂŒr, dass dies eine exakte Ăbereinstimmung mit dem Original ist, nicht nur eine ungefĂ€hre.
Der Zweig fĂŒr die leere Bestellung lĂ€uft weiterhin ĂŒber calculate_shipping, statt eine feste 0 zurĂŒckzugeben. Das Original berechnet Versandkosten sogar fĂŒr eine leere Bestellung, also muss das Refactoring das ebenfalls tun.
Und plan_order prĂŒft den Bestand gegen eine lokale Kopie remaining, die es nach und nach verringert, nicht direkt gegen INVENTORY. So wird eine Bestellung mit demselben SKU auf zwei Zeilen weiterhin gegen einen sinkenden Bestand geprĂŒft, genau wie es die Inline-Verringerung des Originals tat. Beide Zeilen gegen dieselbe unverĂ€nderte Zahl zu prĂŒfen, wĂŒrde einer zweiten Zeile erlauben, eine PrĂŒfung zu bestehen, die die erste bereits hĂ€tte aufbrauchen sollen.
plan_order liest INVENTORY, schreibt aber nie hinein und gibt nichts aus, sodass Sie sie tausendmal in einem Test aufrufen können, ohne irgendetwas zurĂŒckzusetzen. process_order ist jetzt die einzige Stelle, die INVENTORY oder print berĂŒhrt, und passt mit Platz ĂŒbrig auf einen Bildschirm.
Was die refactorte Funktion gegenĂŒber dem Original gewinnt
Die Verschachtelung ist von vier Ebenen auf eine gesunken. Jede Funktion erledigt die eine Aufgabe, die ihr Name beschreibt. INVENTORY wird von genau einer Stelle aus geschrieben, und es bleibt kein kopierter Zweig ĂŒbrig, den man zweimal korrigieren mĂŒsste. Eine âGroĂhandelâ-Stufe hinzuzufĂŒgen ist eine Ein-Zeilen-Ănderung in DISCOUNT_RATES.
Die Historie zĂ€hlt genauso wie der Code. Sie haben jetzt fĂŒnf Commits (den Test, dann vier Refactorings), jeder grĂŒn gegen dieselben Charakterisierungstests. Wenn in sechs Monaten ein Rabatt falsch herauskommt, landet git blame auf der Preiszeile bei einem Commit, der klein genug ist, um ihn vollstĂ€ndig zu lesen. Quetschen Sie alle fĂŒnf beim Merge des Branches zu einem einzigen ârefactorâ-Commit zusammen, und Sie verlieren genau das. Git Rebase vs Merge erklĂ€rt, wie jede Merge-Strategie die Historie formt, die Ihnen bleibt.
Wann man Spaghetti-Code nicht refactoren sollte
Ein Charakterisierungstest braucht ein aktuelles Verhalten, das beobachtbar ist und es wert ist, erhalten zu werden. Wenn die Deadline heute ist und es keinen Test gibt, auf den Sie sich stĂŒtzen können, macht Refactoring aus einem funktionierenden Chaos ein kaputtes Chaos mit besserer Formatierung. Liefern Sie den Fix aus, und planen Sie das AufrĂ€umen dann als eigene Aufgabe. Schmuggeln Sie es nicht in einen Bugfix-Commit, wo es die eigentliche Ănderung verdeckt.
Auch Code, der zur Löschung ansteht, verdient kein Refactoring. Wenn das Feature im nĂ€chsten Sprint ersetzt wird, flieĂen die Stunden in den Ersatz, nicht ins Polieren der alten Version fĂŒr die zwei Wochen, die ihr noch bleiben.
Code ohne jeden Test umzustrukturieren ist eine Neuschreibung, und Sie haben keine Möglichkeit zu wissen, was Sie kaputt gemacht haben. Wenn Sie das aktuelle Verhalten nicht testen können, besteht der erste Schritt darin, es testbar zu machen.
WĂ€hlen Sie eine Funktion aus, um die Sie sonst einen Bogen machen, schreiben Sie einen einzigen Charakterisierungstest gegen das, was sie gerade tut, und nehmen Sie die erste Extraktion vor. Committen Sie, bevor Sie irgendetwas anderes anfassen.