Zum Inhalt
wannacode

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 s
def after_discount(t, d):
return t - d

Was 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 €",
)
Ergebnis
OK test_company_from_500
OK test_company_under_500
OK test_gold
OK test_regular
Tests: 4, bestanden: 4, fehlgeschlagen: 0

Sieh 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 text
Die Tests
OK test_company_from_500
OK test_company_under_500
OK test_gold
OK test_regular
Tests: 4, bestanden: 4, fehlgeschlagen: 0

Rename ä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 text
def 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 discount
Die Tests
OK test_company_from_500
OK test_company_under_500
OK test_gold
OK test_regular
Tests: 4, bestanden: 4, fehlgeschlagen: 0

Jetzt 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.

In PyCharm: Zeilen markieren und Strg+Alt+M drücken. In VS Code: Strg+. und „Extract method“.

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.10
Antwort
Genauer, für Neugierige
Ergebnis
FEHLERtest_company_from_500
FEHLERtest_gold
OK test_company_under_500
OK test_regular
Tests: 4, bestanden: 2, fehlgeschlagen: 2

discount 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 - discount
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.: {(subtotal - discount) * 1.20:.2f} €"
return text
Die Tests
OK test_company_from_500
OK test_company_under_500
OK test_gold
OK test_regular
Tests: 4, bestanden: 4, fehlgeschlagen: 0

Extract 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")
Ausgabe
Rechnung: Maria Becker
Lindenstraße 5, Hamburg 20095
Summe: 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: 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
Die Tests
OK test_company_from_500
OK test_company_under_500
OK test_gold
OK test_regular
Tests: 4, bestanden: 4, fehlgeschlagen: 0

Die 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())
In Python nehmen sowohl der Konstruktor der Dataclass als auch Funktionen Argumente mit Namen entgegen: 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 0
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 text
Die Tests
OK test_company_from_500
OK test_company_under_500
OK test_gold
OK test_regular
Tests: 4, bestanden: 4, fehlgeschlagen: 0
Hier gibt es kein Interface: Jede Klasse hat einfach eine Methode discount, 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 text
Schritt für Schritt, in Worten
  1. Die Funktion vor dem ersten Schritt: 15 Zeilen in einem Block. Unten stehen die vier Tests und die Summen der vier Rechnungen.
  2. Rename: t wird zu subtotal, d wird zu discount, s wird zu text. Die Tests sind grün, die Summen bleiben gleich.
  3. Extract Method: Die Schleife wandert nach items_total, die if-Kette nach discount_for. An ihrer Stelle bleibt je eine Zeile.
  4. Inline: after_discount hat nur abgezogen. Sein Rumpf kehrt in die Zeile zurück, und die Funktion verschwindet.
  5. Introduce Parameter Object: Name, Straße, Stadt und Postleitzahl werden zu einem Kunden.
  6. Replace Conditional with Polymorphism: Das if verschwindet. Jede Kundenart ist eine Klasse, und die Funktion ruft customer_type.discount(subtotal) auf.
  7. 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:

Ausgabe vorher und nachher
Rechnung: Maria Becker
Lindenstraße 5, 20095 Hamburg
Summe: 33.00 €
Rabatt: 0.00 €
Gesamt inkl. MwSt.: 39.60 €
Rechnung: Jonas Keller
Bahnhofstraße 12, 80331 München
Summe: 50.00 €
Rabatt: 2.50 €
Gesamt inkl. MwSt.: 57.00 €
Rechnung: Bäckerei Morgenrot GmbH
Hauptstraße 80, 04109 Leipzig
Summe: 600.00 €
Rabatt: 60.00 €
Gesamt inkl. MwSt.: 648.00 €
Rechnung: Bäckerei Morgenrot GmbH
Hauptstraße 80, 04109 Leipzig
Summe: 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.10

Eine Firma bestellt Kaffee für 33 €:

invoice(Customer("Bäckerei Morgenrot GmbH", "Hauptstraße 80", "Leipzig", "04109"), COFFEE, Company())
Ausgabe
Rechnung: Bäckerei Morgenrot GmbH
Hauptstraße 80, 04109 Leipzig
Summe: 33.00 €
Rabatt: 3.30 €
Gesamt inkl. MwSt.: 35.64 €

In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Genauer, für Neugierige
Die Tests
FEHLERtest_company_under_500
OK test_company_from_500
OK test_gold
OK test_regular
Tests: 4, bestanden: 3, fehlgeschlagen: 1

Die 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 if ist 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.

Ausgabe
Rechnung: Maria Becker
Lindenstraße 5, 20095 Hamburg
Summe: 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

1. Was davon ist Refactoring?
2. Nach Introduce Parameter Object lassen sich die Tests nicht mehr kompilieren, weil sie die Methode mit sechs Parametern aufrufen. Was tust du?
3. Wann ist Inline besser als Extract Method?
4. Die Kette von if über die Kundenart wird zu einem Interface und einer Klasse je Art. Wie heißt das im Modul über Entwurfsmuster?
5. Fünf Schritte, nach jedem Tests. Beim vierten schlägt ein Test fehl. Was heißt das?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·