Generatoren und Iteratoren

Einführung

Wind Power Generators

Was ist ein Iterator? Iteratoren sind Objekte, über die mir einer for-Schleife iteriert werden kann. Wir können auch sagen, dass ein Iterator ein Objekt ist, das Daten Element für Element zurückgibt. Das heißt, sie machen keine Arbeit, bis wir ausdrücklich nach ihrem nächsten Objekt fragen bzw. dieses anfordern. Sie arbeiten nach einem Prinzip, das in der Informatik als Lazy-Evaluation (deutsch: bequeme oder faule Auswertung) bekannt ist. Lazy-Evaluation ist eine Bewertungsstrategie, die die Bewertung eines Ausdrucks verzögert, bis sein Wert wirklich benötigt wird. Aufgrund der "Lazyness" von Python-Iteratoren sind sie eine großartige Möglichkeit, mit Unendlichkeit umzugehen, d. h. Iteratoren, die für immer iterieren können. Man findet kaum Python-Programme, die keine Iteratoren benutzen.

Iteratoren sind ein grundlegendes Konzept von Python. Sie haben bereits in Ihren ersten Python-Programmen gelernt, dass Sie Containerobjekte wie Listen und Zeichenfolgen durchlaufen können. Zu diesem Zweck erstellt Python eine Iteratorversion der Liste oder Zeichenfolge. In diesem Fall kann ein Iterator als Zeiger auf einen Container angesehen werden, sodass wir alle Elemente dieses Containers durchlaufen können. Ein Iterator ist eine Abstraktion, mit der Programmierende auf alle Elemente eines iterierbaren Objekts (eine Menge, eine Zeichenfolge, eine Liste usw.) zugreifen können, ohne die Datenstruktur dieses Objekts genauer zu kennen.

Generatoren sind eine spezielle Art von Funktion, mit der wir Iteratoren implementieren oder generieren können.

Meistens werden Iteratoren implizit verwendet, wie in der for-Schleife von Python. Wir zeigen dies im folgenden Beispiel. Wir iterieren über eine Liste, aber man sollte sich nicht irren: Eine Liste ist kein Iterator, kann aber wie ein Iterator verwendet werden:

In [1]:
städte = ["Paris", "Berlin", "Hamburg", 
          "Frankfurt", "London", "Wien", 
          "Amsterdam", "Den Haag"]
for ort in städte:
    print("ort: " + ort)
ort: Paris
ort: Berlin
ort: Hamburg
ort: Frankfurt
ort: London
ort: Wien
ort: Amsterdam
ort: Den Haag

Wenn eine for-Schleife ausgeführt wird passiert Folgendes: Die Funktion 'iter' wird auf das Objekt angewendet, das dem Schlüsselwort 'in' folgt, z. B. das o in dem Ausdruck for i in o: Zwei Fälle sind möglich: o ist entweder iterierbar oder nicht. Wenn o nicht iterierbar ist, wird eine Ausnahme ausgelöst, die besagt, dass der Typ des Objekts nicht iterierbar ist. Wenn o dagegen iterierbar ist, gibt der Aufruf iter(o) einen Iterator zurück. Nennen wir diesen iterator_obj. Die for-Schleife verwendet diesen Iterator, um mit der next-Methode über das Objekt o zu iterieren. Die for-Schleife stoppt, wenn next(iterator_obj) 'exhausted' (erschöpft) ist, was bedeutet, dass eine StopIteration-Ausnahme zurückgegeben wird. Wir demonstrieren dieses Verhalten im folgenden Codebeispiel:

In [2]:
kenntnisstand = ["Anfänger", "Fortgeschritten", "Experte"]
kenntnisstand_iterator = iter(kenntnisstand)
print("Erster 'next'-Aufruf: ", next(kenntnisstand_iterator))
print("Zweiter'next'-Aufruf: ", next(kenntnisstand_iterator))
Erster 'next'-Aufruf:  Anfänger
Zweiter'next'-Aufruf:  Fortgeschritten

Wir hätten noch zweimal next aufrufen können, aber danach erhalten wir eine StopIteration-Ausnahme.

Wir können dieses Iterationsverhalten der for-Schleife in einer while-Schleife simulieren: Möglicherweise haben Sie bemerkt, dass in unserem Programm etwas fehlt: Wir müssen die Ausnahme "Stop Iteration" abfangen:

In [3]:
andere_städte = ["Strasbourg", "Freiburg", 
                 "Stuttgart", "Wien", 
                 "Hannover", "Berlin", 
                 "Zurich"]

stadt_iterator = iter(andere_städte)
while stadt_iterator:
    try:
        stadt = next(stadt_iterator)
        print(stadt)
    except StopIteration:
        break
Strasbourg
Freiburg
Stuttgart
Wien
Hannover
Berlin
Zurich

Die sequentiellen Basistypen sowie die Mehrheit der Klassen der Standardbibliothek von Python unterstützen die Iteration. Der Wörterbuch-Datentyp dict unterstützt auch Iteratoren. In diesem Fall läuft die Iteration über die Schlüssel des Wörterbuchs:

In [4]:
hauptstädte = {"Frankreich":"Paris", 
               "Niederlande":"Amsterdam", 
               "Deutschland":"Berlin", 
               "Schweiz":"Bern", 
               "Österreich":"Wien"}

for land in hauptstädte:
     print("Die Hauptstadt von " + land + " ist " + hauptstädte[land])
