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:
const decimal FreeDeliveryFrom = 50m;const decimal StandardDeliveryFee = 4.99m;const decimal ExpressSurcharge = 7m;const string SpringCoupon = "SPRING10";const decimal SpringDiscount = 0.10m;
static List<decimal> orders = new List<decimal>();Und das ist die Funktion, durch die jede Bestellung geht:
static void ProcessOrder(string email, decimal[] prices, string coupon, bool express){ decimal amount = 0; foreach (decimal price in prices) { amount += price; } if (coupon == SpringCoupon) { amount = amount * (1 - SpringDiscount); } decimal delivery = amount >= FreeDeliveryFrom ? 0 : StandardDeliveryFee; if (express) { delivery += ExpressSurcharge; } decimal total = amount + delivery; orders.Add(total); Console.WriteLine($"An {email}: Bestellung bestätigt, {total:F2} €"); Console.WriteLine($"[log] Bestellung Nr. {orders.Count} über {total:F2} €");}ProcessOrder("maria@example.com", new decimal[] { 24.50m, 18.00m }, "SPRING10", true);ProcessOrder("ivan@example.com", new decimal[] { 64.00m }, "", 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 ProcessOrder 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:
static void PlaceOrder(string email, decimal[] prices, string coupon, bool express){ decimal total = OrderTotal(prices, coupon, express); orders.Add(total); SendConfirmation(email, total); LogOrder(orders.Count, total);}
static void SendConfirmation(string email, decimal total){ Console.WriteLine($"An {email}: Bestellung bestätigt, {total:F2} €");}
static void LogOrder(int number, decimal total){ Console.WriteLine($"[log] Bestellung Nr. {number} über {total:F2} €");}Darunter kommt die Berechnung, jede Aufgabe in einer eigenen Funktion:
static decimal Subtotal(decimal[] prices){ decimal amount = 0; foreach (decimal price in prices) { amount += price; } return amount;}
static decimal ApplyCoupon(decimal amount, string coupon){ if (coupon == SpringCoupon) { return amount * (1 - SpringDiscount); } return amount;}
static decimal DeliveryFee(decimal amount, bool express){ decimal fee = amount >= FreeDeliveryFrom ? 0 : StandardDeliveryFee; if (express) { fee += ExpressSurcharge; } return fee;}static decimal OrderTotal(decimal[] prices, string coupon, bool express){ decimal amount = Subtotal(prices); decimal discounted = ApplyCoupon(amount, coupon); return discounted + DeliveryFee(discounted, express);}PlaceOrder("maria@example.com", new decimal[] { 24.50m, 18.00m }, "SPRING10", true);PlaceOrder("ivan@example.com", new decimal[] { 64.00m }, "", 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 OrderTotal 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.
decimal amount = Subtotal(prices);decimal discounted = ApplyCoupon(amount, coupon);return discounted + DeliveryFee(discounted, express);static decimal OrderTotal(decimal[] prices, string coupon, bool 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.
static void PrintSlip(decimal[] prices, bool forCourier){ if (forCourier) { Console.WriteLine($"Paket: {prices.Length} Stk."); } else { foreach (decimal price in prices) { Console.WriteLine($"{price:F2} €"); } }}decimal[] cart = new decimal[] { 24.50m, 18.00m };PrintSlip(cart, true);PrintSlip(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:
static void PrintCourierSlip(decimal[] prices){ Console.WriteLine($"Paket: {prices.Length} Stk.");}
static void PrintReceipt(decimal[] prices){ foreach (decimal price in prices) { Console.WriteLine($"{price:F2} €"); }}decimal[] cart = new decimal[] { 24.50m, 18.00m };PrintCourierSlip(cart);PrintReceipt(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:
C# hat benannte Argumente: Beim Aufruf schreibst du vor den Wert den Namen des Parameters.
Console.WriteLine($"{DeliveryFee(38.25m, express: true):F2} €");11.99 €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{ public int Stock { get; private set; }
public Warehouse(int stock) { Stock = stock; }
public bool IsInStock(int quantity) { if (Stock >= quantity) { Stock -= quantity; return true; } return false; }}Der Warenkorb fragt, damit er „vorrätig“ anzeigen kann. Dann fragt die Bezahlung dasselbe:
var lamps = new Warehouse(3);Console.WriteLine("Warenkorb: " + (lamps.IsInStock(2) ? "vorrätig" : "nicht vorrätig"));Console.WriteLine("Bezahlung: " + (lamps.IsInStock(2) ? "vorrätig" : "nicht vorrätig"));Console.WriteLine("Auf Lager: " + lamps.Stock);Warenkorb: vorrätigBezahlung: nicht vorrätigAuf Lager: 1class Warehouse{ public int Stock { get; private set; }
public Warehouse(int stock) { Stock = stock; }
public bool IsInStock(int quantity) { if (Stock >= quantity) { Stock -= quantity; return true; } return false; }}
var lamps = new Warehouse(3);Console.WriteLine("Warenkorb: " + (lamps.IsInStock(2) ? "vorrätig" : "nicht vorrätig"));Console.WriteLine("Bezahlung: " + (lamps.IsInStock(2) ? "vorrätig" : "nicht vorrätig"));Console.WriteLine("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 IsInStock(2): nach dem Namen eine Frage.
- 3 >= 2: Es sind genug da.
- Aber die Frage nimmt auch die Lampen weg: 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: IsInStock(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
lamps.Stock sieht aus wie das Lesen eines Felds. Visual Studio wertet Properties aus, um sie im Debugger anzuzeigen, also ändert eine Property mit Seiteneffekt den Zustand, nur weil du an einem Breakpoint angehalten und das Objekt angesehen hast.Probier es aus
Sag die Ausgabe voraus
Das Lager mit dem IsInStock, das auch reserviert, hat jetzt 5 Lampen. Dieselbe Frage wird dreimal gestellt. Was gibt das Programm aus?
var lamps = new Warehouse(5);Console.WriteLine(lamps.IsInStock(2));Console.WriteLine(lamps.IsInStock(2));Console.WriteLine(lamps.IsInStock(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{ public int Stock { get; private set; }
public Warehouse(int stock) { Stock = stock; }
public bool IsInStock(int quantity) { return Stock >= quantity; }
public void Reserve(int quantity) { if (!IsInStock(quantity)) { throw new InvalidOperationException("Nicht genug Bestand"); } Stock -= quantity; }}var lamps = new Warehouse(3);Console.WriteLine("Warenkorb: " + (lamps.IsInStock(2) ? "vorrätig" : "nicht vorrätig"));Console.WriteLine("Bezahlung: " + (lamps.IsInStock(2) ? "vorrätig" : "nicht vorrätig"));lamps.Reserve(2);Console.WriteLine("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: stack.Pop() entfernt das Element und gibt es zurück, queue.Dequeue() 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
DeliveryFee 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.
Console.WriteLine($"{DeliveryFee(38.25m, false):F2} €");Console.WriteLine($"{DeliveryFee(38.25m, true):F2} €");Console.WriteLine($"{DeliveryFee(64.00m, false):F2} €");Console.WriteLine($"{DeliveryFee(38.25m, true):F2} €");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 ProcessOrder 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:
static void PrintReceipt(List<string> items){ items.Sort(); foreach (string item in items) { Console.WriteLine(" " + item); }}var cart = new List<string> { "Milch", "Orangen", "Brot" };PrintReceipt(cart);Console.WriteLine("Zuletzt hinzugefügt: " + cart[cart.Count - 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:
static void PrintReceipt(List<string> items){ foreach (string item in items.Order()) { Console.WriteLine(" " + item); }}items.Order() (LINQ) gibt eine neue sortierte Folge zurück, während List.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:
static int sent = 0;
static void NotifyShipped(string name, int orderId, bool sms){ sent++; string text = $"Hallo, {name}! Bestellung {orderId} ist unterwegs."; if (sms) { Console.WriteLine("SMS: " + text); } else { Console.WriteLine("E-Mail: " + text); }}NotifyShipped("Maria", 1042, false);NotifyShipped("Jonas", 1043, true);Console.WriteLine("Gesendet: " + 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.
PlaceOrderhat vier Parameter. Fasse sie in einer KlasseOrderzusammen 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
PeekNext()undTakeNext()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 · 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