Moving the platform forward

Einführung in Java SE 8: Was das neue Java Release zu bieten hat

Simon Ritter
Java SE 8 Release

Java ist wahrscheinlich die populärste unter den heute verwendeten Programmiersprachen. Fast neunzehn Jahre sind seit ihrer Veröffentlichung vergangen, und noch immer werden der Plattform signifikante neue Features hinzugefügt. Mit der Veröffentlichung von Java SE 8 werden Entwickler nun die Möglichkeit haben, einen funktionaleren Programmierstil anzuwenden als bisher. Mit der Einführung einer Reihe neuer Features, vor allem im Collections-API, werden zudem Bulk-Operationen deutlich einfacher zu realisieren, zu verstehen und zu warten sein. In diesem Artikel werde ich eine Einführung in Lambda-Ausdrücke (die wohl größte Änderung der Java-Syntax seit Generics), in die Extension Methods für Schnittstellen sowie in das neue Stream-API geben und zeigen, wie diese Neuerungen funktionales Programmieren ermöglichen.

Lambda-Ausdrücke

In Java wird häufig von Single Abstract Method (SAM)-Typen, also Schnittstellen mit nur einer abstrakten Methode, Gebrauch gemacht. Solche Typen sind z.B. Runnable, Callable und ActionListener.  Wenn wir diese verwenden, können wir eine Klasse erzeugen, die die Schnittstelle implementiert und Instanzen der Objekte dieses Typs instantiieren.

Das kann ziemlich umständlich sein, weshalb Java es uns stattdessen erlaubt, mit einer anonymen inneren Klasse zu arbeiten. Das kann beispielsweise folgendermaßen aussehen:

myButton.addActionListener(new ActionListener() {
  public void ActionPerformed(ActionEvent event) {
    System.out.println(“OK”);
  }
});

Anonyme innere Klassen haben den Vorteil, dass wir keine separate explizite Klasse benötigen; und wir sehen direkt an der Stelle des Listeners, welcher Code nach Drücken des Buttons ausgeführt wird. Der Nachteil besteht darin, dass anonyme innere Klassen – gelinde gesagt – eine hässliche Angelegenheit sind: Es werden deutlich mehr Boilerplate-Code und Klammern produziert, als zumutbar wäre. Um dieses Problem zu lösen, wurden mit Java SE 8 Lambda-Ausdrücke in die Syntax eingeführt. Sehen wir uns dasselbe Beispiel noch einmal an, diesmal jedoch unter Verwendung von Lambda-Ausdrücken:

myButton.addActionListener(ae -> System.out.println(“OK”));

Der Code ist nun einfacher, besser lesbar und folglich auch einfacher zu warten. Sieht man sich dieses Beispiel an, würde man vermuten, dass der Compiler hier lediglich den Lambda-Ausdruck zum Äquivalent einer anonymen inneren Klasse macht. Da ein Lambda-Ausdruck per definitionem überall dort verwendet werden kann, wo auch SAM-Typen zum Einsatz kommen, wäre das natürlich möglich. In diesem Fall wären Lambda-Ausdrücke allerdings nichts anderes als eine Vereinfachung der Syntax der Sprache.

Tatsächlich werden Lambdas implementiert, um den invokedynamic bytecode zu nutzen, der mit Java SE 7 eingeführt wurde (der ursprüngliche Grund dafür war, die Implementierung von dynamisch typisierten Sprachen auf der JVM zu vereinfachen). Auf diesem Weg wird die Ausführung des Lambda-Ausdrucks bis zur Runtime verzögert, statt zur Compile-Zeit stattzufinden.

Es bieten sich verschiedene Strategien an, wie z.B. die Verwendung von MethodHandles, um die Performance der Lambda-Ausdrücke im Vergleich zu den äquivalenten, anonymen inneren Klasse zu verbessern. Durch das Ersetzen aller anonymen inneren Klassen in den Klassenbibliotheken durch SAM-Typen erhält man schnellere Java-Anwendungen, ohne den Anwendungs-Code zu verändern.

