Zum Inhalt
wannacode

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 Funktion für HTML kopiert:

def export_csv(orders):
lines = ["Bestellung,Summe"]
total = 0
for order in orders:
if order.cancelled:
continue
lines.append(f"{order.id},{order.total:.2f}")
total += order.total
lines.append(f"gesamt,{total:.2f}")
return "\n".join(lines)
def export_html(orders):
lines = ["<table>", "<tr><th>Bestellung</th><th>Summe</th></tr>"]
total = 0
for order in orders:
lines.append(f"<tr><td>{order.id}</td><td>{order.total:.2f}</td></tr>")
total += order.total
lines.append(f"<tr><td>Gesamt</td><td>{total:.2f}</td></tr>")
lines.append("</table>")
return "\n".join(lines)
orders = [
Order(1001, 80.00),
Order(1002, 25.50),
Order(1003, 40.00, cancelled=True),
]
print(export_csv(orders))
print(export_html(orders))
Ausgabe
Bestellung,Summe
1001,80.00
1002,25.50
gesamt,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: CsvReport und HtmlReport schreiben die Schritte und rufen selbst nichts auf.

Sieh es dir an

Das Gerüst und die beiden Berichte

Report erbt von ABC, und die nötigen Schritte haben @abstractmethod. Ihr Rumpf ist ...: Hier gibt es nichts zu tun, die Unterklasse schreibt ihn. Die beiden Hooks haben den Rumpf pass:
class Report(ABC):
def export(self, orders):
lines = []
self.open(lines)
self.header(lines)
total = 0
for order in orders:
if order.cancelled:
continue
self.row(lines, order)
total += order.total
self.footer(lines, total)
self.close(lines)
return "\n".join(lines)
def open(self, lines):
pass
@abstractmethod
def header(self, lines): ...
@abstractmethod
def row(self, lines, order): ...
@abstractmethod
def footer(self, lines, total): ...
def close(self, lines):
pass

Die beiden Berichte schreiben nur ihre Zeilen. CsvReport schreibt die Hooks nicht und nutzt deshalb die leeren aus der Basisklasse:

class CsvReport(Report):
def header(self, lines):
lines.append("Bestellung,Summe")
def row(self, lines, order):
lines.append(f"{order.id},{order.total:.2f}")
def footer(self, lines, total):
lines.append(f"gesamt,{total:.2f}")
class HtmlReport(Report):
def open(self, lines):
lines.append("<table>")
def header(self, lines):
lines.append("<tr><th>Bestellung</th><th>Summe</th></tr>")
def row(self, lines, order):
lines.append(f"<tr><td>{order.id}</td><td>{order.total:.2f}</td></tr>")
def footer(self, lines, total):
lines.append(f"<tr><td>Gesamt</td><td>{total:.2f}</td></tr>")
def close(self, lines):
lines.append("</table>")
orders = [
Order(1001, 80.00),
Order(1002, 25.50),
Order(1003, 40.00, cancelled=True),
]
print(CsvReport().export(orders))
print(HtmlReport().export(orders))
Ausgabe
Bestellung,Summe
1001,80.00
1002,25.50
gesamt,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>