Die Hauptstadt von Frankreich ist Paris
Die Hauptstadt von Niederlande ist Amsterdam
Die Hauptstadt von Deutschland ist Berlin
Die Hauptstadt von Schweiz ist Bern
Die Hauptstadt von Österreich ist Wien

Off-Topic: Einige Leser sind möglicherweise verwirrt, wenn sie aus unserem Beispiel erfahren, dass die Hauptstadt der Niederlande nicht Den Haag (Den Haag), sondern Amsterdam ist. Amsterdam ist laut Verfassung die Hauptstadt der Niederlande, obwohl sich das niederländische Parlament und die niederländische Regierung in Den Haag befinden, ebenso wie der Oberste Gerichtshof und der Staatsrat.

Iterator implementieren

Eine Möglichkeit, Iteratoren in Python zu erstellen, besteht darin, eine Klasse zu definieren, die die Methoden __init__ und __next__ implementiert. Wir zeigen dies, indem wir einen Klassenzyklus implementieren, mit dem ein iterierbares Objekt für immer durchlaufen werden kann. Mit anderen Worten, eine Instanz dieser Klasse gibt das Element einer Iterable zurück, bis es erschöpft ist. Dann wird die Sequenz auf unbestimmte Zeit wiederholt.

In [5]:
class Zyklus(object):
    
    def __init__(self, iterable):
        self.iterable = iterable
        self.iter_obj = iter(iterable)

    def __iter__(self):
        return self

    def __next__(self):
        while True:
            try:
                next_obj = next(self.iter_obj)
                return next_obj
            except StopIteration:
                self.iter_obj = iter(self.iterable)

      
x = Zyklus("abc")

for i in range(10):
    print(next(x), end=", ")
a, b, c, a, b, c, a, b, c, a, 

Auch wenn der objektorientierte Ansatz zum Erstellen eines Iterators sehr interessant sein mag, ist dies nicht die pythonische Methode.

Der übliche und einfachste Weg, einen Iterator in Python zu erstellen, besteht in der Verwendung einer Generatorfunktion. Wie dies funktioniert zeigen wir um Folgenden.

Generatoren

An der Oberfläche sehen Generatoren in Python wie Funktionen aus, aber es gibt sowohl einen syntaktischen als auch einen semantischen Unterschied. Ein Unterscheidungsmerkmal sind die yield -Anweisungen. Die yield-Anweisung verwandelt eine Funktiondefinition in einen Generator. Ein Generator ist eine Funktion, die ein Generatorobjekt zurückgibt. Dieses Generatorobjekt kann als eine Funktion angesehen werden, die anstelle eines einzelnen Objekts eine Folge von Ergebnissen erzeugt. Diese Folge von Werten wird durch Iteration darüber erzeugt, z. B. mit einer for-Schleife. Die Werte, auf denen iteriert werden kann, werden mithilfe der yield-Anweisung erstellt. Der von der yield-Anweisung erstellte Wert ist der Wert, der auf das yield-Schlüsselwort folgt. Die Ausführung des Codes wird beendet, wenn eine yield-Anweisung erreicht ist. Der Wert des Ausdrucks, der hinter yield folgt, wird zurückgegeben. Die Ausführung des Generators wird jetzt unterbrochen. Sobald next erneut für das Generatorobjekt aufgerufen wird, nimmt die Generatorfunktion die Ausführung direkt nach der yield-Anweisung im Code wieder auf, in dem der letzte Aufruf erfolgt. Die Ausführung wird in dem Zustand fortgesetzt, in dem der Generator nach letzten yield verlassen wurde. Mit anderen Worten, alle lokalen Variablen sind noch vorhanden, da sie zwischen den Aufrufen automatisch gespeichert werden. Dies ist ein grundlegender Unterschied zu Funktionen: Funktionen beginnen ihre Ausführung immer am Anfang des Funktionskörpers, unabhängig davon, wo sie in früheren Aufrufen verlassen worden sind. Sie haben keine statischen oder dauerhaften Werte. Der Code eines Generators kann mehr als eine yield-Anweisung enthalten, oder die yield-Anweisung befindet sich möglicherweise im Hauptteil einer Schleife. Wenn der Code eines Generators eine return-Anweisung enthält, wird die Ausführung mit einem StopIteration-Ausnahmefehler beendet, wenn dieser Code vom Python-Interpreter ausgeführt wird. Das Wort "Generator" wird manchmal mehrdeutig verwendet, um sowohl die Generatorfunktion selbst als auch die Objekte zu bezeichnen, die von einem Generator erzeugt ("generiert") werden.

Alles, was mit einem Generator gemacht werden kann, kann auch mit einem klassenbasierten Iterator implementiert werden. Der entscheidende Vorteil von Generatoren besteht jedoch darin, dass die Methoden __iter__() und next() automatisch erstellt werden. Generatoren bieten eine geeignete Möglichkeit, Daten zu erzeugen, die riesig oder sogar unendlich sind.

Das Folgende ist ein einfaches Beispiel für einen Generator, der verschiedene Städtenamen erzeugen kann.

Mit diesem Generator kann ein Generatorobjekt erstellt werden, das nacheinander die Städtenamen der yield-Anweisungen generiert.

In [6]:
def stadt_generator():
    yield "Hamburg"
    yield "Konstanz"
    yield "Berlin"
    yield "Zürich"
    yield "Schaffhausen"
    yield "Stuttgart"  

Wir haben einen Iterator erstellt, indem wir stadt_generator() aufgerufen haben:

