|
||||||||||||||||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | ||||||||||||||||||||||||||||||||
|
Effective Java - Java 8 - Functional Programming in Java
|
|||||||||||||||||||||||||||||||
Im April 2013 hat Oracle die Java Community darüber informiert, dass der Java-8-Releasetermin von September 2013 auf März 2014 verschoben wird. Diese Verschiebung gibt uns Zeit, uns auf die neuen Sprachmittel und die neuen JDK-Abstraktionen von Java 8 in Ruhe vorzubereiten. Mit diesem Beitrag beginnen wir deshalb eine Serie von Beiträgen zu den Neuerungen in Java 8. Unter anderem wird es neue Sprachelemente geben, die einen eher funktionalen Programmierstil in Java unterstützen werden. Dabei geht es um die sogenannten Lambda-Ausdrücke. Ehe wir uns jedoch die Lambda-Ausdrücke in einem der nachfolgenden Beiträge genauer ansehen, wollen wir uns zunächst damit befassen, was funktionale Programmierung generell ausmacht, wo man funktionale Ansätze praktisch anwenden kann und wie funktionale Programmierung konkret in Java aussehen wird. Objekt-Orientierte vs. Funktionale Programmierung
In objekt-orientierten Programmiersprachen
(wie zum Beispiel Java) spielen Objekte eine wesentliche Rolle. Als Java-Entwickler
beschreiben wir, wie Objekte aussehen, wenn wir eine Klasse definieren,
d.h. wir legen fest, welche Daten den Zustand eines Objekts beschreiben
und welche Methoden die Fähigkeiten eines Objekts ausmachen. Wir erzeugen
Objekte, wenn wir von den Klassen Instanzen bilden. Wir verändern Objekte,
z.B. wenn wir die Felder ändern oder Methoden aufrufen, die dies tun.
Wir reichen Objekte herum, z.B. wenn wir sie als Argumente an Methoden
übergeben. Mit Objekten sind wir als Java-Entwickler bestens vertraut.
In funktionalen Sprachen (wie zum Beispiel
Erlang, Haskell, ...) stehen nicht Objekte, sondern Funktionen im Vordergrund.
Funktionen ähneln Methoden; sie repräsentieren ausführbare Funktionalität.
Sowohl Methoden als auch Funktionen werden aufgerufen und ausgeführt.
Funktionen in funktionalen Sprachen werden aber darüber hinaus herumgereicht.
Man übergibt sie beispielsweise als Argumente an Operationen; diese Operationen
können dann die übergebenen Funktionen in einem bestimmten Kontext aufrufen.
Funktionen können auch als Returnwert einer Operation zurückgegeben werden.
Das heißt, in funktionalen Sprachen werden Funktionen herumgereicht, wie
Objekte in objekt-orientierten Sprachen. Dieses Prinzip des Herumreichens
von Funktionen wird auch als "code-as-data" bezeichnet.
Funktionen werden aber nicht nur übergeben
und aufgerufen, sondern auch kombiniert und verkettet oder manipuliert
und verändert. Es gibt z.B. das sogenannte
Currying (
benannt
nach Haskell Brooks Curry), bei dem aus einer Funktion mit mehreren Argumenten
durch Argument-Binding eine Funktion mit einem Argument gemacht wird.
In einer reinen funktionalen Sprache ("pure functional language") haben
die Funktionen nicht einmal Seiteneffekte. Das heißt insbesondere, dass
Funktionen keine Daten verändern, sondern bestenfalls neue Daten erzeugen.
Soviel zur Theorie. Was fängt man damit in der Praxis an? Kann man funktionale Prinzipien in Java überhaupt gebrauchen? Zur Illustration wollen wir uns ein Idiom ansehen, bei dem Funktionen eine wesentliche Rolle spielen und das auch in Java recht nützlich sein kann. Es geht um das Execute-Around-Method-Pattern . Das Execute-Around-Method-Pattern
Bei dem Execute-Around-Method-Pattern (siehe
/
EAM1
/, /
EAM2
/) geht es darum,
strukturell ähnlichen Code so zu zerlegen, dass die immer wiederkehrende,
identische Struktur heraus gelöst und in eine Hilfsmethode ausgelagert
wird. Der Teil, der in dieser Struktur variiert, wird an die Hilfsmethode
übergeben und in dieser Hilfsmethode eingebettet in die Struktur an der
richtigen Stelle aufgerufen. Beispiele dafür solche wiederkehrenden
Strukturen gibt es viele:
Verwendung von Ressourcen : Wenn eine Ressource verwendet wird, dann ergibt sich oft eine wiederkehrende Struktur, nämlich acquire resource use resource
release resource
Das Anfordern und Freigeben der Ressource
ist oft identisch, aber die Benutzung dazwischen variiert. Ein Beispiel
für solche Ressourcen sind die expliziten Locks wie zum Beispiel das
ReentrantLock
.
Hier ist die immer gleiche Struktur, die sich bei der Benutzung von expliziten
Locks ergibt:
lock.lock(); try { ... critical region ... } finally { lock.unlock();
}
Das Anfordern und Freigeben des Locks ist immer gleich, nur die Anweisungen
dazwischen variieren.
Exception Handling
: Wenn Operationen aus einem bestimmten Framework
verwendet werden, dann werfen sie oft die gleichen Exceptions, die immer
gleich behandelt werden.
try { ... invoke operations ... } catch (ExceptionType_1 e1) { ... } catch (ExceptionType_2 e2) { ... }
catch (ExceptionType_3
e3) { ... }
Die
catch
-Klauseln sind immer gleich,
aber die im
try
-Block aufgerufenen Operationen
sind unterschiedlich.
Iterierung
: Es wird ein Iterator angefordert
und aufs jeweils nächste Element in einer Sequenz weitergeschaltet, bis
das letzte Element erreicht ist.
Iterator iter = seq.iterator(); while (iter.hasNext()) { Object elem = iter.next(); ... use element ...
}
Die Handhabung des Iterators ist immer gleich; lediglich die Verwendung
des jeweiligen Elements variiert.
Beim Execute-Around-Method-Pattern wird der
gemeinsame, wiederkehrende Teil in eine Hilfsmethode ausgelagert. Der veränderliche
Teil wird als Argument an die Hilfsmethode übergeben. Wir wollen es einmal
am Beispiel der Iteration demonstrieren.
Wir definieren eine Hilfsmethode
forEach
:
public class Utilities { public static <E> void forEach(Iterable<E> seq, Consumer<E> block) { Iterator<E> iter = seq.iterator(); while (iter.hasNext()) { E elem = iter.next(); block.accept (elem); } }
}
Die Hilfsmethode
forEach
enthält den strukturell wiederkehrenden Teil, nämlich das Anfordern des
Iterators, die Abfrage auf das Ende der Sequenz und das Weiterschalten
des Iterators auf das jeweils nächste Element. Die Verwendung des Elements
ist der sich unterscheidende Teil. Er wird von Außen an die Hilfsmethode
übergeben und in der Hilfsmethode an entsprechender Stelle aufgerufen.
Die Hilfsmethode
forEach
bekommt deshalb
als Argumente die Sequenz der Elemente, auf der man iterieren will, und
die Funktionalität, die während der Iterierung auf jedes Element in der
Sequenz angewandt werden soll.
Für die Beschreibung der Funktionalität,
die auf jedes Element angewandt wird, definieren wir ein Interface
Consumer
:
public interface Consumer<T> { void accept(T t);
}
Dieses Interface gibt es tatsächlich in Java 8 im Package
java.util.function
.
Mit dieser Zerlegung in die Hilfsmethode
mit dem strukturell identischen Teil und das Interface mit dem variierenden
Teil brauchen wir die Iterierung nicht mehr redundant hinschreiben. Hier
ist ein Benutzungsbeispiel. Wir wollen alle Elemente aus einer Liste
von Zahlen ausgeben.
Herkömmlich sieht es so aus:
List<Integer> numbers = new ArrayList<>(); ... populate list ... Iterator iter = numbers.iterator(); while (iter.hasNext()) { Integer elem = iter.next(); System.out.println(elem);
}
Gemäß Execute-Around-Method-Pattern sieht
es so aus:
List<Integer> numbers = new ArrayList<>(); ... populate list ... Utilities.forEach(numbers, new Consumer<Integer>() { public void accept(Integer elem) { System.out.println(elem); }
});
Nun mag man sich fragen, was an der Execute-Around-Method-Version
besser sein soll als an der guten alten Iterierung per Schleife. Mit
klassischen Java-Mitteln, so wie sie uns in Java 7 zur Verfügung stehen,
ist nichts gewonnen. Man muss eine Implementierung des
Consumer
-Interfaces
definieren, um den Consumer an die Hilfsmethode
forEach
zu übergeben, und das ist selbst unter Verwendung von anonymen inneren
Klassen noch recht umständlich.
Genau diese syntaktische Umständlichkeit
wird in Java 8 mit den Lambda-Ausdrücken verschwinden (siehe /
LAM
/,
/
TUT
/).
In Java 8 mit einem Lambda-Ausdruck sieht
es viel eleganter aus:
List<Integer> numbers = new ArrayList<>(); ... populate list ...
Utilities.forEach(numbers,
e
-> System.out.println(e
)
);
Es geht auch noch eleganter mit Hilfe von
Methoden-Referenzen - einem weiteren neuen Sprachmittel in Java 8.
So sieht es dann in Java 8 mit einer Methoden-Referenz
aus:
List<Integer> numbers = new ArrayList<>(); ... populate list ...
Utilities.forEach(numbers,
System
::
println
);
Auf die Syntax von Lambda-Ausdrücken wie
e
-> System.out.println(e)
und Methoden-Referenzen wie
System
.out
::println
wollen wir in diesem Beitrag nicht näher eingehen. Das besprechen wir
im nächsten Artikel der Serie im Detail. Aber auch ohne große Erläuterung
kann man intuitiv verstehen, dass der Lambda-Ausdruck so etwas Ähnliches
wie eine Funktion ist. Er nimmt ein Argument mit Namen
e
,
dessen Typ sich der Compiler selbst überlegen kann. Augenscheinlich
soll es ein
Integer
aus der Liste sein.
Dieses Argument
e
wird per
System.out.println
-Methode
ausgegeben. Auf diese Weise wird die
forEach
-Methode
alle Zahlen in der Liste
numbers
mit
println
auf
System.out
ausgeben.
Die Methoden-Referenz ist auch selbsterklärend.
System
.out
::println
ist die
println
-Methode von
System.out
.
In der
forEach
-Methode sollen also alle
Elemente mit
println
nach
System.out
ausgegeben werden.
Im JDK 8 wird es solche Hilfsmethoden wie
forEach
geben. Sie sind dann aber nicht in irgendwelchen Utility-Klassen definiert,
sondern die Collections selbst sind erweitert worden. In Java 8 hat jede
Collection aus dem
java.util
Package
eine
forEach
-Methode, und zwar erbt sie
diese von ihrem Super-Interface
Iterable
.
Das
Iterable
-Interface ist für Java
8 erweitert worden und sieht in Java 8 so aus:
public interface Iterable<T> {
Iterator<T> iterator();
default void forEach(Consumer<? super T> action) { for (T t : this) { action.accept(t); } }
}
Das
Iterable
-Interface
hat zusätzlich zur
iterator
-Methode,
die es schon immer hatte, eine
forEach
-Methode
bekommen.
Um die existierenden Interfaces im JDK so
wie oben gezeigt erweitern zu können, hat man mit Java 8 die sogenannten
Default-Methoden
erfunden. Darauf werden wir in einem der Folgebeiträge genauer eingehen.
Hier nur ganz kurz: Normalerweise kann man ein Interface nicht problemlos
erweitern. Wenn man Methoden hinzufügt, dann müssen alle abgeleiteten
Klassen diese Methode implementieren. Andernfalls gibt es Fehlermeldungen
bei der Compilierung. Die Default-Methoden sind nun Methoden, die eine
Implementierung haben. Das heißt, sie sind nicht abstrakt und müssen
von den abgeleiteten Klassen auch nicht implementiert werden. Alle Klassen,
die keine Implementierung für die neue zusätzliche Methode haben, erben
einfach die Default-Implementierung aus dem Interface. Auf diese Weise
kann man existierende Interfaces erweitern, ohne die abgeleiteten Klassen
ändern zu müssen. Die
forEach
-Methode
im
Iterable
-Interface ist eine solche
Default-Methode. Sie hat eine Implementierung. Sie verwendet in der Implementierung
die for-each-Schleife, die es seit Java 5 gibt und die intern einen Iterator
verwendet. Die
forEach
-Methode im Interface
Iterable
entspricht der
forEach
-Methode aus unserer
Utilities
-Klasse,
mit dem kleinen Unterschied, dass sie das erste Argument nicht braucht,
weil sie als nicht-statische Methode der Collection ohnehin über die
this
-Referenz
auf die Collection zugreifen kann.
Das Beispiel von oben sieht in Java 8 letztendlich
so aus:
List<Integer> numbers = new ArrayList<>(); ... populate list ...
numbers.forEach(
System
.out
::println
);
Die herkömmliche Art der Iterierung mit
einem expliziten Iterator bezeichnet man im Übrigen als
externe Iterierung
,
im Gegensatz zur
internen Iterierung
in der
forEach
-Methode
einer Collection (siehe /
GOF
/). Bei der externen Iterierung
wird der Iterator an den externen Benutzer einer Collection gegeben und
der Benutzer bestimmt, wie der den Iterator verwendet, um alle Elemente
der Sequenz zu besuchen. Bei der internen Iterierung bestimmt die Collection
selbst, wie sie in ihrer
forEach
-Methode
alle Elemente besucht. Das kann sie mit einem Iterator machen, so wie
wir es im Beispiel gesehen haben. Sie kann es aber auch ganz anders machen,
zum Beispiel parallel mit vielen Threads statt sequentiell mit nur einem
Thread.
Genau die parallele Ausführung von Operationen wie forEach sind der wesentliche Grund dafür, dass man die Sprache um Lambda-Ausdrücke erweitert hat. Eines der Ziele in Java 8 ist die bessere Unterstützung von Parallelverarbeitung. Deshalb wird es neue Abstraktionen im JDK-Collection-Framework geben, nämlich sogenannte Streams . Diese Streams haben Operationen wie forEach (oder auch sort , filter , etc.) mit interner Iterierung, die wahlweise sequentiell oder parallel ausgeführt werden können. Die Streams und ihr umfangreiches API werden wir uns in einem der nachfolgenden Beiträge im Detail ansehen. Funktionale Programmierung in Java
In dem oben geschilderten Beispiel der
internen Iterierung bzw. des Execute-Around-Method-Patterns sieht man typische
Elemente der funktionalen Programmierung. Beispielsweise sieht man das
"code-as-data"-Prinzip: die
forEach
-Methode
benötigt als Argument eine Funktion, die auf alle Elemente der Collection
angewandt werden soll. Es wird zwar streng genommen ein Objekt als Argument
übergeben, aber dieses Objekt repräsentiert Funktionalität. Das einzige,
was an dem Objekt interessant ist, ist die eine Methode, die es mitbringt
und die auf alle Elemente der Sequenz angewandt werden soll. In diesem
Sinne ist das
Consumer
-Argument der
forEach
-Methode
eine Funktion. In Java 7 muss dafür umständlich eine anonyme innere Klasse
definiert werden. In Java 8 mit den Lambda-Ausdrücken und Methoden-Referenzen
sieht die Funktionalität optisch und syntaktisch so aus, wie man sich
eine Funktion vorstellt
Target Typing und SAM Types
Sehen wir uns die Einbettung von Lambda-Ausdrücken
und Methoden-Referenzen ins Java-Typsystem anhand unseres Beispiels an.
Noch einmal das Beispiel von oben:
List<Integer> numbers = new ArrayList<>(); ... populate list ...
numbers.forEach(
System.out::println
);
Der Compiler geht prinzipiell so vor: er
schaut sich den Kontext an, in dem ein Lambda-Ausdruck oder eine Methoden-Referenz
steht, überlegt, welcher Typ von Objekt an dieser Stelle benötigt wird,
und deduziert daraus den Typ für den Lambda-Ausdruck oder die Methoden-Referenz.
In unserem Beispiel findet er die Methoden-Referenz
System.out::println
als Argument im Aufruf der
forEach
-Methode.
Der Compiler schaut sich also den deklarierten Argumenttyp der
forEach
-Methode
an. Weil es die
forEach
-Methode einer
List<Integer>
ist, stellt der Compiler fest, dass für den Methodenaufruf ein Objekt
vom Typ
Consumer<Integer>
benötigt
wird.
Consumer<Integer>
ist ein
Interface mit einer einzigen abstrakten Methoden, nämlich der
accept
-Methode.
Nun prüft der Compiler, ob die Signatur der
accept
-Methode
kompatibel zur Methoden-Referenz
System.out::println
ist. Die
accept
-Methode von
Consumer<Integer
>
nimmt ein Argument vom Typ
Integer
, gibt
void zurück und wirft keine checked-Exceptions. Das passt zu unserer
Methoden-Referenz
System.out::println
.
Die
println
-Methode ist überladen und
unter all den vielen
println
-Varianten
gibt es eine, die ein Argument vom Typ
Integer
nimmt,
void
zurück gibt und keine checked-Exceptions wirft. Das heißt, die
accept
-Methode
aus dem
Consumer<Integer>
-Interface
hat dieselbe Signatur wie die Methoden-Referenz
System.out::println
.
Der Compiler schließt daraus, dass die -Referenz
System.out::println
in
diesem Kontext vom Typ
Consumer<Integer>
ist.
Diesen Prozess der Deduktion des Typs eines
Lambda-Ausdrucks oder einer Methoden-Referenz wird als
Target Typing
bezeichnet, weil dabei der Zieltyp (Target Type) für den Ausdruck oder
die Referenz aus dem Kontext ermittelt wird. Für Lambda-Ausdrücke funktioniert
das Target Typing ganz analog.
Interfaces wie
Consumer
mit einer einzigen abstrakten Methode heißen übrigens
Functional Interface
Types
(oder auch
SAM Types
, wobei SAM für Single Abstract Method
steht). Die SAM-Typen spielen beim Target Typing eine wesentliche Rolle.
Sie sind nämlich die einzigen Typen, die als Zieltypen in Frage kommen.
Über diesen Trick mit den SAM-Typen und der Deduktion eines kontext-abhängigen Zieltyps konnte es vermieden werden, gravierend in das Typsystem von Java einzugreifen. Deshalb gibt es in Java - anders als in funktionalen Sprachen - keine spezielle Kategorie von Typen, mit denen man Funktionen oder Funktions-Signaturen beschreiben könnte. Seiteneffekte
Das Fehlen von echten Funktionstypen ist
aber nur eine Eigenart, die funktionale Programmierung in Java von funktionalen
Sprachen unterscheidet. In reinen funktionalen Sprachen sind die Funktionen
stets frei von Seiteneffekten. Insbesondere modifiziert eine reine Funktion
keine Daten, sondern produziert ein Ergebnis. Das ist in Java natürlich
anders. Es gibt in Java gar keine Möglichkeit, eine Funktion daran zu
hindern, Felder oder Variablen zu modifizieren.
In unserem Beispiel haben unsere Lambda-Ausdrücke
und Methoden-Referenzen zwar nichts modifiziert, aber einen Seiteneffekt,
nämlich die Ausgabe auf
System.out
,
haben sie dennoch produziert. Für diese Funktionen macht es einen Unterschied,
ob sie mehrfach aufgerufen werden oder in welche Reihenfolge sie aufgerufen
werden, denn es hat Einfluss auf die Ausgabe. Bei einer reinen Funktion,
die keinerlei Seiteneffekte hat, wäre es völlig egal, wie oft und in
welcher Reihenfolge sie ausgeführt wird. Eine reine Funktion wäre beispielsweise
folgender Lambda-Ausdruck:
IntPredicate isEven = (int
i) -> { return i%2==0; };
Dabei ist
IntPredicate
ein SAM-Typ aus dem Package
java.util.function
mit einer einzigen abstrakte Methode, die so aussieht:
boolean
test(int value)
.
Dieser Lambda-Ausdruck
(int
i) -> { return i%2==0; }
nimmt einen
int
-Wert
und liefert
true
zurück, wenn es eine
gerade Zahl ist, und andernfalls
false
.
Hier wird überhaupt kein Seiteneffekt ausgelöst. Es wird einfach nur
ein Wert genommen und ein Boolesches Ergebnis zurück geliefert. Diese
Funktion kann aufrufen werden, so oft man will und in jeder beliebigen
Reihenfolge. Es macht überhaupt keinen Unterschied.
Hier zum Kontrast ein Lambda-Ausdruck, der
Modifikationen macht:
List<Point> points = new ArrayList<>(); ... populate list ...
points.forEach(p -> { p.x
= 0; });
Hier werden alle Elemente der Sequenz modifiziert;
es sind
Point
-Objekte, deren x-Koordinate
in dem Lambda-Ausdruck geändert wird. Das ist im Sinne der funktionalen
Programmierung schlechter Stil, kann aber in Java nicht verhindert werden.
Wenn das Argument einer Funktion eine Referenz auf ein veränderliches
Objekt ist, dann kann die Funktion Modifikationen machen. Java hat einfach
keine Sprachmittel, um solche Modifikationen zu verhindern.
Solche Lambda-Ausdrücke sind u.U. problematisch.
Wir werden in nachfolgenden Beiträgen erläutern warum. Aber bereits
hier sollte schon klar sein, dass man mit modifizierenden Lambda-Ausdrücken
leicht Fehler machen kann. Hier ist eine solche Fehlersituation:
List<Point> points = new ArrayList<>(); ... populate list ...
points.forEach(p -> points.add(new
Point(0,p.y)));
In dem Lambda-Ausdruck werden während der Iterierung neue Elemente in die Collection eingefügt. Das scheitert zur Laufzeit mit einer ConcurrentModificationException . Zusammenfassung und Ausblick
Java 8 wird neue Sprachmittel haben, die
in gewissem Umfang funktionale Programmierung in Java unterstützen.
Die betreffenden Sprachmittel sind Lambda-Ausdrücke und Methoden-Referenzen.
Wir haben uns in diesem Beitrag das Execute-Around-Method-Pattern sowie
die Interne Iterierung als Spezialfall davon angesehen. Beides sind Idiome,
die mit den neuen Sprachmitteln profitieren. Mit Lambda-Ausdrücken und
Methoden-Referenzen sind sie wesentlich einfacher zu benutzen. In Java
8 werden die Collections interne Iterierung unterstützen. Genau für
diese Erweiterungen der Collections im JDK sind die neuen Sprachmittel
entwickelt worden.
Im nächsten Beitrag sehen wir und die
Lambda-Ausdrücke und Methoden-Referenzen genauer an.
Literaturverweise
Die gesamte Serie über Java 8:
|
||||||||||||||||||||||||||||||||
© Copyright 1995-2018 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/70.Java8.FunctionalProg/70.Java8.FunctionalProg.html> last update: 26 Oct 2018 |