class Report(ABC):
def export(self, orders):
lines = []
self.open(lines)
self.header(lines)
total = 0
for order in orders:
if order.cancelled:
continue
self.row(lines, order)
total += order.total
self.footer(lines, total)
self.close(lines)
return "\n".join(lines)
def open(self, lines):
pass
@abstractmethod
def header(self, lines): ...
@abstractmethod
def row(self, lines, order): ...
@abstractmethod
def footer(self, lines, total): ...
def close(self, lines):
pass
Schritt für Schritt, in Worten
  1. new CsvReport().export(orders): Das Gerüst steckt in Report. Es bestimmt die Reihenfolge der Schritte.
  2. open: CsvReport schreibt diesen Schritt nicht, deshalb läuft der leere Rumpf aus Report. Es wird nichts hinzugefügt.
  3. header: Das Gerüst ruft den Schritt auf, und CsvReport schreibt seine Zeile.
  4. row für Bestellung 1001: Das Gerüst ruft den Schritt auf, und CsvReport schreibt seine Zeile.
  5. row für Bestellung 1002: Das Gerüst ruft den Schritt auf, und CsvReport schreibt seine Zeile.
  6. Bestellung 1003 ist storniert: Das Gerüst selbst überspringt sie mit continue. Kein Bericht muss sich diese Regel merken.
  7. footer: Das Gerüst ruft den Schritt auf, und CsvReport schreibt seine Zeile.
  8. close: CsvReport schreibt diesen Schritt nicht, deshalb läuft der leere Rumpf aus Report. Es wird nichts hinzugefügt.
  9. Jetzt HtmlReport: dasselbe Gerüst, andere Schritte.
  10. open: Das Gerüst ruft den Schritt auf, und HtmlReport schreibt seine Zeile.
  11. header: Das Gerüst ruft den Schritt auf, und HtmlReport schreibt seine Zeile.
  12. row für Bestellung 1001: Das Gerüst ruft den Schritt auf, und HtmlReport schreibt seine Zeile.
  13. row für Bestellung 1002: Das Gerüst ruft den Schritt auf, und HtmlReport schreibt seine Zeile.
  14. Bestellung 1003 ist storniert: Das Gerüst selbst überspringt sie mit continue. Kein Bericht muss sich diese Regel merken.
  15. footer: Das Gerüst ruft den Schritt auf, und HtmlReport schreibt seine Zeile.
  16. close: Das Gerüst ruft den Schritt auf, und HtmlReport schreibt seine Zeile.
  17. 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

Python hat kein final. Eine Unterklasse kann ihr eigenes export schreiben, und nichts hält sie auf (@typing.final ist nur ein Hinweis für Programme wie mypy, nicht für Python). Ob das Gerüst unberührt bleibt, ist eine Frage der Vereinbarung. Einen fehlenden Schritt dagegen fängt Python ab, sobald du das Objekt erzeugst:

class TextReport(Report):
def header(self, lines):
lines.append("Bestellung Summe")
def row(self, lines, order):
lines.append(f"{order.id} {order.total:.2f}")
TextReport()
Fehler
TypeError: Can't instantiate abstract class TextReport without an implementation for abstract method 'footer'

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.

def export(orders, header, row, footer):
lines = [header]
total = 0
for order in orders:
if order.cancelled:
continue
lines.append(row(order))
total += order.total
lines.append(footer(total))
return "\n".join(lines)
print(export(
orders,
"Bestellung,Summe",
lambda order: f"{order.id},{order.total:.2f}",
lambda total: f"gesamt,{total:.2f}",
))
print(export(
orders,
"<table>\n<tr><th>Bestellung</th><th>Summe</th></tr>",
lambda order: f"<tr><td>{order.id}</td><td>{order.total:.2f}</td></tr>",
lambda total: f"<tr><td>Gesamt</td><td>{total:.2f}</td></tr>\n</table>",
))
Ausgabe
Bestellung,Summe
1001,80.00
1002,25.50
gesamt,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.

In Python ist das oft der natürlichere Weg: Eine Funktion ist ein Wert, und es braucht keine Vererbung. Eine Unterklasse lohnt sich, wenn es viele Schritte gibt oder sie Daten teilen (zum Beispiel die aktuelle Zeilennummer), die besser in den Feldern eines einzigen Objekts liegen.

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:
def __init__(self, orders):
self.orders = orders
def fetch_page(self, number):
print(f"Seite wird geladen {number}")
start = (number - 1) * 2
return self.orders[start:start + 2]

Der Bericht, die E-Mail an die Kunden und der Export wollen alle Bestellungen, und jeder blättert die Seiten selbst um:

number = 1
page = source.fetch_page(number)
while page:
for order in page:
print(order.id)
number += 1
page = source.fetch_page(number)
Ausgabe
Seite wird geladen 1
1001
1002
Seite wird geladen 2
1003
1004
Seite wird geladen 3
1005
Seite wird geladen 4

Der 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 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 funktioniert mit jedem Objekt, das einen Cursor liefert. In Python ist der Cursor ein Objekt mit __next__, und der einfachste Weg, einen zu bauen, ist ein Generator: eine Funktion mit yield.

Sieh es dir an

Der Cursor, der die Seiten lädt

