Kapitel 1. Eine Einführung in die funktionale Programmierung
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Um besser zu verstehen, wie du einen funktionalen Programmierstil in Java einführen kannst, musst du zuerst verstehen, was es bedeutet, dass eine Sprache funktional ist und was ihre grundlegenden Konzepte sind.
In diesem Kapitel lernst du die Wurzeln der funktionalen Programmierung kennen, die du brauchst, um einen funktionalen Programmierstil in deinen Arbeitsablauf einzubauen.
Was macht eine Sprache funktional?
Programmierparadigmen wie objektorientiert, funktional oder prozedural sind synthetische Gesamtkonzepte, die Sprachen klassifizieren und Möglichkeiten bieten, Programme in einem bestimmten Stil zu strukturieren und verschiedene Lösungsansätze zu verwenden. Wie bei den meisten Paradigmen gibt es auch bei der funktionalen Programmierung keine einheitliche Definition, und es werden viele Grabenkämpfe darüber ausgefochten, was eine Sprache tatsächlich als funktional definiert. Anstatt meine eigene Definition zu geben, werde ich verschiedene Aspekte dessen, was eine Sprache funktional macht, erläutern.
Eine Sprache gilt als funktional, wenn es eine Möglichkeit gibt, Berechnungen durch das Erstellen und Kombinieren abstrakter Funktionen auszudrücken.Dieses Konzept geht auf das formale mathematische System Lambda-Kalkül zurück, das der Logiker Alonzo Church in den 1930er Jahren erfand.1 Es ist ein System, mit dem man Berechnungen mit abstrakten Funktionen ausdrücken und Variablen auf sie anwenden kann. Der Name "Lambda-Kalkül" stammt von dem griechischen Buchstaben "Lambda", der als Symbol gewählt wurde: .
Als objektorientierter Entwickler bist du an die imperative Programmierung gewöhnt: Indem du eine Reihe von Anweisungen definierst, sagst du dem Computer , was er tun soll, um eine bestimmte Aufgabe mit einer Folge von Anweisungen zu erfüllen.
Damit eine Programmiersprache als funktional angesehen werden kann, muss ein deklarativer Stil möglich sein, der die Logik von Berechnungen ausdrückt, ohne den tatsächlichen Kontrollfluss zu beschreiben.In einem solchen deklarativen Programmierstil beschreibst du das Ergebnis und die Funktionsweise deines Programms mit Ausdrücken und nicht mit Anweisungen.
In Java ist ein Ausdruck eine Folge von Operatoren, Operanden und Methodenaufrufen, die eine Berechnung definieren und zu einem einzigen Wert ausgewertet werden:
x
*
x
2
*
Math
.
PI
*
radius
value
==
null
?
true
:
false
Anweisungen hingegen sind Aktionen, die von deinem Code ausgeführt werden und eine vollständige Ausführungseinheit bilden, einschließlich Methodenaufrufen ohne Rückgabewert. Jedes Mal, wenn du den Wert einer Variablen zuweist oder änderst, eine void
Methode aufrufst oder Kontrollflusskonstrukte wie if
-else
verwendest, benutzt du Anweisungen. Normalerweise sind sie mit Ausdrücken vermischt:
int
totalTreasure
=
0
;
int
newTreasuresFound
=
findTreasure
(
6
)
;
totalTreasure
=
totalTreasure
+
newTreasuresFound
;
if
(
totalTreasure
>
10
)
{
System
.
out
.
println
(
"
You have a lot of treasure!
"
)
;
}
else
{
System
.
out
.
println
(
"
You should look for more treasure!
"
)
;
}
Weist einer Variablen einen Anfangswert zu und bringt so einen Zustand in das Programm.
Der Funktionsaufruf
findTreasure(6)
ist ein funktionaler Ausdruck, aber die Zuweisung vonnewTreasuresFound
ist eine Anweisung.Die Neuzuweisung von
totalTreasure
ist eine Anweisung, die das Ergebnis des Ausdrucks auf der rechten Seite verwendet.Die Kontrollflussanweisung
if
-else
gibt an, welche Aktion aufgrund des Ergebnisses des Ausdrucks(totalTreasure > 10)
ausgeführt werden soll.Das Drucken auf
System.out
ist eine Anweisung, weil der Aufruf kein Ergebnis zurückliefert.
Der Hauptunterschied zwischen Ausdrücken und Anweisungen besteht darin, ob ein Wert zurückgegeben wird oder nicht. In einer universellen Mehrzwecksprache wie Java sind die Grenzen zwischen ihnen oft umstritten und können schnell verschwimmen.
Funktionale Programmierkonzepte
Da die funktionale Programmierung in erster Linie auf abstrakten Funktionen basiert, können sich die vielen Konzepte, die das Paradigma ausmachen, auf das "was zu lösen ist" in einem deklarativen Stil konzentrieren, im Gegensatz zum imperativen "wie zu lösen ist" Ansatz.
Wir werden die häufigsten und wichtigsten Aspekte durchgehen, die der funktionalen Programmierung zugrunde liegen. Diese sind jedoch nicht ausschließlich auf das funktionale Paradigma beschränkt. Viele der Ideen, die dahinter stehen, gelten auch für andere Programmierparadigmen.
Reine Funktionen und referenzielle Transparenz
In der funktionalen Programmierung werden Funktionen in zwei Kategorien eingeteilt: reine und unreine.
Reine Funktionen haben zwei elementare Garantien:
- Die gleiche Eingabe erzeugt immer die gleiche Ausgabe
-
Der Rückgabewert einer reinen Funktion darf nur von ihren Eingangsargumenten abhängen.
- Sie sind in sich geschlossen und haben keinerlei Nebenwirkungen
-
Der Code kann sich nicht auf den globalen Zustand auswirken, z. B. Argumentwerte ändern oder E/A verwenden.
Dank dieser beiden Garantien können reine Funktionen in jeder Umgebung sicher verwendet werden, auch parallel. Der folgende Code zeigt, dass eine Methode eine reine Funktion ist, die ein Argument annimmt, ohne etwas außerhalb ihres Kontexts zu beeinflussen:
public
String
toLowerCase
(
String
str
)
{
return
str
.
toLowerCase
();
}
Funktionen, die eine der beiden Garantien verletzen, gelten als unsauber. Der folgende Code ist ein Beispiel für eine unsaubere Funktion, da sie die aktuelle Zeit für ihre Logik verwendet:
public
String
buildGreeting
(
String
name
)
{
var
now
=
LocalTime
.
now
();
if
(
now
.
getHour
()
<
12
)
{
return
"Good morning "
+
name
;
}
else
{
return
"Hello "
+
name
;
}
}
Die Bezeichnungen "rein" und "unrein" sind wegen der Konnotation, die sie hervorrufen können, eher unglücklich gewählt. Unreine Funktionen sind den reinen Funktionen im Allgemeinen nicht unterlegen. Sie werden nur auf unterschiedliche Weise verwendet, je nachdem, welchen Codierungsstil und welches Paradigma du einhalten willst.
Ein weiterer Aspekt von nebenwirkungsfreien Ausdrücken oder reinen Funktionen ist ihre deterministische Natur, die sie referenziell transparent macht. Das bedeutet, dass du sie bei weiteren Aufrufen durch ihr jeweiliges ausgewertetes Ergebnis ersetzen kannst, ohne das Verhalten deines Programms zu verändern.
Funktion:
Ersetzen von ausgewerteten Ausdrücken:
Alle diese Varianten sind gleich und verändern dein Programm nicht. Reinheit und referenzielle Transparenz gehen Hand in Hand und geben dir ein mächtiges Werkzeug an die Hand, weil es einfacher ist, deinen Code zu verstehen und mit ihm zu argumentieren.
Unveränderlichkeit
Objektorientierter Code basiert in der Regel auf einem veränderlichen Programmzustand. Objekte können und werden in der Regel nach ihrer Erstellung mit Hilfe von Setzern verändert. Die Veränderung von Datenstrukturen kann jedoch unerwartete Nebeneffekte hervorrufen. Die Veränderlichkeit ist jedoch nicht auf Datenstrukturen und OOP beschränkt. Auch eine lokale Variable in einer Methode kann veränderlich sein und in ihrem Kontext ebenso zu Problemen führen wie ein verändertes Feld eines Objekts.
Durch die Unveränderlichkeit können sich Datenstrukturen nach ihrer Initialisierung nicht mehr ändern. Da sie sich nie ändern, sind sie immer konsistent, frei von Nebeneffekten, vorhersehbar und einfacher zu verstehen. Wie reine Funktionen können sie sicher in nebenläufigen und parallelen Umgebungen verwendet werden, ohne die üblichen Probleme mit unsynchronisierten Zugriffen oder Zustandsänderungen außerhalb des Bereichs.
Wenn sich Datenstrukturen nach der Initialisierung nie ändern würden, wäre ein Programm nicht sehr nützlich. Deshalb musst du eine neue und aktualisierte Version erstellen, die den geänderten Zustand enthält, anstatt die Datenstruktur direkt zu ändern.
Das Erstellen neuer Datenstrukturen für jede Änderung kann mühsam und ziemlich ineffizient sein, da die Daten jedes Mal kopiert werden müssen. Viele Programmiersprachen verwenden "Struktur-Sharing", um effiziente Kopiermechanismen bereitzustellen und so die Ineffizienz zu minimieren, die durch das Erfordernis neuer Datenstrukturen für jede Änderung entsteht. Auf diese Weise teilen sich verschiedene Instanzen von Datenstrukturen unveränderliche Daten untereinander.In Kapitel 4 wird genauer erklärt, warum die Vorteile von nebenwirkungsfreien Datenstrukturen den zusätzlichen Aufwand überwiegen, der möglicherweise notwendig ist.
Rekursion
Rekursion ist eine Problemlösungstechnik, bei der ein Problem gelöst wird, indem Teilprobleme der gleichen Form gelöst und die Teilergebnisse kombiniert werden, um schließlich das ursprüngliche Problem zu lösen. Laienhaft ausgedrückt rufen sich rekursive Funktionen selbst auf, allerdings mit leicht veränderten Eingangsargumenten, bis sie eine Endbedingung erreichen und einen tatsächlichen Wert zurückgeben.In Kapitel 12 werden wir uns mit den Feinheiten der Rekursion beschäftigen.
Ein einfaches Beispiel ist die Berechnung einer Fakultät, dem Produkt aller positiven ganzen Zahlen, die kleiner oder gleich dem Eingabeparameter sind. Anstatt den Wert mit einem Zwischenzustand zu berechnen, ruft sich die Funktion selbst mit einer dekrementierten Eingabevariablen auf, wie in Abbildung 1-1 dargestellt.
In der reinen funktionalen Programmierung wird oft eine Rekursion anstelle von Schleifen oder Iteratoren verwendet. Einige von ihnen, wie Haskell, gehen noch einen Schritt weiter und haben überhaupt keine Schleifen wie for
oder while
.
Die wiederholten Funktionsaufrufe können ineffizient und sogar gefährlich sein, da die Gefahr besteht, dass der Stack überläuft. Deshalb verwenden viele funktionale Sprachen Optimierungen wie das "Abrollen" von Rekursionen in Schleifen oder die Tail-Call-Optimierung, um die benötigten Stack-Frames zu reduzieren. Java unterstützt keine dieser Optimierungstechniken, worauf ich in Kapitel 12 näher eingehen werde.
Funktionen erster Klasse und höherer Ordnung
Viele der zuvor besprochenen Konzepte müssen nicht als tief integrierte Sprachfunktionen verfügbar sein, um einen funktionalen Programmierstil in deinem Code zu unterstützen. Die Konzepte der Funktionen erster Klasse und höherer Ordnung sind jedoch ein absolutes Muss.
Damit Funktionen sogenannte "Bürger erster Klasse" sind, müssen sie alle Eigenschaften anderer Entitäten der Sprache erfüllen. Sie müssen Variablen zugewiesen werden können und als Argumente und Rückgabewerte in anderen Funktionen und Ausdrücken verwendet werden können.
Funktionenhöherer Ordnung nutzen diese erstklassige Staatsbürgerschaft, um Funktionen als Argumente zu akzeptieren oder eine Funktion als Ergebnis zurückzugeben, oder beides. Dies ist eine wichtige Eigenschaft für das nächste Konzept, die funktionale Komposition.
Funktionale Zusammensetzung
Reine Funktionen können kombiniert werden, um komplexere Ausdrücke zu bilden. Mathematisch ausgedrückt bedeutet das, dass die beiden Funktionen und können zu einer Funktion kombiniert werden , wie in Abbildung 1-2 zu sehen.
Auf diese Weise können Funktionen so klein und punktuell wie möglich sein und daher leichter wiederverwendet werden. Um eine komplexere und vollständige Aufgabe zu erstellen, können solche Funktionen bei Bedarf schnell zusammengestellt werden.
Currying
Function Currying bedeutet, dass eine Funktion, die mehrere Argumente benötigt, in eine Folge von Funktionen umgewandelt wird, die jeweils nur ein einziges Argument benötigen.
Hinweis
Die Currying-Technik verdankt ihren Namen dem Mathematiker und Logiker Haskell Brooks Curry (1900-1982). Er ist nicht nur der Namensgeber der funktionalen Technik namens Currying, sondern nach ihm sind auch drei verschiedene Programmiersprachen benannt: Haskell, Brook und Curry.
Stell dir eine Funktion vor, die drei Argumente akzeptiert. Sie kann wie folgt kuriert werden:
Anfangsfunktion:
Curry-Funktionen:
Abfolge von gecurten Funktionen:
Einige funktionale Programmiersprachen spiegeln das allgemeine Konzept des Currying in ihren Typdefinitionen wider, wie z.B. Haskell:
add
::
Integer
->
Integer
->
Integer
add
x
y
=
x
+
y
Die Funktion
add
wird so deklariert, dass sie eineInteger
akzeptiert und eine andere Funktion zurückgibt, die eine andereInteger
akzeptiert, die wiederum eineInteger
zurückgibt.Die eigentliche Definition spiegelt die Deklaration wider: zwei Eingabeparameter und das Ergebnis des Körpers als Rückgabewert.
Auf den ersten Blick kann sich dieses Konzept für einen OO- oder imperativen Entwickler seltsam und fremd anfühlen, wie viele Prinzipien, die auf der Mathematik basieren. Dennoch vermittelt es perfekt, wie eine Funktion mit mehr als einem Argument als eine Funktion von Funktionen dargestellt werden kann, und das ist eine wichtige Erkenntnis, um das nächste Konzept zu unterstützen.
Anwendung einer Teilfunktion
Einepartielle Funktionsanwendung ist der Prozess, bei dem eine neue Funktion erstellt wird, indem nur eine Teilmenge der erforderlichen Argumente an eine bestehende Funktion übergeben wird. Sie wird oft mit Currying verwechselt, aber ein Aufruf einer partiell angewendeten Funktion gibt ein Ergebnis zurück und nicht eine andere Funktion einer Currying-Kette.
Das Currying-Beispiel aus dem vorherigen Abschnitt kann teilweise angewendet werden, um eine spezifischere Funktion zu erstellen:
add
::
Integer
->
Integer
->
Integer
add
x
y
=
x
+
y
add3
=
add
3
add3
5
Die Funktion
add
wird wie zuvor deklariert und nimmt zwei Argumente entgegen.Der Aufruf der Funktion
add
mit nur einem Wert für das erste Argumentx
liefert als teilweise angewandte Funktion vom TypInteger → Integer
, die an den Namenadd3
gebunden ist.Der Aufruf
add3 5
ist gleichbedeutend mitadd 3 5
.
Mit der partiellen Anwendung kannst du neue, weniger ausführliche Funktionen im Handumdrehen erstellen oder spezialisierte Funktionen aus einem allgemeineren Pool, um sie an den aktuellen Kontext und die Anforderungen deines Codes anzupassen.
Faule Bewertung
Lazy Evaluation ist eine Evaluierungsstrategie, die die Evaluierung eines Ausdrucks so lange hinauszögert, bis das Ergebnis buchstäblich benötigt wird, indem sie die Frage, wie du einen Ausdruck erstellst, davon trennt, ob oder wann du ihn tatsächlich verwendest. Auch dies ist ein Konzept, das nicht in der funktionalen Programmierung verwurzelt oder auf sie beschränkt ist, aber es ist ein Muss, um andere funktionale Konzepte und Techniken zu verwenden.
Viele nicht-funktionale Sprachen, darunter auch Java, sind in erster Linie strikt oder eifrig ausgewertet, d.h. ein Ausdruck wird sofort ausgewertet. In diesen Sprachen gibt es noch einige faule Konstrukte, z.B. Kontrollflussanweisungen wie if
-else
oder Schleifen oder logische Kurzschlussoperatoren. Die sofortige Auswertung beider Zweige eines if
-else
Konstrukts oder aller möglichen Schleifeniterationen wäre nicht sehr sinnvoll, oder? Stattdessen werden nur die unbedingt erforderlichen Zweige und Iterationen zur Laufzeit ausgewertet.
Faulheit ermöglicht bestimmte Konstruktionen, die sonst nicht möglich wären, wie unendliche Datenstrukturen oder effizientere Implementierungen einiger Algorithmen.Es funktioniert auch sehr gut mit referentieller Transparenz. Wenn es keinen Unterschied zwischen einem Ausdruck und seinem Ergebnis gibt, kannst du die Auswertung verzögern, ohne dass dies Auswirkungen auf das Ergebnis hat. Eine verzögerte Auswertung kann sich trotzdem auf die Leistung des Programms auswirken, weil du den genauen Zeitpunkt der Auswertung nicht kennst.
In Kapitel 11 werde ich erörtern, wie du mit den dir zur Verfügung stehenden Werkzeugen einen Lazy Approach in Java erreichen kannst und wie du deine eigenen erstellen kannst.
Vorteile der funktionalen Programmierung
Nachdem du die gängigsten und wichtigsten Konzepte der funktionalen Programmierung kennengelernt hast, kannst du sehen, wie sie sich in den Vorteilen widerspiegeln, die ein funktionaler Ansatz bietet:
- Einfachheit
-
Ohne veränderbare Zustände und Seiteneffekte sind deine Funktionen in der Regel kleiner und tun "nur das, was sie tun sollen".
- Konsistenz
-
Unveränderliche Datenstrukturen sind zuverlässig und konsistent. Du musst dir keine Sorgen mehr über unerwartete oder ungewollte Programmzustände machen.
- (Mathematische) Korrektheit
-
Einfacherer Code mit konsistenten Datenstrukturen führt automatisch zu "korrekterem" Code mit einer geringeren Fehleroberfläche. Je "reiner" dein Code ist, desto einfacher ist er zu verstehen, was wiederum die Fehlersuche und das Testen vereinfacht.
- Sichere Gleichzeitigkeit
-
Gleichzeitigkeit ist eine der schwierigsten Aufgaben in "klassischem" Java. Mit funktionalen Konzepten kannst du dir viele Kopfschmerzen ersparen und sicherere Parallelverarbeitung (fast) umsonst bekommen.
- Modularität
-
Kleine und unabhängige Funktionen führen zu einer einfacheren Wiederverwendbarkeit und Modularität. In Kombination mit funktionaler Komposition und partieller Anwendung hast du mächtige Werkzeuge, um aus diesen kleineren Teilen ganz einfach komplexere Aufgaben zu bauen.
- Prüfbarkeit
-
Viele der funktionalen Konzepte wie reine Funktionen, referenzielle Transparenz, Unveränderlichkeit und die Trennung von Belangen erleichtern das Testen und Verifizieren.
Nachteile der funktionalen Programmierung
Die funktionale Programmierung hat zwar viele Vorteile, aber es ist auch wichtig, ihre möglichen Fallstricke zu kennen.
- Lernkurve
-
Die fortgeschrittene mathematische Terminologie und die Konzepte, auf denen die funktionale Programmierung basiert, können ziemlich einschüchternd sein. Um deinen Java-Code zu verbessern, musst du aber definitiv nicht wissen, dass "eine Monade nur ein Monoid in der Kategorie der Endofunktionen ist.2"Dennoch wirst du mit neuen und oft ungewohnten Begriffen und Konzepten konfrontiert.
- Höhere Abstraktionsebene
-
Während OOP Objekte verwendet, um seine Abstraktion zu modellieren, nutzt FP eine höhere Abstraktionsebene, um seine Datenstrukturen darzustellen, was sie zwar elegant, aber oft schwieriger zu erkennen macht.
- Der Umgang mit dem Staat
-
Der Umgang mit Zuständen ist keine leichte Aufgabe, unabhängig vom gewählten Paradigma. Auch wenn der unveränderliche Ansatz von FP viele mögliche Fehlerquellen eliminiert, macht er es auch schwieriger, Datenstrukturen zu verändern, wenn sie sich tatsächlich ändern müssen, vor allem wenn du es gewohnt bist, Setter in deinem OO-Code zu haben.
- Auswirkungen auf die Leistung
-
Funktionale Programmierung ist in nebenläufigen Umgebungen einfacher und sicherer. Das bedeutet jedoch nicht, dass sie von Natur aus schneller ist als andere Paradigmen, vor allem nicht in einem Single-Thread-Kontext. Trotz ihrer vielen Vorteile können viele funktionale Techniken wie Unveränderlichkeit oder Rekursion unter dem erforderlichen Overhead leiden. Deshalb nutzen viele FP-Sprachen eine Fülle von Optimierungen, um dies abzumildern, z. B. spezielle Datenstrukturen, die das Kopieren minimieren, oder Compiler-Optimierungen für Techniken wie Rekursion.3.
- Optimaler Problemkontext
-
Nicht alle Problemstellungen eignen sich für einen funktionalen Ansatz. Bereiche wie Hochleistungsrechner, I/O-lastige Probleme oder Low-Level-Systeme und eingebettete Steuerungen, bei denen du eine feinkörnige Kontrolle über Dinge wie Datenlokalität und explizite Speicherverwaltung brauchst, passen nicht gut zur funktionalen Programmierung.
Als Programmierer müssen wir ein Gleichgewicht zwischen den Vor- und Nachteilen eines jeden Paradigmas und Programmieransatzes finden. Deshalb zeigt dir dieses Buch, wie du die besten Teile der funktionalen Entwicklung von Java auswählst und sie zur Erweiterung deines objektorientierten Java-Codes einsetzt.
Mitbringsel
-
Die funktionale Programmierung basiert auf dem mathematischen Prinzip desLambda-Kalküls.
-
Ein deklarativer Kodierungsstil, der auf Ausdrücken anstelle von Anweisungen basiert, ist für die funktionale Programmierung unerlässlich.
-
Viele Programmierkonzepte fühlen sich von Natur aus funktional an, aber sie sind keine zwingende Voraussetzung dafür, dass eine Sprache oder dein Code "funktional" ist. Auch nicht-funktionaler Code profitiert von den zugrunde liegenden Ideen und der allgemeinen Denkweise.
-
Reinheit, Konsistenz und Einfachheit sind wichtige Eigenschaften, die du auf deinen Code anwenden musst, um das Beste aus einem funktionalen Ansatz herauszuholen.
-
Es kann sein, dass Kompromisse zwischen den funktionalen Konzepten und ihrer realen Anwendung notwendig sind. Ihre Vorteile überwiegen jedoch in der Regel die Nachteile oder können zumindest in irgendeiner Form abgemildert werden.
1 Alonzo Church, "An Unsolvable Problem of Elementary Number Theory", American Journal of Mathematics, Vol. 58 (1936): 345-363.
2 James Iry verwendete diesen Satz in seinem humorvollen Blogbeitrag "A Brief, Incomplete, and Mostly Wrong History of Programming Languages", um die Komplexität von Haskell zu veranschaulichen. Er ist auch ein gutes Beispiel dafür, dass man nicht alle zugrundeliegenden mathematischen Details einer Programmiertechnik kennen muss, um von ihren Vorteilen zu profitieren. Aber wenn du wirklich wissen willst, was er bedeutet, dann schau in Saunders Mac Lanes Buch Categories for the Working Mathematician (Springer, 1998) nach, in dem der Ausdruck ursprünglich verwendet wurde.
3 Der Java Magazine Artikel "Curly Braces #6: Recursion and tail-call optimization" gibt einen guten Überblick über die Bedeutung der Tail-Call-Optimierung in rekursivem Code.
Get Ein funktionaler Ansatz für Java now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.