Frischer Wind in Java – Scala und Java 8 – Part II: Streams und Lambda-Ausdrücke

Einführung

Mit Java 8 halten eine Menge neue Funktionen Einzug in die Sprache. Vieles davon kommt einem bekannt vor, wenn man sich die JVM-basierte Sprache Scala (http://www.scala-lang.org/) ein bisschen näher anschaut – ein guter Grund, sich mit den Grundlagen von Scala zu beschäftigen. In dieser Serie sollen einige grundlegende Sprachfeatures an praxisnahen Beispielen verglichen und die Unterschiede zwischen Java und Scala beleuchtet werden. Schließlich wird gezeigt, welche der Scala-Funktionalitäten mit Version 8 in die Java-Welt aufgenommen wurden.

In Teil II geht es um Streams als Kontrollstrukturen in Scala und Java 8. Ausgehend von der in Part I bereits vorgestellten und hier erweiterten Datenstruktur soll eine einfache Funktionalität umgesetzt werden.

So sehen die verwendeten Datenklassen in Scala aus (zur Bedeutung siehe Teil I der Serie):

case class Customer(var name: String) {
  override def toString = name
}
case class Order(val customer: Customer, val product: String, val value: Int) {
  override def toString = customer + ", " + product + ": " + value
}

Die Aufgabe besteht nun darin, eine Liste von Aufträgen (die vom Typ List<Order> vorliegt) zu filtern mit dem Filter value > 50000. Dann sollen die ersten fünf Aufträge ausgegeben werden.
Zuerst eine der vielen Möglichkeiten, dies in Java 7 umzusetzen:

Umsetzung in Java 7

ArrayList<Order> bigOrders = new ArrayList<Order>();
for (Order order : orders) {
	if (order.value > 50000) {
		bigOrders.add(order);
	}
}

for(int i=0; i < 5 && i < bigOrders.size(); i++) {
	System.out.println(bigOrders.get(i));
}

Zuerst werden alle Aufträge mit einem Wert über 50000 gesucht und in einer zweiten Liste zwischengespeichert. Dann werden daraus die ersten fünf Einträge ausgegeben.

Umsetzung in Scala

orders.toStream.filter(o => o.value > 50000).take(5).foreach(println)

In Scala lässt sich dies leicht auf eine Zeile komprimieren. Die Operationen sind selbsterklärend: filtern, 5 Elemente nehmen, und jedes davon ausgeben. Hierbei stoßen wir gleich auf zwei wichtige Sprachfeatures, die in Java 7 nicht vorhanden sind: Streams und Lambda-Ausdrücke. An dieser Stelle möchte ich kurz die für dieses Beispiel wichtigen Eigenschaften erwähnen:

  • Die Liste der Aufträge wird mit toStream als Stream behandelt. Dadurch werden der Filter wie auch alle anderen an Operationen nicht sofort angewendet, sondern erst beim tatsächlichen Abruf der Elemente im foreach. Besonders bei großen Datenmengen, noch nicht vollständig zur Verfügung stehenden Datenmenge oder komplexen Operationen ist dies hilfreich.
  • Der Lambda-Ausdruck ist in diesem Beispiel o => o.value > 50000. Man kann sich Lambda-Ausdrücke als anonyme Funktionen vorstellen, die implizit deklariert werden. Es wird also eine Funktion mit einem Parameter o generiert, die ein Boolean zurückgibt.
    Auch foreach erwartet eine solche Funktion, kann aber direkt mit einem Funktionspointer (println) auf eine Funktion mit identischen Parametern umgehen.

Umsetzung in Java 8

Streams und Lamda-Ausdrücke haben nun auch in Java 8 Einzug gehalten. Im Detail und vor allem in der internen Umsetzung sehr verschieden, aber von der Benutzung sehr ähnlich:

orders.stream().filter(o -> o.value > 50000).limit(5).
	forEach(System.out::println);

In Java 8 können wir nun auch beliebige Listen als Stream verwenden. Es gibt äußerst ähnlich aussehende Lambda-Ausdrücke, auch wenn der innenliegende Mechanismus grundlegend anders ist (vgl. [3], Takipiblog: Compiling Lambda Expressions: Scala vs. Java 8). Schlussendlich kann nun sogar in Java ein Pointer auf eine Funktion übergeben werden, die direkt die Listenelemente verarbeiten kann.
Außerdem hat Java hier noch ein paar Eigenschaften von Scala übernommen. Parametertypen müssen nicht mehr angegeben werden, sofern sie vom Compiler aus dem Kontext zu erschließen sind, return wie auch Klammern dürfen weggelassen werden.

… und ein bisschen Magie

Zusätzlich zu der beeindruckenden Mächtigkeit dieser neuen Konstrukte bekommen wir noch etwas geschenkt:

orders.toStream.par.filter(o => o.value > 50000).take(5).foreach(println)

bzw.

orders.stream().parallel().filter(o -> o.value > 50000).limit(5).
	forEach(System.out::println);

Und zwar kann man in Scala wie auch in Java 8 die Verarbeitung parallelisieren. Dazu muss man nichts machen, außer den Stream als parallel zu markieren. Die Operatoren wissen selbst, wie sie parallel arbeiten können und die gesamte Verwaltung der Threads passiert automatisch. Dies eröffnet viele Möglichkeiten, persönlich sehe ich jedoch zwei große Gefahren:

  1. Performance: Die Implementierung der Parallelverarbeitung kann je nach Anwendung zu unerwarteten Performanceproblemen führen, z.B. wenn an anderes Stelle zeitgleich eine zweite parallele Verarbeitung durchgeführt wird. Die Verteilung der Rechenleistung ist nicht intuitiv erkennbar.
  2. Verständlichkeit: Durch die mächtigen Konstrukte mit nur einem Schlüsselwort sinkt das Bewusstsein für die Komplexität der im Hintergrund durchgeführten Berechnungen. Als Entwickler ist man versucht, sich keine Gedanken über die Auswirkungen zu machen, und für Dritte ist Code im schlimmsten Fall bedeutend schwieriger zu verstehen.

Zusammenfassung

An diesem Beispiel wird deutlich, dass Java 8 deutliche Einflüsse von Scala aufweist und viele typische Programmieraufgaben erleichtert. Auch wenn im Vergleich zu Scala unter der Haube das meiste anders funktioniert, ist die Benutzung sehr ähnlich und intuitiv. Selbstverständlich sollte man sich als Entwickler vor der Benutzung mit der internen Umsetzung von Lambda-Ausdrücken, Streams etc. beschäftigen, denn es gibt Probleme bzgl. Performance, Testbarkeit, Lesbarkeit usw. – dazu ein paar interessante Artikel als Link.

Interessante Links zum Thema

  1. Oracle: Java 8 Streams API
  2. Oracle: Java 8 Lambda Expressions Tutorial
  3. Takipiblog: Compiling Lambda Expressions: Scala vs. Java 8
    Blogeintrag zur internen Umsetzung von Lambda-Ausdrücken
  4. Takipiblog: The Dark Side Of Lambda Expressions in Java 8
    Blogeintrag mit einigen negativen Auswirkungen von Lambda-Ausrücken
  5. StackOverflow: Unit Testing for Java 8 Lambdas
    Wie testet man Lambda-Ausdrücke?
  6. ZeroTurnaround: Java Parallel Streams Are Bad for Your Health!
    Blogeintrag zu Performanceproblemen von parallelen Streams

Leave a Reply