Eine Funktion mit yield ist ein Generator: Sie aufzurufen tut noch nichts, sie gibt einen Cursor zurück. Jedes Mal, wenn for nach der nächsten Bestellung fragt, läuft die Funktion bis zum nächsten yield, gibt die Bestellung heraus und hält genau dort an. Deshalb wird die nächste Seite erst geladen, wenn die aktuelle zu Ende ist:
def all_orders(source):
number = 1
while True:
page = source.fetch_page(number)
if not page:
return
for order in page:
yield order
number += 1

Der Aufrufer weiß nichts von den Seiten:

for order in all_orders(source):
print(order.id)
Ausgabe
Seite wird geladen 1
1001
1002
Seite wird geladen 2
1003
1004
Seite wird geladen 3
1005
Seite wird geladen 4

def all_orders(source):
number = 1
while True:
page = source.fetch_page(number)
if not page:
return
for order in page:
yield order
number += 1
for order in all_orders(source):
print(order.id)
Schritt für Schritt, in Worten
  1. Die Schleife verlangt die erste Bestellung. Noch ist nichts geladen.
  2. Der Generator hat noch keine Seite, deshalb lädt er Seite 1.
  3. 1001: Die erste Bestellung der Seite geht an die Schleife, und die Schleife gibt sie aus.
  4. 1002: von derselben Seite. Es wird nichts geladen.
  5. Die Seite ist zu Ende. Erst jetzt lädt der Cursor Seite 2.
  6. 1003 und 1004 von Seite 2.
  7. Wieder die Grenze: Seite 3.
  8. 1005: Auf Seite 3 steht nur eine Bestellung.
  9. Seite 4 ist leer: Es gibt keine Bestellungen mehr.
  10. 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 Generator, und die drei Stellen, die die Bestellungen durchlaufen, bleiben unberührt.

Sieh es dir an

Was for-each innen macht

for ist eine Kurzschreibweise für diese Schleife mit dem Cursor:

it = iter(all_orders(source))
while True:
try:
order = next(it)
except StopIteration:
break
print(order.id)
Ausgabe
Seite wird geladen 1
1001
1002
Seite wird geladen 2
1003
1004
Seite wird geladen 3
1005
Seite wird geladen 4
for ruft einmal iter() auf und dann bei jedem Schritt next(), bis der Cursor StopIteration auslöst. Ein Generator ist sein eigener Cursor: iter() gibt den Generator selbst zurück.

Willst du etwas, das sich jedes Mal wieder durchlaufen lässt wie eine Liste, schreibe eine Klasse, die bei jedem Durchlauf einen neuen Generator liefert:

class AllOrders:
def __init__(self, source):
self.source = source
def __iter__(self):
return all_orders(self.source)

Jetzt beginnt for order in AllOrders(source) bei jedem Durchlauf von vorn, genau wie ein Iterable in Java.

Probier es aus

Sage die Ausgabe voraus

Die Schleife hört nach der dritten Bestellung auf. Was gibt das Programm aus?

shown = 0
for order in all_orders(source):
print(order.id)
shown += 1
if shown == 3:
break
Antwort

Probier es aus

Finde den Fehler

Die E-Mail an die Kunden sagt zuerst, wie viele Bestellungen es sind, und listet sie dann auf:

orders = all_orders(OrderSource(all))
count = 0
for order in orders:
count += 1
print(f"Bestellungen: {count}")
for order in orders:
print(order.id)
Ausgabe
Seite wird geladen 1
Seite wird geladen 2
Seite wird geladen 3
Seite wird geladen 4
Bestellungen: 5
Die Anzahl ist 5, aber die Nummern kommen nicht heraus. Welche Zeile musst du ändern?

Wähle die Zeile mit dem Fehler.

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 Funktion ohne Schritte ist klarer;
  • es ein oder zwei Schritte gibt. Reiche sie als Funktionen 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 if darin.

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:

Ausgabe
| 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 in HtmlReport?
  • Die ersten N. Schreibe einen Generator, 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

1. Der Shop will den Bericht auch als JSON. Was schreibst du mit Template Method?
2. Warum hat der Schritt open in der Basisklasse einen leeren Rumpf, header aber nicht?
3. Eine Schleife läuft über den Generator der Bestellungen und hört bei der zweiten mit break auf. Wie viele Seiten werden geladen?
4. Ein Generator wurde einmal bis zum Ende durchlaufen. Was macht ein zweites for mit demselben Generator?

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