|
|||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | |||||||||||||||||||
|
Effective Java
|
||||||||||||||||||
In unserer kleinen Reihe über das Stream API von Java 8 haben wir bereits erläutert, was Streams überhaupt sind (siehe / KRE4 /), wie man sie erzeugt und welche Operationen sie haben (siehe / KRE5 /). Wir haben gesehen, dass jeder Stream-Operation in der Regel eine Funktion übergeben wird, die wir üblicherweise als Lambda-Ausdruck oder Methoden-Referenz ausdrücken. Diesmal wollen wir uns mit der Stream-Operation collect() befassen, die auf den ersten Blick recht unscheinbar aussieht, jedoch zusammen mit ihrem Parameter vom Typ Collector umfangreiche Funktionalität bietet. Collectors und collect()
Die
collec
t()
Methode dient dazu, die Elemente aus einem Stream aufzusammeln, z.B. in
einer Collection. Die
collect
-Operation
ist eine terminale Operation. Ihre Vielseitigkeit liegt in der Fülle
von Kollektoren, die an sie übergeben werden können. Das heißt, im Wesentlichen
verrichten die Kollektoren die Arbeit, nicht so sehr die
collect()
Methode selbst. Prinzipiell kann man Kollektoren selber bauen, indem
man das
Collector
-Interface implementiert.
Es gibt aber bereits mehr als dreißig vordefinierten Kollektoren. Die
Factory-Methoden für diese vordefinierten Kollektoren findet man in der
Klasse
Collectors
. Im Folgenden wollen
wir sie uns genauer ansehen.
String-Kollektor
So gibt es zum Beispiel Kollektoren, die
Zeichenketten (d.h. Subtypen von
CharSequence
wie
String
,
StringBuilder
,
StringBuffer
,
etc.) zu einem String konkatenieren. Die Ergebnis-Collection ist ein
String. Den betreffenden Kollektor erhält man über die Factory-Methode
joining
der Klasse
Collectors
. Hier ist ein
Beispiel:
List<String> streamSource = …; String resultString = streamSource.stream()
.collect(Collectors.
joining()
);
Wir haben mit Hilfe von der
collect
-Operation
und dem
joining
-Kollektor alle Elemente
aus einer String-Liste zu einem einzigen String zusammengesetzt. Eine überladene
Version der
joining
-Factory-Methode erlaubt
es, als Parameter noch einen Separator mitgeben. Damit kann man zum Beispiel
eine kommagetrennte Auflistung der Streamelemente als String produzieren.
Eine weitere überladene Version ermöglicht es, zusätzlich zum Separator
ein Präfix und ein Suffix mitzugeben.
Dies ist die einfachste und performanteste
Möglichkeit, Streams von Zeichenketten zu konkatenieren, auch wenn es
Alternativen gibt, die zum gleichen Resultat führen. Schauen wir uns
deshalb der Vollständigkeit halber auch noch kurz die Alternativen an.
Die erste Alternative basiert auf der
reduce
–Stream-Operation
auf Basis des Operators+ für
String
s:
List<String> streamSource = …; String resultString = streamSource.stream()
.reduce("",
(s1,s2)
-> s1+s2
);
Diese Lösung sieht recht elegant aus und
liefert auch das richtige Ergebnis. Sie ist aber sehr unperformant, weil
beim
reduce
für
jedes Streamelement ein temporäres
String
-Objekt
erzeugt werden muss. Bei einem Benchmark mit einem Stream, der 20.000 Elemente
enthielt, brauchte diese Lösung etwas 1.200 mal länger als die Lösung
mit dem
joining
-Collector. Man sieht:
die alte Regel, den Operator+ für
String
s
aus Performancegründen besser nicht zu verwenden, gilt hier auch.
Eine weitere Alternative sieht so aus:
List<String> streamSource = …; StringBuilder sb = new StringBuilder(); streamSource.stream().forEach( s -> sb.append(s) );
String resultString = sb.toString();
Kritisch an dieser Lösung ist, dass das
Lambda, das wir als Parameter an
fo
r
Each()
übergeben,
stateful
ist. In dem obigen Beispiel ist es noch kein
wirkliches Problem, weil wir einen sequentiellen (und keinen parallelen)
Stream verwenden. Grundsätzlich sollte man sich bemühen, Stream-Operationen
so zu nutzen, dass sie für sequentielle und parallele Streams gleichermaßen
funktionieren. Immerhin könnten Kollegen auf die Idee kommen,
parallelStream()
statt
stream()
auf der
streamSource
aufzurufen, weil sie sich von der Parallelisierung eine Performancesteigerung
versprechen. Das ist dann natürlich ein kapitaler Fehler, da der
StringBuilder
sb
, in dem wir die Strings aufsammeln, nicht thread-sicher ist und
somit der konkurrierende Zugriff nicht problemlos funktioniert.
Es bleibt dann nur die Möglichkeit, ein
sb
vom Type
StringBuffer
zu verwenden, bei
dem die
append()
Methoden
synchronized
sind:
List<String> streamSource = …; StringBuffer sb = new StringBuffer() ; streamSource. parallelStream() .forEach (s -> sb.append(s));
String resultString =
sb.toString();
Bei dieser Lösung ist wegen der Synchronisation
die Performance nicht so gut. Bei einem Benchmark auf einem Zwei-Core-Prozessor
mit einem parallelen Stream, der 20.000 Elemente enthielt, brauchte diese
Lösung etwa 5,2 mal länger als die Lösung mit einem
joining
-Collector.
Zusätzlich muss man bei dieser Lösung damit leben, dass die
String
s
aus dem Stream nicht in ihrer Originalreihenfolgen im
resultString
stehen, da sie konkurrierend von mehreren Threads an den
StringBuffer
angehängt werden.
Noch eine letzte Anmerkungen zu dem Beispiel: Grundsätzlich ist es angebracht, eine gesunde Skepsis gegenüber Lösungen zu haben, die stateful Lambdas nutzten. Wir werden dieses Thema ausführlicher im nächsten Artikel diskutieren. Da geht es dann um die Problematik von Seiteneffekte bei Lambdas, die man an Stream-Operationen übergibt. Collection-Kollektoren
Wie in der Einleitung schon kurz erwähnt,
gibt es Kollektoren, die die Stream-Elemente in einer Collection ablegen.
Hier ist ein Beispiel:
List<String> streamSource = …; Set<String> resultSet = streamSource.parallelStream() .filter(w->w.length() > 0) .filter(w->Character.isUpperCase(w.charAt(0)))
.collect(Collectors.
toSet()
);
In diesem Beispiel werden alle Strings, die
mit einem Großbuchstaben beginnen, in einer Ergebnis-Collection aufgesammelt.
Den Kollektor haben wir mit der Factory-Method
Collectors
.
toSet
besorgt. Er legt die Elemente in einem Set ab. Wenn man das Programm
ablaufen lässt, stellt man fest, dass der Set in diesem Fall ein
HashSet
ist, das heißt, die String sind unsortiert. Wenn man wegen der Sortierung
einen
TreeSet
haben will, dann muss einen
anderen Kollektor verwenden, nämlich
toCollection
.
Hier ist die Alternative:
List<String> streamSource = …; Set<String> resultSet = streamSource.parallelStream() .filter(w->w.length() > 0) .filter(w->Character.isUpperCase(w.charAt(0)))
.collect(Collectors.
toCollection(TreeSet::new)
);
Die Factory-Methode
toCollection
braucht eine Funktion, die die zu verwendende Collection liefert. Wir
haben die Konstruktor-Referenz
TreeSet::new
mitgegeben, das heißt, die Stream-Elemente werden in einem neu erzeugten
TreeSet
abgelegt.
Neben den oben gezeigten Kollektoren gehört
zu den Collection-Kollektoren noch der von
Collectors.toList()
erzeugte Kollektor, der die Elemente in eine List aufsammelt.
Wir wollen an dieser Stelle explizit darauf hinweisen, dass die Collection-Kollektoren (wie alle anderen Kollektoren) natürlich auch mit parallelen Streams funktionieren, obwohl die JDK Collections (z.B. TreeSet ) bekanntermaßen nicht thread-sicher sind. Auf die Hintergründe wollen wir jetzt nicht weiter eingehen. Wir schauen sie uns in einem zukünftigen Artikel über die Interna von parallelen Streams genauer an. Hier machen wir mit den vordefinierten Kollektoren aus Collections weiter. Map-Kollektor
Etwas komplizierter ist das Aufsammeln
von Stream-Elementen in Maps. Um eine Map als Ergebnis-Collection zu
erzeugen, werden im einfachsten Fall ein Key-Generator und ein Value-Generator
gebraucht. Hier ist ein Beispiel:
Collection<Person> people = …; Map<Integer, String> idToName = people.stream()
.collect(Collectors.
toMap(Person::getTIN
,
Person::getName)
);
Aus einer Sequenz von
Person
-Objekten
wird eine Map erzeugt, die als Schlüssel einen Identifikator (z.B. die
TIN = Taxpayer Identification Number) und als assoziierten Wert den Namen
der Person enthält. Als Key- und Value-Generator dienen Methoden der
Klasse
Person
. Hier ist noch ein Beispiel:
Collection<Person> people = …; Map<Integer, Person> idToPerson = people.stream() .collect(Collectors. toMap(Person::get TIN , Function.identity()) );
Als Schlüssel verwenden wir wieder die Steuernummer
und als assoziierter Wert wird das
Person
-Objekt
selbst eingetragen. Als Value-Generator benötigen wir deshalb eine Funktion,
die ein
Person
-Objekt aus der Sequenz
bekommt und es 1:1 wieder zurückgibt. Diese Funktionalität der Selbstabbildung
könnten wir als Lambda-Ausdruck
p->p
ausdrücken. Alternativ kann man die Selbstabbildung über die Factory-Methode
identity
im
Function
-Interface bekommen: die
identity
-Methode
gibt eine Funktion zurück, die ihr Input-Argument als Returnwert zurückgibt.
Das entspricht genau unserem Lambda-Ausdruck
p->p
.
Bei den obigen Beispielen haben wir unterstellt,
dass der Schlüssel eindeutig ist. Sollte einer der Schlüssel mehrfach
auftreten, so wird eine
IllegalStateException
mit der Information "Duplicate key" geworfen. In solchen Fällen muss
der
toMap
-Kollektor wissen, was er mit
den mehreren Werten pro Schlüssel machen soll. Er könnte sie in irgendeiner
Form hintereinander hängen, z.B. könnte er sie in einer Liste aufsammeln.
Hier ist ein Beispiel:
Collection<Person> people = …; Map<Address, List<Person>> addressToPerson = people.stream() .collect(Collectors.toMap(Person::getAddress, p->{List<Person> tmp = new ArrayList<>(); tmp.add(p); return tmp;} , // 1 (l1,l2)->{l1.addAll(l2); return l1;} // 2
));
Wir verwenden die Adresse als Schlüssel.
Da es zu jeder Adresse mehrere Personen geben kann, wollen wir zu jeder
Adresse eine Liste von dort wohnenden Personen anlegen. Unser assoziierter
Wert ist deshalb nicht mehr die Person selbst, sondern wir verpacken die
Person in eine Liste (siehe Zeile //1). Wenn nun derselbe Schüssel erneut
auftritt, dann wird die weitere Person zu dieser Adresse ebenfalls in eine
Liste verpackt und die beiden Listen werden konkateniert (siehe Zeile //2).
Es gibt noch eine Variante des
toMap
-Kollektors,
bei der wir als viertes Argument mitgeben können, wie die Map erzeugt
wird. Wir könnten dafür eine Konstruktor- Referenz wie z.B.
TreeMap::new
mitgeben.
Neben den oben gezeigten toMap -Kollektoren, gibt es in Collections auch noch toConcurrentMap -Kollektoren. Sie sammeln, wie der Name schon sagt, in eine ConcurrentMap . Was die Aufruf-Parameter angeht haben sie die gleichen Signaturen wie die toMap -Kollektoren. Gruppierungen
Das Aufsammeln aller Personen zu einer
Adresse geht auch einfacher. Wir haben im obigen Beispiel händisch für
jedes Element eine Liste erzeugt und die Listen konkateniert. Dafür
gibt es bereits vorgefertigte Kollektoren. Sie werden über Factory-Methoden
mit dem Namen
groupingBy
geliefert.
Für eine Gruppierung muss man im einfachsten Fall nur spezifizieren, wie
der Schlüssel ermittelt wird. Dafür muss ein sogenannter
Klassifizierer
übergeben werden. Hier ist das obige Beispiel mit einem
groupingBy
-Kollektor
anstelle eines
toMap
-Kollektors:
Collection<Person> people = …; Map< Address ,List<Person>> addressToPerson = people.stream()
.collect(Collectors.collect(Collectors.
groupingBy
(
Person::getAddress
));
Die Klassifizierung erfolgt über die Adresse,
d.h. es wird eine Map angelegt mit der Adresse als Schlüssel und einer
Liste von Personen als assoziierter Wert. Hier noch ein anderes Beispiel:
List<String> streamSource = …; Map< Character ,List<String>> resultMap = streamSource.stream() .filter(w->w.length()>0) .distinct()
.collect(Collectors.
groupingBy
(
w->w.charAt(0)
);
Hier werden aus einer Liste von Worten alle
Strings der Länge 0 (mit
filter
) und
alle Duplikate (mit
distinct
) entfernt.
Danach werden die Strings gemäß ihrem Anfangsbuchstaben gruppiert (mit
collect
und
groupingBy
-Kollektor). Heraus kommt
eine Map mit dem Anfangsbuchstaben als Schlüssel und einer Liste von Worten
mit diesem Anfangsbuchstaben als assoziierter Wert.
Wenn man die Variante
Collectors.groupingByConcurrent()
nutzt, bekommt man statt einer
Map
eine
ConcurrentMap
zurück.
Eine spezielle Form der Gruppierung ist die
Partitionierung
.
Dabei ist der Klassifizierer ein Prädikat, d.h. eine Funktion, die die
Sequenz-Elemente bewertet und
true
oder
false
zurückgibt. Heraus kommt eine Map mit
true
oder
false
als Schlüssel und einer Liste
von Elementen mit der jeweiligen Eigenschaft als assoziierter Wert. Hier
ist ein Beispiel:
List<String> streamSource = …; Map< Boolean ,List<String>> resultMap = streamSource.stream() .filter(w->w.length()>0) .distinct()
.collect(Collectors.
partitioningBy
(w->Character.
isUpperCase(w.charAt(0))
));
Wir partitionieren die Worte in unserer Sequenz danach, ob ihr Anfangsbuchstabe ein Klein- oder ein Großbuchstabe ist. Wir erhalten eine Map mit dem Schlüssel false und der Liste der Worte in Kleinschreibung und dem Schlüssel true und der Liste Worte mit großem Anfangsbuchstaben. Kollektoren mit Downstream-Kollektoren
Bisher haben wir uns den
groupingBy
-Collector
mit einem Parameter (dem
classifier
)
angesehen. Die Signatur der Factory-Methode für diesen
groupingBy
-Collector
sieht so aus:
static <T,K> Collector <T,?, Map <K, List <T>>>
groupingBy(
Function
<?
super T,? extends K>
classifier)
Es gibt eine weitere überladene Version,
die folgendermaßen aussieht:
static <T,K,A,D> Collector <T,?, Map <K,D>>
g
roupingBy
(
Function
<?
super T,? extends K>
classifier,
Collector
<?
super T,A,D>
downstream)
Zusätzlich zu dem
classifier
hat die Factory-Methode einen zweiten Parameter, den
downstream
Kollektor. Intuitiv erkennt man nicht sogleich, worum es hierbei geht.
Schauen wir uns deshalb ein Beispiel an. Wir knüpfen dabei an das Beispiel
oben an, bei dem wir eine Map mit dem Anfangsbuchstaben als Schlüssel
und einer Liste von Worten mit diesem Anfangsbuchstaben als assoziierter
Wert erzeugt haben. Nehmen wir an, uns interessiert nicht die gesamte
Liste, sondern nur der größte String zu jedem Anfangsbuchstaben (im Sinne
der
St
r
ing.compareTo()
-Methode).
Wir können dann das Beispiel von oben folgendermaßen abwandeln:
List<String> streamSource = …; Map<Character, Optional<String> > resultMap = streamSource.stream() .filter(w->w.length()>0) .distinct()
.collect(Collectors.groupingBy(w->w.charAt(0),
Collectors.maxBy(String::compareTo
));
Der Downstream-Collector
Collectors.maxBy()
mit
String::compareTo
als Parameter wird
nun auf jede Liste angewandt, um den jeweils größten String in der Liste
zu bestimmen. Wenn man sich den Ergebnistyp der
resultMap
genau ansieht, erkennt man, dass der größte String nicht vom Typ
String
,
sondern vom Typ
Optional<String>
ist.
Dazu später mehr.
Rekapitulieren wir hier noch mal ganz kurz
die Idee des Downstream-Kollektors: er wird nachgelagert (also
downstream
)
auf
das Ergebnis des Haupt-Kollektors (
groupingBy()
)
angewandt. In gewisser Weise kann man es als eine Fortführung des
Fluent
Programmings
ansehen. Nach den intermediären Operationen
filter()
,
destinct()
und der terminalen Operation
collect()
möchte man noch eine weitere Operation anwenden. Da das
collect()
aber schon die terminale Operation ist, geht es nur, indem man dem Haupt-Kollektor
des
collect()
den Downstream-Kollektor
als Parameter übergibt.
Es gibt übrigens noch mehr Kollektoren in
Collectors
,
die Downstream-Kollektoren akzeptieren, nämlich
groupingByConcurrent()
,
partitioningBy()
und
mapping()
.
Im Beispiel oben haben wir den
maxBy
-Kollektor
als Downstream-Kollektor benutzt. Es stellt sich die Frage: Könnte man
den
maxBy
-Kollektor auch als eigenständigen
Haupt-Kollektor benutzen? Das geht tatsächlich. Der
maxBy
-Kollektor
kann verwendet werden, um den größten String in unserem Stream zu ermitteln,
nämlich so:
List<String> streamSource = …; Optional<String> largestElem = streamSource.stream() .filter(w->w.length()>0) .distinct()
.collect(
Collectors.maxBy(String::compareTo
));
Das geht aber auch genauso gut ohne
collect()
:
List<String> streamSource = …; Optional<String> largestElem = streamSource.stream() .filter(w->w.length()>0) .distinct()
.max(
String::compareTo
);
Mit anderen Worten: einige Kollektoren aus Collections (wie z.B. der maxBy -Kollektor) werden fast ausschließlich als Downstream-Kollektoren verwendet, denn nur dort sind sie wirklich benötigt. Für die Verwendung als Haupt-Kollektor gibt es eine einfachere Alternative ohne Kollektoren. Optional
Kommen wir noch mal zu
Optional
.
Warum ist in den letzten beiden Beispielen das größte Element eines
Stream<String>
kein
String
, sondern ein
Optional<String>
?
Es hängt damit zusammen, dass der Stream leer sein könnte. Was wäre
denn das größte Element in einem leeren Stream? In diesem Falle gibt
es kein Ergebnis. Was soll dann eine Methode wie
max()
zurück geben?
Optional löst dieses Dilemma mit den leeren Streams. Es sagt, ob es überhaupt ein Ergebnis gibt ( isPresent() ), und wenn ja, was das Ergebnis ist ( get() ). Dies ist erst einmal eine ganz knappe Erklärung. Wir werden auf den neuen Typ Optional in einem zukünftigen Artikel noch einmal ausführlicher eingehen. Zusammenfassung
Wir haben uns in diesem Beitrag angesehen,
welche Kollektoren in der Klasse
Collectors
vordefiniert sind und wie sie zusammen mit der Stream-Operation
collect()
genutzt werden können. In einem zukünftigen Artikel werden wir noch
mal auf das Thema zurückkommen und schauen wie Kollektoren intern funktionieren.
Die genaue Kenntnis der Interna ist eine unerlässliche Voraussetzung,
um eigene Kollektoren implementieren zu können.
Im nächsten Artikel schauen wir uns aber
erst mal an, welche Anforderungen die Funktionalität erfüllen muss, die
wir als Lambda-Ausdruck oder Methodenreferenz an eine Stream-Operation
übergeben.
Die gesamte Serie über Java 8:
|
|||||||||||||||||||
© Copyright 1995-2018 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/76.Java8.Stream-Collectors/76.Java8.Stream-Collectors.html> last update: 26 Oct 2018 |