In [7]:
stadt = stadt_generator()
In [8]:
print(next(stadt))
Hamburg
In [9]:
print(next(stadt))
Konstanz
In [10]:
print(next(stadt))
Berlin
In [11]:
print(next(stadt))
Zürich
In [12]:
print(next(stadt))
Schaffhausen
In [13]:
print(next(stadt))
Stuttgart
In [14]:
print(next(stadt))
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
/tmp/ipykernel_1088582/2559774412.py in <module>
----> 1 print(next(stadt))

StopIteration: 

Wie wir sehen können, haben wir in der interaktiven Shell einen Iterator stadt generiert. Jeder Aufruf der Methode next(stadt) gibt eine andere Stadt zurück. Nachdem die letzte Stadt, d. h. Stuttgart, erstellt wurde, löst ein weiterer Aufruf von next(stadt) eine Ausnahme aus, die besagt, dass die Iteration gestoppt wurde, d. h. StopIteration. "Können wir einen Reset an einen Iterator senden?" ist eine häufig gestellte Frage, damit die Iteration von vorne beginnen kann. Es gibt kein Reset, aber es ist möglich, einneues Generatorobjekt zu erstellen, indem man erneut die Anweisung stadt = city_generator() ausführen lässt. Obwohl die yield-Anweisung auf den ersten Blick wie die Return-Anweisung einer Funktion aussieht, können wir in diesem Beispiel sehen, dass es einen großen Unterschied gibt. Wenn wir im vorherigen Beispiel eine return-Anweisung anstelle eines yield hätten, wäre dies eine Funktion. Aber diese Funktion würde immer nur die erste STadt, also "Hamburg" zurückliefern und niemals eine der anderen Städte, d. h. "Konstanz", "Berlin", "Zürich", "Schaffhausen" und "Stuttgart"

Funktionsweise

Wie wir in der Einleitung dieses Kapitels ausgeführt haben, bieten die Generatoren eine komfortable Möglichkeit zum Generieren von Iteratoren. Deshalb werden sie als Generatoren bezeichnet.

Arbeitsweise:

  • Ein Generator wird aufgerufen wie eine Funktion. Sein Rückgabewert ist ein Iterator, d. h. ein Generatorobjekt. Der Code des Generators wird zu diesem Zeitpunkt nicht ausgeführt.
  • Der Iterator kann durch Aufrufen der next-Methode aufgerufen werden. Beim ersten Aufruf wird mit der ersten Codezeile des Iteratorobjektes begonnen. Der Code wird solange ausgeführt, bis eine yield-Anweisung erreicht wird.
  • yield gibt den Wert des Ausdrucks zurück, der dem Schlüsselwort yield folgt. Beim nächsten Aufruf wird die Ausführung mit der Anweisung fortgesetzt, die auf die yield-Anweisung folgt, und die Variablen haben dieselben Werte wie beim vorherigen Aufruf.
  • Der Iterator ist beendet, wenn der Generatorkörper vollständig durchgearbeitet worden ist oder wenn der Programmablauf auf eine return-Anweisung ohne Wert stößt.

Wir werden dieses Verhalten im folgenden Beispiel veranschaulichen. Der Generator count erstellt einen Iterator, der eine Folge von Werten erstellt, indem er mit dem Startwert firstval zu zählen beginnt und den Wert von step als Inkrement für die Zählung verwendet:

In [15]:
def zahl(firstval=0, step=1):
    x = firstval
    while True:
        yield x
        x += step
        
zähler = zahl() # count beginnt mit 0
for i in range(10):
    print(next(zähler), end=", ")

start_wert = 2.1
stop_wert = 0.3
print("\nNeuer Zähler:")
zähler = zahl(start_wert, stop_wert)
for i in range(10):
    neue_wert = next(zähler)
    print(f"{neue_wert:2.2f}", end=", ")
 
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 
Neuer Zähler:
2.10, 2.40, 2.70, 3.00, 3.30, 3.60, 3.90, 4.20, 4.50, 4.80, 

Fibonacci-Sequenz als Generator:

Die Fibonacci-Sequenz ist nach Leonardo von Pisa benannt, der als Fibonacci bekannt war (eine Kontraktion von Filius Bonacci, "Sohn von Bonaccio"). In seinem Lehrbuch Liber Abaci, das im Jahr 1202 erschien, hatte er eine Aufgabe, die sich mit Kaninchen und ihre Fortpflanzung: Es geht los einem neugeborenen Kaninchenpaar, d. h. mit einem Männchen und einem Weibchen. Es dauert einen Monat, bis sie sich paaren können. Am Ende des zweiten Monats bringt das Weibchen ein neues Kaninchenpaar zur Welt. Nehmen wir nun an, dass jedes weibliche Kaninchen jeden Monat nach dem Ende des ersten Monats ein weiteres Kaninchenpaar zur Welt bringt. Wir müssen erwähnen, dass Fibonaccis Kaninchen niemals sterben. Nun kann man sich fragen, wie groß die Population nach einer bestimmten Anzahl von Monaten sein wird.

Dies erzeugt eine Folge von Zahlen: 0, 1, 1, 2, 3, 5, 8, 13

Diese Sequenz kann in mathematischen Begriffen wie folgt definiert werden:

$F_n = F_{n - 1} + F_{n - 2}$ mit den Startwerten: $F_0 = 0$ and $F_1 = 1$

In [16]:
def fibonacci(n):
    """ Ein Generator zum Erstellen der Fibonacci-Zahlen"""
    a, b, zähler = 0, 1, 0
    while True:
        if (zähler > n): 
            return
        yield a
        a, b = b, a + b
        zähler += 1
