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 FREE_DELIVERY_FROM = 50;const STANDARD_DELIVERY_FEE = 4.99;const EXPRESS_SURCHARGE = 7;const SPRING_COUPON = "SPRING10";const SPRING_DISCOUNT = 0.1;
const orders = [];Und das ist die Funktion, durch die jede Bestellung geht:
function processOrder(email, prices, coupon, express) { let amount = 0; for (const price of prices) { amount += price; } if (coupon === SPRING_COUPON) { amount = amount * (1 - SPRING_DISCOUNT); } let delivery = amount >= FREE_DELIVERY_FROM ? 0 : STANDARD_DELIVERY_FEE; if (express) { delivery += EXPRESS_SURCHARGE; } const total = amount + delivery; orders.push(total); console.log(`An ${email}: Bestellung bestätigt, ${total.toFixed(2)} €`); console.log(`[log] Bestellung Nr. ${orders.length} über ${total.toFixed(2)} €`);}processOrder("maria@example.com", [24.5, 18], "SPRING10", true);processOrder("ivan@example.com", [64], "", 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:
function placeOrder(email, prices, coupon, express) { const total = orderTotal(prices, coupon, express); orders.push(total); sendConfirmation(email, total); logOrder(orders.length, total);}
function sendConfirmation(email, total) { console.log(`An ${email}: Bestellung bestätigt, ${total.toFixed(2)} €`);}
function logOrder(number, total) { console.log(`[log] Bestellung Nr. ${number} über ${total.toFixed(2)} €`);}Darunter kommt die Berechnung, jede Aufgabe in einer eigenen Funktion:
function subtotal(prices) { let amount = 0; for (const price of prices) { amount += price; } return amount;}
function applyCoupon(amount, coupon) { if (coupon === SPRING_COUPON) { return amount * (1 - SPRING_DISCOUNT); } return amount;}
function deliveryFee(amount, express) { let fee = amount >= FREE_DELIVERY_FROM ? 0 : STANDARD_DELIVERY_FEE; if (express) { fee += EXPRESS_SURCHARGE; } return fee;}function orderTotal(prices, coupon, express) { const amount = subtotal(prices); const discounted = applyCoupon(amount, coupon); return discounted + deliveryFee(discounted, express);}placeOrder("maria@example.com", [24.5, 18], "SPRING10", true);placeOrder("ivan@example.com", [64], "", 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.
const amount = subtotal(prices);function orderTotal(prices, coupon, express) {}const discounted = applyCoupon(amount, coupon);return discounted + deliveryFee(discounted, 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.
function printSlip(prices, forCourier) { if (forCourier) { console.log(`Paket: ${prices.length} Stk.`); } else { for (const price of prices) { console.log(`${price.toFixed(2)} €`); } }}const cart = [24.5, 18];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:
function printCourierSlip(prices) { console.log(`Paket: ${prices.length} Stk.`);}
function printReceipt(prices) { for (const price of prices) { console.log(`${price.toFixed(2)} €`); }}const cart = [24.5, 18];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:
JavaScript hat keine benannten Argumente, deshalb übergibst du ein Options-Objekt, und die Funktion entpackt es in ihre Parameter:
function deliveryFee(amount, { express = false } = {}) { let fee = amount >= FREE_DELIVERY_FROM ? 0 : STANDARD_DELIVERY_FEE; if (express) { fee += EXPRESS_SURCHARGE; } return fee;}
console.log(`${deliveryFee(38.25, { express: true }).toFixed(2)} €`);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 { constructor(stock) { this.stock = stock; }
isInStock(quantity) { if (this.stock >= quantity) { this.stock -= quantity; return true; } return false; }}Der Warenkorb fragt, damit er „vorrätig“ anzeigen kann. Dann fragt die Bezahlung dasselbe:
const lamps = new Warehouse(3);console.log("Warenkorb: " + (lamps.isInStock(2) ? "vorrätig" : "nicht vorrätig"));console.log("Bezahlung: " + (lamps.isInStock(2) ? "vorrätig" : "nicht vorrätig"));console.log("Auf Lager: " + lamps.stock);Warenkorb: vorrätigBezahlung: nicht vorrätigAuf Lager: 1class Warehouse { constructor(stock) { this.stock = stock; }
isInStock(quantity) { if (this.stock >= quantity) { this.stock -= quantity; return true; } return false; }}
const lamps = new Warehouse(3);console.log("Warenkorb: " + (lamps.isInStock(2) ? "vorrätig" : "nicht vorrätig"));console.log("Bezahlung: " + (lamps.isInStock(2) ? "vorrätig" : "nicht vorrätig"));console.log("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: this.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
get stock()) ist es noch heimtückischer: lamps.stock sieht aus wie das Lesen eines Felds, ist aber eine Funktion. Die Browser-Konsole und der Debugger werten Getter aus, wenn du sie aufklappst, und das kann den Zustand ändern.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?
const lamps = new Warehouse(5);console.log(lamps.isInStock(2));console.log(lamps.isInStock(2));console.log(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 { constructor(stock) { this.stock = stock; }
isInStock(quantity) { return this.stock >= quantity; }
reserve(quantity) { if (!this.isInStock(quantity)) { throw new Error("Nicht genug Bestand"); } this.stock -= quantity; }}const lamps = new Warehouse(3);console.log("Warenkorb: " + (lamps.isInStock(2) ? "vorrätig" : "nicht vorrätig"));console.log("Bezahlung: " + (lamps.isInStock(2) ? "vorrätig" : "nicht vorrätig"));lamps.reserve(2);console.log("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, iterator.next() 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.log(`${deliveryFee(38.25, false).toFixed(2)} €`);console.log(`${deliveryFee(38.25, true).toFixed(2)} €`);console.log(`${deliveryFee(64, false).toFixed(2)} €`);console.log(`${deliveryFee(38.25, true).toFixed(2)} €`);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:
function printReceipt(items) { items.sort(); for (const item of items) { console.log(" " + item); }}const cart = ["Milch", "Orangen", "Brot"];printReceipt(cart);console.log("Zuletzt hinzugefügt: " + cart[cart.length - 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:
function printReceipt(items) { for (const item of items.toSorted()) { console.log(" " + item); }}items.toSorted() gibt ein neues Array zurück, während items.sort() das Array selbst ändert (und es zurückgibt, wodurch der Fehler noch schwerer zu bemerken ist).Ü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:
let sent = 0;
function notifyShipped(name, orderId, sms) { sent++; const text = `Hallo, ${name}! Bestellung ${orderId} ist unterwegs.`; if (sms) { console.log("SMS: " + text); } else { console.log("E-Mail: " + text); }}notifyShipped("Maria", 1042, false);notifyShipped("Jonas", 1043, true);console.log("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 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
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 · Arrays: push, splice 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