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:

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);
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 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);
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 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.

  1. decimal amount = Subtotal(prices);
  2. decimal discounted = ApplyCoupon(amount, coupon);
  3. return discounted + DeliveryFee(discounted, express);
  4. 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);
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:

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);
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:

C# hat benannte Argumente: Beim Aufruf schreibst du vor den Wert den Namen des Parameters.

Console.WriteLine($"{DeliveryFee(38.25m, express: true):F2} €");
Ausgabe
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);
Ausgabe
Warenkorb: vorrätig
Bezahlung: nicht vorrätig
Auf Lager: 1

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;
}
}
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
  1. Im Lager stehen 3 Lampen im Regal.
  2. Der Warenkorb will nur zeigen, ob es 2 gibt. Er fragt IsInStock(2): nach dem Namen eine Frage.
  3. 3 >= 2: Es sind genug da.
  4. 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.
  5. Der Warenkorb bekommt true und zeigt „vorrätig“.
  6. Der Kunde geht zur Bezahlung. Die Bezahlung fragt dasselbe: IsInStock(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 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));
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
{
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);
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: 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} €");
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 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]);
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:

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);
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. PlaceOrder hat vier Parameter. Fasse sie in einer Klasse 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 PeekNext() und TakeNext() 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 ProcessOrder mehr als eine Aufgabe erledigt?
2. PrintSlip(cart, true). Was ist falsch?
3. IsInStock(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 ·