f = fibonacci(5)
for x in f:
    print(x, " ", end="") # 
print()
0  1  1  2  3  5  

Der obige Generator kann verwendet werden, um die ersten n durch Leerzeichen getrennten Fibonacci-Zahlen oder bessere (n + 1) Zahlen zu erstellen, da auch die 0. Zahl enthalten ist. Im nächsten Beispiel stellen wir eine Version vor, die einen endlosen Iterator zurückgeben kann. Wir müssen bei der Verwendung dieses Iterators darauf achten, dass ein Beendigungskriterium verwendet wird:

In [17]:
def fibonacci():
    """Erzeugt bei Bedarf eine unendliche Folge von Fibonacci-Zahlen"""
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

f = fibonacci()

zähler = 0
for x in f:
    print(x, " ", end="")
    zähler += 1
    if (zähler > 10): 
        break 
print()
0  1  1  2  3  5  8  13  21  34  55  

Verwenden einer 'Return'-Anweisung in einem Generator

Seit Python 3.3 können Generatoren auch return-Anweisungen verwenden, aber ein Generator benötigt immer noch mindestens eine yield-Anweisung, um ein Generator zu sein! Eine return-Anweisung in einem Generator entspricht einer StopIteration

Schauen wir uns einen Generator an, in dem wir StopIteration explizit auslösen:

In [18]:
def gen():
    yield 1
    raise StopIteration(42)
    yield 2
In [19]:
g = gen()
In [20]:
next(g)
Out[20]:
1
In [21]:
next(g)
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
/tmp/ipykernel_1088582/2435216007.py in gen()
      2     yield 1
----> 3     raise StopIteration(42)
      4     yield 2

StopIteration: 42

The above exception was the direct cause of the following exception:

RuntimeError                              Traceback (most recent call last)
/tmp/ipykernel_1088582/4253931490.py in <module>
----> 1 next(g)

RuntimeError: generator raised StopIteration

Wir zeigen jetzt, dass die return-Anweisung nahezu dem expliziten Auslösen der "StopIteration"-Ausnahme entspricht.

In [22]:
def gen():
    yield 1
    return 42
    yield 2
In [23]:
g = gen()
next(g)
Out[23]:
1
In [24]:
next(g)
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
/tmp/ipykernel_1088582/4253931490.py in <module>
----> 1 next(g)

StopIteration: 42

send Methode / Coroutinen

Generatoren können nicht nur Objekte mittels yield an den Aufrufenden zurückliefern, sondern sie können auch Objekte empfangen. Das Senden einer Nachricht, d. h. eines Objekts, an den Generator kann mittels der send-Funktion realisiert werden. Beachten Sie, dass send sowohl einen Wert an den Generator sendet als auch den vom Generator mittels yield gelieferten Wert zurückgibt. Wir werden dieses Verhalten im folgenden einfachen Beispiel eines Generators demonstrieren:

In [25]:
def einfache_koroutine():
    print("Koroutine wurde gestartet!")
    while True:
        x = yield "foo"
        print("Koroutine empfing diesen Wert: ", x)
    
    
cr = einfache_koroutine()
cr
Out[25]:
<generator object einfache_koroutine at 0x7f7ba8051580>
In [26]:
next(cr)
Koroutine wurde gestartet!
Out[26]:
'foo'
In [27]:
ret_wert = cr.send("Hi")
print("'send' gibt zurück: ", ret_wert)
Koroutine empfing diesen Wert:  Hi
'send' gibt zurück:  foo

Wir mussten zuerst den Generator einmal mittels next starten, weil der Generator gestartet werden muss. Die Verwendung von send an einen Generator, der noch nicht gestartet wurde, führt zu einer Ausnahme.

Um die send-Methode verwenden zu können, muss bereits ein yield erfolgt sein, und dieses yield muss auf der rechten Seite einer Zuweisung stehen. Erfolg dann ein next oder send wird ein Wert an die Variable auf der linken Seite gesenden. Ein next-Anruf sendet und empfängt ebenfalls. Von next wird ein None-Objekt gesendet. Die von "next" und "send" gesendeten Werte werden einer Variablen im Generator zugewiesen: Diese Variable wird im folgenden Beispiel "new_counter_val" genannt.

Im folgenden Beispiel wird die Anzahl der Generatoren aus dem vorherigen Unterkapitel durch Hinzufügen einer Funktion "Senden" geändert.

In [28]:
def zähle(erstewert=0, schritt=1):
    zähler = erstewert
    while True:
        neu_zähler_wert = yield zähler
        if neu_zähler_wert is None:
            zähler += schritt
        else:
            zähler = neu_zähler_wert
            
start_wert = 2.1
stop_wert = 0.3
zähler = zähle(start_wert, stop_wert) 
for i in range(10):
    neu_wert = next(zähler)
    print(f"{neu_wert:2.2f}", end=", ")
print() 
print("Setzen Sie den aktuellen Zählwert auf einen anderen Wert:")
zähler.send(100.5)
for i in range(10):
    neu_wert = next(zähler)
    print(f"{neu_wert:2.2f}", end=", ")
2.10, 2.40, 2.70, 3.00, 3.30, 3.60, 3.90, 4.20, 4.50, 4.80, 
Setzen Sie den aktuellen Zählwert auf einen anderen Wert:
100.80, 101.10, 101.40, 101.70, 102.00, 102.30, 102.60, 102.90, 103.20, 103.50, 

