Übung: eine chaotische Klasse, Schritt für Schritt aufgeräumt
Kurz gesagt: Eine Refactoring-Übung: Eine Scooter-Verleih-Klasse wird in fünf kleinen Schritten aufgeräumt (Namen, magische Zahlen, Geld, Abfrage getrennt vom Befehl, Tarife), während die Tests grün bleiben.
Problem
Die Scooter, die keiner anzufassen wagt
Ein Unternehmen, das E-Scooter vermietet, rechnet jede Fahrt mit der Klasse RideMgr ab. Sie läuft seit drei Jahren. Die Methode calc berechnet den Preis nach dem Tarif des Kunden (Pay-as-you-go, Student oder Monatskarte), lässt ab der vierten Fahrt des Tages die Entsperrgebühr weg, schlägt einen Nachtzuschlag auf, addiert den Umsatz und schickt einen Kassenbon:
class RideMgr: def __init__(self): self.c = {} self.s = 0 self.out = []
# Der Preis: 1 € zum Entsperren und 0,15 € pro Minute. # Nachts steigt der Preis um 50 %. def calc(self, to, t, m, f): self.c[to] = self.c.get(to, 0) + 1 p = 0 if t == "payg": p = 1.0 + m * 0.2 elif t == "student": p = (1.0 + m * 0.2) * 0.8 elif t == "monthly": if m > 45: p = (m - 45) * 0.2 if self.c[to] > 3 and t != "monthly": p = p - 1.0 if f: p = p + 0.5 self.s = self.s + p self.out.append(f"{to}: {m} Min., {p:.2f} €") return pNächsten Monat kommen zwei Änderungen:
- ein Firmentarif: keine Entsperrgebühr, 0,18 € pro Minute, kein Rabatt für die vierte Fahrt;
- der Preis vor dem Ende: Die App zeigt, was die Fahrt kosten wird, während sie noch läuft.
Die zweite Änderung geht mit dieser Klasse nicht. Das passiert, wenn die App calc aufruft, um den Preis zu zeigen, und dann noch einmal, um das Geld abzubuchen:
rides = RideMgr()shown = rides.calc("ana@example.com", "payg", 10, False)print(f"Preis vor dem Ende: {shown:.2f} €")charged = rides.calc("ana@example.com", "payg", 10, False)print(f"Abgebucht: {charged:.2f} €")print(f"Kassenbons: {len(rides.out)}")print(f"Umsatz: {rides.s:.2f} €")Preis vor dem Ende: 3.00 €Abgebucht: 3.00 €Kassenbons: 2Umsatz: 6.00 €Eine Fahrt, zwei Kassenbons und 6 € Umsatz. calc klingt nach einer Frage, aber es zählt, addiert und verschickt.
In dieser Lektion gibt es keine neue Idee. Es ist eine Übung: Die Klasse geht in kleinen Schritten durch die Werkzeuge des Moduls, und nach jedem Schritt lässt du die Tests laufen.
Sieh es dir an
Schritt 0: das Sicherheitsnetz
Zuerst das Netz, wie in der Lektion über Tests. Neun Tests halten fest, was die Klasse heute tut: den Preis für jeden Tarif, die Nacht, die vierte Fahrt, den Kassenbon und den Umsatz.
import unittest
from rides import RideMgr
EMAIL = "ana@example.com"
def money(amount): return f"{amount:.2f}"
class RideMgrTest(unittest.TestCase): def setUp(self): self.rides = RideMgr()
def ride(self, plan, minutes, night): return money(self.rides.calc(EMAIL, plan, minutes, night))
def test_pay_as_you_go_ride(self): self.assertEqual(self.ride("payg", 10, False), "3.00")
def test_student_ride(self): self.assertEqual(self.ride("student", 10, False), "2.40")
def test_monthly_within_free_minutes(self): self.assertEqual(self.ride("monthly", 30, False), "0.00")
def test_monthly_over_free_minutes(self): self.assertEqual(self.ride("monthly", 60, False), "3.00")
def test_night_surcharge(self): self.assertEqual(self.ride("payg", 10, True), "3.50")
def test_fourth_ride_unlock_is_free(self): self.ride("payg", 10, False) self.ride("payg", 10, False) self.ride("payg", 10, False) self.assertEqual(self.ride("payg", 10, False), "2.00")
def test_student_fourth_ride(self): # So ist es heute: der Rabatt ist der ganze 1 €, nicht 0,80 €. self.ride("student", 10, False) self.ride("student", 10, False) self.ride("student", 10, False) self.assertEqual(self.ride("student", 10, False), "1.40")
def test_receipt(self): self.ride("payg", 10, False) self.assertEqual(self.rides.out, ["ana@example.com: 10 Min., 3.00 €"])
def test_revenue(self): self.ride("payg", 10, False) self.ride("student", 10, False) self.assertEqual(money(self.rides.s), "5.40")OK test_fourth_ride_unlock_is_freeOK test_monthly_over_free_minutesOK test_monthly_within_free_minutesOK test_night_surchargeOK test_pay_as_you_go_rideOK test_receiptOK test_revenueOK test_student_fourth_rideOK test_student_rideTests: 9, bestanden: 9, fehlgeschlagen: 0Die Tests rufen die Klasse nur über die Hilfsmethode ride auf. Wenn sich die Form der Klasse in den nächsten Schritten ändert, ändern sich die Hilfsmethode und die Aufrufe, aber nie die erwarteten Werte.
Einer der Tests ist merkwürdig. Ein Student zahlt 80 % des Preises, sein Entsperren kostet also 0,80 €. Bei der vierten Fahrt zieht die Klasse aber den vollen 1 € ab, und der Student zahlt 1,40 € statt 1,60 €. Ist das ein Fehler oder ein Geschenk? Das entscheidest nicht du, und nicht jetzt. Der Test hält fest, was heute passiert, mit einem Kommentar, und die Frage geht an den Product Owner. Beim Aufräumen ändert sich das Verhalten nicht, auch wenn es falsch aussieht.
Genauer, für Neugierige
Die Beträge werden als Text mit zwei Nachkommastellen verglichen ("2.40"), so wie der Kunde sie sieht. Ein Vergleich von Zahlen würde über die kleinen binären Fehler von float stolpern:
print((1.0 + 10 * 0.2) * 0.8)2.4000000000000004Und in Schritt 3 ändert sich der Typ der Beträge. Der Text mit zwei Nachkommastellen ist davor und danach derselbe.
Sieh es dir an
Schritt 1: die Namen und das Flag
t, m, f, p, c und s werden zu Wörtern (Namen). Der wichtigste Name ist der der Methode: Sie berechnet nicht, sie beendet die Fahrt. Der ehrliche Name ist finish_ride, und jetzt steht der Seiteneffekt im Namen. Die Klasse heißt jetzt RideBilling.
class RideBilling: def __init__(self): self.rides_today = {} self.revenue = 0 self.receipts = []
# Der Preis: 1 € zum Entsperren und 0,15 € pro Minute. # Nachts steigt der Preis um 50 %. def finish_ride(self, email, plan, minutes, *, night=False): self.rides_today[email] = self.rides_today.get(email, 0) + 1 price = 0 if plan == "payg": price = 1.0 + minutes * 0.2 elif plan == "student": price = (1.0 + minutes * 0.2) * 0.8 elif plan == "monthly": if minutes > 45: price = (minutes - 45) * 0.2 if self.rides_today[email] > 3 and plan != "monthly": price = price - 1.0 if night: price = price + 0.5 self.revenue = self.revenue + price self.receipts.append(f"{email}: {minutes} Min., {price:.2f} €") return priceDas Flag bleibt vorerst (Funktionen). Ein true im Aufruf sagt nichts. Nacht sind Daten über die Fahrt, nicht die Wahl einer anderen Aufgabe, deshalb werden daraus keine zwei Funktionen. Der Aufruf muss sagen, was er meint:
night ein Schlüsselwortargument (nach *) mit einem Standardwert: Tag hat kein Argument, und Nacht ist night=True.def ride(self, plan, minutes, night): return money(self.rides.calc(EMAIL, plan, minutes, night))
def test_night_surcharge(self): self.assertEqual(self.ride("payg", 10, True), "3.50")def ride(self, plan, minutes, *, night=False): return money(self.billing.finish_ride(EMAIL, plan, minutes, night=night))
def test_night_surcharge(self): self.assertEqual(self.ride("payg", 10, night=True), "3.50")OK test_fourth_ride_unlock_is_freeOK test_monthly_over_free_minutesOK test_monthly_within_free_minutesOK test_night_surchargeOK test_pay_as_you_go_rideOK test_receiptOK test_revenueOK test_student_fourth_rideOK test_student_rideTests: 9, bestanden: 9, fehlgeschlagen: 0self.s) nicht überall, deshalb sagen dir hier die Tests mehr als in Java oder C#.Sieh es dir an
Schritt 2: die Zahlen und der Kommentar, der lügt
1.0, 0.2, 0.8, 45, 3 und 0.5 werden zu Konstanten mit Namen (magische Zahl → Konstante).
Der Kommentar über der Methode sagt 0,15 € pro Minute und 50 % Nachtzuschlag. Der Code berechnet 0,20 € und schlägt 0,50 € auf. Was stimmt? Die Tests halten fest, was der Code tut, und die Kunden zahlen seit Jahren danach. Der Kommentar lügt (Kommentare) und wird gelöscht. Die Konstanten sagen dasselbe und können nicht von allein veralten.
UNLOCK_FEE = 1.00PER_MINUTE = 0.20STUDENT_SHARE = 0.80MONTHLY_FREE_MINUTES = 45PAID_UNLOCKS_PER_DAY = 3NIGHT_SURCHARGE = 0.50
class RideBilling: def __init__(self): self.rides_today = {} self.revenue = 0 self.receipts = []
def finish_ride(self, email, plan, minutes, *, night=False): self.rides_today[email] = self.rides_today.get(email, 0) + 1 price = 0 if plan == "payg": price = UNLOCK_FEE + minutes * PER_MINUTE elif plan == "student": price = (UNLOCK_FEE + minutes * PER_MINUTE) * STUDENT_SHARE elif plan == "monthly": if minutes > MONTHLY_FREE_MINUTES: price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE if self.rides_today[email] > PAID_UNLOCKS_PER_DAY and plan != "monthly": price = price - UNLOCK_FEE if night: price = price + NIGHT_SURCHARGE self.revenue = self.revenue + price self.receipts.append(f"{email}: {minutes} Min., {price:.2f} €") return priceOK test_fourth_ride_unlock_is_freeOK test_monthly_over_free_minutesOK test_monthly_within_free_minutesOK test_night_surchargeOK test_pay_as_you_go_rideOK test_receiptOK test_revenueOK test_student_fourth_rideOK test_student_rideTests: 9, bestanden: 9, fehlgeschlagen: 0Die Tests bleiben unberührt: Weder der Name noch die Aufrufe haben sich geändert.
Die Konstanten stehen auf Modulebene, über der Klasse: So sieht sie jeder andere Code in der Datei, und in Schritt 5 auch die Tarife.Probier es aus
Finde den Fehler
Ein Kollege macht denselben Schritt, vertraut aber dem Kommentar und behält ihn:
Wähle die Zeile mit dem Fehler.
Der Preis pro Minute stammt aus dem Kommentar: 0,15 € statt 0,20 €. Das Refactoring senkt also stillschweigend den Preis jeder Fahrt. Acht der neun Tests schlagen fehl; nur die Monatskarte bis 45 Minuten besteht, bei der die Minuten nicht bezahlt werden. Der Kommentar hätte gelöscht werden müssen, und die Zahl hätte aus dem Code kommen müssen, den die Tests schon bewachen.
Genauer, für Neugierige
FEHLERtest_fourth_ride_unlock_is_freeFEHLERtest_monthly_over_free_minutesFEHLERtest_night_surchargeFEHLERtest_pay_as_you_go_rideFEHLERtest_receiptFEHLERtest_revenueFEHLERtest_student_fourth_rideFEHLERtest_student_rideOK test_monthly_within_free_minutesTests: 9, bestanden: 1, fehlgeschlagen: 8Sieh es dir an
Schritt 3: das Geld
Geld in einem float ist Primitive Obsession (Code-Smells). Heute ergibt es in keinem Test einen falschen Cent, weil jeder Betrag erst beim Ausgeben gerundet wird. Deshalb ist der Schritt ein Refactoring: Die Ausgabe ist dieselbe. Aber der Umsatz ist eine Summe aus Tausenden solcher Beträge, und dort häufen sich die kleinen Fehler.
Die Zahlen werden zuDecimal, aus einem String erzeugt. Die Formeln bleiben, wie sie sind, denn Decimal funktioniert mit + und * und mit ganzen Zahlen.from decimal import Decimal
UNLOCK_FEE = Decimal("1.00")PER_MINUTE = Decimal("0.20")STUDENT_SHARE = Decimal("0.80")MONTHLY_FREE_MINUTES = 45PAID_UNLOCKS_PER_DAY = 3NIGHT_SURCHARGE = Decimal("0.50")
class RideBilling: def __init__(self): self.rides_today = {} self.revenue = Decimal("0") self.receipts = []
def finish_ride(self, email, plan, minutes, *, night=False): self.rides_today[email] = self.rides_today.get(email, 0) + 1 price = Decimal("0") if plan == "payg": price = UNLOCK_FEE + minutes * PER_MINUTE elif plan == "student": price = (UNLOCK_FEE + minutes * PER_MINUTE) * STUDENT_SHARE elif plan == "monthly": if minutes > MONTHLY_FREE_MINUTES: price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE if self.rides_today[email] > PAID_UNLOCKS_PER_DAY and plan != "monthly": price = price - UNLOCK_FEE if night: price = price + NIGHT_SURCHARGE self.revenue = self.revenue + price self.receipts.append(f"{email}: {minutes} Min., {price:.2f} €") return pricef"{amount:.2f}" funktioniert auch mit Decimal.def money(amount): return f"{amount:.2f}"OK test_fourth_ride_unlock_is_freeOK test_monthly_over_free_minutesOK test_monthly_within_free_minutesOK test_night_surchargeOK test_pay_as_you_go_rideOK test_receiptOK test_revenueOK test_student_fourth_rideOK test_student_rideTests: 9, bestanden: 9, fehlgeschlagen: 0Probier es aus
Ergänze
Der Preis pro Minute im Geldtyp der Sprache:
PER_MINUTE = Decimal()Decimal("0.20"). Aus einem float (Decimal(0.20)) steckt der Fehler der binären Form schon drin.Sieh es dir an
Schritt 4: die Abfrage getrennt vom Befehl
Jetzt die Änderung, die schon wartete: Der Preis vor dem Ende muss eine Abfrage sein, die nichts ändert (Befehl oder Abfrage). Die Berechnung wird in eine Methode price ausgelagert (Extract Method aus der Refactoring-Lektion). finish_ride bleibt ein Befehl: Er fragt nach dem Preis, dann zählt er, addiert und verschickt.
Ein Kollege macht es so. Der Zähler wandert hinter den Preis, weil eine Fahrt erst zählt, wenn sie beendet ist:
Was sagen die neun Tests?
def price(self, email, plan, minutes, *, night=False): price = Decimal("0") if plan == "payg": price = UNLOCK_FEE + minutes * PER_MINUTE elif plan == "student": price = (UNLOCK_FEE + minutes * PER_MINUTE) * STUDENT_SHARE elif plan == "monthly": if minutes > MONTHLY_FREE_MINUTES: price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE if self.rides_today.get(email, 0) > PAID_UNLOCKS_PER_DAY and plan != "monthly": price = price - UNLOCK_FEE if night: price = price + NIGHT_SURCHARGE return price
def finish_ride(self, email, plan, minutes, *, night=False): price = self.price(email, plan, minutes, night=night) self.rides_today[email] = self.rides_today.get(email, 0) + 1 self.revenue = self.revenue + price self.receipts.append(f"{email}: {minutes} Min., {price:.2f} €") return priceIm alten calc stieg der Zähler vor der Prüfung, also hieß „mehr als 3“: „das ist die vierte Fahrt“. Jetzt fragt price vor dem Zählen, und der Zähler ist um eins niedriger. Die vierte Fahrt gilt als dritte und zahlt die Entsperrgebühr. Die anderen Tests bestehen, weil sie nie bis zu einer vierten Fahrt kommen.
Genauer, für Neugierige
FEHLERtest_fourth_ride_unlock_is_free: '3.00' != '2.00'- 3.00? ^+ 2.00? ^
FEHLERtest_student_fourth_ride: '2.40' != '1.40'- 2.40? ^+ 1.40? ^
OK test_monthly_over_free_minutesOK test_monthly_within_free_minutesOK test_night_surchargeOK test_pay_as_you_go_rideOK test_receiptOK test_revenueOK test_student_rideTests: 9, bestanden: 7, fehlgeschlagen: 2Sieh es dir an
Schritt 4, zweiter Versuch
Das Rot zeigt auf den letzten Schritt. Du suchst den Fehler nicht in der ganzen Klasse: Du machst den Schritt rückgängig und machst ihn neu. Die Reihenfolge der Seiteneffekte war ein versteckter Teil des Verhaltens, und nur die Tests wussten davon.
price rechnet aus, die wievielte Fahrt des Tages das wird:
def price(self, email, plan, minutes, *, night=False): ride_of_day = self.rides_today.get(email, 0) + 1 price = Decimal("0") if plan == "payg": price = UNLOCK_FEE + minutes * PER_MINUTE elif plan == "student": price = (UNLOCK_FEE + minutes * PER_MINUTE) * STUDENT_SHARE elif plan == "monthly": if minutes > MONTHLY_FREE_MINUTES: price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE if ride_of_day > PAID_UNLOCKS_PER_DAY and plan != "monthly": price = price - UNLOCK_FEE if night: price = price + NIGHT_SURCHARGE return price
def finish_ride(self, email, plan, minutes, *, night=False): price = self.price(email, plan, minutes, night=night) self.rides_today[email] = self.rides_today.get(email, 0) + 1 self.revenue = self.revenue + price self.receipts.append(f"{email}: {minutes} Min., {price:.2f} €") return priceOK test_fourth_ride_unlock_is_freeOK test_monthly_over_free_minutesOK test_monthly_within_free_minutesOK test_night_surchargeOK test_pay_as_you_go_rideOK test_receiptOK test_revenueOK test_student_fourth_rideOK test_student_rideTests: 9, bestanden: 9, fehlgeschlagen: 0Die Tests bleiben wieder unberührt: Sie rufen finish_ride auf, und das tut dasselbe wie vorher.
Die ganze Klasse nach Schritt 4
from decimal import Decimal
UNLOCK_FEE = Decimal("1.00")PER_MINUTE = Decimal("0.20")STUDENT_SHARE = Decimal("0.80")MONTHLY_FREE_MINUTES = 45PAID_UNLOCKS_PER_DAY = 3NIGHT_SURCHARGE = Decimal("0.50")
class RideBilling: def __init__(self): self.rides_today = {} self.revenue = Decimal("0") self.receipts = []
def price(self, email, plan, minutes, *, night=False): ride_of_day = self.rides_today.get(email, 0) + 1 price = Decimal("0") if plan == "payg": price = UNLOCK_FEE + minutes * PER_MINUTE elif plan == "student": price = (UNLOCK_FEE + minutes * PER_MINUTE) * STUDENT_SHARE elif plan == "monthly": if minutes > MONTHLY_FREE_MINUTES: price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE if ride_of_day > PAID_UNLOCKS_PER_DAY and plan != "monthly": price = price - UNLOCK_FEE if night: price = price + NIGHT_SURCHARGE return price
def finish_ride(self, email, plan, minutes, *, night=False): price = self.price(email, plan, minutes, night=night) self.rides_today[email] = self.rides_today.get(email, 0) + 1 self.revenue = self.revenue + price self.receipts.append(f"{email}: {minutes} Min., {price:.2f} €") return priceSieh es dir an
Schritt 5: die Tarife
Übrig bleibt die Kette von if auf dem Text des Tarifs (lange if-Kette). Jeder neue Tarif ist ein neuer Zweig, und "monthly" wird an zwei Stellen geprüft. Jeder Tarif wird zu einer Klasse, die ihren Preis und ihren Rabatt für die vierte Fahrt kennt. Das ist Replace Conditional with Polymorphism aus der Refactoring-Lektion:
class PayAsYouGo: def price(self, minutes): return UNLOCK_FEE + minutes * PER_MINUTE
def loyalty_discount(self): return UNLOCK_FEE
class Student: def price(self, minutes): return PayAsYouGo().price(minutes) * STUDENT_SHARE
def loyalty_discount(self): # So ist es heute: der ganze 1 €, obwohl das Entsperren für Studierende 0,80 € kostet. return UNLOCK_FEE
class Monthly: def price(self, minutes): return max(0, minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE
def loyalty_discount(self): return Decimal("0")Die Prüfung für die Monatskarte verschwindet: Sie liefert einfach einen Rabatt von 0. Das merkwürdige Verhalten beim Studenten steht jetzt in einer Zeile, mit einem Kommentar, der sagt, warum es so ist (der gute Kommentar aus der Lektion über Kommentare).
from decimal import Decimal
UNLOCK_FEE = Decimal("1.00")PER_MINUTE = Decimal("0.20")STUDENT_SHARE = Decimal("0.80")MONTHLY_FREE_MINUTES = 45PAID_UNLOCKS_PER_DAY = 3NIGHT_SURCHARGE = Decimal("0.50")
class RideBilling: def __init__(self): self.rides_today = {} self.revenue = Decimal("0") self.receipts = []
def price(self, email, plan, minutes, *, night=False): ride_of_day = self.rides_today.get(email, 0) + 1 price = plan.price(minutes) if ride_of_day > PAID_UNLOCKS_PER_DAY: price = price - plan.loyalty_discount() if night: price = price + NIGHT_SURCHARGE return price
def finish_ride(self, email, plan, minutes, *, night=False): price = self.price(email, plan, minutes, night=night) self.rides_today[email] = self.rides_today.get(email, 0) + 1 self.revenue = self.revenue + price self.receipts.append(f"{email}: {minutes} Min., {price:.2f} €") return priceIn den Tests übergeben die Aufrufe ein Objekt statt Text:
self.assertEqual(self.ride("payg", 10, night=True), "3.50")self.assertEqual(self.ride(PayAsYouGo(), 10, night=True), "3.50")OK test_fourth_ride_unlock_is_freeOK test_monthly_over_free_minutesOK test_monthly_within_free_minutesOK test_night_surchargeOK test_pay_as_you_go_rideOK test_receiptOK test_revenueOK test_student_fourth_rideOK test_student_rideTests: 9, bestanden: 9, fehlgeschlagen: 0Das ist Strategy aus dem Modul Entwurfsmuster: Die Regel ist ein eigenes Objekt, und die Abrechnung fragt nur.
Sieh es dir an
Die beiden Änderungen jetzt
Der Firmentarif ist eine neue Klasse. Die Abrechnungsklasse bleibt unberührt (offen für Erweiterung):
BUSINESS_PER_MINUTE = Decimal("0.18")
class Business: def price(self, minutes): return minutes * BUSINESS_PER_MINUTE
def loyalty_discount(self): return Decimal("0")Der Preis vor dem Ende ist die Abfrage price. Wie oft du sie auch aufrufst, nichts wird gezählt, und kein Kassenbon geht raus:
billing = RideBilling()payg = PayAsYouGo()billing.finish_ride("ana@example.com", payg, 10)billing.finish_ride("ana@example.com", payg, 5)billing.finish_ride("ana@example.com", payg, 12, night=True)print(f"Preis vor dem Ende: {billing.price("ana@example.com", payg, 8):.2f} €")print(f"Preis vor dem Ende: {billing.price("ana@example.com", payg, 8):.2f} €")print(f"Abgebucht: {billing.finish_ride("ana@example.com", payg, 8):.2f} €")print(f"Firma, 12 Min.: {billing.finish_ride("office@example.com", Business(), 12):.2f} €")print(f"Kassenbons: {len(billing.receipts)}")Preis vor dem Ende: 1.60 €Preis vor dem Ende: 1.60 €Abgebucht: 1.60 €Firma, 12 Min.: 2.16 €Kassenbons: 5Fünf Kassenbons: drei Fahrten vor der Vorschau, die vierte und die der Firma. Die zwei Vorschauen hinterlassen keine Spur, und sie zeigen genau, was danach abgebucht wird.
Die Idee
Vorher und nachher
| Smell | Schritt | Lektion |
|---|---|---|
| nichts schützt das Verhalten | 0: Tests, die das heutige Verhalten festhalten | das Sicherheitsnetz |
Namen aus einem Buchstaben, calc, das nicht sagt, dass es verschickt |
1: Rename | Namen |
true im Aufruf |
1: ein Argument, das sagt, was es meint | Funktionen |
| magische Zahlen | 2: Konstanten mit Namen | Namen |
| ein Kommentar, der lügt | 2: gelöscht | Kommentare |
| Geld in einem float | 3: ein Typ für Geld | Code-Smells |
| eine Abfrage mit versteckten Seiteneffekten, eine lange Methode | 4: Extract Method, Befehl und Abfrage | Funktionen, Refactoring |
eine Kette von if auf dem Tarif als Text |
5: Replace Conditional with Polymorphism | Code-Smells, Refactoring, SOLID |
Die Klasse ist länger geworden, nicht kürzer. Aber jede Regel steht an einer Stelle und hat einen Namen, und jede Änderung vom Anfang ist eine Klasse.
Der Tag mit den Fahrten, gelaufen mit der Klasse vor Schritt 1 und mit der Klasse nach Schritt 5, gibt Zeile für Zeile dasselbe aus. Hier laufen beide Programme bei jeder Prüfung der Seite.
rides = RideMgr()rides.calc("ana@example.com", "payg", 10, False)rides.calc("ivan@example.com", "student", 25, False)rides.calc("ana@example.com", "payg", 5, False)rides.calc("maria@example.com", "monthly", 60, True)rides.calc("ana@example.com", "payg", 12, True)rides.calc("ana@example.com", "payg", 8, False)
for receipt in rides.out: print(receipt)print(f"Umsatz: {rides.s:.2f} €")billing = RideBilling()payg = PayAsYouGo()billing.finish_ride("ana@example.com", payg, 10)billing.finish_ride("ivan@example.com", Student(), 25)billing.finish_ride("ana@example.com", payg, 5)billing.finish_ride("maria@example.com", Monthly(), 60, night=True)billing.finish_ride("ana@example.com", payg, 12, night=True)billing.finish_ride("ana@example.com", payg, 8)
for receipt in billing.receipts: print(receipt)print(f"Umsatz: {billing.revenue:.2f} €")ana@example.com: 10 Min., 3.00 €ivan@example.com: 25 Min., 4.80 €ana@example.com: 5 Min., 2.00 €maria@example.com: 60 Min., 3.50 €ana@example.com: 12 Min., 3.90 €ana@example.com: 8 Min., 1.60 €Umsatz: 18.80 €Die Idee
Wo du aufhörst
Du hörst auf, wenn die kommenden Änderungen leicht werden. Hier sind die beiden Änderungen eine Klasse und eine Zeile. Deshalb sind manche Schritte bewusst nicht gemacht:
- der Kassenbon in einer eigenen Klasse. Der Text des Kassenbons ist ein zweiter Grund, die Klasse zu ändern (ein Grund zur Änderung). Heute ändert er sich nicht. Sobald das Marketing ihn jeden Monat bearbeitet, wird er zu einer eigenen Klasse (Aufgabe 1);
- das Versenden hinter einem Interface. Die Kassenbons sind eine Liste, keine echte E-Mail. Wenn die echte E-Mail kommt, darf der Test sie nicht verschicken, und dann kommt die Abstraktion (von einer Abstraktion abhängen);
- das Verhalten beim Studenten. Das ist eine Änderung des Verhaltens, kein Aufräumen. Sie wird getrennt gemacht, mit einem eigenen Test, wenn das Produkt entscheidet.
Und die Regel aus der Refactoring-Lektion: Du änderst entweder die Form oder das Verhalten, nie beides in einem Schritt.
Übung
Mach selbst weiter
Löse die Aufgaben in deinem Editor, ausgehend von der Klasse nach Schritt 5. Lass nach jedem Schritt die neun Tests laufen.
1. Der Kassenbon in einer eigenen Klasse. Verschiebe den Text des Kassenbons und die Liste der verschickten Kassenbons in eine Klasse ReceiptBook. Die Abrechnungsklasse sagt ihr nur „send“. Die Tests bleiben unberührt.
Eine mögliche Lösung
class ReceiptBook: def __init__(self): self.sent = []
def send(self, email, minutes, price): self.sent.append(f"{email}: {minutes} Min., {price:.2f} €")from decimal import Decimal
UNLOCK_FEE = Decimal("1.00")PER_MINUTE = Decimal("0.20")STUDENT_SHARE = Decimal("0.80")MONTHLY_FREE_MINUTES = 45PAID_UNLOCKS_PER_DAY = 3NIGHT_SURCHARGE = Decimal("0.50")
class RideBilling: def __init__(self): self.rides_today = {} self.revenue = Decimal("0") self.receipt_book = ReceiptBook()
def price(self, email, plan, minutes, *, night=False): ride_of_day = self.rides_today.get(email, 0) + 1 price = plan.price(minutes) if ride_of_day > PAID_UNLOCKS_PER_DAY: price = price - plan.loyalty_discount() if night: price = price + NIGHT_SURCHARGE return price
def finish_ride(self, email, plan, minutes, *, night=False): price = self.price(email, plan, minutes, night=night) self.rides_today[email] = self.rides_today.get(email, 0) + 1 self.revenue = self.revenue + price self.receipt_book.send(email, minutes, price) return price
@property def receipts(self): return self.receipt_book.sentOK test_fourth_ride_unlock_is_freeOK test_monthly_over_free_minutesOK test_monthly_within_free_minutesOK test_night_surchargeOK test_pay_as_you_go_rideOK test_receiptOK test_revenueOK test_student_fourth_rideOK test_student_rideTests: 9, bestanden: 9, fehlgeschlagen: 0Das Programm für den Tag aus der Karte „Vorher und nachher“ gibt mit dieser Klasse dieselbe Ausgabe aus.
2. Der Tarif aus Text. Der Tarif kommt als Text ("payg") aus der Datenbank. Schreib die Tabelle, die ihn in ein Objekt verwandelt. Was hat die alte Klasse mit einem unbekannten Tarif gemacht? Halte es mit einem Test auf der alten Klasse fest, bevor du entscheidest, was die neue tun soll.
3. Nacht aus der Uhrzeit. Die App weiß nicht mehr, wann Nacht ist; sie übergibt die Startzeit. Die Regel „von 22:00 bis 6:00 Uhr“ muss an einer Stelle stehen. Schreib zuerst die Tests für 21:59, 22:00, 5:59 und 6:00.
4. Der Student. Das Produkt hat entschieden: Bei der vierten Fahrt bekommt ein Student 0,80 €, nicht 1 €. Das ist eine Änderung des Verhaltens: Ändere zuerst den erwarteten Wert im Test und sieh ihn rot, dann ändere eine Zeile im Tarif.
Test