Um Lambda-Ausdrücke effektiv zu nutzen, müssen indes einige Punkte beachtet werden.

[ header = Seite 2: Effektive Lambda-Ausdrücke ]

Ein Lambda-Ausdruck repräsentiert eine anonyme Funktion. Die Struktur eines Lambda-Ausdrucks ist wie die einer Methode, sie ist jedoch nicht mit einer Klasse verbunden. Wie eine Methode so haben auch Lambda-Ausdrücke typisierte Parameter (links vom ->), einen body (rechts vom ->), einen Rückgabe-Typ und geworfene Exceptions.

Wenn die Funktion keine Parameter übernimmt,  sollten geschlossene Klammern () verwendet werden. Für den Rückgabe-Typ kann ein explizites Return verwendet werden. In einfachen Fällen, wie beispielsweise String s -> s.startsWith(“foo”), wird der Rückgabe-Typ vom Compiler ermittelt.

Der Typ eines Lambda-Ausdrucks ist ein funktionales Interface. Ein funktionales Interface wiederum ist definiert als ein Interface, das über eine abstrakte Methode verfügt. Hierbei müssen einige subtile Unterscheidungen vorgenommen werden, da einige funktionale Interfaces mehr als nur eine Methode haben. Das neue Interface Predicate zum Beispiel hat fünf Methoden, doch nur eine davon, test(), ist abstrakt. Drei der Methoden haben Default-Implementierungen (dazu später mehr) und eine ist eine statische Methode.

Die Möglichkeit, statische Methoden in ein Interface einzubinden, ist ein weiteres neues Feature von Java SE 8. Bei der Erstellung eines eigenen funktionalen Interfaces kann die Annotation @FunctionalInterface verwendet werden; der Compiler verifiziert in diesem Fall, dass das Interface die korrekte Struktur aufweist.

Lambda-Ausdrücke können genutzt werden, um eine Instanz eines funktionalen Interfaces zu erstellen. Sie können sowohl bei Zuweisungen als auch bei Methoden-Aufrufen verwendet werden. Welches funktionale Interface der Lambda-Ausdruck repräsentiert, wird aus dem Kontext erschlossen. Beispielsweise ist es bei dieser Zuweisung

Comparator<String> c = 
    (String x, String y) -> x.length() – y.length();

leicht zu sehen, dass der Typ des Lambda-Ausdrucks Comparator<String> ist.

In diesem Beispiel eines Methodenaufrufs

sort(myList, (String x, String y) -> x.length() – y.length());

sieht sich der Compiler den Typ des zweiten Arguments der sort-Methode an und nutzt diesen für den Lambda-Ausdruck. Natürlich muss die Struktur des Lambda-Ausdrucks die richtige für den verwendeten Typ sein.

Bei der Verwendung einer Variablen aus dem umgebenden Scope in einer anonymen inneren Klasse muss diese Variable als final markiert werden. Für Lambda-Ausdrücke wurde diese Beschränkung ein wenig gelockert; jede Variable, auf die aus dem umgebenden Scope zugegriffen wird, muss „effektiv final“ sein. Das bedeutet: die Variable muss nicht explizit als final markiert werden, aber sich so verhalten, als wäre sie es; d.h. ihr Wert kann nur einmal gesetzt werden.

Da ein Lambda-Ausdruck eine Instanz einer anonymen Funktion repräsentiert, ist der Umstand, dass er nicht mit einer Klasse verbunden ist, nicht unerheblich. Wenn man in einem Lambda-Ausdruck eine Referenz zu ‘this’ verwendet, bezieht sie sich im Gegensatz zu einer anonymen inneren Klasse auf die Klasse des umgebenden Scopes.

Genauso wie der Compiler den Rückgabe-Typ eines Lambda-Ausdruck ableiten kann, ist das in vielen Fällen auch bei den Typen der Parameter möglich. In diesem Beispiel:

List<String> l = getList();
Collections.sort(l, 
  (String x, String y) -> x.length() – y.length()); 

