Refactoring in kleinen Schritten
Kurz gesagt: Refactoring ändert die Struktur von Code, ohne sein Verhalten zu ändern, in kleinen benannten Schritten wie Rename und Extract Method, mit grünen Tests nach jedem.
Problem
Die Rechnung, die niemand anfassen will
Der Online-Shop druckt Rechnungen mit der Funktion inv. Sie läuft seit zwei Jahren:
def inv(n, street, city, zip, items, type): t = 0 for i in items: t = t + i.price * i.qty d = 0 if type == "gold": d = t * 0.05 elif type == "company": if t >= 500: d = t * 0.10 s = "Rechnung: " + n + "\n" s = s + street + ", " + zip + " " + city + "\n" s = s + f"Summe: {t:.2f} €\n" s = s + f"Rabatt: {d:.2f} €\n" s = s + f"Gesamt inkl. MwSt.: {after_discount(t, d) * 1.20:.2f} €" return sdef after_discount(t, d): return t - dWas ist t? Und d? Sechs Parameter hintereinander, vier davon Text: Wer Stadt und Postleitzahl vertauscht, bekommt eine Rechnung mit „Hamburg 20095“ an der falschen Stelle, und der Compiler sagt nichts. Die Kette von if über die Kundenart wächst weiter: Nächsten Monat kommen Partner mit 3 %. Und after_discount macht eine einzige Subtraktion, zwingt dich aber, woanders hinzuspringen, um sie zu sehen.
Du willst aufräumen. Aber die Rechnungen gehen an die Buchhaltung, und ein Fehler darin ist ein Fehler in den Steuern.
Die Idee
Refactoring: Die Form ändert sich, das Verhalten nicht
Refactoring ist eine Änderung an der Form des Codes, bei der das Verhalten gleich bleibt: Dieselben Eingaben ergeben dieselben Ausgaben. Es ist kein „Neuschreiben“. Es ist auch kein „ich habe nebenbei noch einen Fehler behoben“. Das sind andere Änderungen, die getrennt gemacht werden.
Es geschieht in kleinen benannten Schritten. Jeder Schritt ist so klein, dass er kaum schiefgehen kann, und nach jedem führst du die Tests aus (das Sicherheitsnetz aus der vorherigen Lektion). Die Namen stammen aus dem Katalog von Martin Fowler und sind in jeder Sprache und jeder IDE gleich: Rename, Extract Method, Inline, Introduce Parameter Object, Replace Conditional with Polymorphism.
Zuerst kommt das Netz. Vier Tests halten die vier Arten von Rechnung fest, die der Shop heute druckt: ein normaler Kunde, ein Goldkunde, eine Firma über und unter 500 €.
COFFEE = [Item("Kaffeebohnen", 12.50, 2), Item("Tasse", 8.00, 1)]MORE_COFFEE = [Item("Kaffeebohnen", 12.50, 4)]FLOUR = [Item("Mehl 25 kg", 30.00, 20)]
class InvoiceTest(unittest.TestCase): def test_regular(self): self.assertEqual( inv("Maria Becker", "Lindenstraße 5", "Hamburg", "20095", COFFEE, "regular"), "Rechnung: Maria Becker\nLindenstraße 5, 20095 Hamburg\nSumme: 33.00 €\nRabatt: 0.00 €\nGesamt inkl. MwSt.: 39.60 €", )
def test_gold(self): self.assertEqual( inv("Jonas Keller", "Bahnhofstraße 12", "München", "80331", MORE_COFFEE, "gold"), "Rechnung: Jonas Keller\nBahnhofstraße 12, 80331 München\nSumme: 50.00 €\nRabatt: 2.50 €\nGesamt inkl. MwSt.: 57.00 €", )
def test_company_from_500(self): self.assertEqual( inv("Bäckerei Morgenrot GmbH", "Hauptstraße 80", "Leipzig", "04109", FLOUR, "company"), "Rechnung: Bäckerei Morgenrot GmbH\nHauptstraße 80, 04109 Leipzig\nSumme: 600.00 €\nRabatt: 60.00 €\nGesamt inkl. MwSt.: 648.00 €", )
def test_company_under_500(self): self.assertEqual( inv("Bäckerei Morgenrot GmbH", "Hauptstraße 80", "Leipzig", "04109", COFFEE, "company"), "Rechnung: Bäckerei Morgenrot GmbH\nHauptstraße 80, 04109 Leipzig\nSumme: 33.00 €\nRabatt: 0.00 €\nGesamt inkl. MwSt.: 39.60 €", )OK test_company_from_500OK test_company_under_500OK test_goldOK test_regularTests: 4, bestanden: 4, fehlgeschlagen: 0Sieh es dir an
Schritt 1: Rename
Ein-Buchstaben-Namen werden zu Wörtern: t wird subtotal, d wird discount, s wird text, inv wird invoice. zip und type sind in Python auch Namen eingebauter Funktionen, die die Parameter verdecken. Sie werden zu postcode und customer_type.
def invoice(name, street, city, postcode, items, customer_type): subtotal = 0 for item in items: subtotal = subtotal + item.price * item.qty discount = 0 if customer_type == "gold": discount = subtotal * 0.05 elif customer_type == "company": if subtotal >= 500: discount = subtotal * 0.10 text = "Rechnung: " + name + "\n" text = text + street + ", " + postcode + " " + city + "\n" text = text + f"Summe: {subtotal:.2f} €\n" text = text + f"Rabatt: {discount:.2f} €\n" text = text + f"Gesamt inkl. MwSt.: {after_discount(subtotal, discount) * 1.20:.2f} €" return textOK test_company_from_500OK test_company_under_500OK test_goldOK test_regularTests: 4, bestanden: 4, fehlgeschlagen: 0Rename ändert auch jede Stelle, die die Funktion aufruft, die Tests eingeschlossen. Die erwarteten Rechnungen werden nicht angefasst.
In PyCharm ist es Umschalt+F6, in VS Code F2. Ohne Typen rät die IDE aber manchmal: Ein Name in einem String,getattr(obj, "name") oder ein Dictionary-Schlüssel sind keine Verbindungen, die sie sehen kann. Deshalb sagen dir in Python nach einem Rename die Tests mehr als in Java oder C#.Sieh es dir an
Schritt 2: Extract Method
Die Funktion macht zwei getrennte Aufgaben, die mit dem Rechnungstext nichts zu tun haben: die Summe der Positionen und den Rabatt. Jede wandert in eine eigene Funktion mit einem Namen, der sagt, was sie zurückgibt. Das ist Extract Method:
def invoice(name, street, city, postcode, items, customer_type): subtotal = items_total(items) discount = discount_for(customer_type, subtotal) text = "Rechnung: " + name + "\n" text = text + street + ", " + postcode + " " + city + "\n" text = text + f"Summe: {subtotal:.2f} €\n" text = text + f"Rabatt: {discount:.2f} €\n" text = text + f"Gesamt inkl. MwSt.: {after_discount(subtotal, discount) * 1.20:.2f} €" return textdef items_total(items): total = 0 for item in items: total = total + item.price * item.qty return total
def discount_for(customer_type, subtotal): discount = 0 if customer_type == "gold": discount = subtotal * 0.05 elif customer_type == "company": if subtotal >= 500: discount = subtotal * 0.10 return discountOK test_company_from_500OK test_company_under_500OK test_goldOK test_regularTests: 4, bestanden: 4, fehlgeschlagen: 0Jetzt liest sich invoice von oben nach unten wie eine Liste: wie viel es ist, wie hoch der Rabatt ist, wie der Text lautet. Die Einzelheiten sind einen Sprung entfernt, nur wenn du sie brauchst.
Die IDE schaut, welche Variablen in die markierten Zeilen hineingehen und welche herauskommen, und bildet daraus die Parameter und den Rückgabewert. Wenn du den Schritt von Hand machst, geht genau hier etwas schief.
Probier es aus
Sage die Ausgabe voraus
Jemand macht Extract Method von Hand. Der Rabatt wandert in eine Funktion, die ihn in ihrem eigenen Parameter speichert, statt ihn zurückzugeben:
def invoice(name, street, city, postcode, items, customer_type): subtotal = items_total(items) discount = 0 apply_discount(customer_type, subtotal, discount) text = "Rechnung: " + name + "\n" text = text + street + ", " + postcode + " " + city + "\n" text = text + f"Summe: {subtotal:.2f} €\n" text = text + f"Rabatt: {discount:.2f} €\n" text = text + f"Gesamt inkl. MwSt.: {after_discount(subtotal, discount) * 1.20:.2f} €" return text
def apply_discount(customer_type, subtotal, discount): if customer_type == "gold": discount = subtotal * 0.05 elif customer_type == "company": if subtotal >= 500: discount = subtotal * 0.10Genauer, für Neugierige
FEHLERtest_company_from_500FEHLERtest_goldOK test_company_under_500OK test_regularTests: 4, bestanden: 2, fehlgeschlagen: 2discount in apply_discount ist eine Kopie des Wertes. Der neue Wert bleibt dort drin und verschwindet, wenn die Funktion endet, und draußen ist discount weiterhin 0. Wo der Rabatt ohnehin 0 ist (ein normaler Kunde, eine Firma unter 500 €), laufen die Tests durch. Deshalb hat das Netz für jeden Zweig des if einen Test. Der extrahierte Code muss den Wert zurückgeben, so wie es die IDE macht.
Sieh es dir an
Schritt 3: Inline
Das Gegenteil von Extract Method. after_discount macht eine Subtraktion, und sein Name sagt nicht mehr als subtotal - discount. Es wird an einer einzigen Stelle benutzt. Der Rumpf wandert dorthin, wo der Aufruf steht, und die Funktion verschwindet. Das ist Inline:
def after_discount(subtotal, discount): return subtotal - discountdef invoice(name, street, city, postcode, items, customer_type): subtotal = items_total(items) discount = discount_for(customer_type, subtotal) text = "Rechnung: " + name + "\n" text = text + street + ", " + postcode + " " + city + "\n" text = text + f"Summe: {subtotal:.2f} €\n" text = text + f"Rabatt: {discount:.2f} €\n" text = text + f"Gesamt inkl. MwSt.: {(subtotal - discount) * 1.20:.2f} €" return textOK test_company_from_500OK test_company_under_500OK test_goldOK test_regularTests: 4, bestanden: 4, fehlgeschlagen: 0Extract Method und Inline widersprechen sich nicht. Du extrahierst, wenn der Name etwas erklärt, was der Code nicht sagt (items_total). Du setzt es zurück, wenn der Name nur ein Sprung ist (after_discount). In PyCharm ist es Strg+Alt+N.
Sieh es dir an
Schritt 4: Introduce Parameter Object
Der Name, die Straße, die Stadt und die Postleitzahl reisen immer zusammen, und doch sind es vier Parameter hintereinander. Eine Vertauschung fällt nicht auf:
invoice("Maria Becker", "Lindenstraße 5", "20095", "Hamburg", COFFEE, "regular")Rechnung: Maria BeckerLindenstraße 5, Hamburg 20095Summe: 33.00 €Rabatt: 0.00 €Gesamt inkl. MwSt.: 39.60 €Die vier werden zu einem Objekt: dem Kunden. Das ist Introduce Parameter Object.
@dataclass schreibt __init__ und den Vergleich:
@dataclass(frozen=True)class Customer: name: str street: str city: str postcode: strdef invoice(customer, items, customer_type): subtotal = items_total(items) discount = discount_for(customer_type, subtotal) text = "Rechnung: " + customer.name + "\n" text = text + customer.street + ", " + customer.postcode + " " + customer.city + "\n" text = text + f"Summe: {subtotal:.2f} €\n" text = text + f"Rabatt: {discount:.2f} €\n" text = text + f"Gesamt inkl. MwSt.: {(subtotal - discount) * 1.20:.2f} €" return textOK test_company_from_500OK test_company_under_500OK test_goldOK test_regularTests: 4, bestanden: 4, fehlgeschlagen: 0Die Aufrufe in den Tests ändern sich mit der Form, und die erwarteten Rechnungen bleiben gleich. Hier ist ein Aufruf durch die Schritte:
inv("Jonas Keller", "Bahnhofstraße 12", "München", "80331", MORE_COFFEE, "gold")invoice("Jonas Keller", "Bahnhofstraße 12", "München", "80331", MORE_COFFEE, "gold")invoice(Customer("Jonas Keller", "Bahnhofstraße 12", "München", "80331"), MORE_COFFEE, "gold")invoice(Customer("Jonas Keller", "Bahnhofstraße 12", "München", "80331"), MORE_COFFEE, Gold())Customer(name="...", city="...", ...). Dann spielt die Reihenfolge keine Rolle.Sieh es dir an
Schritt 5: Replace Conditional with Polymorphism
Übrig bleibt die Kette von if über die Kundenart in discount_for. Jede neue Art ist hier ein neuer Zweig, und morgen auch an anderen Stellen (die E-Mail, der Bericht). Jede Art wird zu einer Klasse mit einer Methode discount und kennt ihren eigenen Rabatt:
class Regular: def discount(self, subtotal): return 0
class Gold: def discount(self, subtotal): return subtotal * 0.05
class Company: def discount(self, subtotal): if subtotal >= 500: return subtotal * 0.10 return 0def invoice(customer, items, customer_type): subtotal = items_total(items) discount = customer_type.discount(subtotal) text = "Rechnung: " + customer.name + "\n" text = text + customer.street + ", " + customer.postcode + " " + customer.city + "\n" text = text + f"Summe: {subtotal:.2f} €\n" text = text + f"Rabatt: {discount:.2f} €\n" text = text + f"Gesamt inkl. MwSt.: {(subtotal - discount) * 1.20:.2f} €" return textOK test_company_from_500OK test_company_under_500OK test_goldOK test_regularTests: 4, bestanden: 4, fehlgeschlagen: 0discount, und die Rechnung ruft sie auf (Duck Typing). Wenn die Regel eine einzige Berechnung ist, genügt eine Funktion in einer Tabelle, wie in der Lektion über Strategy.Das ist Strategy aus dem Modul über Entwurfsmuster: Die Regel ist ein eigenes Objekt, und die Rechnung fragt nur. Wenn sich die Kundenart ändert, während das Programm läuft (nach der zehnten Bestellung wird er zum Goldkunden), führt derselbe Schritt zu State. Der Text "gold" aus der Datenbank wird an einer Stelle zu einem Objekt, am Eingang, mit einer Tabelle wie in der Lektion über Strategy.
@dataclass(frozen=True)class Customer: name: str street: str city: str postcode: str
def invoice(customer, items, customer_type): subtotal = items_total(items) discount = customer_type.discount(subtotal) text = "Rechnung: " + customer.name + "\n" text = text + customer.street + ", " + customer.postcode + " " + customer.city + "\n" text = text + f"Summe: {subtotal:.2f} €\n" text = text + f"Rabatt: {discount:.2f} €\n" text = text + f"Gesamt inkl. MwSt.: {(subtotal - discount) * 1.20:.2f} €" return textdef inv(n, street, city, zip, items, type): t = 0 for i in items: t = t + i.price * i.qty d = 0 if type == "gold": d = t * 0.05 elif type == "company": if t >= 500: d = t * 0.10 s = "Rechnung: " + n + "\n" s = s + street + ", " + zip + " " + city + "\n" s = s + f"Summe: {t:.2f} €\n" s = s + f"Rabatt: {d:.2f} €\n" s = s + f"Gesamt inkl. MwSt.: {after_discount(t, d) * 1.20:.2f} €" return sdef invoice(name, street, city, postcode, items, customer_type): subtotal = 0 for item in items: subtotal = subtotal + item.price * item.qty discount = 0 if customer_type == "gold": discount = subtotal * 0.05 elif customer_type == "company": if subtotal >= 500: discount = subtotal * 0.10 text = "Rechnung: " + name + "\n" text = text + street + ", " + postcode + " " + city + "\n" text = text + f"Summe: {subtotal:.2f} €\n" text = text + f"Rabatt: {discount:.2f} €\n" text = text + f"Gesamt inkl. MwSt.: {after_discount(subtotal, discount) * 1.20:.2f} €" return textdef invoice(name, street, city, postcode, items, customer_type): subtotal = items_total(items) discount = discount_for(customer_type, subtotal) text = "Rechnung: " + name + "\n" text = text + street + ", " + postcode + " " + city + "\n" text = text + f"Summe: {subtotal:.2f} €\n" text = text + f"Rabatt: {discount:.2f} €\n" text = text + f"Gesamt inkl. MwSt.: {after_discount(subtotal, discount) * 1.20:.2f} €" return textdef invoice(name, street, city, postcode, items, customer_type): subtotal = items_total(items) discount = discount_for(customer_type, subtotal) text = "Rechnung: " + name + "\n" text = text + street + ", " + postcode + " " + city + "\n" text = text + f"Summe: {subtotal:.2f} €\n" text = text + f"Rabatt: {discount:.2f} €\n" text = text + f"Gesamt inkl. MwSt.: {(subtotal - discount) * 1.20:.2f} €" return text@dataclass(frozen=True)class Customer: name: str street: str city: str postcode: str
def invoice(customer, items, customer_type): subtotal = items_total(items) discount = discount_for(customer_type, subtotal) text = "Rechnung: " + customer.name + "\n" text = text + customer.street + ", " + customer.postcode + " " + customer.city + "\n" text = text + f"Summe: {subtotal:.2f} €\n" text = text + f"Rabatt: {discount:.2f} €\n" text = text + f"Gesamt inkl. MwSt.: {(subtotal - discount) * 1.20:.2f} €" return text@dataclass(frozen=True)class Customer: name: str street: str city: str postcode: str
def invoice(customer, items, customer_type): subtotal = items_total(items) discount = customer_type.discount(subtotal) text = "Rechnung: " + customer.name + "\n" text = text + customer.street + ", " + customer.postcode + " " + customer.city + "\n" text = text + f"Summe: {subtotal:.2f} €\n" text = text + f"Rabatt: {discount:.2f} €\n" text = text + f"Gesamt inkl. MwSt.: {(subtotal - discount) * 1.20:.2f} €" return textSchritt für Schritt, in Worten
- Die Funktion vor dem ersten Schritt: 15 Zeilen in einem Block. Unten stehen die vier Tests und die Summen der vier Rechnungen.
- Rename: t wird zu subtotal, d wird zu discount, s wird zu text. Die Tests sind grün, die Summen bleiben gleich.
- Extract Method: Die Schleife wandert nach items_total, die if-Kette nach discount_for. An ihrer Stelle bleibt je eine Zeile.
- Inline: after_discount hat nur abgezogen. Sein Rumpf kehrt in die Zeile zurück, und die Funktion verschwindet.
- Introduce Parameter Object: Name, Straße, Stadt und Postleitzahl werden zu einem Kunden.
- Replace Conditional with Polymorphism: Das if verschwindet. Jede Kundenart ist eine Klasse, und die Funktion ruft customer_type.discount(subtotal) auf.
- Fünf Schritte. Die Form von invoice ist eine andere, aber die vier Tests und die vier Rechnungen sind dieselben wie am Anfang.
Das Programm, das die vier Rechnungen druckt, lief mit dem Code von vor dem ersten Schritt und mit dem Code nach dem fünften. Die Ausgabe ist dieselbe:
Rechnung: Maria BeckerLindenstraße 5, 20095 HamburgSumme: 33.00 €Rabatt: 0.00 €Gesamt inkl. MwSt.: 39.60 €
Rechnung: Jonas KellerBahnhofstraße 12, 80331 MünchenSumme: 50.00 €Rabatt: 2.50 €Gesamt inkl. MwSt.: 57.00 €
Rechnung: Bäckerei Morgenrot GmbHHauptstraße 80, 04109 LeipzigSumme: 600.00 €Rabatt: 60.00 €Gesamt inkl. MwSt.: 648.00 €
Rechnung: Bäckerei Morgenrot GmbHHauptstraße 80, 04109 LeipzigSumme: 33.00 €Rabatt: 0.00 €Gesamt inkl. MwSt.: 39.60 €Probier es aus
Finde den Fehler
Jemand schreibt die Klasse für eine Firma so:
class Company: def discount(self, subtotal): return subtotal * 0.10Eine Firma bestellt Kaffee für 33 €:
invoice(Customer("Bäckerei Morgenrot GmbH", "Hauptstraße 80", "Leipzig", "04109"), COFFEE, Company())Rechnung: Bäckerei Morgenrot GmbHHauptstraße 80, 04109 LeipzigSumme: 33.00 €Rabatt: 3.30 €Gesamt inkl. MwSt.: 35.64 €In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
Als der Zweig für die Firma aus dem if in eine Klasse wanderte, blieb die Prüfung subtotal >= 500 weg. Eine Firma unter 500 € bekommt 10 %. Der Test für eine Firma unter 500 € schlägt sofort fehl: test_company_under_500. Ohne ihn hätten Firmen mit kleinen Bestellungen monatelang weniger bezahlt.
Genauer, für Neugierige
FEHLERtest_company_under_500OK test_company_from_500OK test_goldOK test_regularTests: 4, bestanden: 3, fehlgeschlagen: 1Die Idee
Wann du aufhörst
Refactoring hat kein Ende, wenn du ihm keins setzt. Du hörst auf, wenn der Code für die kommende Änderung klar genug ist. Die Aufgabe ist, Partner mit 3 % zu ergänzen? Schritt 5 macht daraus eine neue Klasse. Die Aufgabe ist eine andere? Vielleicht brauchst du Schritt 5 gar nicht.
Es lohnt sich nicht, wenn:
- niemand den Code anfasst oder liest. Hässlicher Code, der funktioniert und sich nicht ändert, ist nicht dringend;
- es zwei Varianten gibt und sie nicht wachsen. Ein
ifist klarer als ein Interface und zwei Klassen; - unter dem Code kein Netz liegt. Dann ist der erste Schritt das Netz, nicht das Refactoring.
Und eine Regel: nicht mischen. Du änderst entweder die Form oder das Verhalten, nie beides in einem Schritt. Kent Beck nennt das „zwei Hüte“: Mit dem einen räumst du um und die Tests bleiben unberührt, mit dem anderen ergänzt du und schreibst einen neuen Test. Du wechselst den Hut mit Absicht.
Übung
Partner mit 3 %
Löse die Aufgabe in deinem Editor.
Partner. Der Shop ergänzt die Kundenart „Partner“ mit 3 % Rabatt auf jede Bestellung. Starte mit dem Code nach Schritt 5. Schreibe zuerst einen Test für einen Partner mit der Bestellung über 33 €, dann die Klasse Partner. Die anderen Klassen und invoice werden nicht angefasst.
Rechnung: Maria BeckerLindenstraße 5, 20095 HamburgSumme: 33.00 €Rabatt: 0.99 €Gesamt inkl. MwSt.: 38.41 €Weitere Aufgaben
- Extract Method für den Text. Extrahiere die Adresszeile in eine Funktion address_line(customer). Führe nach dem Schritt die Tests aus.
- Die andere Reihenfolge. Mach die fünf Schritte in anderer Reihenfolge: zuerst Introduce Parameter Object, dann Rename. Bestehen die Tests nach jedem? Welcher Schritt war früher einfacher?
- Die Art aus der Datenbank. Die Kundenart kommt als Text herein (
"gold"). Schreibe die Tabelle, die ihn in ein Objekt verwandelt, und entscheide, was mit einer unbekannten Art passiert. Der heutige Code gibt für eine unbekannte Art einen Rabatt von 0: Behalte das mit einem Test bei.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Namen im Code · Funktionen: eine Aufgabe, wenige Parameter, keine versteckten Seiteneffekte · Code-Smells: lange Methode, große Klasse, Duplikate, Primitive statt Typen, lange if-Ketten · Tests als Sicherheitsnetz
- Geht weiter in: SOLID ohne Dogma · Übung: eine chaotische Klasse, Schritt für Schritt aufgeräumt
- Aufgaben zum Thema: Sauberer Code und Refactoring