|
|||||||||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | |||||||||||||||||||||||||
|
Effective Java
|
||||||||||||||||||||||||
Im März 2014
wurden mit Java 8 die neuen Sprachmittel der Lambda-Ausdrücke und Methodenreferenzen
freigegeben. Die Syntax dieser neuen Sprachmittel ist wahrscheinlich
vielen Java-Entwicklern mittlerweile geläufig. Jeder, der die Streams
aus Java 8 verwendet oder zumindest einmal ausprobiert hat, wird unweigerlich
Lambda-Ausdrücke oder Methodenreferenzen als Argumente an die Stream-Operationen
übergeben haben. Wir wollen uns in diesem Beitrag von der reinen Benutzung
der neuen Sprachmittel abwenden und uns einen anderen Aspekt der Lambda-Ausdrücke
ansehen, nämlich das Design von Schnittstellen, die Lambda-Ausdrücke
als Argumente entgegen nehmen. Neue Sprachmittel ermöglichen neue Ausdrucksmöglichkeiten
und neue Programmiertechniken. Wir wollen der Frage nachgehen: was
würde man
mit
Lambdas anders machen als
ohne
? Was lässt
sich mit den neuen Sprachmitteln besser oder eleganter ausdrücken, als
es früher ohne diese Sprachmittel möglich war?
API-Design mit Lambdas
Mit Lambda-Ausdrücken und Methodenreferenzen
lässt sich Funktionalität ausdrücken. Im Prinzip ist ein Lambda-Ausdruck
so etwas wie eine anonyme Methode, die keinen Namen hat und zu keiner Klasse
gehört, sondern einfach nur Argumente nimmt und damit etwas tut. Hier
ein Beispiel zur Illustration:
Comparator<String> lengthComparator
= (s1,s2)->Integer.compare(s1.length(),s2.length());
Der gezeigte Lambda-Ausdruck auf der rechten
Seite der Zuweisung implementiert das funktionale Interface
Comparator<String>
und beschreibt, wie man zwei Strings ihrer Länge nach vergleicht. (Der
Begriff "funktionales Interface" wird seit Java 8 verwendet für Interfaces
mit einer einzigen abstrakten Methode. Implementierungen solcher funktionalen
Interfaces können durch Lambda-Ausdrücke oder Methodenreferenzen ausgedrückt
werden.) Zu beachten ist, dass die Funktionalität, die der Lambda-Ausdruck
auf der rechten Seite der Zuweisung beschreibt, noch gar nicht ausgeführt
worden ist. Hier wird nichts verglichen, sondern nur spezifiziert, wie
der Vergleich gemacht werden soll, wenn er denn irgendwann einmal gebraucht
wird. Die Funktionalität, die der Lambda-Ausdruck formuliert, wird erst
ausgeführt, wenn die
compare()
-Methode
des
lengthComparator
s aufgerufen wird,
also zum Beispiel hier:
int result = lengthComparator.compare("abc","12345");
Jetzt wird die Funktionalität des Lambda-Ausdrucks
ausgeführt, die Längen der beiden Strings werden verglichen und es kommt
-1
raus.
Die Tatsache, dass ein Lambda-Ausdruck Funktionalität
nur beschreibt, aber nicht gleich ausführt, ist die entscheidende Eigenschaft,
die Lambda-Ausdrücke von allen bislang in Java vorhandenen Sprachmitteln
unterscheidet. Wie hat Cay Horstmann es in seinem Buch "Java SE 8 for
the Really Impatient" (/
CAY
/) so treffend ausgedrückt:
"
The point of all lambdas is deferred execution.
" Es geht darum,
Funktionalität zu beschreiben, die man jemandem gibt, der sie später
ausführt.
Genau diese Eigenschaft nutzen APIs, die
Lambdas als Argumente entgegen nehmen. Es wird getrennt zwischen dem
API-Benutzer, der Funktionalität mit Hilfe von Lambdas beschreibt und
an das API übergibt, und der API-Implementierung, die die Funktionalität
entgegen nimmt und ausführt. Es ist dann der API-Implementierung überlassen,
wann genau und wie die Funktionalität angestoßen wird. Je nach Situation
ruft die API-Implementierung die Funktionalität vielleicht mehrfach auf,
oder vielleicht auch gar nicht, oder nur unter bestimmten Bedingungen,
oder möglicherweise parallel in mehreren Threads oder später asynchron
in einem Hintergrunds-Thread oder sonst irgendwie. Der API-Benutzer stellt
lediglich die Funktionalität zur Verfügung und die API-Implementierung
kümmert sich um die Details der späteren Ausführung ("deferred execution")
der Funktionalität.
Schauen wir uns den oben gezeigten Comparator
noch einmal an. Wir haben mit dem Lambda-Ausdruck beschrieben, wie zwei
Strings ihrer Länge nach verglichen werden können. Wir selbst führen
diesen String-Vergleich nie aus, sondern definieren den Comparator nur,
um ihn an eine Abstraktion zu übergeben, die den String-Vergleich ausführen
will. Zum Beispiel könnte man den oben gezeigten Comparator zum Sortieren
eines String-Arrays verwenden.
String[] strings = {"Hückelhoven-Ratheim", "21345", "abc", "XYZ", "Lieschen Müller"};
Arrays.sort(strings,lengthComparator);
Die
sort()
-Methode
ruft intern die
compare()
-Methode des
lengthComparator
s
auf. Als Benutzer der
sort()
-Methode
spezifizieren wir lediglich die Vergleichsfunktionalität als Lambda-Ausdruck
und die
sort()
-Methode führt die Funktionalität
später mehrfach an geeigneter Stelle im Sortieralgorithmus aus. Das
ist ein Beispiel für "deferred execution".
Nun ist die
sort()-
Methode
keineswegs neu in Java und Comparatoren gibt es auch schon seit der Version
1.2 von Java. Neu in Java 8 ist lediglich, dass es jetzt eine
parallelSort()
-Methode
gibt, die den Comparator in mehreren Threads gleichzeitig aufruft. Neu
ist auch, dass man das
Comparator
-Interface
nicht mehr mit einer (anonymen) Klasse implementieren muss, sondern dass
man es mit einem Lambda-Ausdruck oder einer Methodenreferenz implementieren
kann. Die Idee, Funktionalität zu übergeben, ist als solche also nicht
neu; sie lässt sich aber mit den neuen Sprachmitteln leichter umsetzen.
Ganz allgemein gesprochen lassen sich mit
Lambda-Ausdrücken flexiblere und andersartige Schnittstellen gestalten,
die man ohne Lambda-Ausdrücke nicht gut hätte benutzen können. Deshalb
sind solche lambda-fähigen APIs früher nicht in nennenswerter Zahl entstanden.
Jetzt, mit den neuen Sprachmittel, lassen sich manche Dinge leichter umsetzen.
Deshalb findet man im JDK 8 zahlreiche neue Schnittstellen, die Lambda-Ausdrücke
(d.h. Funktionalität) als Argument entgegen nehmen. Wir wollen im Folgenden
einige der Ideen anschauen mit dem Ziel, sich als Entwickler von den neuen
JDK-Schnittstellen für das Design eigener lambda-fähiger Schnittstellen
inspirieren zu lassen.
Wir wollen uns die unterschiedlichen API-Ideen ansehen und klassifizieren die Ansätze wie folgt: • Strategies & Policies • Suppliers & Factories • Handlers & Reactions
•
Pluggable
Abstractions
Strategies & Policies
Das Strategy- oder Policy-Pattern (siehe
/
GOF
/) haben wir oben schon am Beispiel des Sortierens
gesehen. Es geht darum, eine Operation wie z.B.
sort()
so zu flexibilisieren, dass sie viele verschiedene Strategien verwenden
kann, statt nur eine einzige fest verdrahteten Strategie zu benutzen.
Betrachten wir das Beispiel einer beliebigen
Klasse und schauen uns daran an, wie man sie mit einem flexiblen, lambda-fähigen
API ausstatten würde. Die Klasse heißt
Box
und ist äußerst simpel; sie enthält nur einen einzigen Wert. Diesen
enthaltenen Wert möchte man möglicherweise auf eine Eigenschaft hin untersuchen.
Die Eigenschaft könnte man starr festlegen. Nehmen wir einmal an, wir
wollen prüfen, ob die Box eine Zeichenkette mit weniger als drei Zeichen
enthält. Das würde so aussehen:
class Box<V> { private V value; ... public boolean holdsCharSeqShorterThanThreeChars() { return (value instanceof CharSequence) ? ((CharSequence)value).length() < 3 : false; }
}
Hier ist die Eigenschaft, die abgeprüft
wird, bis ins Letzte festgelegt. Vielleicht möchte man es flexibler
gestalten. Eine traditionelle Art der Flexibilisierung wäre die Parametrisierung
der Prüfmethode zum Beispiel mit der Länge, die zur Prüfung verwendet
werden soll. Also so:
class Box<V> { ... public boolean holdsCharSeqShorterThan(int length ) { return (value instanceof CharSequence) ? ((CharSequence)value).length() < length : false; }
}
Jetzt können wir auf unterschiedliche Stringlängen
abprüfen. Aber die Prüfung ist immer noch sehr starr. Sie funktioniert
nur für Zeichenketten (d.h. Subtypen von
CharSequence
)
und sie prüft eine ganz bestimmte Eigenschaft, nämlich die Länge.
Wenn man die Prüfmethode so gestalten würde, dass sie sich die zu prüfende
Eigenschaft als Argument geben ließe, dann wäre die Prüfung völlig
beliebig und wir hätten maximale Flexibilität erreicht. Das könnte
so aussehen:
class Box<V> { ... public boolean holdsValueWith( Predicate<? super V> property ) { return property.test (value); }
}
Dabei ist
Predicate
ein vordefiniertes funktionales Interface aus dem Package
java.util.function
des JDK 8.
Jetzt kann man in der Box prüfen, was man
will. Zum Beispiel:
Box<String> box = new Box<>("10001010"); boolean hasProperty1 = box.holdsValueWith(cs -> cs.length() < 3);
boolean hasProperty2 = box.holdsValueWith(cs -> cs.charAt(0)
== '1');
Box<BigInteger> bigBox = new Box<>(new BigInteger("10010101"));
boolean hasProperty3 = bigBox.holdsValueWith(b -> b.equals(BigInteger.ZERO));
Nun ist es dem Benutzer der
Box
-Schnittstelle
überlassen, welche Eigenschaft er prüfen möchte. Er übergibt die
Prüffunktionalität als Argument an die Prüfmethode und mit Hilfe der
neuen Sprachmittel in Java 8 lässt sich die Prüffunktionalität knapp
und kurz mit einem Lambda-Ausdruck formulieren. Solche Schnittstellen
hätte man früher (ohne Lambdas) auch anbieten können; das hat man teilweise
auch getan, aber jetzt mit den Lambda-Ausdrücken ist die Benutzung deutlich
übersichtlicher und lesbarer.
Im JDK 8 gibt es viele sowohl neue als auch
alte Beispiele für Schnittstellen, die Strategien als Argumente entgegen
nehmen. Zum Beispiel:
•
Arrays.sort(T[],Comparator<T>)
oder
Stream.sorted(Comparator<T>)
•
Stream
<T>
.filter(Predicate
<T>
)
•
Collection
<T>
.removeIf(Predicate
<T>
)
•
Collections.min(
Collection<T>,
Comparator
<T>
)
•
File.list(FilenameFilter)
•
...
Die Liste lässt sich beliebig lang fortsetzen.
Praktisch alle Situationen, in denen das Strategy-Pattern Anwendung findet,
sind als Schnittstellen gemacht, an denen Funktionalität übergeben wird.
Diese Funktionalität kann der Benutzer der Schnittstelle seit Java 8 bequem
als Lambda-Ausdruck oder Methodenreferenz ausdrücken.
Die Funktionalität, die an ein API übergeben wird, muss aber nicht immer eine Strategy oder Policy sein. Es gibt eine Reihe anderer Anwendungsfälle. Schauen wir uns als Nächstes Schnittstellen an, die Funktionalität für das Erzeugen von Objekten - sogenannte Suppliers oder Factories - entgegen nehmen. Suppliers & FactoriesBetrachten wir wieder unsere Box -Klasse. Einen Konstruktor dafür würde man normalerweise so implementieren, dass er den Initialwert für den enthaltenen Wert als Argument nimmt, d.h. so:class Box<V> { private V value; ... public Box(V value) { this.value = value; }
}
Nun könnte es ja sein, dass der Initialwert
in manchen Situationen nie gebraucht wird und dass die Beschaffung des
Initialwerts in irgendeiner Form teuer ist, so dass man die Initialisierung
vielleicht nur dann bzw. erst dann machen möchte, wenn sie wirklich erforderlich
ist. Mit anderen Worten, es soll eine Lazy Initialization gemacht werden.
Man könnte es dadurch erreichen, dass sich der Konstruktor vom Benutzer
nicht den Initialwert als Argument geben lässt, sondern nur die Funktionalität,
um den Initialwert zu erzeugen. Dann kann die
Box
-Klasse
selbst entscheiden, ob und wann sie den Initialwert erzeugen will. Das
könnte so aussehen:
class Box<V> { private V value; private Supplier<? extends V> valueProvider; ... public Box(Supplier<? extends V> valueProvider) { this.valueProvider = valueProvider; } public V getValue() { if (value == null) value = valueProvider.get(); return value; }
}
Dabei ist
Supplier
ein vordefiniertes
funktionales Interface aus dem Package
java.util.function
des JDK 8.
Jetzt sagt der Benutzer nicht mehr:
Box<BigInteger> bigBox = new Box<>(new BigInteger("10010101"));
sondern:
Box<BigInteger> bigBox = new Box<>(
()
->
new BigInteger("10010101"));
Syntaktisch und von der Lesbarkeit her ist
der Unterschied für den Benutzer minimal. Von der Performance her kann
die verzögerte Initialisierung unter Umständen jedoch einen großen Unterschied
machen. Dieses Beispiel der verzögerten Initialisierung illustriert
sehr schön das Prinzip der "deferred execution". Die Funktionalität
für das Erzeugen des Initialwerts wird nicht sofort verwendet, sondern
zunächst einmal nur abgelegt und erst später, wenn der Initialwert wirklich
gebraucht wird, ausgeführt.
Auch für diese Art von Schnittstellen, die
Factories oder Suppliers entgegen nehmen, gibt es zahlreiche alte und neue
Beispiele im JDK 8:
•
ThreadPoolExecutor.setThreadFactory(ThreadFactory
factory
)
•
Map<K,V>.computeIfAbsent(K
key, Function<K,V> mapper)
•
ThreadLocal
.withInitial(Supplier<S>
supplier)
•
Collectors.toCollection(Supplier<C>
collectionFactory)
•
Optional
<T>
.orElse
Get
(Supplier<
T
>
value
Supplier)
•
...
Auch hier ließe sich die Liste beliebig
fortsetzen. Allen Beispielen ist gemeinsam, dass sie Funktionalität
für das Erzeugen von Objekten entgegen nehmen, die sie aber erst später
und/oder unter gewissen Bedingungen anstoßen. Der Benutzer übergibt
nicht mehr den Wert, der ja möglicherweise gar nicht gebraucht wird, sondern
nur noch die Funktionalität für die Erzeugung des Werts.
Besonders schön sieht man das Prinzip der
"deferred execution" am Beispiel der Klasse
Optional
.
Ein
Optional
wird als optionales Ergebnis
von Stream-Operationen wie
min()
und
max()
zurück geliefert. Wenn der Stream leer ist, dann ist auch das
Optional
leer; andernfalls enthält es das kleinste bzw. größte Stream-Element.
Wenn man von einem leeren
Optional
mit
get()
den nicht vorhandenen Wert abholen will, dann wird eine
NoSuchElementException
-
Exception ausgelöst.
Wenn man anstelle der Exception einen Ersatzwert haben möchte, dann kann
man die Methoden
orElse(T other)
und
orEls
eGet(Supplier<?
extend T> other
) verwenden. Beide Methoden sorgen dafür, dass ein
leeres
Optional
einen Ersatzwert liefert,
statt eine Exception zu werfen. Sie werden wie folgt verwendet:
Stream<String> emptyStream = …; Optional<String> optionalMin = emptyStream.min(String::compareTo); String min = optionalMin.get(); // wirft NoSuchElementException String min = optionalMin.orElse(""); // liefert ""
String min = optionalMin.orElseGet(()->readFrom(System.in));
//
liefert Ersatzwert
durch Einlesen
Die Methode
orElse()
bekommt den Ersatzwert als Aufrufargument der Methode übergeben; im Gegensatz
dazu erhält die Methode
orElseGet()
nur
die Funktionalität für das Beschaffen des Ersatzwerts, nicht den Ersatzwert
selbst. Das hat den Vorteil, dass der Ersatzwert dann und nur dann berechnet
wird, wenn er auch tatsächlich gebraucht wird. Wenn es aufwändig ist,
den Ersatzwert zu beschaffen, dann kann
orElseGet()
günstiger sein. Umgekehrt wird sicherlich die Methode
orElse()
günstiger sein, wenn das Berechnen des Ersatzwerts keinen Aufwand erfordert,
denn die zusätzliche Indirektion über den Supplier kostet natürlich
auch Zeit.
Man sieht am Beispiel dieser beiden Methoden aus dem API der Klasse Optional , welche zusätzlichen Möglichkeiten sich beim API-Design eröffnen: der API-Designer kann dem API-Benutzer die "deferred execution" als Alternative zu herkömmlichen Schnittstellen anbieten und damit Optimierungen erlauben. Handlers & Reactions
Eine andere Kategorie von Funktionalität,
die an Schnittstellen zum Zwecke der späteren Ausführung übergeben wird,
sind sogenannte Handler, die nur unter gewissen Umständen ausgeführt
werden. Ein klassisches Beispiel sind Error Handler für die Fehlerbehandlung.
Hier steht im Vordergrund, dass die übergebene Funktionalität nur ausgeführt
wird, wenn ein bestimmtes Ereignis (z.B. eine Fehlersituation) eintritt.
Schauen wir es uns wieder am Beispiel unserer
Box
-Klasse
an. Nehmen wir mal an, dass der enthaltene Wert eine bestimmte Eigenschaft
haben muss, damit er als gültig angesehen wird. Um die Gültigkeit zu
prüfen, gibt es eine
is
Legal
()
-Methode.
Es stellt sich die Frage, was getan werden soll, wenn der Wert als ungültig
erkannt wird. Soll dann eine Exception ausgelöst werden? Oder soll
ein gültiger Ersatzwert verwendet werden? Oder ein ungültiger Ersatzwert,
der als solcher zu erkennen ist, z.B. eine
null
-Referenz?
Oft sind Implementierungen so, dass sie eine dieser Reaktionen fest verdrahtet
haben, wie zum Beispiel in dieser Fassung unserer
Box
-Klasse:
class Box<V> { private V value; private boolean isLegal(V v) { … } ... public void setValue(V v) { if (isLegal(v)) value = v; else value = null; }
}
Wenn der neue Wert für die Box ungültig
ist, dann ignorieren wir ihn und ersetzen ihn stillschweigend durch eine
null
-Referenz.
Die Reaktion auf den ungültigen Wert könnte aber auch konfigurierbar
sein, indem ein Handler verwendet wird, der weiß, was im Falle eines ungültigen
Wertes zu tun ist. Das könnte so aussehen:
class Box<V> { private V value; private boolean isLegal(V v) { … }
private UnaryOperator<V> illegalValueHandler
= v->{ throw new IllegalValueException(); };
public void setIllegalValueHandler(UnaryOperator<V> handler) { illegalValueHandler = handler; } ... public void setValue(V v) { if (isLegal(v)) value = v; else value = illegalValueHandler.apply(v); }
}
Die
Box
-Klasse
hat einen Handler für den Fall, dass ein ungültiger Wert erkannt wird.
Der Default-Handler wirft eine
IllegalValueException
.
Dieser Default-Handler kann aber vom Benutzer ersetzt werden durch eine
andere Fehlerbehandlungsfunktionalität, wie zum Beispiel hier:
Box<BigInteger> b = … ;
b.setIllegalValueHandler(v->BigInteger.ZERO);
Der neue Handler wirft keine Exception mehr,
sondern verwendet als Ersatz für den ungültigen Wert den gültigen Wert
BigInteger.ZERO
.
Ein Handler muss dabei nicht zwingend für
die Fehlerbehandlung gedacht sein. Es gibt auch andere Ereignisse, für
deren Behandlung man Funktionalität bereitstellen möchte. Ein klassisches
Beispiel ist die GUI-Programmierung, wo Handler verwendet werden, die auf
ein bestimmtes GUI-Event (z.B. Button-Click) reagieren. Weitere Beispiele
gibt es in der Multithread-Programmierung. Dort werden häufig Callback-Funktionen
verwendet, um asynchron produzierte Daten zu verarbeiten. Es gibt ganz
unterschiedliche Anwendungsfälle für Handler. Hier einige alte und
neue Beispiele aus dem JDK 8:
•
Thread.setUncaughtExceptionHandler(UncaughtExceptionHandler
handler)
•
ThreadPoolExecutor.setRejectedExecutionHandler(RejectedExecutionHander
handler)
•
Map<K,V>.merge(K
key, V value, BiFunction<V,V,V> remappingFunction)
•
Button.
addActionListener(ActionListener
l
istener
)
•
CompletableFuture<T>
.thenAccept
(Consumer<?
super T>
action
)
•
Optional<T>.ifPresent(Consumer<?
super T> consumer)
•
...
Auch diese Liste ließe sich fortsetzen.
Funktionalität für die Behandlung von Ausnahmesituationen ist schon immer
in Java an entsprechende Schnittstellen übergeben worden. In Java 8
ist neu, dass es eine zunehmende Zahl von Schnittstellen gibt, an denen
man Reaktionen auf ein bestimmtes Ereignis oder einen bestimmten Zustand
übergeben kann.
CompletableFuture
und
Optional
sind Beispiele dafür
Ein Future dient der Übergabe eines Ergebnisses von einem Thread, der
das Ergebnis produziert hat, an einen anderen Thread, der das Ergebnis
verarbeiten möchte. Traditionell musste der empfangende Thread sich
das Ergebnis aktiv besorgen, nämlich so:
Future<Result> future = ... ; Result result = future.get();
processResult(result);
Der Thread muss aktiv mit
get()
nach dem Ergebnis fragen und ggf. warten, denn die
get()
-Methode
blockiert, solange bis sie mit dem Ergebnis zurückkommt. Erst danach
kann das Ergebnis verarbeitet werden. In Java 8 hingegen kann der empfangende
Thread mit Hilfe von
CompletableFuture
die Reaktion auf das Ergebnis spezifizieren, lange bevor das Ergebnis überhaupt
fertig ist. Das geht z.B. so:
CompletableFuture<Result> future = ... ;
future.thenAccept(result->processResult(result));
Die Verarbeitung des Ergebnisses wird als
Lambda-Ausdruck übergeben und genau dann ausgeführt, wenn das Ergebnis
fertig ist. Der empfangende Thread muss nicht mehr aktiv warten, bis das
Ergebnis da ist. Stattdessen spezifiziert er nur noch die Reaktion auf
das Ergebnis in Form der Verarbeitungsfunktionalität und dann kann der
Thread ohne zu warten sofort etwas anderes tun. Die Verarbeitung des
Ergebnisses erfolgt asynchron in dem Thread, der das Ergebnis fabriziert
hat. Wer sich genauer für CompletableFuture interessiert, kann die Details
in einem unserer früheren Beiträge (siehe /
KRE
/) nachlesen.
Das
Optional
ist ähnlich. Wie oben schon erwähnt, wird
Optional
als optionales Ergebnis von Stream-Operationen wie
min()
und
max()
zurück geliefert. Wenn der
Stream leer ist, dann ist auch das
Optional
leer; andernfalls enthält es das kleinste bzw. größte Stream-Element.
Man kann sich aktiv nach dem Ergebnis erkundigen, z.B. so:
Optional<Result> result = stream.min(); if (result. isPresent ())
System.out.print(result.
get
());
Das
Optional
hat zusätzlich eine reaktive Schnittstelle, an der der Benutzer die Reaktion
auf das Vorhandensein (oder auch das Fehlen) des Ergebnisses als Funktionalität
übergeben kann. Das könnte dann so aussehen:
Optional<Result> result = stream.min();
result.
ifPresent
(System.out::print);
Sowohl beim Future als auch beim Optional
hat der Benutzer die Auswahl zwischen Aktion und Reaktion: er kann aktiv
Zustände abfragen und Ergebnisse abholen (und dabei in Kauf nehmen, dass
er ggf. warten muss) oder er kann reaktiv vorgehen. Dann formuliert er
lediglich die Reaktion und überlässt das Auswerten von Zuständen und
das Abholen und Verarbeiten von Ergebnissen der Schnittstelle, an der er
seine vorbereitete Reaktionsfunktionalität übergeben hat.
Allen oben erwähnten Schnittstellen ist gemeinsam, dass sie Funktionalität (Handler oder Reaktionen) entgegen nehmen, die sie in bestimmten Situationen oder beim Eintreten gewisser Ereignisse anstoßen. Das Ereignis kann eine Ausnahmesituation sein oder aber auch eine wünschenswerte "normale" Situation, die irgendwann eintritt. In allen Fällen ermöglichen Lambda-Ausdrücke und Methodenreferenzen eine übersichtliche Verwendung solcher Schnittstellen. Pluggable Abstractions
Da sich mit Lambda-Ausdrücken und Methodenreferenzen
Funktionalität gut lesbar ausdrücken lässt, kann man sogar so weit gehen,
dass man Klassen ganz anders implementiert. Wenn zum Beispiel ein Interface
implementiert werden soll, dann hat man es traditionell in Java so gemacht,
dass man eine oder mehrere Klassen schreibt, die von dem Interface abgeleitet
sind und die abstrakten Interface-Methoden implementieren. Wenn man unterschiedliche
Implementierungen haben will, dann muss man mehrere implementierende Klassen
schreiben. Zum Beispiel sieht das traditionelle Vorgehen so aus:
interface Abstraction<A,R> { R method1(A arg); R method2(A arg);
}
class Implementation1<A,R> implements Abstraction<A,R> { public R method1(A arg) { ... } public R method2(A arg) { ... }
}
class Implementation2<A,R> implements Abstraction<A,R> { public R method1(A arg) { ... } public R method2(A arg) { ... }
}
Mit Hilfe der neuen Sprachmittel könnte
man unterschiedliche Implementierungen des Interfaces mit nur einer einzigen
implementierenden Klasse bauen, indem die Implementierungen der abstrakten
Methoden einfach durch Komposition zusammengestellt werden. Ein solcher
Kompositionsansatz könnte beispielsweise so aussehen:
class Implementation<A,R> implements Abstraction<A,R> { public static <S,T> Implementation<S,T> of (Function<S,T> f1, Function<S,T> f2) { return new Implementation<S,T>(f1,f2); } private final Function<A,R> f1, f2; private Implementation(Function<A,R> f1, Function<A,R> f2) { this.f1=f1; this.f2=f2; } public R method1(A arg) { return f1.apply(arg); }
public R method2(A arg) { return f2.apply(arg);
}
}
Die Klasse
Implementation
hat eine Factory-Methode
of()
, der zwei
Funktionen übergeben werden. Diese beiden Funktionen liefern die Implementierungen
der beiden abstrakten Interface-Methoden. Mit diesem Ansatz können nun
beliebige Implementierungen des Interfaces per Komposition gebaut werden,
ohne dass weitere implementierende Klassen gebraucht werden.
Implementation<String,Long> impl1 = Implementation.of (a->{… functionality for method m1() …}, b->{… functionality for method m2() …}); Long r1 = impl1.method1("abc");
Long r2 = impl1.method2("xyz");
Implementation<String,Long> impl2 = Implementation.of (a->{… other functionality for method m1() …}, b->{… other functionality for method m2() …}); Long r3 = impl2.method1("abc");
Long r4 = impl2.method2("xyz");
Das Beispiel sieht vielleicht etwas abstrakt
und esoterisch aus, aber auch für diese Vorgehensweise gibt es praktische
Beispiele im JDK. Ein solches Beispiel ist das Interface
Collector
aus dem Package
java.util.stream
. Ein
Kollektor wird gebraucht, wenn man alle Elemente eines Streams mit der
Stream-Operation
collect()
in einem Zielcontainer
aufsammeln will. Die Stream-Operation
collect()
nimmt
als Argument eine Implementierung des
Collector
-Interfaces.
Das Interface hat fünf abstrakte Methoden und natürlich könnte man hingehen
und einen Kollektor bauen, indem man vom
Collector
-Interface
ableitet und die fünf abstrakten Methoden überschreibt. Als Alternative
bietet das
Collector
-Interface eine Factory-Methode
of()
an. Dieser Factory-Methode werden die Implementierungen der fünf abstrakten
Interface-Methoden übergeben. Hier zur Illustration ein Auszug aus dem
Collector
-Interface:
public interface Collector<T,A,R> { Supplier<A> supplier(); BiConsumer<A,T> accumulator(); BinaryOperator<A> combiner(); Function<A,R> finisher(); Set<Characteristics> characteristics(); … public static<T,A,R> Collector<T,A,R> of(Supplier<A> supplier, BiConsumer<A,T> accumulator, BinaryOperator<A> combiner, Function<A,R> finisher, Characteristics... characteristics) { return new Collectors.CollectorImpl<> (supplier, accumulator, combiner, finisher, cs); }
}
Das
Collector
-Interface
hat fünf abstrakte Methoden und die
of()
-Methode
nimmt genau fünf funktionale Argumente, nämlich exakt die fünf Funktionalitäten
für die Implementierung der fünf abstrakten Methoden. Hier ist ein
Beispiel für einen Kollektor, der mit der
of()
-Methode
erzeugt wird; er fügt einen Stream von Strings zu einem einzigen String
zusammen:
String concatenate(Stream<String> strings) { return strings.collect(Collector.of(StringBuilder::new, StringBuilder::append, StringBuilder::append, StringBuilder::toString) );
}
Man kann auf einen Blick sehen, dass der Kollektor - einen StringBuilder verwendet, - die Strings mit append() aneinanderfügt und - den StringBuilder am Ende mit toString() in einen String verwandelt.
Eine herkömmliche Implementierung des Kollektors durch
Ableiten vom
Collector
-Interface und
Überschreiben der abstrakten Interface-Methoden wäre sicherlich weit
weniger übersichtlich geworden.
Das Beispiel illustriert, dass die neuen Sprachmittel der Lambda-Ausdrücke und Methodenreferenzen in der Tat zu neuen Implementierungstechniken und alternativen API-Designs einladen, die zur Verständlichkeit und Übersichtlichkeit des Java-Source-Codes beitragen können. Zusammenfassung
Mit Lambda-Ausdrücken und Methodenreferenzen
lässt sich Funktionalität lesbar und übersichtlich formulieren. Solche
neuen Sprachmittel erlauben neue Ausdrucksmöglichkeiten. Entsprechend
bietet es sich an, Schnittstellen zu entwerfen, die Funktionalität in
Form von Lambda-Ausdrücken und Methodenreferenzen entgegen nehmen. Der
wesentliche Unterschied zu herkömmlichen Schnittstellen besteht darin,
dass die Funktionalität erst einmal nur formuliert und übergeben, aber
nicht sofort ausgeführt wird. Vielmehr entscheidet der Empfänger der
Funktionalität, wann und wie die Funktionalität angestoßen wird ("deferred
execution").
Zur Ausführung kommt die Funktionalität - wenn ein bestimmtes Ereignis eintritt (z.B. Fehlerbehandlung oder Ergebnisverarbeitung) - wenn eine bestimmte Situation vorliegt (z.B. Map.ifAbsent()) - mehrfach (z.B. Stream.forEach()) - gar nicht bzw. nur wenn erforderlich (Optional.ifPresent()) - synchron (z.B Arrays.sort()) - asynchron in einem anderen Thread (z.B. Executor.execute()) - parallel in mehreren anderen Threads (z.B. Arrays.parallelSort())
-
usw.
Wir haben einige Beispiele für solche Schnittstellen
aus dem JDK gesehen und es ist zu erwarten, dass in modernem Java ab Version
8 noch viele weitere lambda-fähige APIs entstehen werden.
Literaturverweise
Die gesamte Serie über Java 8:
|
|||||||||||||||||||||||||
© Copyright 1995-2018 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/86.Java8.API-Design-With-Lambdas/86.Java8.API-Design-With-Lambdas.html> last update: 26 Oct 2018 |