macht der Typparameter der List klar, dass der Comparator zum Typ String gehört, weshalb x und y ebenfalls zum Typ String gehören müssen. Demzufolge müssen wir den Typ nicht explizit spezifizieren und können den folgenden Code verwenden, ohne uns darüber Gedanken machen zu müssen, dass dieser nicht vollständig statisch typisiert ist:

Collections.sort(l, (x, y) -> x.length() – y.length());

Als eine weitere “Abkürzung” bei der Verwendung von Lambda-Ausdrücken können Methoden-Referenzen genutzt werden. Wenn der body eines Lambda-Ausdrucks lediglich der Aufruf einer Methode ist, wie bei:

FileFilter f = File f -> f.canRead();

können wir dies folgendermaßen abkürzen:

FileFilter f = File::canRead;

[ header = Seite 3: Evolution der Bibliotheken ]

Evolution der Bibliotheken

Einer der wichtigsten Aspekte bei den Lambda-Ausdrücken in Java ist, dass wir nun eine einfache Möglichkeit zur Hand haben, um Verhalten als Parameter weiterzugeben, und nicht nur Werte. Um die bestehenden Klassenbibliotheken zu verbessern, kann man sich dies zu Nutze machen. Mit am effektivsten ist dies im Collections API. Wenn zum Zeitpunkt seiner Entwicklung bereits Lambda-Ausdrücke zur Verfügung gestanden hätten, sähe das API heute wohl deutlich anders aus. Der Großteil des Collections API verwendet Interfaces; um Verbesserungen durchzuführen, müssen wir neue Methoden zu bereits existierenden Interfaces hinzufügen. In diesem Fall wäre allerdings keine Abwärtskompatibilität mehr gegeben – ein nicht zu unterschätzendes Problem, immerhin war diese für Sun – und jetzt Oracle – immer vorrangig, wenn es darum ging, neue Features zu implementieren.

Man muss also einerseits einen Weg finden, Methoden zu einem Interface hinzuzufügen und andererseits mit Fällen umzugehen wissen, in denen eine existierende Implementierung des Interfaces nicht über diese Methode verfügt.

Die Antwort auf dieses Problem liefern die Extensions oder Default-Methoden. Wenn man in Java 8 SE eine Methode in einem Interface definiert, kann man ebenfalls eine Default-Implementierung bereitstellen, die genutzt werden kann, wenn die Implementierungsklasse keinen body für die Methode anbietet.

Ein Beispiel hierfür wäre:

default Stream<E> stream() {
  return StreamSupport(spliterator());
}

Wir fügen dem Interface jetzt eine Implementierung hinzu – heißt das aber nicht, dass wir im Endeffekt Multiple Inheritance (Mehrfachvererbung) zu Java hinzufügen? Die Antwort darauf lautet: ja und nein. Java hat bereits die Mehrfachvererbung von Typen, da wir multiple Interfaces in der selben Klasse implementieren können. Was jetzt noch hinzukommt ist die Mehrfachvererbung von Verhalten. Im Gegensatz zu Sprachen wie C++ wird die Mehrfachvererbung von Zuständen bislang noch nicht unterstützt.

In gewissen Situationen kann die Mehrfachvererbung von Verhalten indes zu Problemen führen. Wenn z.B. eine Klasse zwei Interfaces implementiert, die beide jeweils Methoden mit Default-Implementierungen enthalten, die über identischen Signaturen verfügen, und zusätzlich die Klasse keine Implementierung bereithält, weiß der Compiler nicht, welche Default-Implementierung er verwenden soll. In diesem Fall bekommt man einen Compiler-Error und muss die Angelegenheit manuell lösen.

[ header = Seite 4: Das Streams API ]

Das Streams API

Dank Lambda-Ausdrücken können wir Verhalten auf einfache Weise als Parameter weitergeben. Außerdem erlauben Extension Methods, neue Methoden zu bestehenden Interfaces hinzuzufügen, ohne die Rückwärts-Kompatibilität zu beeinträchtigen. Wenn man diese Features nutzt, eröffnen einem die neuen java.util.stream  und java.util.function Packages zusammen mit den Änderungen des Collections API neue und ausdrucksstarke Wege, um Code in Java zu schreiben, der von Natur aus funktional ist. Wie wir noch sehen werden, bietet das mehrere Vorteile.

