Template Method und Iterator
Kurz gesagt: Template Method hält das gemeinsame Gerüst eines Algorithmus in einer Basisklasse, während Unterklassen die Schritte ausfüllen; Iterator gibt Elemente eins nach dem anderen heraus, ohne zu zeigen, wie sie gespeichert sind.
Problem
Zwei Berichte mit demselben Gerüst
Die Buchhaltung will die Bestellungen als CSV, und die Website will dieselben als HTML-Tabelle. Stornierte Bestellungen kommen nicht in den Bericht. Jemand hat den CSV-Bericht geschrieben und dann die Methode für HTML kopiert:
static String exportCsv(List<Order> orders) { List<String> lines = new ArrayList<>(); lines.add("Bestellung,Summe"); double sum = 0; for (Order order : orders) { if (order.cancelled()) { continue; } lines.add(order.id() + "," + String.format("%.2f", order.total())); sum += order.total(); } lines.add("gesamt," + String.format("%.2f", sum)); return String.join("\n", lines);}
static String exportHtml(List<Order> orders) { List<String> lines = new ArrayList<>(); lines.add("<table>"); lines.add("<tr><th>Bestellung</th><th>Summe</th></tr>"); double sum = 0; for (Order order : orders) { lines.add("<tr><td>" + order.id() + "</td><td>" + String.format("%.2f", order.total()) + "</td></tr>"); sum += order.total(); } lines.add("<tr><td>Gesamt</td><td>" + String.format("%.2f", sum) + "</td></tr>"); lines.add("</table>"); return String.join("\n", lines);}List<Order> orders = List.of( new Order(1001, 80.00, false), new Order(1002, 25.50, false), new Order(1003, 40.00, true));System.out.println(exportCsv(orders));System.out.println(exportHtml(orders));Bestellung,Summe1001,80.001002,25.50gesamt,105.50<table><tr><th>Bestellung</th><th>Summe</th></tr><tr><td>1001</td><td>80.00</td></tr><tr><td>1002</td><td>25.50</td></tr><tr><td>1003</td><td>40.00</td></tr><tr><td>Gesamt</td><td>145.50</td></tr></table>Die beiden Berichte zeigen eine unterschiedliche Summe: CSV sagt 105.50 €, und HTML sagt 145.50 €. Die Regel „stornierte Bestellungen zählen nicht“ gibt es nur in der ersten Kopie. Beide Kopien tun dasselbe: Sie öffnen, schreiben eine Kopfzeile, laufen über die Bestellungen, schreiben die Summe und schließen. Nur die Zeilen, die sie schreiben, sind verschieden. Ein drittes Format wird eine dritte Kopie, und die Regel muss an noch einer Stelle im Kopf behalten werden.
Die Idee
Template Method: ein Gerüst, verschiedene Schritte
Was sich nicht ändert, ist die Reihenfolge der Schritte und die Regeln dazwischen: öffnen, Kopfzeile, eine Zeile für jede Bestellung außer den stornierten, Summe, schließen. Was sich ändert, ist, wie jeder Schritt aussieht.
Das Gerüst kommt in die Basisklasse Report, in die Methode export. An jeder Stelle, an der sich die Formate unterscheiden, ruft sie einen Schritt auf: header, row, footer. Die Unterklasse schreibt nur die Schritte. Ein Schritt, der optional ist (open und close, die CSV nicht nutzt), hat in der Basisklasse einen leeren Rumpf und heißt Hook.
Das ist das Muster Template Method. Es hat zwei Rollen:
- die Basisklasse mit dem Gerüst: Sie bestimmt die Reihenfolge und ruft die Schritte auf;
- die Unterklassen:
CsvReportundHtmlReportschreiben die Schritte und rufen selbst nichts auf.
Sieh es dir an
Das Gerüst und die beiden Berichte
final: Eine Unterklasse kann es nicht ändern. Die Schritte sind protected und abstract, bis auf die beiden Hooks mit leerem Rumpf:abstract class Report { public final String export(List<Order> orders) { List<String> lines = new ArrayList<>(); open(lines); header(lines); double sum = 0; for (Order order : orders) { if (order.cancelled()) { continue; } row(lines, order); sum += order.total(); } footer(lines, sum); close(lines); return String.join("\n", lines); }
protected void open(List<String> lines) { }
protected abstract void header(List<String> lines);
protected abstract void row(List<String> lines, Order order);
protected abstract void footer(List<String> lines, double sum);
protected void close(List<String> lines) { }}Die beiden Berichte schreiben nur ihre Zeilen. CsvReport schreibt die Hooks nicht und nutzt deshalb die leeren aus der Basisklasse:
class CsvReport extends Report { @Override protected void header(List<String> lines) { lines.add("Bestellung,Summe"); }
@Override protected void row(List<String> lines, Order order) { lines.add(order.id() + "," + String.format("%.2f", order.total())); }
@Override protected void footer(List<String> lines, double sum) { lines.add("gesamt," + String.format("%.2f", sum)); }}
class HtmlReport extends Report { @Override protected void open(List<String> lines) { lines.add("<table>"); }
@Override protected void header(List<String> lines) { lines.add("<tr><th>Bestellung</th><th>Summe</th></tr>"); }
@Override protected void row(List<String> lines, Order order) { lines.add("<tr><td>" + order.id() + "</td><td>" + String.format("%.2f", order.total()) + "</td></tr>"); }
@Override protected void footer(List<String> lines, double sum) { lines.add("<tr><td>Gesamt</td><td>" + String.format("%.2f", sum) + "</td></tr>"); }
@Override protected void close(List<String> lines) { lines.add("</table>"); }}List<Order> orders = List.of( new Order(1001, 80.00, false), new Order(1002, 25.50, false), new Order(1003, 40.00, true));System.out.println(new CsvReport().export(orders));System.out.println(new HtmlReport().export(orders));Bestellung,Summe1001,80.001002,25.50gesamt,105.50<table><tr><th>Bestellung</th><th>Summe</th></tr><tr><td>1001</td><td>80.00</td></tr><tr><td>1002</td><td>25.50</td></tr><tr><td>Gesamt</td><td>105.50</td></tr></table>abstract class Report { public final String export(List<Order> orders) { List<String> lines = new ArrayList<>(); open(lines); header(lines); double sum = 0; for (Order order : orders) { if (order.cancelled()) { continue; } row(lines, order); sum += order.total(); } footer(lines, sum); close(lines); return String.join("\n", lines); }
protected void open(List<String> lines) { }
protected abstract void header(List<String> lines);
protected abstract void row(List<String> lines, Order order);
protected abstract void footer(List<String> lines, double sum);
protected void close(List<String> lines) { }}Schritt für Schritt, in Worten
- new CsvReport().export(orders): Das Gerüst steckt in Report. Es bestimmt die Reihenfolge der Schritte.
- open: CsvReport schreibt diesen Schritt nicht, deshalb läuft der leere Rumpf aus Report. Es wird nichts hinzugefügt.
- header: Das Gerüst ruft den Schritt auf, und CsvReport schreibt seine Zeile.
- row für Bestellung 1001: Das Gerüst ruft den Schritt auf, und CsvReport schreibt seine Zeile.
- row für Bestellung 1002: Das Gerüst ruft den Schritt auf, und CsvReport schreibt seine Zeile.
- Bestellung 1003 ist storniert: Das Gerüst selbst überspringt sie mit continue. Kein Bericht muss sich diese Regel merken.
- footer: Das Gerüst ruft den Schritt auf, und CsvReport schreibt seine Zeile.
- close: CsvReport schreibt diesen Schritt nicht, deshalb läuft der leere Rumpf aus Report. Es wird nichts hinzugefügt.
- Jetzt HtmlReport: dasselbe Gerüst, andere Schritte.
- open: Das Gerüst ruft den Schritt auf, und HtmlReport schreibt seine Zeile.
- header: Das Gerüst ruft den Schritt auf, und HtmlReport schreibt seine Zeile.
- row für Bestellung 1001: Das Gerüst ruft den Schritt auf, und HtmlReport schreibt seine Zeile.
- row für Bestellung 1002: Das Gerüst ruft den Schritt auf, und HtmlReport schreibt seine Zeile.
- Bestellung 1003 ist storniert: Das Gerüst selbst überspringt sie mit continue. Kein Bericht muss sich diese Regel merken.
- footer: Das Gerüst ruft den Schritt auf, und HtmlReport schreibt seine Zeile.
- close: Das Gerüst ruft den Schritt auf, und HtmlReport schreibt seine Zeile.
- Zwei Berichte, ein Gerüst. Beide Summen sind 105.50 €, weil die Regel zu stornierten Bestellungen an einer einzigen Stelle steht.
Die Summe ist in beiden Berichten 105.50 €: Die Regel zu stornierten Bestellungen steckt im Gerüst, an einer einzigen Stelle.
Sieh es dir an
Das Gerüst wird nicht angefasst
Eine Unterklasse, die versucht, ihr eigenes export zu schreiben, lässt sich nicht kompilieren:
class QuickCsvReport extends CsvReport { @Override public String export(List<Order> orders) { return "Bestellung,Summe"; }}error: export(List<Order>) in QuickCsvReport cannot override export(List<Order>) in Report public String export(List<Order> orders) { ^ overridden method is finalfinal. Ohne es ändert die Unterklasse das Gerüst unbemerkt, und die Regel zu stornierten Bestellungen verschwindet nur für diesen Bericht.Sieh es dir an
Ohne Vererbung: die Schritte als Funktionen
Die Schritte müssen keine Methoden einer Unterklasse sein. Das Gerüst kann sie als Parameter nehmen: Die Kopfzeile ist Text, und die Zeile und die Summe sind Funktionen.
static String export(List<Order> orders, String header, Function<Order, String> row, Function<Double, String> footer) { List<String> lines = new ArrayList<>(); lines.add(header); double sum = 0; for (Order order : orders) { if (order.cancelled()) { continue; } lines.add(row.apply(order)); sum += order.total(); } lines.add(footer.apply(sum)); return String.join("\n", lines);}System.out.println(export(orders, "Bestellung,Summe", order -> order.id() + "," + String.format("%.2f", order.total()), sum -> "gesamt," + String.format("%.2f", sum)));System.out.println(export(orders, "<table>\n<tr><th>Bestellung</th><th>Summe</th></tr>", order -> "<tr><td>" + order.id() + "</td><td>" + String.format("%.2f", order.total()) + "</td></tr>", sum -> "<tr><td>Gesamt</td><td>" + String.format("%.2f", sum) + "</td></tr>\n</table>"));Bestellung,Summe1001,80.001002,25.50gesamt,105.50<table><tr><th>Bestellung</th><th>Summe</th></tr><tr><td>1001</td><td>80.00</td></tr><tr><td>1002</td><td>25.50</td></tr><tr><td>Gesamt</td><td>105.50</td></tr></table>Das Gerüst steckt weiterhin an einer einzigen Stelle, und stornierte Bestellungen zählen weiterhin nicht.
Das ist Strategy für jeden Schritt:Function<Order, String> statt einer abstrakten Methode. Für zwei oder drei kurze Schritte genügt es. Eine Unterklasse lohnt sich, wenn es viele Schritte gibt, sie Felder teilen oder manche optional sind, wie die Hooks open und close.Problem
Jeder, der die Bestellungen will, blättert die Seiten um
Die Bestellungen liegen in einem System, das sie seitenweise liefert: zwei pro Anfrage, weil eine Anfrage langsam ist. Hier ist die Quelle eine Liste im Speicher, und jedes Laden gibt eine Zeile aus, damit du siehst, wann es passiert:
class OrderSource { private final List<Order> orders;
OrderSource(List<Order> orders) { this.orders = orders; }
List<Order> fetchPage(int number) { System.out.println("Seite wird geladen " + number); int from = Math.min((number - 1) * 2, orders.size()); int to = Math.min(from + 2, orders.size()); return orders.subList(from, to); }}Der Bericht, die E-Mail an die Kunden und der Export wollen alle Bestellungen, und jeder blättert die Seiten selbst um:
int number = 1;List<Order> page = source.fetchPage(number);while (!page.isEmpty()) { for (Order order : page) { System.out.println(order.id()); } number++; page = source.fetchPage(number);}Seite wird geladen 110011002Seite wird geladen 210031004Seite wird geladen 31005Seite wird geladen 4Der Code, der nur „jede Bestellung der Reihe nach“ will, weiß, dass es Seiten gibt, wie sie gezählt werden und dass das Ende eine leere Seite ist. Dieselben Zeilen stehen in drei Dateien. Wenn das System von Seitenzahlen auf „einen Schlüssel für die nächste Seite“ umsteigt, änderst du alle drei.
Die Idee
Iterator: etwas hinter einem Cursor durchlaufen
Der Aufrufer will eines: die nächste Bestellung, solange es eine gibt. Wie man zu ihr kommt (eine Liste, Seiten, eine Datei, das Netzwerk), ist die Sache von jemand anderem.
Dieser jemand ist der Cursor: ein Objekt, das sich merkt, wie weit es gekommen ist, und die Frage „gibt es noch mehr?“ selbst beantwortet, wobei es eine neue Seite erst lädt, wenn die vorige zu Ende ist. Der Aufrufer schreibt ein gewöhnliches for-each und weiß nichts von den Seiten.
Das ist das Muster Iterator. Es hat zwei Rollen:
- die Collection: das, was durchlaufen wird. Sie liefert einen Cursor;
- der Cursor: merkt sich die Stelle und liefert das nächste Element.
Dieses Muster ist in jede der vier Sprachen eingebaut: for-each funktioniert mit jedem Objekt, das einen Cursor liefert. In Java ist die Collection ein Iterable, und der Cursor ist ein Iterator mit hasNext() und next().
Sieh es dir an
Der Cursor, der die Seiten lädt
yield, deshalb wird der Cursor von Hand geschrieben. hasNext lädt die nächste Seite erst, wenn die aktuelle aufgebraucht ist:class PageIterator implements Iterator<Order> { private final OrderSource source; private List<Order> page = List.of(); private int number = 0; private int index = 0; private boolean done = false;
PageIterator(OrderSource source) { this.source = source; }
@Override public boolean hasNext() { if (index == page.size() && !done) { number++; page = source.fetchPage(number); index = 0; done = page.isEmpty(); } return index < page.size(); }
@Override public Order next() { if (!hasNext()) { throw new NoSuchElementException(); } return page.get(index++); }}Iterable ist die Collection: Jedes for-each verlangt von ihr einen neuen Cursor.
class AllOrders implements Iterable<Order> { private final OrderSource source;
AllOrders(OrderSource source) { this.source = source; }
@Override public Iterator<Order> iterator() { return new PageIterator(source); }}Der Aufrufer weiß nichts von den Seiten:
for (Order order : new AllOrders(source)) { System.out.println(order.id());}Seite wird geladen 110011002Seite wird geladen 210031004Seite wird geladen 31005Seite wird geladen 4class PageIterator implements Iterator<Order> { private final OrderSource source; private List<Order> page = List.of(); private int number = 0; private int index = 0; private boolean done = false;
PageIterator(OrderSource source) { this.source = source; }
@Override public boolean hasNext() { if (index == page.size() && !done) { number++; page = source.fetchPage(number); index = 0; done = page.isEmpty(); } return index < page.size(); }
@Override public Order next() { if (!hasNext()) { throw new NoSuchElementException(); } return page.get(index++); }}
for (Order order : new AllOrders(source)) { System.out.println(order.id());}Schritt für Schritt, in Worten
- Die Schleife verlangt die erste Bestellung. Noch ist nichts geladen.
- Der Cursor (PageIterator) hat noch keine Seite, deshalb lädt er Seite 1.
- 1001: Die erste Bestellung der Seite geht an die Schleife, und die Schleife gibt sie aus.
- 1002: von derselben Seite. Es wird nichts geladen.
- Die Seite ist zu Ende. Erst jetzt lädt der Cursor Seite 2.
- 1003 und 1004 von Seite 2.
- Wieder die Grenze: Seite 3.
- 1005: Auf Seite 3 steht nur eine Bestellung.
- Seite 4 ist leer: Es gibt keine Bestellungen mehr.
- Der Cursor sagt „keine mehr“, und die Schleife endet. Die Schleife hat nie eine Seite gesehen.
Die Ausgabe ist dieselbe, und das Zählen der Seiten steckt an einer einzigen Stelle. Wenn das System die Art ändert, wie es Seiten liefert, änderst du den Cursor, und die drei Stellen, die die Bestellungen durchlaufen, bleiben unberührt.
Sieh es dir an
Was for-each innen macht
for-each ist eine Kurzschreibweise für diese Schleife mit dem Cursor:
Iterator<Order> it = new AllOrders(source).iterator();while (it.hasNext()) { Order order = it.next(); System.out.println(order.id());}Seite wird geladen 110011002Seite wird geladen 210031004Seite wird geladen 31005Seite wird geladen 4for (Order order : orders) ruft einmal orders.iterator() auf und dann bei jedem Schritt hasNext() und next(). Deshalb funktioniert es mit einer Liste, mit AllOrders und mit allem, was Iterable ist.Probier es aus
Sage die Ausgabe voraus
Die Schleife hört nach der dritten Bestellung auf. Was gibt das Programm aus?
int shown = 0;for (Order order : new AllOrders(source)) { System.out.println(order.id()); shown++; if (shown == 3) { break; }}Probier es aus
Finde den Fehler
Die E-Mail an die Kunden sagt zuerst, wie viele Bestellungen es sind, und listet sie dann auf:
AllOrders orders = new AllOrders(new OrderSource(all));int count = 0;for (Order order : orders) { count++;}System.out.println("Bestellungen: " + count);for (Order order : orders) { System.out.println(order.id());}Seite wird geladen 1Seite wird geladen 2Seite wird geladen 3Seite wird geladen 4Bestellungen: 5Seite wird geladen 110011002Seite wird geladen 210031004Seite wird geladen 31005Seite wird geladen 4Wähle die Zeile mit dem Fehler.
AllOrders ist ein Iterable: Jedes for-each nimmt einen neuen Cursor und beginnt von vorn, wobei alle Seiten geladen werden. Brauchst du die Bestellungen zweimal, sammle sie einmal in einer Liste: List<Order> orders = new ArrayList<>(); new AllOrders(...).forEach(orders::add);. Dann werden die Seiten nur einmal geladen.Die Idee
Wann du sie nicht brauchst
Template Method hat einen Preis: eine Basisklasse und Vererbung, und Vererbung ist eine Bindung, die sich schwer lösen lässt. Es lohnt sich nicht, wenn:
- es ein einziges Format gibt. Eine einzige Methode ohne Schritte ist klarer;
- es ein oder zwei Schritte gibt. Reiche sie als Lambdas weiter, wie in der Karte „Ohne Vererbung“;
- die Unterklassen auch die Reihenfolge der Schritte ändern wollen. Dann ist das Gerüst nicht gemeinsam, und wenn du es erzwingst, bekommst du Schritte mit einem
ifdarin.
Einen eigenen Iterator brauchst du nicht, wenn die Daten schon in einer Liste liegen: Sie lässt sich schon durchlaufen. Du schreibst einen eigenen, wenn dahinter etwas steckt, das keine Collection im Speicher ist: Seiten, eine zeilenweise gelesene Datei, ein Baum oder eine endlose Folge (Rechnungsnummern).
Übung
Ein Bericht in Markdown
Löse die Aufgabe in deinem Editor.
Markdown. Die Dokumentationsseite will den Bericht als Markdown-Tabelle. Schreibe MarkdownReport, der nur die Schritte schreibt. Die Kopfzeile besteht aus zwei Zeilen. Report und die anderen Berichte werden nicht angefasst. Mit den Bestellungen aus der Lektion:
| Bestellung | Summe ||---|---|| 1001 | 80.00 || 1002 | 25.50 || **Gesamt** | 105.50 |Weitere Aufgaben
- Zeilennummern. Der HTML-Bericht nummeriert seine Zeilen (
<td>1</td>,<td>2</td>), und stornierte Bestellungen zählen nicht. Wo hältst du den Zähler: im Gerüst oder inHtmlReport? - Die ersten N. Schreibe einen Cursor, der nur die ersten N Bestellungen aus einem anderen Cursor liefert. Wie viele Seiten werden für die ersten drei geladen?
- Nur die großen. Laufe über die Bestellungen der Quelle und liefere nur die über 30 €, wieder ohne dass der Aufrufer etwas von den Seiten weiß.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Lambdas · record, switch und Muster · Entwurfsmuster · Strategy
- Aufgaben zum Thema: Entwurfsmuster