Funktionen: eine Aufgabe, wenige Parameter, keine versteckten Seiteneffekte
Kurz gesagt: Eine gute Funktion erledigt eine Aufgabe auf einer Abstraktionsebene, hat wenige Parameter und keine booleschen Flags und verändert nicht still einen Zustand, den ihr Name nicht verspricht.
Problem
Die Funktion, die alles macht
Die Namen aus der letzten Lektion stehen. Die Konstanten des Shops:
FREE_DELIVERY_FROM = 50STANDARD_DELIVERY_FEE = 4.99EXPRESS_SURCHARGE = 7SPRING_COUPON = "SPRING10"SPRING_DISCOUNT = 0.10
orders = []Und das ist die Funktion, durch die jede Bestellung geht:
def process_order(email, prices, coupon, express): amount = 0 for price in prices: amount += price if coupon == SPRING_COUPON: amount = amount * (1 - SPRING_DISCOUNT) delivery = 0 if amount >= FREE_DELIVERY_FROM else STANDARD_DELIVERY_FEE if express: delivery += EXPRESS_SURCHARGE total = amount + delivery orders.append(total) print(f"An {email}: Bestellung bestätigt, {total:.2f} €") print(f"[log] Bestellung Nr. {len(orders)} über {total:.2f} €")process_order("maria@example.com", [24.50, 18.00], "SPRING10", True)process_order("ivan@example.com", [64.00], "", False)An maria@example.com: Bestellung bestätigt, 50.24 €[log] Bestellung Nr. 1 über 50.24 €An ivan@example.com: Bestellung bestätigt, 64.00 €[log] Bestellung Nr. 2 über 64.00 €Sie funktioniert. Jetzt will das Marketing, dass der Gutschein nicht für die Lieferung gilt. Die Buchhaltung will die Gebühr getrennt im Warenkorb sehen. Die Testerin will die Lieferung unter und über 50 € prüfen und muss dafür jedes Mal eine Bestellung aufgeben, also speichern und eine E-Mail schicken. Alle fassen dieselbe Funktion an. Und was True am Ende des Aufrufs bedeutet, sieht man immer noch nicht.
Die Idee
Eine Aufgabe und eine Ebene
Versuch zu sagen, was process_order tut: „Sie summiert die Preise, und wendet den Gutschein an, und berechnet die Lieferung, und speichert, und schickt eine E-Mail, und schreibt ins Log.“ Jedes „und“ ist eine eigene Aufgabe. Jede ändert sich aus einem eigenen Grund und wegen einer anderen Person.
Die Regel lautet: eine Aufgabe pro Funktion. Eine Funktion mit einer Aufgabe ist klein, hat einen Namen, der die ganze Aufgabe nennt, und wenige Parameter. So erkennst du, dass es eine Aufgabe ist:
- Du beschreibst sie in einem Satz ohne „und“.
- Du kannst kein Stück mit einem Namen herausziehen, der nicht bloß den Code nacherzählt.
Die zweite Regel ist eine Abstraktionsebene. Die oberste Funktion liest sich wie das Inhaltsverzeichnis eines Buchs: Summe berechnen, speichern, bestätigen, loggen. Wie genau die Preise addiert werden (die Schleifen, die Vergleiche, das Format des Strings), liegt eine Ebene tiefer, in den kleinen Funktionen. Wenn „die Bestellung speichern“ und amount += price in derselben Methode stehen, springt der Leser in jeder Zeile zwischen den zwei Ebenen.
Sieh es dir an
Dieselbe Bestellung, aufgeteilt
Oben steht das Inhaltsverzeichnis:
def place_order(email, prices, coupon, express): total = order_total(prices, coupon, express) orders.append(total) send_confirmation(email, total) log_order(len(orders), total)
def send_confirmation(email, total): print(f"An {email}: Bestellung bestätigt, {total:.2f} €")
def log_order(number, total): print(f"[log] Bestellung Nr. {number} über {total:.2f} €")Darunter kommt die Berechnung, jede Aufgabe in einer eigenen Funktion:
def subtotal(prices): amount = 0 for price in prices: amount += price return amount
def apply_coupon(amount, coupon): if coupon == SPRING_COUPON: return amount * (1 - SPRING_DISCOUNT) return amount
def delivery_fee(amount, express): fee = 0 if amount >= FREE_DELIVERY_FROM else STANDARD_DELIVERY_FEE if express: fee += EXPRESS_SURCHARGE return feedef order_total(prices, coupon, express): amount = subtotal(prices) discounted = apply_coupon(amount, coupon) return discounted + delivery_fee(discounted, express)place_order("maria@example.com", [24.50, 18.00], "SPRING10", True)place_order("ivan@example.com", [64.00], "", False)An maria@example.com: Bestellung bestätigt, 50.24 €[log] Bestellung Nr. 1 über 50.24 €An ivan@example.com: Bestellung bestätigt, 64.00 €[log] Bestellung Nr. 2 über 64.00 €Die Ausgabe ist dieselbe. Jetzt steht der Gutschein in einer Funktion mit drei Zeilen, die Lieferung in einer anderen, und jede ändert sich, ohne die E-Mail anzufassen. Es sind mehr Zeilen Code, aber jedes Stück wird einzeln gelesen, geändert und geprüft.
Genauer, für Neugierige
Wie klein? Eine Zahl gibt es nicht. Eine Funktion mit 30 Zeilen, die eine Sache tut (zum Beispiel ein langes, aber flaches switch mit ähnlichen Zweigen), ist in Ordnung. Eine Funktion mit 8 Zeilen, drei Verschachtelungsebenen und zwei Flags nicht. Das Maß ist die Beschreibung in einem Satz, nicht die Zeilenzahl. Eine lange Funktion in Schritten zu zerlegen, sodass das Programm nach jedem Schritt funktioniert, heißt Extract Method und steht in der sechsten Lektion dieses Moduls.
Probier es aus
Bring die Zeilen in Ordnung
Bring order_total in die Reihenfolge, in der der Gutschein vor der Lieferung angewendet wird. Warum die Reihenfolge zählt: Der Rabatt kann die Bestellung unter 50 € drücken, und dann ist die Lieferung nicht mehr kostenlos.
return discounted + delivery_fee(discounted, express)discounted = apply_coupon(amount, coupon)amount = subtotal(prices)def order_total(prices, coupon, express):
Die Idee
Wenige Parameter und keine Flags
Jeder Parameter ist etwas, das der Leser im Kopf behalten muss und das jeder Aufruf in der richtigen Reihenfolge übergeben muss. Null, ein und zwei Parameter lesen sich leicht. Drei sind schon viel. Vier oder mehr bedeuten meistens, dass die Parameter eine Sache sind, die in Stücke zerbrochen wurde: Die E-Mail, die Preise, der Gutschein und die Lieferung zusammen sind eine Bestellung. Dann werden sie zu einem Objekt. Das ist das Refactoring Introduce Parameter Object aus der sechsten Lektion.
Der schlimmste Parameter ist ein Flag: ein boolescher Wert, der wählt, was die Funktion tut.
def print_slip(prices, for_courier): if for_courier: print(f"Paket: {len(prices)} Stk.") else: for price in prices: print(f"{price:.2f} €")cart = [24.50, 18.00]print_slip(cart, True)print_slip(cart, False)Paket: 2 Stk.24.50 €18.00 €Beim Aufruf sagt True nichts. Und innen hat die Funktion zwei getrennte Wege, also zwei Aufgaben. Die Lösung sind zwei Funktionen mit Namen:
def print_courier_slip(prices): print(f"Paket: {len(prices)} Stk.")
def print_receipt(prices): for price in prices: print(f"{price:.2f} €")cart = [24.50, 18.00]print_courier_slip(cart)print_receipt(cart)Paket: 2 Stk.24.50 €18.00 €Manchmal ist der boolesche Wert wirklich Daten und keine Wahl des Verhaltens: Die Expresslieferung gehört zur Bestellung. Dann sollte wenigstens der Aufruf sagen, was er bedeutet:
Python hat Schlüsselwortargumente, die nur so übergeben werden können: Alles nach * in der Definition kann nur über seinen Namen übergeben werden. Ein Versuch ohne Namen ist ein Fehler:
def delivery_fee(amount, *, express=False): fee = 0 if amount >= FREE_DELIVERY_FROM else STANDARD_DELIVERY_FEE if express: fee += EXPRESS_SURCHARGE return fee
print(f"{delivery_fee(38.25, express=True):.2f} €")
try: delivery_fee(38.25, True)except TypeError as error: print(error)11.99 €delivery_fee() takes 1 positional argument but 2 were givenDie Idee
Seiteneffekt
Alles, was eine Funktion außerhalb von sich verändert, ist ein Seiteneffekt: ein Feld eines Objekts, eine Liste, die sie bekommen hat, eine globale Variable, eine Datei, eine Datenbank, eine E-Mail, eine Zeile auf dem Bildschirm.
Seiteneffekte sind nicht von sich aus schlecht. Die Bestellung muss irgendwo gespeichert werden, und die E-Mail muss rausgehen. Schlecht ist es, wenn der Effekt versteckt ist: Der Name sagt „ich frage“, aber die Funktion handelt auch. Wer sie aufruft, erwartet keine Änderung und ruft sie ohne Nachdenken zweimal auf.
Sieh es dir an
Die Frage, die die Antwort ändert
Das Lager sagt, ob es Lampen gibt. Jemand hat entschieden, dass es praktisch ist, sie gleich zu reservieren, wenn welche da sind:
class Warehouse: def __init__(self, stock): self.stock = stock
def is_in_stock(self, quantity): if self.stock >= quantity: self.stock -= quantity return True return FalseDer Warenkorb fragt, damit er „vorrätig“ anzeigen kann. Dann fragt die Bezahlung dasselbe:
lamps = Warehouse(3)print("Warenkorb: " + ("vorrätig" if lamps.is_in_stock(2) else "nicht vorrätig"))print("Bezahlung: " + ("vorrätig" if lamps.is_in_stock(2) else "nicht vorrätig"))print(f"Auf Lager: {lamps.stock}")Warenkorb: vorrätigBezahlung: nicht vorrätigAuf Lager: 1class Warehouse: def __init__(self, stock): self.stock = stock
def is_in_stock(self, quantity): if self.stock >= quantity: self.stock -= quantity return True return False
lamps = Warehouse(3)print("Warenkorb: " + ("vorrätig" if lamps.is_in_stock(2) else "nicht vorrätig"))print("Bezahlung: " + ("vorrätig" if lamps.is_in_stock(2) else "nicht vorrätig"))print(f"Auf Lager: {lamps.stock}")Schritt für Schritt, in Worten
- Im Lager stehen 3 Lampen im Regal.
- Der Warenkorb will nur zeigen, ob es 2 gibt. Er fragt is_in_stock(2): nach dem Namen eine Frage.
- 3 >= 2: Es sind genug da.
- Aber die Frage nimmt auch die Lampen weg: self.stock wird von 3 zu 1. Niemand hat etwas gekauft, und der Warenkorb weiß nicht, dass das passiert ist.
- Der Warenkorb bekommt True und zeigt „vorrätig“.
- Der Kunde geht zur Bezahlung. Die Bezahlung fragt dasselbe: is_in_stock(2).
- 1 >= 2 ist False. Dieselbe Frage bekommt eine andere Antwort, und der Kunde sieht „nicht vorrätig“.
- Auf Lager: 1. Die Antwort hat sich geändert, weil die erste Frage einen versteckten Seiteneffekt hatte.
Niemand hat etwas gekauft, zwei Lampen fehlen, und der Kunde kann nicht bezahlen. In einem echten Shop ist das eine verlorene Bestellung wegen einer Seite, die nach dem Bestand fragt. Und der Fehler ist schwer zu finden, weil jede Zeile für sich in Ordnung aussieht.
Genauer, für Neugierige
@property ist es noch heimtückischer: lamps.stock sieht aus wie das Lesen eines Attributs, kann aber eine Funktion sein, die etwas ändert. Der Debugger von PyCharm wertet Properties aus, um sie anzuzeigen, und das kann den Zustand ändern.Probier es aus
Sag die Ausgabe voraus
Das Lager mit dem is_in_stock, das auch reserviert, hat jetzt 5 Lampen. Dieselbe Frage wird dreimal gestellt. Was gibt das Programm aus?
lamps = Warehouse(5)print(lamps.is_in_stock(2))print(lamps.is_in_stock(2))print(lamps.is_in_stock(2))Jedes „ja“ nimmt 2 Lampen weg: Aus 5 werden 3, dann 1. Bei der dritten Frage ist 1 weniger als 2. Wenn die Frage nur fragen würde, wären alle drei Antworten „ja“.
Die Idee
Ein Befehl oder eine Abfrage, nicht beides
Die Regel heißt Trennung von Befehl und Abfrage (Command-Query Separation): Eine Funktion beantwortet entweder eine Frage und ändert nichts, oder sie tut etwas und gibt sich nicht als Frage aus. Das Lager wird zu:
class Warehouse: def __init__(self, stock): self.stock = stock
def is_in_stock(self, quantity): return self.stock >= quantity
def reserve(self, quantity): if not self.is_in_stock(quantity): raise ValueError("Nicht genug Bestand") self.stock -= quantitylamps = Warehouse(3)print("Warenkorb: " + ("vorrätig" if lamps.is_in_stock(2) else "nicht vorrätig"))print("Bezahlung: " + ("vorrätig" if lamps.is_in_stock(2) else "nicht vorrätig"))lamps.reserve(2)print(f"Auf Lager: {lamps.stock}")Warenkorb: vorrätigBezahlung: vorrätigAuf Lager: 1Warenkorb und Bezahlung fragen, so oft sie wollen. Die Lampen werden in einer Zeile reserviert, die genau das sagt: reserve(2).
Genauer, für Neugierige
Es gibt bekannte Ausnahmen, und der Name verrät sie: items.pop() entfernt das Element und gibt es zurück, next(iterator) rückt vor und gibt zurück. Das sind Verben, keine Fragen, deshalb ruft sie niemand „nur zur Kontrolle“ auf. Die Regel lautet nicht „ein Befehl gibt nie etwas zurück“, sondern „eine Abfrage ändert nie etwas“.
Die Idee
Die reine Funktion
delivery_fee aus der aufgeteilten Bestellung antwortet nur aus ihren Parametern und ändert nichts. Eine solche Funktion heißt rein: Mit denselben Parametern gibt sie immer dasselbe zurück, egal wie oft und in welcher Reihenfolge du sie aufrufst.
print(f"{delivery_fee(38.25, False):.2f} €")print(f"{delivery_fee(38.25, True):.2f} €")print(f"{delivery_fee(64.00, False):.2f} €")print(f"{delivery_fee(38.25, True):.2f} €")4.99 €11.99 €0.00 €11.99 €Die Lieferung unter und über der Schwelle zu prüfen, braucht vier Zeilen. Es gibt kein Lager zu füllen, keine E-Mail zu schicken, keine Datenbank. Mit process_order musstest du dafür eine Bestellung aufgeben. Darum hält man die Berechnungen rein und sammelt die Seiteneffekte in wenigen klar benannten Befehlen oben. Wie aus solchen Prüfungen Tests werden, die den Code bei jeder Änderung schützen, ist das Thema der fünften Lektion dieses Moduls.
Probier es aus
Finde den Fehler
Der Knopf „Letztes entfernen“ soll das Produkt entfernen, das der Kunde zuletzt hinzugefügt hat. Der Kunde legt Milch, Orangen und Brot in den Warenkorb. Davor gibt der Shop einen alphabetisch sortierten Bon aus:
def print_receipt(items): items.sort() for item in items: print(" " + item)cart = ["Milch", "Orangen", "Brot"]print_receipt(cart)print("Zuletzt hinzugefügt: " + cart[-1]) Brot Milch OrangenZuletzt hinzugefügt: OrangenDas Brot wurde zuletzt hinzugefügt, aber das Programm sagt Orangen. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
items.sort() sortiert genau die Liste, die die Funktion bekommen hat, und das ist der Warenkorb des Aufrufers. Der Bon wollte nur ausgeben, hat aber still die Reihenfolge des Warenkorbs geändert. Die Lösung: eine Kopie sortieren und den Warenkorb in Ruhe lassen:
def print_receipt(items): for item in sorted(items): print(" " + item)sorted(items) gibt eine neue Liste zurück, während items.sort() die Liste selbst ändert.Übung
Die Benachrichtigung „Bestellung versandt“
Löse die Aufgabe in deinem Editor.
Die Benachrichtigung. Diese Funktion baut den Text, wählt den Kanal mit einem Flag, gibt aus und zählt, was gesendet wurde:
sent = 0
def notify_shipped(name, order_id, sms): global sent sent += 1 text = f"Hallo, {name}! Bestellung {order_id} ist unterwegs." if sms: print("SMS: " + text) else: print("E-Mail: " + text)notify_shipped("Maria", 1042, False)notify_shipped("Jonas", 1043, True)print("Gesendet: " + str(sent))E-Mail: Hallo, Maria! Bestellung 1042 ist unterwegs.SMS: Hallo, Jonas! Bestellung 1043 ist unterwegs.Gesendet: 2Teile sie in drei Funktionen: eine reine, die nur den Text baut, und zwei Befehle, einen für den Versand per E-Mail und einen per SMS, die jeweils zählen, was sie gesendet haben. Das Flag fällt weg. Die Ausgabe muss gleich bleiben:
E-Mail: Hallo, Maria! Bestellung 1042 ist unterwegs.SMS: Hallo, Jonas! Bestellung 1043 ist unterwegs.Gesendet: 2Weitere Aufgaben
- Ein Parameterobjekt.
place_orderhat vier Parameter. Fasse sie in einem Objektorderzusammen und sieh dir an, wie der Aufruf aussieht. - Der Gutschein gilt nicht für die Lieferung. Das Marketing ändert die Regel. Welche Funktion fasst du an, und wie viele andere bleiben unberührt?
- Die Warteschlange. Schreibe
peek_next()undtake_next()für eine Warteschlange von Bestellungen für den Kurier. Welche ist eine Abfrage und welche ein Befehl?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Exceptions · Werfen und eigene Exceptions · Listen: append, remove und sort · Namen im Code
- Geht weiter in: Kommentare, die lügen, und Kommentare, die helfen · Code-Smells: lange Methode, große Klasse, Duplikate, Primitive statt Typen, lange if-Ketten · Tests als Sicherheitsnetz · Refactoring in kleinen Schritten
- Aufgaben zum Thema: Sauberer Code und Refactoring