Wenn wir Anwendungscode in Java schreiben, führen wir relativ oft Aggregat-Operationen auf Collections durch, so z.B. wenn man ermitteln möchte, welches Produkt in einer bestimmten Region am profitabelsten ist, oder wenn man alle durchgeführten Transaktionen nach Währung sortiert. In Java verwendet man dafür typischerweise imperativen Code und externe Iterationen. Wenn man z.B. eine bestimmte Anzahl von Studenten hat und wissen will, was die beste Note im Jahr 2013 war, würde man folgenden Code benutzen:

List<Student> students;
int maxScore = 0;

for (Student s : students) {
  if (s.gradYear == 2013)
    if (s.score > maxScore)
      maxScore = s.score;
}

Soweit ist das alles völlig korrekt, aber da man eine externe Iteration (die „for“-Schleife) und einen veränderbaren Zustand (maxScore) hat, ist der Code grundsätzlich seriell und nicht Thread-sicher. Wenn man stattdessen Lambda-Ausdrücke, Default-Methoden und das Streams API verwendet, können wir das Ganze umschreiben und gelangen zu:

int maxScore = students.
    stream().
    filter(s -> s.gradYear == 2013).
    map(s -> s.score).
    max();

Bei diesem Ansatz verketten wir Methoden-Aufrufe, um die Definition des Endergebnisses aufzubauen, nach dem wir suchen. Da es keine externe Iteration und keinen veränderlichen Zustand gibt, haben wir jetzt die Möglichkeit, den Code in seiner Implementierung zu parallelisieren. Das funktioniert recht einfach, indem wir den Library Code verwenden – tatsächlich ist es nicht schwerer, als den stream() Methoden-Aufruf durch parallelStream() zu ersetzen.

Schauen wir uns einmal etwas genauer an, was hier passiert. Als erstes benutzen wir die Methoden-Aufrufe, um das zu definieren, was wir wollen: Wir möchten die Daten so filtern, um die Werte, an denen wir interessiert sind, zu extrahieren. Anschließend verknüpfen wir diese Werte mit den spezifischen Daten, die wir benötigen, und im letzten Schritt bestimmen wir daraus das Ergebnis. Wie genau das durchgeführt werden soll, wird durch die Lambda-Ausdrücke spezifiziert, die das Verhalten an die Methoden weitergeben. Der Stream erzeugt dafür eine Pipeline, die die Beschreibung der von uns durchgeführten Aggregat-Berechnungen vereinfacht. Diese Pipeline besteht aus drei Dingen:

1) Eine Quelle, die für drei Dinge sorgt:

– In diesem Beispiel stellt sie den Zugang zu den Elementen der Collection bereit (ähnlich einem Iterator).

– Die Fähigkeit, die Elemente in einzelne Bestandteile aufzugliedern, so dass sie parallel verarbeitet werden können (intern wird dafür das Fork-Join-Framework benutzt).

– Die Charakteristika des Streams. Fragen wie z.B.: Ist die Größe des Value-Streams fix? Ist er geordnet? Sind alle Werte distinkt? Sind sie sortiert, etc. Diese Informationen können intern dazu verwendet werden, um die Art der Verarbeitung der Werte zu optimieren.

2) Intermediäre Operationen. Das sind Methoden, die einen Stream als Input nehmen und einen anderen Stream als Output erzeugen. Meistens sind die Streams unterschiedlich. In dem Beispiel wird der Stream der Elemente in der Studenten-Collection zum Filter geführt. Der Output ist ein Stream, in dem nur die Studenten mit Abschluss im Jahr 2013 auftauchen.

3) Eine Schluss-Operation, die ein bestimmtes Ergebnis oder einen Neben-Effekt erzeugt (etwas auf dem Bildschirm darzustellen ist ein Beispiel für einen Neben-Effekt).

