Wenn alle Programmierschritte nacheinander linear ablaufen, artet das Umsetzen bestimmter Lösungen schnell in immensen Aufwand aus. Funktionale Programmierung eröffnet da andere Möglichkeiten.
Jeder Programmierstil orientiert sich an einem bestimmten Denkmuster und bringt entsprechende Vor- und Nachteile mit sich. Funktionale Programmierung bildet da keine Ausnahme. In der letzten Ausgabe haben wir im fünften Teil [1] unserer Artikelserie die Behauptung aufgestellt, der funktionalen Programmierung eile der Ruf voraus, ein Türöffner zu sicherer, zuverlässiger Parallelverarbeitung in Industriequalität zu sein. Dem gehen wir in dieser Folge auf den Grund.
Obwohl im Kern objektorientiert, unterstützt die Programmiersprache Python gleichermaßen sowohl imperative, prozedurale und objektorientierte als auch funktionale Konzepte. In einer Anwendung kann eine objektorientierte grafische Benutzeroberfläche mit einer funktionalen Verarbeitung koexistieren. Intern versteht Python ohnehin alles als Objekte [2], selbst Variablen und Funktionen [3].
Codeanalysen zeigen, dass sich mit funktionaler Programmierung manche Dinge leichter (also mit weniger Code) oder überhaupt erst umsetzen lassen. Doch dazu braucht es zunächst einen Lernprozess, um herauszufinden, für welche Situationen sich dieser Programmierstil eignet. Gewohnheiten und Wissen aus anderen Programmiersprachen spielen ebenso eine Rolle. In der Praxis zeigt sich letztendlich, was nicht nur in der jeweiligen Situation am besten funktioniert, sondern auch verständlich ist und sich dementsprechend unkompliziert warten lässt. Pauschale Aussagen kann man hier nicht treffen, häufig mischen sich im Alltag unterschiedliche Paradigmen.
Was dahintersteckt
Funktionale Programmierung arbeitet weniger mit ausschließlichen Zuweisungen, sondern zielt darauf ab, Programmieraufgaben mithilfe von Funktionen, deren Anwendung und deren Aneinanderreihung zu bewältigen. Eine Entsprechung findet sich im Konzept der Pipes zwischen Linux-Kommandos: Auch dort löst man komplexe Probleme durch das Zerlegen und Zusammenwirken spezialisierter Werkzeuge. Dabei liegt der Fokus nicht darauf, einen Zustand zu ändern, sondern auf dem Datenfluss zwischen den Funktionen beziehungsweise Werkzeugen.
Der Stil der funktionalen Programmierung lässt sich eher als deklarativ denn als imperativ beschreiben. Konkreter ausgedrückt, sagen Sie dem Computer nicht mehr bei jedem Schritt, was er tun soll. Stattdessen bestimmt die Funktionskette den Zustand der Anwendung. Die Funktionen besitzen einen eigenen Gültigkeitsbereich (Scope).
Speicher und Variablen bleiben lokal, dort vorgenommene Zuweisungen wirken sich lediglich innerhalb der jeweiligen Funktion aus. Das Verwenden globaler Variablen steht im sprichwörtlichen Giftschrank, gleich neben der Goto-Anweisung, und bleibt somit außen vor. Damit gibt es kaum eine Chance für Seiteneffekte und verborgene Zustände, wie sie bei imperativer Programmierung und bei Objekten vorkommen. Im Ergebnis vereinfachen sich die Tests auf die Korrektheit des Programmcodes sowie die Fehlersuche.
Bei Multithreading und beim Einsatz von Nebenläufigkeit verringern sich die Race Conditions und Deadlocks, da auf weniger Daten und weitere Ressourcen gleichzeitig gemeinsam zugegriffen wird. Die Teilung verstärkt das Nutzen von Parallelisierung und ermöglicht dementsprechend, die volle Leistungsfähigkeit von Mehrkernprozessoren auszuschöpfen [4].
Zu den mit Fokus auf funktionale Programmierung entworfenen Sprachen gehören beispielsweise Scala, Haskell, Objective Caml (OCaml) und Common Lisp. Zu Python kamen ab Version 2 vermehrt funktionale Elemente hinzu. Nur einen kurzen Blick darauf zu werfen, genügt jedoch kaum, denn es braucht eine Weile, um in die Denkweise der funktionalen Programmierung mit den genannten Sprachen hineinzufinden.
Intern
Spannend ist, wie Python Funktionen intern handhabt. Wie schon erwähnt, organisiert der Interpreter alle Elemente als Objekte. Somit übergeben Sie Argumente an Funktionen, erhalten Ergebnisse als Funktionen zurück und speichern sie in Variablen. Höherwertige und bereits mitgelieferte Funktionen wie map(), filter() oder reduce() dienen als Anker für komplexere Ausdrücke.
Weil Mengen (Sets) anstelle von Listen zum Einsatz kommen, reduziert sich der Umfang des Speichers, den Python intern für die Datenstruktur reserviert. Listing 1 und Listing 2 stellen gegenüber, wie sich identische Elemente in zwei Listen identifizieren lassen – zunächst mit einer vergleichsweise langsamen For-Schleife und danach mithilfe des Vereinigungsoperators &. Bei einer kleinen Anzahl Elemente fällt die Ausführungszeit beider Implementierungen nahezu identisch aus. Bereits bei Listen aus 1000 Elementen erhöht sich der Faktor auf 10. In unserem Test verringerte sich die Ausführungsdauer von 0,3 auf 0,01 Sekunden.
Das Konzept der List Comprehension, frei übersetzt das Verständnis von Listen, bildet eine weitere Alternative zu Schleifen. Damit erzeugen Sie elegant neue Listen aus bestehenden Listen [5]. Wie Sie das umsetzen, stellen wir nun zusammen mit Iteratoren, Generatoren und Lambda-Funktionen genauer vor.
Listing 1
Identische Elemente mit einer For-Schleife finden
# define two lists
ListeA = [1,2,3,4,5] * 1000
ListeB = [2,3,4,5,6] * 1000
# define result of identical elements
overlaps = []
# for loop to iterate over the two lists
for x in ListeA:
if x in ListeB:
if x not in overlaps:
overlaps.append(x)
# output result
print(overlaps)
Listing 2
Identische Elemente per Vereinigungsoperator finden
ListeA = [1,2,3,4,5] * 1000 ListeB = [2,3,4,5,6] * 1000 # calculate the similar elements in both lists overlaps = set(ListeA) & set(ListeB) # output result print(overlaps)
Iteratoren
Bei Iteratoren handelt es sich um Verweise in einer Datenstruktur, die auf ein bestimmtes Element zeigen. Grob vereinfacht verbirgt sich dahinter eine Aufzählung mit Zustandsmerker [6]. Sie kennen das Konzept womöglich von Programmiersprachen wie C#, Java, PHP, Ruby und Rust.
Als Rückgabewert erhält man stets den Wert des Elements, auf das gerade verwiesen wird. In Python ist das initial stets das erste Element. Mit dem Aufruf der Funktion next() rutschen Sie in der Aufzählung ein Element weiter, sofern Sie sich nicht bereits am Ende der Datenstruktur befinden. Listing 3 demonstriert das anhand der Liste number, die Sie in der ersten Zeile definieren. Dabei enthält number die drei Zahlenwerte 1, 2 und 3. In der zweiten Zeile erstellen Sie mithilfe der Funktion iter() aus der Liste den Iterator it. Mittels next() hüpfen Sie anschließend durch die Elemente von numbersIT (Zeile 3 bis 6).
Listing 3
Iterator benutzen
numbers = [1, 2, 3] numbersIT = iter(numbers) print(next(numbersIT)) print(next(numbersIT)) print(next(numbersIT)) print(next(numbersIT))
Für die Zeilen 3 bis 5 klappt das gut und Sie erhalten das erhoffte Resultat. In Zeile 6 produzieren Sie allerdings einen Laufzeitfehler: Sie greifen auf ein Element zu, das sich nach dem Listenende befindet. Diesen Ausnahmefall gilt es zu verhindern. Listing 4 zeigt deswegen anhand einer While-Schleife in Kombination mit Try-Except, wie Sie die Situation sauber abfangen. Ausgelöst wird die Ausnahme StopIteration, bei deren Eintreten Sie die Schleife abbrechen (Zeile 10 bis 12). Für die Ausgabe zu Listing 4 werfen Sie bitte einen Blick auf Listing 5.
Listing 4
Iterator, While-Schleife und Ausnahmebehandlung kombiniert
numbers = [1, 2, 3]
# transform list into an iterator
numbersIT = iter(numbers)
# loop though the items one after the next
while True:
try:
value = next(numbersIT)
square = value * value
print("the square of number", value, "is", square)
except StopIteration:
print("end of iterator reached")
break
Listing 5
Ausgabe von Listing 4
$ python3 square-iterator.py
the square of number 1 is 1
the square of number 2 is 4
the square of number 3 is 9
end of iterator reached
Ein Iterator gilt zwar als nützlich, hat aber seine Grenzen. Mit dem Schlüsselwort next() geht es ausschließlich vorwärts. Ein Rückwärtsgang oder das Überspringen von Elementen sind ebenso wenig vorgesehen wie ein Reset oder das Erzeugen einer Kopie des Iterators. Für ein Durchlaufen in umgekehrter Reihenfolge liegt ein Lösungsweg darin, die Funktion reversed() zwischenzuschalten, damit die Liste umzudrehen und letztlich daraus einen Iterator zu erzeugen.
Generatoren
Bei regulären Funktionen erfolgt die Berechnung eines oder mehrerer Werte und dessen beziehungsweise deren Rückgabe über die Anweisung return. Sobald das Programm sie erreicht, geht alles innerhalb der Funktion verloren. Es kommen quasi die Heinzelmännchen der Müllabfuhr des Python-Interpreters herbei gehuscht und räumen freundlicherweise hinter Ihnen auf.
Bei Generatoren hingegen merkt sich Python den Zustand der Funktion. Alle Variablen und deren Zustände bleiben bis zum Programmende erhalten, das Aufräumen entfällt. Intern erzeugt Python beim Aufruf der Funktion ein Generator-Objekt, das das Iterator-Protokoll unterstützt. Damit Python weiß, dass es sich bei einer Funktion um einen Generator handelt und es Speicher sowie Stack nicht aufräumen muss, erfolgt die Rückkehr zum Aufrufpunkt mit dem Schlüsselwort yield anstelle von return.
In Listing 6 verarbeitet square() als Generator eine Werteliste. Bei einem Aufruf liefert square() nicht sämtliche berechneten Werte auf einmal, sondern stets nur einen einzigen. Als Ausgangspunkt dient der Zustand, den sich Python für den Generator gemerkt hat. Der Generator spielt seinen Vorteil erst bei großen Wertemengen voll aus: Nicht alle Zwischenergebnisse müssen gleichzeitig errechnet und im Speicher gehalten werden, sondern stets nur ein einziges.
Listing 6
Quadrat eines Werts als Generator
def square(valueList):
"""Return the square of a number"""
for value in valueList:
yield value * value
numbers = [1, 2, 3]
for value in square(numbers):
print(value)
Kompakt
Kombinieren Sie Generator-Ausdrücke mit List Comprehensions, dann ergibt sich eine überaus kompakte Schreibweise. Listing 7 entspricht Listing 6, allerdings (Kommentare außen vor) als Vierzeiler. Zunächst definieren Sie in der ersten Zeile eine Zahlenliste numbers und daraus in Zeile 3 eine Menge aus den Quadraten der Zahlen darin. In Zeile 5 iterieren Sie mithilfe einer For-Schleife wertweise über die Menge und geben den berechneten Wert daraufhin in der letzten Zeile aus.
Listing 7
Kompaktversion zu Listing 6
numbers = [1, 2, 3] # create the generator object as a set squares_generator = (value * value for value in numbers) # iterate over the generator and print the values for value in squares_generator: print(value)
Funktionen und Module
In dem Python-Werkzeugkasten finden Sie etliche Schubladen, bei denen es lohnt, sie aufzuziehen. Kluge Entwickler haben Funktionen und Module beigesteuert, die Ihnen im Alltag bereits etliches an Denk- und Tipparbeit abnehmen [7]. Die Tabelle “Nützliche Funktionen für die funktionale Programmierung” stellt eine Auswahl passender Funktionen zusammen, die Tabelle “Nützliche Module für die funktionale Programmierung” zeigt die Module. Der Begriff Aufzählung entspricht der deutschen Übersetzung für Iterable. Das kommt dem Original recht nahe und passt beispielsweise sowohl auf eine Liste als auch auf eine Menge und ein Tupel.
|
Funktion |
Bedeutung |
Beispiel |
|---|---|---|
|
|
Wende eine Funktion auf jedes Element der Aufzählung an. |
|
|
|
Erzeuge einen Iterator für jedes Element der Aufzählung, bei dem der Funktionsaufruf |
|
|
|
Zähle die Elemente in der Aufzählung ab und gib ein Tupel aus Nummer und Element zurück. |
– |
|
|
Sortiere eine Aufzählung aufsteigend. |
– |
|
|
Drehe eine Aufzählung um. |
– |
|
|
Gib |
– |
|
|
Gib |
– |
|
|
Entnimmt jeweils das n-te Element aus den Aufzählungen und gibt ein passendes Tupel aus Index und Element zurück. |
– |
|
Modul |
Bedeutung |
Beispiel |
|---|---|---|
|
|
Funktionen mit Iteratoren zum effizienten Durchlaufen von Schleifen [8] |
– |
|
operator |
als Funktionen ausgeführte Standardoperatoren [9] |
– |
|
|
Funktionen höherer Ordnung und Operationen auf aufrufbare Objekte [10] |
Lambda-Funktionen
Oft bestehen Funktionen nur aus wenigen Anweisungen und werden lediglich ein- oder zweimal aufgerufen. Hier kommt das interessante Konzept der Lambda-Funktionen ins Spiel, das den Code auf das Wesentliche reduziert.
Als Lambda-Funktionen bezeichnet man anonyme Funktionen ohne Funktionsnamen. Der Anzahl der Parameter sind keine Grenzen gesetzt, Lambda-Funktionen liefern Rückgabewerte wie herkömmliche Funktionen. Die Begrenzung liegt im Funktionskörper, der nur aus einem Ausdruck bestehen darf und nicht aus einem mehrzeiligen Anweisungsblock. Eine Kombination mit anderen Funktionsaufrufen ist hingegen zulässig, solang sich am Ende wieder ein einziger gültiger Ausdruck ergibt.
Statt mit def wie reguläre Funktionen beginnen Lambda-Funktionen mit dem Schlüsselwort lambda. Das Beenden der Funktion erfordert weder ein return noch ein yield. Listing 8 zeigt einen einfachen Aufruf zum Berechnen des Quadrats für die beiden Zahlenwerte 5 und 10.
Listing 8
Quadrat mittels Lambda-Funktion
square = lambda x: x * x
print("the square of 5 is", square(5))
print("the square of 10 is", square(10))
Wie Sie in Listing 8 sehen, können Sie die Lambda-Funktion jederzeit mit anderen Werten aufrufen. Für eine größere Menge von Werten erweist sich das Vorgehen jedoch als unpraktisch und langsam. Es vereinfacht sich mit der bereits in der Tabelle “Nützliche Funktionen für die funktionale Programmierung” vorgestellten Funktion map(). Listing 9 berechnet das Quadrat für jeden Wert in der Liste numbers und speichert das Ergebnis im Generator squared_numbers. Wenden Sie danach list() auf squared_numbers an, erhalten Sie eine Liste mit den errechneten Quadraten.
Listing 9
Quadrat für Listenwerte mittels Lambda-Funktion
numbers = [1, 2, 3]
# calculate vie map() and anonymous lambda function
squared_numbers = map(lambda x: x * x, numbers)
# print the result: [1, 4, 9]
print("numbers:", numbers)
print("squared numbers:", list(squared_numbers))
Was mit einer explizit definierten Lambda-Funktion klappt, klappt ebenso gut auch implizit. Listing 10 veranschaulicht, wie Sie die Funktion filter() und eine Lambda-Funktion miteinander kombinieren. Zunächst legen Sie eine Liste von Zahlen an, die ages heißt und Altersangaben repräsentiert (erste Zeile). Um daraus die Werte aller Personen zu ermitteln, die älter als 18 Jahre alt sind, rufen Sie die Funktion filter() auf. Sie erwartet als Parameter die Filterfunktion und die Liste, auf der sie arbeiten soll. Erstere ist als anonyme Lambda-Funktion mit einem simplen Vergleich ausgeführt, bei der Liste handelt es sich um das zuvor definierte ages.
Das Ergebnis des Aufrufs wandeln Sie wieder mittels list() in eine Liste um (Zeile 3) und geben sie aus (Zeile 5). Effektiv umfasst Listing 10 nur drei Zeilen Programmcode. Ohne Lambda-Funktion würde das Gezeigte mithilfe einer If-Else-Schachtelung ebenfalls funktionieren, fiele aber deutlich länger aus.
Listing 10
filter() und Lambda-Funktion kombinieren
ages = [13, 90, 17, 59, 21, 60, 5] # determine the adults from the list using filter, and a lambda function adults = list(filter(lambda age: age > 18, ages)) # output the list of adults print(adults)
Von den in der Tabelle “Nützliche Module für die funktionale Programmierung” genannten Modulen nehmen wir functools genauer unter die Lupe, speziell die Methode reduce() [11]. Sie erwartet zwei Parameter, eine Funktion zur Auswertung und eine Aufzählung (Iterable). reduce() wendet die Funktion zur Auswertung stets auf die ersten beiden Elemente der Aufzählung an und ersetzt sie durch den Rückgabewert der Funktion zur Auswertung. Das wiederholt sich so lange, bis die Aufzählung nur noch aus einem Element besteht.
In Listing 11 ist die Funktion zur Auswertung als Lambda-Funktion unter dem Bezeichner compare_numbers definiert (Zeile 5). Sie liefert jeweils den größeren der beiden ihr übermittelten Werte als Ergebnis. Stück für Stück verkürzt sich die Liste, bis am Ende der Wert 6 als größter Wert übrig bleibt und als Ergebnis ausgegeben wird (letzte Zeile). Das Listing enthält effektiv erneut lediglich vier Zeilen Programmcode und ist ähnlich kompakt wie Listing 10. Ohne Lambda-Funktion lässt sich dasselbe mit einer If-Else-Schachtelung oder der Funktion max() implementieren.
Listing 11
Finde das Maximum in einer Zahlenliste
# import reduce from the functools module
from functools import reduce
numbers = [1, 3, 5, 6, 2]
# define a lambda function to compare two numbers
compare_numbers = lambda a, b: a if a > b else b
# apply reduce() to numbers
print("The maximum element of the list is", reduce(compare_numbers, numbers))
Interessanterweise ergibt der Vergleich mit max() und einer unsortierten Liste aus 100 000 Elementen, dass max() etwa doppelt so schnell arbeitet wie die Kombination aus reduce() und compare_numbers. In Bezug auf den Speicherverbrauch unterscheiden sich beide jedoch nicht und liegen bei rund 50 MByte. Listing 12 zeigt die dazugehörige Ausgabe des Python Memory Profilers [12].
Listing 12
Ausgabe des Python Memory Profilers
$ python3 find-max-reduce-lambda-memory-usage.py
The maximum element of the list is 6
Filename: find-max-reduce-lambda-memory-usage.py
Line # Mem usage Increment Occurrences Line Contents
================================================================
17 46.8 MiB 46.8 MiB 1 @profile
18 def findMax():
19 # define a list of numbers
20 50.6 MiB 3.8 MiB 1 numbers = [1, 3, 5, 6, 2] * 100000
21
22 # define a lambda function to compare two numbers
23 50.6 MiB
0.0 MiB 999999 compare_numbers = lambda a, b: a if a > b else b
24
25 # apply reduce () to numbers
26 50.6 MiB 0.0 MiB 1 print("The maximum element of the list is",
reduce(compare_numbers, numbers))
Im Vergleich
Abschließend interessiert uns, wie gut sich der Programmcode im Vergleich zur imperativen Programmierung warten lässt. Dazu befragten wir Radon [13], die Ergebnisse sehen Sie in Listing 13. Alle Implementierungen wurden als sehr gut eingestuft, wobei die Buchstaben A, B und C den Farbwerten rot, gelb und grün aus der Microsoft-Entwicklerdokumentation entsprechen. Weitergehende Informationen zu den Aufrufparametern von Radon sowie zur Einordnung der erzielten Ergebnisse liefert die Dokumentation [14].
Die erreichten Werte decken sich mit unseren Erwartungen. Einfache Umsetzungen wie eine For-Schleife mögen länger sein, sind aber verständlicher und einfacher zu warten. Je komplexer die Anweisungen ausfallen – und bei funktionaler Programmierung tun sie das durchaus –, umso mehr müssen Sie darüber nachdenken, was der Programmcode tatsächlich bedeutet und bewirkt. Das spiegelt sich im Wartbarkeitsindex wider, der hier niedriger liegt. Er liegt beispielsweise für einen einfachen Generator bei 94,42, für eine Lambda-Funktion dagegen lediglich bei 67,23.
Der Programmcode reduziert sich, da die Schleifenzähler nicht mehr zu sehen sind: Sie wurden in die eingebauten Funktionen der Sprache verlagert. Das verbessert die Bewertung des Codes im Vergleich zu den anderen Faktoren etwas.
Listing 13
Wartbarkeit der Lösungen im Vergleich
$ radon mi -s *.py
find-overlap-elements-for-loop-simple.py - A (94.34) # Listing 1
find-overlap-elements-set.py - A (83.04) # Listing 2
square-for-loop.py - A (94.68)
square-function.py - A (87.73)
square-generator-lc.py - A (74.63) # Listing 7
square-generator.py - A (94.42) # Listing 6
square-iterator.py - A (98.53) # Listing 5
square-lambda-minimal.py - A (60.49) # Listing 8
square-lambda.py - A (67.23) # Listing 11
Fazit
Funktionale Programmierung erzeugt überaus kompakten Code, der sich oft auf verblüffend einfachen Weg erreichen lässt [15]. Im Github-Repository namens Awesome Functional Programming [16] sammelt Lucas Viola interessante Unterlagen zu dem Themengebiet, auch für andere Programmiersprachen als Python. Bisher blieb die Ausführungszeit von Programmcode außen vor. Mit diesem Aspekt werden wir uns im nächsten Teil dieser Artikelserie genauer auseinandersetzen. Dabei werten wir den Programmcode mithilfe des Moduls timeit [17] und anderer Werkzeuge aus. (csi)
Danksagung
Der Autor bedankt sich bei Gerold Rupprecht und Wolfram Eifler für deren Unterstützung und Anregungen bei der Erstellung des Artikels.
Der Autor
Frank Hofmann arbeitet zumeist von unterwegs, bevorzugt in Berlin, Genf und Kapstadt, als Entwickler, Trainer und Autor. Er ist Co-Autor des Debian-Paketmanagement-Buchs [18].
Infos
-
Guter Python-Code (Teil 5): Frank Hofmann, “Putzkolonne”, LU 10/2024, S. 86: https://www.linux-community.de/51003
-
“Python Internals: An Introduction.”: https://blog.sourcerer.io/python-internals-an-introduction-d14f9f70e583
-
“Everything’s an object”: https://pythoninternal.wordpress.com/2014/08/11/everythings-an-object/
-
“How to Use 100 percent of All CPU Cores in Python”: https://superfastpython.com/python-use-all-cpu-cores/
-
“Python – List Comprehension”: https://www.w3schools.com/python/python_lists_comprehension.asp
-
“Iterators and simple generators”: https://developer.ibm.com/articles/l-pycon/
-
“Python Functional Programming HOWTO”: https://docs.python.org/3/howto/functional.html
-
Python-Modul
itertools: https://docs.python.org/3/library/itertools.html#module-itertools -
Python-Modul
operator: https://docs.python.org/3/library/operator.html#module-operator -
Python-Modul
functools: https://docs.python.org/3/library/functools.html#module-functools -
“Python Lambda Functions”: https://www.geeksforgeeks.org/python-lambda-anonymous-functions-filter-map-reduce/
-
Python Memory Profiler: https://github.com/pythonprofilers/memory_profiler
-
Optionen für Radon: https://radon.readthedocs.io/en/latest/commandline.html
-
“Python Performance Tuning: 20 Simple Tips”: https://stackify.com/20-simple-python-performance-tuning-tips/
-
“Awesome Functional Programming”: https://github.com/lucasviola/awesome-functional-programming
-
Python-Modul
timeit: https://docs.python.org/3/library/timeit.html -
Debian-Paketmanagement-Buch: https://dpmb.org





