Zum Inhalt
wannacode

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 = 50
STANDARD_DELIVERY_FEE = 4.99
EXPRESS_SURCHARGE = 7
SPRING_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)
Ausgabe
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 fee
def 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)
Ausgabe
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.

  1. return discounted + delivery_fee(discounted, express)
  2. discounted = apply_coupon(amount, coupon)
  3. amount = subtotal(prices)
  4. 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)
Ausgabe
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)
Ausgabe
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)
Ausgabe
11.99 €
delivery_fee() takes 1 positional argument but 2 were given

Die 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 False

Der 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}")
Ausgabe
Warenkorb: vorrätig
Bezahlung: nicht vorrätig
Auf Lager: 1

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 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
  1. Im Lager stehen 3 Lampen im Regal.
  2. Der Warenkorb will nur zeigen, ob es 2 gibt. Er fragt is_in_stock(2): nach dem Namen eine Frage.
  3. 3 >= 2: Es sind genug da.
  4. 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.
  5. Der Warenkorb bekommt True und zeigt „vorrätig“.
  6. Der Kunde geht zur Bezahlung. Die Bezahlung fragt dasselbe: is_in_stock(2).
  7. 1 >= 2 ist False. Dieselbe Frage bekommt eine andere Antwort, und der Kunde sieht „nicht vorrätig“.
  8. 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
In einer @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))
Antwort

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 -= quantity
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"))
lamps.reserve(2)
print(f"Auf Lager: {lamps.stock}")
Ausgabe
Warenkorb: vorrätig
Bezahlung: vorrätig
Auf Lager: 1

Warenkorb 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} €")
Ausgabe
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])
Ausgabe
Brot
Milch
Orangen
Zuletzt hinzugefügt: Orangen

Das Brot wurde zuletzt hinzugefügt, aber das Programm sagt Orangen. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Ü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))
Ausgabe
E-Mail: Hallo, Maria! Bestellung 1042 ist unterwegs.
SMS: Hallo, Jonas! Bestellung 1043 ist unterwegs.
Gesendet: 2

Teile 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:

Ausgabe
E-Mail: Hallo, Maria! Bestellung 1042 ist unterwegs.
SMS: Hallo, Jonas! Bestellung 1043 ist unterwegs.
Gesendet: 2

Weitere Aufgaben

  • Ein Parameterobjekt. place_order hat vier Parameter. Fasse sie in einem Objekt order zusammen 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() und take_next() für eine Warteschlange von Bestellungen für den Kurier. Welche ist eine Abfrage und welche ein Befehl?

Test

Teste dich

1. Woran erkennst du, dass process_order mehr als eine Aufgabe erledigt?
2. print_slip(cart, True). Was ist falsch?
3. is_in_stock(2) verringert den Bestand, wenn genug da ist. Der Warenkorb fragt einmal, die Bezahlung fragt noch einmal. Was passiert bei 3 Lampen?
4. Welche dieser Funktionen ist rein?
5. Was sagt die Trennung von Befehl und Abfrage (Command-Query Separation)?

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