Eines der wichtigsten Dinge zum Verständnis eines Streams ist, dass es sich dabei nicht um eine Daten-Struktur handelt. Streams erlauben das Herstellen einer Pipeline, die spezifiziert, wie Daten verarbeitet werden sollen. Die Daten-Verarbeitung beginnt erst dann, wenn die Schluss-Operation aufgerufen wird. Das macht das Verarbeiten von Streams so effizient, da filter(), map() und max() in einen einzigen Data-Durchlauf zusammengezogen werden und intern eine lazy Evaluierung vorgenommen werden kann. Wenn wir nach dem ersten Studenten suchen würden, der 2013 über die Punktzahl von 80 gekommen wäre, und er sich auf dem dritten Platz in einer Liste von zehntausend Studenten befände, würden wir nur drei Einträge verarbeiten, anstatt durch alle Zehntausend zu gehen, um diejenigen zu finden, die 2013 ihren Abschluss machten, bevor wir uns ihre Punktzahlen anschauen würden.

Das Thema Streams hat noch eine Menge mehr zu bieten hinsichtlich der verfügbaren Methoden und Interfaces. Aber ich will noch ein weiteres Beispiel anführen, um die Leistungsstärke dieses Features zu verdeutlichen. In Java SE 8 hat die BufferedReader Klasse eine Methode lines(), welche einen Stream von Textzeilen zurückgibt. Stellen wir uns z.B. vor, dass wir eine Liste aller Wörter, die in einer Datei auftauchen, erstellen wollen. Dafür können wir folgenden Code benutzen:

List<String> words = fileBufferedReader.
    lines().
    flatMap(line -> Stream.of(line.split(REGULAR_EXPRESSION)).
    filter(word -> word.length() > 0).
    map(String::toLowerCase).
    distinct().
    sorted().
    collect(Collectors.toList());

Sobald man mit dem API vertraut ist, versteht man relativ leicht, was hier genau passiert: Die Textzeilen, die aus der Datei eingelesen werden, werden nach flatMap weitergereicht, das einen verschachtelten Stream aus Streams erzeugt (jede Zeile wird zu einem Stream aus individuellen Wörtern, indem man die Zeilen mit einem regulären Ausdruck aufteilt). Dieser wird in Kleinbuchstaben konvertiert. Anschließend werden multiple Einträge entfernt, das Ganze wird alphabetisch sortiert und dann in eine Liste gepackt. Man braucht sich nur vorzustellen, wie viel Code man benötigen würde, wenn man das alles in Java ohne das Streams API schreiben müsste.

[ header = Seite 5: Fazit ]

Fazit

Lambda-Ausdrücke, Extension-Methoden und das Streams API bedeuten einen großen Wandel für Java. Entwicklern steht dadurch ein funktionaler, ausdrucksstärkerer und intuitiverer Ansatz zur Verfügung, um Bulk- und Aggregat-Operationen auf Collections anzuwenden. Da das indes nur eine Anwendungsmöglichkeit von Lambda-Ausdrücken unter vielen ist, ist das nur der Anfang der potenziellen Verbesserungen, nicht das Ende.

Java SE 8 hält zudem eine ganze Reihe zusätzlicher Verbesserungen bereit, wie z.B. das neue Date and Time API, Annotationen für Java-Typen und das Projekt Nashorn, eine komplett neue JavaScript-Engine als Ersatz für Rhino. Auch wenn es inzwischen fast zwei Jahrzehnte alt ist: Java bleibt eine lebendige Plattform, die immer noch Aufregendes zu bieten hat!

Geschrieben von
Simon Ritter
Simon Ritter
Simon Ritter manages the Java Technology Evangelist team at Oracle Corporation. Simon has been in the IT business since 1984 and holds a Bachelor of Science degree in Physics from Brunel University in the U.K. Originally working in the area of UNIX development for AT&T UNIX System Labs and then Novell, Simon moved to Sun in 1996. At this time he start- 
ed working with Java technology and has spent time working both in Java develop- ment and consultancy. Having moved to Oracle as part of the Sun acquisition he now focuses on the core Java platform, Java for client applications and embedded Java. He also continues to develop demonstrations that push the boundaries of Java for applications like gestural interfaces, embedded robot controllers and in-car systems.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: