Streams: Ergebnis sammeln

Aus MimiPedia

Genau wie die hier beschriebene reduce-Methode, dient die collect-Methode des Stream-Interface dem Einkochen des Ergebnisses auf einen einzelnen Wert. Betrachtet man eine Liste von Objekten selbst als Objekt, so kann man das Sammeln der Ergebnis-Objekte, die die Verarbeitung durch den Stream überlebt haben und schließlich aus dem Abfluß tropfen als Spezialfall davon betrachten. Tatsächlich sind beide Konstrukte (reduce() und collect) Sichtweisen auf den gleichen Mechanismus und nachdem wir hier reduziert haben, kollektivieren wir nun.

Das Sammeln übernimmt ein passendes Objekt das das Interface Collector implementiert. Die Klasse Collectors (man beachte das Plural-s) stellt über statische Methoden nützliche Implementierungen zur Verfügung von denen einige im Folgenden kurz vorgestellt werden sollen. Im Anschluß betrachten wir, wie mit der collect-Methode beliebige Datenstrukturen befüllt werden können.

Listen und Mengen

Um die Ergebnis-Objekte in ein List-Objekt zu füllen, liefert die – recht intuitive – Methode Collectors.toList() einen passenden Collector:

List<String> zahlen = Arrays.stream("eins", "zwei", "drei").collect(Collectors.toList());

damit werden die drei Strings aus dem Array in eine Liste überführt. So läßt sich aber auch eine Methode schreiben, die eine Liste beliebigen Typs in eine neue Liste kopiert:

public static <T> List<T> copy(List<T> quelle) {
    return quelle.stream().collect(Collectors.toList());
}

Analog dazu liefert die Methode Collectors.toSet() einen Collector, der einen Set des entsprechenden Typs erzeugt.

Zeichenketten

Möchten wir eine Liste von Strings anstelle zu einer Liste zu einem (einzigen) String zusammenführen, haben wir gleich drei Möglichkeiten dafür einen Collector zu erzeugen:

joining()
klebt einfach die Strings aneinander
Aus Arrays.stream("1", "2", "3").collect(Collectors.joining()) ensteht so der String "123"
joining(delimiter)
fügt zwischen zwei Strings den angegebenen "delimiter" ein.
Aus Arrays.stream("1", "2", "3").collect(Collectors.joining(",")) ensteht so der String "1,2,3"
joining(delimiter, prefix, suffix)
erweitert die Liste um eine Anfangs- und eine Ende-Markierung.
Aus Arrays.stream("1", "2", "3").collect(Collectors.joining(",", "[", "]")) entsteht so der String "[1,2,3]"

Maps

Mit der Methode Collectors.toMap() läßt sich der Objekt-Strom auch in eine (Hash-)Map abfüllen. Da ein Map-Eintrag aus zwei Teilen, nämlich Key und Value, besteht, sind hier zusätzliche Angaben erforderlich; nämlich wie Key und Value aus dem Objekt berechnet werden. Das geschieht – Überraschung – mithilfe von λ-Ausdrücken. Betrachten wir folgende Klasse, die jeweils einen Kunden und einen Artikel zusammenfaßt und eine Bestellung darstellen möge:

class Bestellungen {
    private String kdnr;
    private String artNr;
    public String getKdnr() { return kdnr; }
    public String getArtNr() { return artNr; }
}

Wir möchten eine Map generieren, die jeder Kundennummer die Nummer des Artikels zuweist, den der Kunde bestellt hat. Der Key errechnet sich daher direkt aus der Kundennummer:

(Bestellung b) -> b.getKdnr()

und der Value aus der Artikel-Nummer:

(Bestellung b) -> b.getArtNr()

Eine Bestellungen-Liste läßt sich dann mit der Methode Collectors.toMap() in eine String-String-Map umwandeln:

public Map<String, String> mapping(List<Bestellung> liste) {
    return liste.stream().collect(Collectors.toMap(
        (Bestellung b) -> b.getKdnr(),
        (Bestellung b) -> b.getArtNr()
    ));
}

Oder – kompakter geschrieben – so:

public Map<String, String> mapping(List<Bestellung> liste) {
    return liste.stream().collect(Collectors.toMap(
        Bestellung::getKdnr,
        Bestellung::getArtNr
    ));
}

Die toMap-Methode hat allerdings eine recht unschöne Eigenart. Da sie die merge-Methode von HashMap verwendet, läßt sie keine null-Werte zu. Wenn man also nicht sicher weiß, daß der value-Ausdruck niemals null liefert, fällt man früher oder später der Null-Pointer-Exception zum Opfer. Alternativ kann man die Map von Hand erzeugen und statt mit collect() die Objekte mit forEach() der Map hinzufügen:

public Map<String, String> mapping(List<Bestellung> liste) {
     Map<String, String> result = new HashMap<>();
     liste.stream().forEach(best -> result.put(best.getKdnr(), best.getArtNr()));
     return result;
 }

Oder man wählt die Variante, die als letztes vorgestellt werden soll:

Eigene Datenstrukturen befüllen

Die letzte Variante kann als Verallgemeinerung der vorangegangenen Varianten auf beliebige Datenstrukturen verstanden werden. Neben der Umschiffung des genannten map-Problems kann man sie auch verwenden wenn man eine selbstgebaute Datenstruktur mit der collect-Methode aus einem Stream befüllen möchte. Dazu werden drei Angaben in Form von λ-Ausdrücken benötigt:

  • Ein Erzeuger für eine Datenstruktur
  • Eine Anweisung die der Datenstruktur ein Objekt hinzuzufügt
  • Eine Anweisung die zwei Datenstrukturen zusammengeführt

Daß ein Erzeuger für die Datenstruktur benötigt wird ist klar, daß eine Möglichkeit benötigt wird, ein neues Objekt in die Datenstruktur einzuordnen ist auch klar, aber wozu dient die dritte Anweisung – die im JDK-Source als "combiner" bezeichnet wird?

Java-Streams sind darauf ausgelegt, in einer nebenläufigen Umgebung parallelisiert verarbeitet zu werden. Das bedeutet technisch, daß der Stream in viele kleine Streams aufgeteilt wird die – jeder für sich – einen Teil des Gesamt-Streams verarbeiten. Nach getaner Arbeit müssen die einzelnen Teilergebnisse zusammengeführt werden, dabei enstehen im Falle von collect mehrere Datenstrukturen (Strings, List-Objekte oder was auch immer) die am Ende zu einer einzigen Datenstruktur zusammengefaßt werden müssen. Dazu dient der combiner, der zwei Datenstrukturen zusammenfaßt und am Ende solange auf die Teilergebnisse angewandt wird bis nur noch eine einzige große Sammlung da ist. Als Beispiel erzeugen wir die HashMap aus dem vorangegangenen Abschnitt nun mit der collect-Methode.

  1. Als Erzeuger dient new. Verlangt wird dafür ein λ-Ausdruck der keine Parameter hat und eine HashMap liefert:
    () -> new HashMap<String, String>()
  2. Die Objekte werden der HashMap mit put() hinzugefügt. Parameter sind hier zunächst die HashMap und dann das Objekt mit den hinzuzufügenden Daten:
    (map, bestellung) -> map.put(bestellung.getKdnr(), bestellung.getArtNr())
  3. Zum Zusammenführen bietet das Map-Interface die Methode putAll() an:
    (map1, map2) -> map1.putAll(map2))

die vollständige Methode zur Erzeugung der Map sieht dann so aus:

public Map<String, String> mapping(List<Bestellung> liste) {
    return liste.stream().collect(
        () -> new HashMap<String, String>(),
        (map, bestellung) -> map.put(bestellung.getKdnr(), bestellung.getArtNr()),
        (map1, map2) -> map1.putAll(map2)
    );
}

Mit Verwendung der ::-Notation wird das Ganze noch etwas übersichtlicher und wir überlassen es dem Compiler, die richtigen Datentypen für die Hash-Map zu ermitteln:

public Map<String, String> mapping(List<Bestellung> liste) {
    return liste.stream().collect(
        HashMap::new,
        (map, bestellung) -> map.put(bestellung.getKdnr(), bestellung.getArtNr()),
        HashMap::putAll
    );
}

Wem das noch nicht genügt, der kann das Collector-Interface selbst implementieren und einen eigenen Collector damit bauen. Aber das ist eine andere Geschichte, die ein anderes Mal erzählt werden soll. Die Collectors-Klasse hat noch weitere Methoden für parallele Verarbeitung von Maps, Gruppierung, Summierung und ähnliches – auch das wird ein andermal erzählt werden.