Die Throw Methode

Die throw-Methode löst an der Stelle, an der der Generator angehalten wurde, eine Ausnahme aus und gibt den nächsten vom Generator mittels yield ausgegebenen Wert zurück. Es löst StopIteration aus, wenn der Generator beendet wird, ohne auf ein yield zu stoßen. Der Generator muss die übergebene Ausnahme abfangen, andernfalls wird die Ausnahme an den Aufrufer weitergegeben.

Der Iterator aus unserem vorherigen Beispiel liefert unaufhörlich die Elemente der sequentiellen Daten, aber wir haben keine Informationen über den Index oder den Status der Variablen von count. Wir können diese Informationen erhalten, indem wir eine Ausnahme mit der throw-Methode auslösen. Wir fangen diese Ausnahme im Generator ab und drucken die Werte der Variablen von count:

In [29]:
def zähle(erstewert=0, schritt=1):
    zähler = erstewert
    while True:
        try:
            neu_zähler_wert = yield zähler
            if neu_zähler_wert is None:
                zähler += schritt
            else:
                zähler = neu_zähler_wert
        except Exception:
            yield (erstewert, schritt, zähler)

Im folgenden Codeblock zeigen wir, wie dieser Generator verwendet werden kann:

In [30]:
c = zähle()
for i in range(3):
    print(next(c))
print("Schauen wir uns den Zustand des Iterators an:")
i = c.throw(Exception)
print(i)
print("Nun können wir fortfahren:")
for i in range(3):
    print(next(c))
0
1
2
Schauen wir uns den Zustand des Iterators an:
(0, 1, 2)
Nun können wir fortfahren:
2
3
4

Wir können das vorherige Beispiel verbessern, indem wir unsere eigene Ausnahmeklasse StateOfGenerator definieren:

In [31]:
class GeneratorStatus(Exception):
     def __init__(self, nachricht=None):
         self.nachricht = nachricht

def zahl(erstewert=0, schritt=1):
    zähler = erstewert
    while True:
        try:
            neu_zähler_wert = yield zähler
            if neu_zähler_wert is None:
                zähler += schritt
            else:
                zähler = neu_zähler_wert
        except GeneratorStatus:
            yield (erstewert, schritt, zähler)

Wir können den vorherigen Generator folgendermaßen verwenden:

In [32]:
c = zahl()
for i in range(3):
    print(next(c))
print("Lassen Sie uns sehen, wie der Zustand des Iterators ist:")
i = c.throw(GeneratorStatus("hi"))
print(i)
print("Jetzt können wir fortfahren:")
for i in range(3):
    print(next(c))
0
1
2
Lassen Sie uns sehen, wie der Zustand des Iterators ist:
(0, 1, 2)
Jetzt können wir fortfahren:
2
3
4

Yield from

"Yield from" ist seit Python 3.3 verfügbar! Die yield from <expr>-Anweisung kann im Körper eines Generators verwendet werden. <expr> muss ein Ausdruck sein, der zu einem iterierbaren Objekt auswerten lässt, aus dem dann ein Iterator extrahiert wird. Der Iterator wird ausgeführt bis er "exhausted" ist, d. h. bis er auf eine StopIteration-Ausnahme stößt. Dieser Iterator liefert und empfängt Werte an oder von dem Aufrufer des Generators, d. h. derjenigen, der die yield from-Anweisung enthält.

Wir können aus dem folgenden Beispiel lernen, indem wir uns die beiden Generatoren 'gen1' und 'gen2' ansehen, dass die yield from-Anweisungen in gen2 die for-Schleifen von gen1 ersetzen:

In [33]:
def gen1():
    for char in "Python":
        yield char
    for i in range(5):
        yield i

def gen2():
    yield from "Python"
    yield from range(5)

g1 = gen1()
g2 = gen2()
print("g1: ", end=", ")
for x in g1:
    print(x, end=", ")
print("\ng2: ", end=", ")
for x in g2:
    print(x, end=", ")
print()
g1: , P, y, t, h, o, n, 0, 1, 2, 3, 4, 
g2: , P, y, t, h, o, n, 0, 1, 2, 3, 4, 

Wir können an der Ausgabe erkennen, dass beide Generatoren, was das Verhalten und die Ergebnisse betrifft, gleich sind.

Der Vorteil einer yield from-Anweisung kann als eine Möglichkeit angesehen werden, einen Generator in mehrere Generatoren aufzuteilen. Das haben wir in unserem vorherigen Beispiel getan, und wir werden dies im folgenden Beispiel deutlicher demonstrieren:

In [34]:
def städte():
    for stadt in ["Berlin", "Hamburg", "München", "Freiburg"]:
        yield stadt

def quadrat():
    for nummer in range(10):
        yield nummer ** 2
        
def generator_all_in_one():
    for stadt in städte():
        yield stadt
    for nummer in quadrat():
        yield nummer
        
def generator_splitted():
    yield from städte()
    yield from quadrat()
    
lst1 = [el for el in generator_all_in_one()]
lst2 = [el for el in generator_splitted()]
print(lst1 == lst2)
True

Der vorherige Code gibt True zurück, da die Generatoren generator_all_in_one und generator_splitted dieselben Elemente liefern. Dies bedeutet, dass, wenn der <expr> von yield from ein anderer Generator ist, der Effekt derselbe ist, als wäre der Körper des Subgenerators an der Stelle der yield from-Anweisung eingefügt worden. Darüber hinaus darf der Subgenerator eine return-Anweisung mit einem Wert ausführen, und der Wert wird der Wert von yield from. Wir zeigen dies mit dem folgenden kleinen Skript:

