Streams: Daten einkochen

Aus MimiPedia
Version vom 9. März 2023, 18:17 Uhr von Ullrich (Diskussion | Beiträge)
(Unterschied) ← Nächstältere Version | Aktuelle Version (Unterschied) | Nächstjüngere Version → (Unterschied)

Map-Reduce ist ein Schlagwort, das nicht zuletzt durch Google bekannt geworden ist und gewöhnlich mit "Big-Data" in Verbindung gebracht wird. Das Konzept dahinter ist denkbar einfach: man nehme eine Liste von Daten-Objekten, verarbeite jedes Objekt mit irgendeiner Funktion (die map-Phase) und kocht dann die entstandenen – gemappten – Objekte zu einem einziegn Ergebnis ein (die reduce-Phase). Entscheidend dabei ist, daß man jedes Objekt unabhängig von den anderen verarbeitet und die Reihenfolge in der Reduce-Phase irrelevant ist. Dann hat man die Möglichkeit in einer nebenläufigen Umgebung gewaltige Datenmengen schnell und sicher zu verarbeiten.

Daß das Map-Reduce-Pattern seine Wurzeln in der funktionalen Programmierung hat sollte nicht verwundern. Dort war die Verwendung der Funktionen map und reduce schon üblich bevor der Bedarf nach "Big Data" überhaupt erfunden wurde. Stellen wir die Map-Reduce-Frameworks erstmal zurück, betrachten das zugrundeliegende Konzept im Lichte der Java-Streams und schauen mal was sich daraus machen läßt.

Die map-Methode des Stream-Interface haben wir bereits in der Einführung kennengelernt. Wir wissen also, daß wir die Objekte eines Stroms in nahezu beliebiger Weise verarbeiten können. Kommen wir damit gleich zur reduce-Phase und sehen uns an, wie man die Objekte, die die map-Phase ausgespuckt hat, eindampfen kann. Betrachten wir als Beispiel eine Liste von Zahlen, die wir addieren möchten. Besteht die Liste aus den Zahlen 17, 23 und 31, dann berechnet sich die Summe wie folgt:

17 + 23 + 31

oder

0 + 17 + 23 + 31

oder

(((0 + 17) + 23) + 31)

Zum Initialwert 0 wird die erste Zahl – die 17 – addiert. Dann wird zum entstandenen Ergebnis die zweite Zahl – die 23 – addiert. Zum Ergebnis wird die dritte Zahl – die 31 – addiert. Die Summe entsteht also durch wiederholte Anwendung der Funktion "addiere" auf das Zwischenergebnis und die nächste Zahl der Liste.

Wir können das in Java auch mit Integer-Objekten und der Methode Integer.sum() schreiben. Wir erlauben uns dabei, das Autoboxing-Feature von Java zu nutzen um die Integer-Objekte nicht explizit erzeugen zu müssen:

Integer.sum(Integer.sum(Integer.sum(0, 17), 23), 31)

Der geneigte Leser wird sich nun vielleicht fragen, was der ganze Blödsinn soll, für Zahlen gibt es in Java den einfachen Typ int, zum Addieren das + und für Schleifen das for.

Die Addition von Zahlen ist wohl das einfachste Beispiel und lenkt die Aufmerkamkeit nicht vom Wesentlichen ab. An Stelle der Integer-Objekte lassen sich natürlich Objekte jeden beliebigen Typs verwenden und Schleifen sind out – den Streams gehört die Zukunft.

Also erstmal das Wie, dann das Warum:

Wie führt man die Verarbeitung mit einem Stream durch?

Einen Stream aus den Zahlen zu erzeugen ist einfach:

Stream<Integer> stream = Stream.of(17, 23, 31);

Wie wenden wir nun die Summe darauf an? Das Stream-Interface bietet dafür die Methode reduce(). Nach der Klassifizierung in der Einführung handelt es sich um eine Methode des Schrittes drei, die die Verarbeitung im Stream abschließt. Sie kommt in drei Geschmacksrichtungen, von denen wir diese – ausgesprochen generisch definierte – Form verwenden:

T reduce(T identity, BinaryOperator<T> accumulator);

Zur Erklärung:

  • T ist der Typ des Stroms, in unserem Falle also Integer.
  • identity ist hier der Startwert der Reduktion, für die Summen-Bildung also die 0.
  • accumulator ist die Funktion, die sukzessive angewandt werden soll, also Integer.sum()

Die Anwendung von reduce() erfordert für die Berechnung also einen Startwert und eine Funktion – in Form eines λ-Ausdrucks – die zwei Objekte vom Typ des Stroms als Argumente übernimmt und ein Objekt dieses Typs zurückgibt. Die Methode sum() erfüllt genau diese Bedingungen für den Typ Integer. Damit ist die Summenbildung schon geschrieben.

int summe = Stream.of(17, 23, 31).reduce(0, Integer::sum);

In unserem Beispiel hat der Start-Wert immer den gleichen Wert 0, die Summe kann also ebensogut mit dem ersten Wert des Stroms beginnen. Wir können daher die einfachere Form der reduce-Methode verwenden, die ohne die Angabe eines Startwertes auskommt:

int summe = Stream.of(17, 23, 31).reduce(Integer::sum).orElse(0);

Da der Stream leer sein kann, liefert die zweite reduce-Variante – weil sie keinen initialen Wert hat – kein Integer-Objekt, sondern verpackt das Ergebnis in ein Objekt des Typs Optional<Integer>. Wir fügen daher den Wert für die leere Liste über die orElse-Methode hinzu.

Was bringt das nun?

Warum führt man die Verarbeitung mit einem Stream durch?

Zunächst einmal sind Stream-Methoden wie count, min, max nach diesem Mechanismus implementiert – mit der entsprechenden Code-Ersparnis. Darüber hinaus finden sich jederzeit weitere Anwendungsmöglichkeiten. Erinnern wir uns an die auf zwei Argumente limitierte Methode Stream.concat(), die zwei Streams mischt. Die konventionelle Lösung erstreckte sich über endlose fünf Zeilen.

Mit reduce() wird eine daraus:

private <T> Stream<T> concat(Stream<T>... streams) {
    return Stream.of(streams).reduce(Stream::concat).orElseGet(Stream::empty);
}

Im Detail:

  • Zunächst erzeugen wir aus dem Array von Stream-Objekten einen Strom.
  • Dann reduzieren wir die Streams auf einen einzigen indem wir sukzessive die Streams mit der concat()-Methode zusammenketteln.
  • Schließlich reagieren wir hier auf den leeren Stream mit dem Aufruf der orElse-Methode des Optional
(und geben so der etwas esoterisch anmutenden empty-Methode endlich eine Daseinsberechtigung).

Wenn erforderlich kann man map-reduce-Code unter Verwendung eines passenden Frameworks auch mit sehr großen Datenmengen in hochperformante Lösungen verwandeln, weil das Problem schon entsprechend aufbereitet wurde. Aber auch wenn das nicht der Fall ist – das Ergebnis ist elegant, kompakt und skaliert hervorragend nach unten: Auch kleine Datenmengen werden performant verarbeitet. Die Verwendung des map-reduce-Pattern mit Java-Streams ist enorm flexibel. Daß das nicht immer sofort in's Auge springt liegt daran, daß das auf Objektorientierung getrimmte Entwicklerhirn funktionale Muster nur sehr widerwillig erkennt. Wer sie ignoriert ist kein schlechter Mensch, sondern verpaßt nur hie und da die Gelegenheit sich das Leben zu erleichtern.

Two roads diverged in a wood, and I—
I took the one less traveled by,
And that has made all the difference.

Robert Frost