Vorlesungsskript 24
Operationen auf Iterables
Ein Iterable ist ein Objekt, das andere Objekte als Elemente enthält
und es erlaubt, auf diese Elemente eins nach dem anderen zuzugreifen. Anders gesagt:
Alles, worüber man mit Hilfe einer for
-Schleife iterieren
kann, ist ein Iterable.
Typische Iterables sind Datenstrukturen, bei denen sich die Elemente im Arbeitsspeicher befinden: Listen, Dictionarys, Sets, Tupel, Strings. Aber auch ein Dateiobjekt ist ein Iterable. Seine Elemente sind die Zeilen. Und wenn man beginnt, über ein Dateiobjekt zu iterieren, sind noch nicht unbedingt alle Zeilen von der Festplatte in den Arbeitsspeicher geladen.
Welcher Art die Iterables auch immer sind, Operationen auf Iterables spielen beim Programmieren eine extrem wichtige Rolle. Dabei geht es um das (nicht-destruktive) Erzeugen von neuen Iterables (Output-Iterables), oft auf Grundlage von bestehenden Iterables (Input-Iterables). Zwei der wichtigsten Operationen auf Iterables sind das Zuordnen (engl. mapping) und das Filtern.
Beim Zuordnen wird dieselbe Operation auf alle Elemente des Input-Iterables angewendet. Das Output-Iterable enthält die Ergebnisse. Das folgende Beispiel zeigt eine Funktion, die eine Zuordnung vornimmt. Sie nimmt eine Liste von Strings als Argument und gibt eine Liste zurück mit den gleichen Strings, aber in Kleinbuchstaben umgewandelt:
def map_lower(strings):
result = []
for string in strings:
result.append(string.lower())
return result
Beim Filtern wird auf jedes Element des Input-Iterables ein Test angewendet.
Besteht es den Test, wird es in das Output-Iterable aufgenommen. Wenn nicht,
dann nicht. Ein Beispiel für eine Funktion, die filtert, ist unsere
alte Bekannte remove_stopwords
:
def remove_stopwords(words, stopwords):
result = []
for word in words:
if word not in stopwords:
result.append(word)
return result
Die obigen Funktionen stellen Operationen auf Iterables dar, weil sie Listen zurückgeben, und Listen sind Iterables. Operationen auf Iterables können aber eleganter, flexibler und effizienter bewerkstelligt werden. Hierfür stellt Python zwei spezielle Werkzeuge zur Verfügung: Generator-Funktionen und Comprehensions.
Generator-Funktionen
In den obigen Funktionen (map_lower
und remove_stopwords
) legen wir
zunächst eine neue Liste an (result
),
füllen diese und geben sie am Ende mit return
zurück. Mit
Generator-Funktionen (engl. generator functions)
können wir uns das Anlegen und Zurückgeben der Liste sparen und
stattdessen jedes Element einzeln zurückgeben, und zwar mit dem
Schlüsselwort yield
. Python
sorgt dann automatisch dafür, dass ein Iterable
zurückgegeben wird, das diese Elemente enthält. So
sieht die erste Funktion aus, jetzt als
Generator-Funktion definiert:
def map_lower(strings):
for string in strings:
yield string.lower()
Und so die zweite Funktion:
def remove_stopwords(words, stopwords):
for word in words:
if word not in stopwords:
yield word
Das ist deutlich kürzer und daher angenehmer zu schreiben und zu lesen. Man
muss nur beachten, dass Generator-Funktionen keine Listen zurückgeben, sondern
einen anderen Typ von Iterable, nämlich einen Generator. Über
Generatoren kann man iterieren, aber sonst kann man nicht viel mit ihnen
machen, man kann z.B. nicht direkt auf ein Element an einem bestimmten Index
zugreifen, und gibt man einen Generator aus, enthält man nur einen
kryptischen String. Man kann Generatoren aber mit der
Funktion list
(oder auch
set
) leicht in den
gewünschten Sammeldatentyp umwandeln:
>>> words = ['The', 'company', 'was', 'sold', 'to', 'Google']
>>> stopwords = {'the', 'a', 'an', 'am', 'are', 'is', 'was', 'were', 'to'}
>>> words_lower = map_lower(words)
>>> words_lower
<generator object map_lower at 0x7f10b415d728>
>>> words_lower[1]
Traceback (most recent call last):
File "", line 1, in
TypeError: 'generator' object is not subscriptable
>>> words_lower = list(words_lower)
>>> words_lower
['the', 'company', 'was', 'sold', 'to', 'google']
>>> words_lower[1]
'company'
>>> content_words = list(remove_stopwords(words_lower, stopwords))
>>> content_words
['company', 'sold', 'google']
Dass Generator-Funktionen keine Listen zurückgeben, ist tatsächlich ein weiterer ihrer Vorteile. Will man nämlich mehrere Zuordnungs- und Filteroperationen hintereinander vornehmen, wäre das Anlegen immer neuer Listen nämlich ineffizient. Es reicht, wenn man das einmal am Ende macht. Hier wenden wir zum Beispiel unsere zwei Generator-Funktionen direkt hintereinander an:
>>> list(remove_stopwords(map_lower(words), stopwords))
['company', 'sold', 'google']
Komplexere Operationen mit Generator-Funktionen
Generator-Funktionen können mehr als nur zuordnen und filtern: Man kann damit gegenüber dem Input-Iterable neue Elemente ins Output-Iterable hinzufügen, mehrere Elemente zusammenfassen oder ein Element in mehrere aufteilen. Hier einige Beispiele:
Anstandsdame
Die folgende Generator-Funktion nimmt ein Iterable von Strings, die Jungen und Mädchen repräsentieren. Immer, wenn ein Junge neben einem Mädchen sitzt, wird dazwischen eine Anstandsdame (engl. chaperone) eingefügt:
def chaperonize(people):
last_person = None
for person in people:
if {last_person, person} == {'boy', 'girl'}:
yield 'chaperone'
yield person
last_person = person
>>> list(chaperonize(['boy', 'boy', 'girl', 'boy', 'girl']))
['boy', 'boy', 'chaperone', 'girl', 'chaperone', 'boy', 'chaperone', 'girl'
Paare addieren
Die folgende Generator-Funktion nimmt eine Liste mit einer geraden Anzahl von Zahlen und addiert immer zwei nebeneinander stehende:
def add_pairs(numbers):
for i in range(0, len(numbers), 2):
yield numbers[i] + numbers[i + 1]
>>> list(add_pairs([2, 3, 5, 7, 11, 13, 17, 19]))
[5, 12, 24, 36]
flatten
Hier ist die Funktion flatten
, die verschachtelte Listen
in eine flache Liste umwandelt, als Generator-Funktion geschrieben:
def flatten(L):
for element in L:
if isinstance(element, list):
for subelement in flatten(element):
yield subelement
else:
yield element
>>> nested = [2, [3, 5, [7, 11]], [13], 17]
>>> flat = list(flatten(nested))
>>> flat
[2, 3, 5, 7, 11, 13, 17]
Die Funktion iteriert über die Listenelemente. Elemente, die nicht selbst
Listen sind, werden einfach „geyieldet“. Ist ein Element selbst eine Liste,
wird flatten
rekursiv aufgerufen und dann jedes
Element des Ergebnisses „geyieldet“.
Für das Yielden aller Elemente eines Iterables stellt Python ab
Version 3.3 auch die yield from
-Syntax zur
Verfügung. Wir könnten damit oben statt
for subelement in flatten(element):
yield subelement
auch kürzer schreiben:
yield from flatten(element)
Comprehensions
Wenn wir beim Verarbeiten von Iterables nur zuordnen und filtern, können wir das noch kürzer schreiben, nämlich in einem einzigen Ausdruck. Wollen wir zum Beispiel jedem String seine Kleinbuchstaben-Version zuordnen, können wir das so tun:
>>> words = ['The', 'company', 'was', 'bought', 'by', 'Google']
>>> words_lower = [w.lower() for w in words]
>>> words_lower
['the', 'company', 'was', 'sold', 'to', 'google']
Und so können wir Stopwörter herausfiltern:
>>> content_words = [w for w in words_lower if w not in stopwords]
>>> content_words
['company', 'sold', 'google']
Die Ausdrücke in eckigen Klammern, die an eine Mischung aus Listen-Literalen
und for
-Schleifen erinnern, sind
Listen-Comprehensions (manchmal auch Listen-Abstraktionen
genannt). Ihre allgemeine Form ist die folgende:
[AUSDRUCK for ITERATIONSVARIABLE in INPUTSEQUENZ if BEDINGUNG]
INPUTSEQUENZ
ist ein Iterable, über das die Comprehension
iteriert, wie bei for
-Schleifen. Bei jedem Durchlauf
wird das nächste Element in der Variablen ITERATIONSVARIABLE
gespeichert. Falls dann BEDINGUNG
wahr ist (oder fehlt), wird der
Ausdruck AUSDRUCK
ausgewertet und das Ergebnis in die Output-Liste
übernommen. Die Comprehension gibt eine Liste mit allen übernommenen Elementen
zurück.
Im ersten Beispiel haben wir nur zugeordnet und nicht gefiltert, weil wir
keinen if
-Teil verwendet haben. Im zweiten Beispiel
haben wir nur gefiltert und nicht zugeordnet, weil AUSDRUCK
mit
ITERATIONSVARIABLE
, also Input-Elemente mit Output-Elementen identisch waren.
Wir können das Filtern und Zuordnen auch in einer Comprehension kombinieren.
Um zum Beispiel gleichzeitig Stopwörter herauszufiltern und Wörter in Kleinbuchstaben
umzuwandeln:
>>> content_words = [w.lower() for w in words if w.lower() not in stopwords]
>>> content_words
['company', 'sold', 'google']
Neben Listen-Comprehensions gibt es noch drei weitere Arten von Comprehensions. Hier eine Übersicht über ihre Formen:
Art | Form |
---|---|
Listen-Comprehension | [AUSDRUCK for ITERATIONSVARIABLE in INPUTSEQUENZ if BEDINGUNG] |
Set-Comprehension | {AUSDRUCK for ITERATIONSVARIABLE in INPUTSEQUENZ if BEDINGUNG} |
Dictionary-Comprehension | {SCHLÜSSEL: WERT for ITERATIONSVARIABLE in INPUTSEQUENZ if BEDINGUNG} |
Generator-Comprehension (auch: Generator-Ausdruck) | (AUSDRUCK for ITERATIONSVARIABLE in INPUTSEQUENZ if BEDINGUNG) |
Die Formen der verschiedenen Comprehensions unterscheiden sich vor allem
durch die Klammern, die sie umschließen. Dictionary-Comprehensions haben
außerdem statt dem einfachen AUSDRUCK
ein SCHLÜSSEL:
WERT
-Paar.
Set-Comprehensions geben als Output-Iterable keine Liste, sondern ein Set
zurück, wodurch natürlich die Reihenfolge und Duplikate verloren gehen. Für
Dictionary-Comprehensions gilt Ähnliches, wobei hier sowohl Schlüssel als auch
Werte generiert werden. Generator-Comprehensions sind nützlich, wenn das
Output-Iterable zur Weiterverarbeitung in einer for
-Schleife,
einer weiteren Comprehension oder einer Generator-Funktion gedacht ist und das
überflüssige Erzeugen einer Liste mit dem Zwischenergebnis vermieden werden soll.
Das folgende Beispiel illustriert die Unterschiede zwischen den verschiedenen Comprehensions:
>>> [x % 3 for x in range(10)]
[0, 1, 2, 0, 1, 2, 0, 1, 2, 0]
>>> {x % 3 for x in range(10)}
{0, 1, 2}
>>> {x: x % 3 for x in range(10)}
{0: 0, 1: 1, 2: 2, 3: 0, 4: 1, 5: 2, 6: 0, 7: 1, 8: 2, 9: 0}
>>> (x % 3 for x in range(10))
<generator object <genexpr> at 0x7f10b21c3830>