In [35]:
def subgenerator():
    yield 1
    return 42

def delegierender_generator():
    x = yield from subgenerator()
    print(x)

for x in delegierender_generator():
    print(x)
1
42

Rekursive Generatoren

Das folgende Beispiel ist ein Generator zum Erstellen aller Permutationen einer bestimmten Liste von Elementen.

Für diejenigen, die nicht wissen, was Permutationen sind, haben wir eine kurze Einführung:

Formale Definition:

Das Begriff Permutation kommt vom Lateinischen "permutare", was im Deutschen "vertauschen" bedeutet. Eine Permutation ist eine Vertauschung bzw. Neuanordnung der Elemente einer geordneten Liste. Mit anderen Worten: Jede Anordnung von n Elementen wird als Permutation bezeichnet.

In den folgenden Zeilen zeigen wir alle Permutationen der Buchstaben a, b und c:

a b c
a c b
b a c
b c a
c a b
c b a

Die Anzahl der Permutationen auf einer Menge von n Elementen ist gegeben durch n!

n! = n (n-1) (n-2) ... 2 * 1

n! heißt die Fakultät von n.

Der Permutationsgenerator kann mit einer beliebigen Liste von Objekten aufgerufen werden. Der von diesem Generator zurückgegebene Iterator generiert alle möglichen Permutationen:

In [36]:
def permutationen(items):
    n = len(items)
    if n==0: yield []
    else:
        for i in range(len(items)):
            for cc in permutationen(items[:i]+items[i+1:]):
                yield [items[i]]+cc

for p in permutationen(['r','e','d']): print(''.join(p))
for p in permutationen(list("game")): print(''.join(p) + ", ", end="")
red
rde
erd
edr
dre
der
game, gaem, gmae, gmea, geam, gema, agme, agem, amge, ameg, aegm, aemg, mgae, mgea, mage, maeg, mega, meag, egam, egma, eagm, eamg, emga, emag, 

Das vorherige Beispiel kann für Neulinge schwer zu verstehen sein. Wie immer bietet Python eine bequeme Lösung. Zu diesem Zweck benötigen wir das Modul itertools. Itertools ist ein sehr praktisches Tool zum Erstellen und Bearbeiten von Iteratoren.

Erstellen von Permutationen mit itertools:

In [37]:
import itertools
perms = itertools.permutations(['r','e','d'])
list(perms)
Out[37]:
[('r', 'e', 'd'),
 ('r', 'd', 'e'),
 ('e', 'r', 'd'),
 ('e', 'd', 'r'),
 ('d', 'r', 'e'),
 ('d', 'e', 'r')]

Der Begriff "Permutationen" kann manchmal in einer schwächeren Bedeutung verwendet werden. Permutationen können in dieser schwächeren Bedeutung eine Folge von Elementen bezeichnen, wobei jedes Element nur einmal vorkommt, ohne dass jedoch alle Elemente einer bestimmten Menge enthalten sein müssen. In diesem Sinne ist (1, 3, 5, 2) eine Permutation der Ziffernmenge {1, 2, 3, 4, 5, 6}. Wir können zum Beispiel alle Sequenzen einer festen Länge k von Elementen bauen, die aus einer gegebenen Menge der Größe n mit k ≤ n entnommen sind.

Dies sind alle 3-Permutationen der Menge {"a", "b", "c", "d"}.

Wählt man aus einer n-elementigen Menge von Objekten k Objekte unter Berücksichtigung der Reihenfolge ohne Zurücklegen aus, so bezeichnet man dies als eine Variation ohne Wiederholung. Die Permutationen ergeben sich als ein Sonderfall für n = k.

Die Anzahl solcher k-Permutationen von n wird mit $P_{n,k}$ bezeichnet und ihr Wert wird durch das Produkt berechnet:

$n · (n - 1) · … (n - k + 1)$

Unter Verwendung der Fakultätsnotation kann der oben erwähnte Ausdruck wie folgt geschrieben werden:

$P_{n, k} = n! / (n - k)!$

Ein Generator zur Erzeugung von k-Variationen von n Objekten sieht unserem vorherigen Permutationsgenerator sehr ähnlich:

In [38]:
def k_variations(items, n):
    if n==0: 
        yield []
    else:
        for item in items:
            for kp in k_variations(items, n-1):
                if item not in kp:
                    yield [item] + kp
                    
for kp in k_variations("abcd", 3):
    print(kp)
['a', 'b', 'c']
['a', 'b', 'd']
['a', 'c', 'b']
['a', 'c', 'd']
['a', 'd', 'b']
['a', 'd', 'c']
['b', 'a', 'c']
['b', 'a', 'd']
['b', 'c', 'a']
['b', 'c', 'd']
['b', 'd', 'a']
['b', 'd', 'c']
['c', 'a', 'b']
['c', 'a', 'd']
['c', 'b', 'a']
['c', 'b', 'd']
['c', 'd', 'a']
['c', 'd', 'b']
['d', 'a', 'b']
['d', 'a', 'c']
['d', 'b', 'a']
['d', 'b', 'c']
['d', 'c', 'a']
['d', 'c', 'b']

Ein Generator von Generatoren

Der zweite Generator unseres Fibonacci-Sequenzbeispiels erzeugt einen Iterator, der theoretisch alle Fibonacci-Zahlen erzeugen kann, d. h. unendlich viele. Man sollte jedoch nicht versuchen, alle diese Zahlen in einer Liste mit der folgenden Zeile zu erstellen.

 list(fibonacci())

Dies würde dir sehr schnell die Grenzen deines Computers aufzeigen. In den meisten praktischen Anwendungen benötigen wir nur die ersten n Elemente von diesem und anderen "unendlichen" Iteratoren. r können einen anderen Generator verwenden, in unserem Beispiel firstn, um die ersten n Elemente eines Generators generator zu erstellen:

In [39]:
def firstn(generator, n):
    g = generator()
    for i in range(n):
        yield next(g)

Das folgende Skript gibt die ersten 10 Elemente der Fibonacci-Sequenz zurück:

In [40]:
def fibonacci():
    """ Ein Fibonacci-Zahlengenerator """
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

print(list(firstn(fibonacci, 10)))   
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Aufgaben

Aufgabe 1

Schreibe einen Generator, der den laufenden Durchschnitt berechnet.

Aufgabe 2

Schreibe einen Generator frange, der sich wie range verhält, aber float Werte akzeptiert.

Aufgabe 3

Schreibe einen Generator trange, der eine Folge von Zeittupeln von Start bis Stopp erzeugt, die schrittweise erhöht werden. Ein Zeittupel ist ein 3-Tupel von ganzen Zahlen: (Stunden, Minuten, Sekunden) Ein Aufruf von trange könnte also so aussehen:

trange ((10, 10, 10), (13, 50, 15), (0, 15, 12))

Aufgabe 4

Schreibe eine Version "rtrange" des vorherigen Generators, die Nachrichten empfangen kann, um den Startwert zurückzusetzen.

Aufgabe 5

Schreibe ein Programm mit dem neu geschriebenen Generator "trange", um eine Datei "times_and_temperatures.txt" zu erstellen. Die Zeilen dieser Datei enthalten eine Zeit im Format hh::mm::ss und zufällige Temperaturen zwischen 10,0 und 25,0 Grad. Die Zeiten sollten in Schritten von 90 Sekunden ab 6:00:00 Uhr ansteigen. Beispielsweise:

 06:00:00 20.1
 06:01:30 16.1
 06:03:00 16.9
 06:04:30 13.4
 06:06:00 23.7
 06:07:30 23.6
 06:09:00 17.5
 06:10:30 11.0

Bitstrom von Nullen und Einsen

Aufgabe 6

Schreibe einen Generator mit dem Namen random_ones_and_zeroes, der in jeder Iteration einen Bitstrom zurückgibt, d. h. jeweils eine Null oder eine Eins. Die Wahrscheinlichkeit p für die Rückgabe einer 1 ist in einer Variablen p definiert. Der Generator initialisiert diesen Wert auf 0.5. Mit anderen Worten, Nullen und Einsen werden mit der gleichen Wahrscheinlichkeit zurückgegeben.

Aufgabe 7

Wir haben eine Klasse Zyklus am Anfang dieses Kapitels unseres Python-Tutorials geschrieben. Schreibe nun einen Generator namens zyklus, der dieselbe Aufgabe erfüllt.

Lösungen für unsere Aufgaben

Lösung zur Aufgabe 1

In [41]:
def laufender_durchschnitt():
    total = 0.0
    zähler = 0
    durchschnitt = None
    while True:
        term = yield durchschnitt
        total += term
        zähler += 1
        durchschnitt = total / zähler


ra = laufender_durchschnitt()  # Initialisieren Sie die Coroutine
next(ra)                # Wir müssen die Coroutine starten
for wert in [7, 13, 17, 231, 12, 8, 3]:
    out_str = "gesendet: {wert:3d}, laufender Durchschnitt: {drc:6.2f}"
    print(out_str.format(wert=wert, drc=ra.send(wert)))
gesendet:   7, laufender Durchschnitt:   7.00
gesendet:  13, laufender Durchschnitt:  10.00
gesendet:  17, laufender Durchschnitt:  12.33
gesendet: 231, laufender Durchschnitt:  67.00
gesendet:  12, laufender Durchschnitt:  56.00
gesendet:   8, laufender Durchschnitt:  48.00
gesendet:   3, laufender Durchschnitt:  41.57

Lösung zur Aufgabe 2

In [42]:
def frange(*args):
    startwert = 0
    schrittgrösse = 1    
    if len(args) == 1:
        endwert = args[0]
    elif len(args) == 2:
        startwert, endwert = args 
    elif len(args) == 3:
        startwert, endwert, schrittgrösse = args
      
    wert = startwert
    while wert < endwert:
        yield wert
        wert += schrittgrösse

Die Verwendung von frange kann folgendermaßen aussehen:

In [43]:
for i in frange(5.6):
    print(i, end=", ")
print()
for i in frange(0.3, 5.6):
    print(i, end=", ")
print()
for i in frange(0.3, 5.6, 0.8):
    print(i, end=", ")
print()
0, 1, 2, 3, 4, 5, 
0.3, 1.3, 2.3, 3.3, 4.3, 5.3, 
0.3, 1.1, 1.9000000000000001, 2.7, 3.5, 4.3, 5.1, 

Lösung zur Aufgabe 3

In [44]:
%%writefile timerange.py


def trange(start, stopp, schrittweite):
    """ 
    trange (stopp) -> Zeit als 3-Tupel (Stunden, Minuten, Sekunden)
    trange (start, stopp [, schrittweite]) -> Zeittupel

    start: Zeittupel (Stunden, Minuten, Sekunden)
    stopp: Zeittupel
    schrittweite: Zeittupel

     Gibt eine Folge von Zeittupeln von `start`  bis `stopp` zurück, 
     die mittels `schrittweise` erhöht werden
    """        

    aktuell = list(start)
    while aktuell < list(stopp):
        yield tuple(aktuell)
        sekunde = schrittweite[2] + aktuell[2]
        minuten_übertrag = 0
        stunden_übertrag = 0
        if sekunde < 60:
            aktuell[2] = sekunde
        else:
            aktuell[2] = sekunde - 60
            min_übertrag = 1
        minuten = schrittweite[1] + aktuell[1] + minuten_übertrag
        if minuten < 60:
            aktuell[1] = minuten 
        else:
            aktuell[1] = minuten - 60
            stunden_übertrag = 1
        stunden = schrittweite[0] + aktuell[0] + stunden_übertrag
        if stunden < 24:
            aktuell[0] = stunden 
        else:
            aktuell[0] = stunden - 24
Overwriting timerange.py
In [45]:
from timerange import trange

for time in trange((10, 10, 10), (19, 53, 15), (1, 24, 12) ):
    print(time)   
(10, 10, 10)
(11, 34, 22)
(12, 58, 34)
(14, 22, 46)
(15, 46, 58)
(17, 10, 10)
(18, 34, 22)

Lösung zur Aufgabe 4

In [46]:
%%writefile rtimerange.py

def rtrange(start, stopp, schrittweite):
    """ 
        trange (stopp) -> Zeit als 3-Tupel (Stunden, Minuten, Sekunden)
        trange (start, stopp [, schrittweite]) -> Zeittupel

        start: Zeittupel (Stunden, Minuten, Sekunden)
        stopp: Zeittupel
        schrittweite: Zeittupel

         Gibt eine Folge von Zeittupeln von `start`  bis `stopp` zurück, 
         die mittels `schrittweise` erhöht werden
        
         Der Generator kann durch Senden eines neuen "Start"-Werts resettiert werden.
    """        

    aktuell = list(start)
    while aktuell < list(stopp):
        neu_start = yield tuple(aktuell)
        if neu_start != None:
            aktuell = list(neu_start)
            continue        
        sekunde = schrittweite[2] + aktuell[2]
        minuten_übertrag = 0
        stunden_übertrag = 0
        if sekunde < 60:
            aktuell[2] = sekunde
        else:
            aktuell[2] = sekunde - 60
            min_übertrag = 1
        minuten = schrittweite[1] + aktuell[1] + minuten_übertrag
        if minuten < 60:
            aktuell[1] = minuten 
        else:
            aktuell[1] = minuten - 60
            stunden_übertrag = 1
        stunden = schrittweite[0] + aktuell[0] + stunden_übertrag
        if stunden < 24:
            aktuell[0] = stunden 
        else:
            aktuell[0] = stunden - 24
            
Overwriting rtimerange.py
In [47]:
from rtimerange import rtrange

        
ts = rtrange((10, 10, 10), (17, 50, 15), (1, 15, 12) )  
for _ in range(3):
    print(next(ts))

print(ts.send((8, 5, 50)))
for _ in range(3):
    print(next(ts))
(10, 10, 10)
(11, 25, 22)
(12, 40, 34)
(8, 5, 50)
(9, 20, 2)
(10, 35, 14)
(11, 50, 26)

Lösung zur Aufgabe 5

In [48]:
from timerange import trange
import random

fh = open("times_and_temperatures.txt", "w")

for zeit in trange((6, 0, 0), (23, 0, 0), (0, 1, 30) ):
    zufallszahl = random.randint(100, 250) / 10
    lst = zeit + (zufallszahl,)
    ausgabe = "{:02d}:{:02d}:{:02d} {:4.1f}\n".format(*lst)
    fh.write(ausgabe)

Weitere Details und den mathematischen Hintergrund zu dieser Übung finden Sie in unserem Kapitel über Gewichtete Wahrscheinlichkeiten.

Lösung zur Aufgabe 6

In [49]:
import random

def zufalls_einsen_und_nullen():
    p = 0.5
    while True:
        x = random.random()
        nachricht = yield 1 if x < p else 0
        if nachricht != None:
            p = nachricht
            
x = zufalls_einsen_und_nullen()
next(x)  # Der Rückgabewert interessiert uns nicht
for p in [0.2, 0.8]:
    print("\nWir ändern die Wahrscheinlichkeit auf : " + str(p))
    x.send(p)    
    for i in range(20):
        print(next(x), end=" ")
print()
Wir ändern die Wahrscheinlichkeit auf : 0.2
0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0 
Wir ändern die Wahrscheinlichkeit auf : 0.8
1 1 1 1 1 1 0 1 1 1 1 1 1 0 1 1 1 1 1 1 

Lösung zur Aufgabe 7

Der "cycle"-Generator ist Teil des Moduls 'itertools'. Der folgende Code ist die Implementierung in itertools:

In [50]:
def cycle(iterable):
    # cycle('ABCD') --> A B C D A B C D A B C D ...
    saved = []
    for element in iterable:
        yield element
        saved.append(element)
    while saved:
        for element in saved:
              yield element
                
länder = ["Germany", "Switzerland", "Austria"]
länder_iterator = cycle(länder)
for i in range(7):
    print(next(länder_iterator))
Germany
Switzerland
Austria
Germany
Switzerland
Austria
Germany