Java 8 – Lambdas und Streams

Mit Java 8 sind zwei komplett neue Features in die Java Welt gekommen. Lambda Expressions und Streams. In diesem Artikel möchte ich diese kurz beleuchten.

Lambda Expressions

Mit Lambdas bekommen Funktionen in Java einen ganz neuen Stellenwert. Funktionen können beschrieben werden, ohne dass dafür eine eigene Klasse implementiert werden muss. Und Funktionen können, wie schon bei JavaScript üblich, als Aufruf-Parameter oder Rückgabe-Wert übergeben werden. Damit werden in Java erstmals Closures unterstützt und Funktionen sind nicht mehr nur Teil einer Klasse sondern existieren eigenständig. Ein erster Schritt in Richtung Funktionale Programmierung.
Lamba Expressions haben eine kompakte, und zumindest im ersten Blick, gewöhnungsbedürftige Schreibweise.

Ein Praxisbeispiel soll das veranschaulichen. Wir haben eine Methode, der eine Liste von Strings übergeben wird und die nur bestimmte Strings aus der Liste ausgeben soll. Dazu wird der Methode ein Filter übergeben.

protected void showEntries(final List<String> liste, final StringFilter filter) {
	for (String entry : liste) {
		if (filter.check(entry)) {
			System.out.println(entry);
		}
	}
}

Für den Filter ist folgendes Interface definiert.

public interface StringFilter {
	public boolean check(String s);
}

Die Methode showEntries geht die Liste in einer Schleife durch und ruft für jeden Eintrag die Methode check des StringFilter auf. Liefert dieser true zurück, wird der String auf der Konsole ausgegeben.

Klassisch wird showEntries mit einer anonymen Klasse aufgerufen. Diese implementiert das Interface StringFilter und muss in der Methode check die Filterlogik implementieren. Im Beispiel wird geprüft, ob der String mehr als 5 Zeichen hat. Es werden also nur die Strings auf der Konsole ausgegeben, die eine Länge größer 5 haben.

public void testClassic() {
	showEntries(testData, new StringFilter() {

		@Override
		public boolean check(String s) {
			return s.length() > 5;
		}
	});
}

Das ist jede Menge unnötiger und unübersichtlicher Code. Mit einer Lambda Expression geht das deutlich kompakter.

public void testLambda() {
	showEntries(testData, s -> s.length() > 5);
}

Das sieht erst mal ungewöhnlich aus, ist aber viel übersichtlicher.
Es wird wieder showEnrtries aufgerufen und übergibt, als zweiten Parameter, keine anonyme Klasse, sondern eine Funktion in Form einer Lambda Expression, die dem Interface StringFilter entspricht. Die Funktion implementiert also die abstrakte Methode check(String s) aus dem Interface StringFilter.
Mit diesem Hintergrund wird die Schreibweise s -> s.length() > 5 verständlicher. Das s vor dem -> ist der Aufrufparameter, der der Funktion übergeben wird, also der String, der geprüft werden soll. Das entspricht also dem Funktionskopf check(String s) aus der klassischen Implementierung. Der Code s.length() > 5 nach -> ist die Implementierung der Funktionalität, nämlich die Prüfung, ob s größer 5 ist. Das Ergebnis der Prüfung wird automatisch zurück gegeben, die Anweisung return wird nicht angegeben. Das entspricht also return s.length() > 5; aus der klassischen Implementierung.

Es gehen natürlich auch mehrzeilige Lambda Expressions. In dem Fall muss die return-Anweisung angegeben werden.

public void testLambdaMultiLines() {
	showEntries(testData, s -> {
		System.out.println("Testing " + s);
		return s.length() > 5;
	});
}

Voraussetzung für Lambda Expressions sind Functional Interfaces. Das sind Interfaces, die genau eine abstrakte Methode haben. Unser Interface StringFilter erfüllt diese Voraussetzung. Optional kann dies auch im Code deutlich gemacht werden, indem das Interface die Annotation @FunctionalInterface bekommt.
Eine Methode, die ein Functional Interface als Aufrufparameter hat, kann mit einer Lambda Expression aufgerufen werden. Der Kompiler prüft, ob die Lambda Expression dem Functional Interface entspricht. Die Methode showEntries hat mit StringFilter ein Functional Interface als Aufrufparameter. Somit kann hier eine Lambda Expression angewandt werden.

Java 8 bringt im Package java.util.function schon eine ganze Reihe Functional Interfaces mit. Es lohnt sich also erst mal zu schauen, ob es bereits ein passendes gibt, bevor man ein eigenes erstellt.
Für unser Beispiel gibt es mit Predicate ein passendes Functional Interface. Die Methode test bekommt ein Object übergeben und gibt einen boolean zurück.
Wir können uns somit das Interface StringFilter sparen und die Methode showEntries wie folgt umstellen.

protected void showEntries(final List<String> liste, final Predicate<String> filter) {
	for (String entry : liste) {
		if (filter.test(entry)) {
			System.out.println(entry);
		}
	}
}

Als zweiten Parameter erwarten wir jetzt einen Predicate an Stelle eines StringFilters und in der if-Abfrage rufen wir test statt check auf. Für den Aufrufer ändert sich nichts, showEntries wird immer noch mit

showEntries(testData, s -> s.length() > 5);

aufgerufen.

Tiefer möchte ich in Lambdas nicht einsteigen und auf den Artikel Lambda Expressions von Oracle verweisen.

Streams

Mit Streams habe ich mich, genau wie mit Lambdas, zunächst gar nicht befasst. Ich dachte das hat etwas mi I/O-Streams wie InputStream zu tun. Weit gefehlt, bei Streams handelt es sich um Pipelines, durch die Daten fließen und dabei bearbeitet und manipuliert werden. Ausgangspunkt ist eine Liste mit Daten, die Eintrag für Eintrag abgearbeitet werden. Klingt zunächst wie eine normale for-Schleife oder ein Iterator. Man muss sich das aber eher wie ein Fließband vorstellen, über das die Datenpakete laufen. Auf dem Fließband werden die Pakete aussortiert, manipuliert und verarbeitet. Der Code ist dabei sehr kompakt und dank eines Fluent-Interfaces sehr übersichtlich.

Zurück zu unserem Beispiel mit den Lambdas. Wir wollen aus einer Liste mit Strings nur die ausgeben, die größer 5 sind.

public void filterList() {
	final List<String> testData = Arrays.asList("Marco", "Tine", "Julia", "Daniel", "Manuel", "Rebekka");

	testData.stream()
		.filter(s -> s.length() > 5)
		.forEach(System.out::println);
}

Das ist kompakt und gut lesbar. Wir erzeugen aus der Liste testData einen Stream. Dieser Stream wird mit der Methode filter gefiltert. Dieser wird eine Lambda Expression übergeben in der geprüft wird, ob der String größer 5 ist. Nur die Strings, die dem Filter-Kriterium entsprechen, werden an forEach übergeben und dort auf der Konsole ausgegeben.

Jetzt machen wir noch das klassische Hello-World daraus und verändern den String, indem wir vor jeden gefilterten String ein Hallo setzen.

public void filterList() {
	final List<String> testData = Arrays.asList("Marco", "Tine", "Julia", "Daniel", "Manuel", "Rebekka");

	testData.stream()
		.filter(s -> s.length() > 5)
		.map(s -> "Hallo " + s)
		.forEach(System.out::println);
}

Die Ausgabe ist jetzt

Hallo Daniel
Hallo Manuel
Hallo Rebekka

Tiefer möchte ich in die Streams API nicht einsteigen und auf das Stream Tutorial von Benjamin Winterberg verweisen.

Nur noch zwei wichtige Hinweise. Es gibt Stream-Operationen, die den Stream beenden, z.B. forEach. Danach kann nicht mehr mit dem Stream gearbeitet werden. Andere Operationen wie filter oder map geben wieder einen Stream zurück und es kann weiter mit dem Stream gearbeitet werden.
Man kann die Verarbeitung eines Streams sehr einfach parallelisieren, indem man statt stream(), parallelStream() zur Erzeugung des Streams nutzt.

Ich möchte hier noch einen kleinen interessanten Anwendungsfall zeigen.

Situation ist, dass das Log-File vom Spiel Elite Dangerous analysiert werden soll. Immer wenn der Spieler in ein anderes System springt soll eine Aktion ausgeführt werden. Erkannt wird dies an einem bestimmten Eintrag im Log-File, in dem der Name des Systems steht, in dem der Spieler aktuell ist. Ändert sich der Name des Systems, wurde in ein anderes System gesprungen.
Der folgende Code zeigt einen Ausschnitt aus dem Log-File, Zeile 3 ist die relevante Zeile. Der Spieler ist im System Koria.

{16:51:11} EnterLocation: Activity=EstablishServers&note=complete&Start=0.00&Duration=0.15
{16:51:11} EnterLocation: Activity=WaitForEDServer&note=complete&Start=0.17&Duration=0.00
{16:51:11} System:20(Koria) Body:34 Pos:(-912.113,387.702,-133.182)
{16:51:12} EnterLocation: Activity=DestinationLocationFailure&note=complete&Start=0.00&Duration=0.60
{16:51:14} Took over 0.000000 seconds waiting for
[ 1 ] Shared Objects that still are not live
 OutpostIndMedium02 0x00000000c0639e00 State=AwaitingReadyToGoLive
Carrying on bravely in the hope that nothing breaks

Mit der Stream-API kann die Analyse des Log-Files sehr kompakt implementiert werden.

private static final String INPUT_FILE_NAME    = "D:/workspaces/test/netLog.1505111649.02.log";
private static final String PATTERN_EXPRESSION = "\\{\\d+:\\d+:\\d+\\} System:\\d+\\(([^)]+)\\).*";
private static final Pattern PATTERN           = Pattern.compile(PATTERN_EXPRESSION);

private int startLine = 1;
private String currentSystem = "";

public void filterFile() {
	try (Stream<String> lines = Files.lines(Paths.get(INPUT_FILE_NAME), Charset.forName("ISO-8859-1"))) {
		lines
		.skip(startLine)
		.filter(line -> {
			Matcher m = PATTERN.matcher(line);
			return m.matches();
		})
		.map(line -> {
			Matcher m = PATTERN.matcher(line);
			m.matches();
			return m.group(1);
		})
		.filter(system -> {
			if (system.equals(currentSystem)) {
				return false;
			} else {
				currentSystem = system;
				return true;
			}
		})
		.map(system -> "jumped to " + system)
		.forEach(System.out::println);
	} catch (IOException ioe) {
		System.out.println(ioe.toString());
	}

In Zeile 2 und 3 wird ein Regulärer Ausdruck definiert, der die relevante Zeile im Log-File mit dem Systen-Name erkennt und eine Gruppe festlegt, die den System-Namen enthält.
In Zeile 9 wird das Log-File geöffnet und die einzelnen Zeilen des Files als Stream im Attribute lines zurück gegeben. Eine Besonderheit ist, dass die File-Operation im try-Statement erfolgt. Dadurch muss man keinen finally-Block angeben, da das File automatisch von Java wieder geschlossen wird.
Zeile 11 überspringt mit der skip-Anweisung die angegebene Zahl von Zeilen (falls man das File schon mal eingelesen hat und an einer bestimmten Zeile wieder aufsetzen möchte).
Zeile 12 filtert und lässt nur die Zeilen durch, die dem Regulären Ausdruck entsprechen.
Zeile 16 ermittelt über den Regulären Ausdruck den System-Namen und gibt diesen weiter.
Zeile 21 prüft, ob sich der System-Name geändert hat, nur in diesem Fall wird er an den nächsten Schritt weiter gegeben.
Zeile 29 fügt vor den System-Namen noch den Text „jumped to“ hinzu.
Zeile 30 gibt den String auf der Konsole aus.

Die folgende Tabelle zeigt, wie ein Datensatz durch die Pipeline des Streams läuft und sich verändert.

Operation Aktion Output
skip in Zeile 11 überspringt die angegebene Anzahl Zeilen {16:51:11} System:20(Koria) Body:34 Pos:(-912.113,387.702,-133.182)
filter in Zeile 12 Lässt nur Zeilen durch, die dem Regulären Ausdruck entsprechen {16:51:11} System:20(Koria) Body:34 Pos:(-912.113,387.702,-133.182)
map in Zeile 16 Ermittelt mit dem regulären Ausdruck den System-Namen Koria
filter in Zeile 21 Lässt nur die System-Namen durch, die nicht dem currentSystem entsprechen. Wenn neuer Name wird dieser currentSystem zugewiesen Koria
map in Zeile 29 Stellt vor den System-Name den String „jumped to“ jumped to Koria
forEach in Zeile 30 Gibt den String auf der Konsole aus

In einem realen Szenario könnte in Zeile 29 ein JSON-Objekt erzeugt werden, das in Zeile 30 an einen Server gesendet wird.

Der Filter in Zeile 21 ist im Sinne der Funktionalen Programmierung nicht sauber. Diese schreibt vor, dass Funktionen frei von Seiten-Effekten sein müssen. Da hier auf eine Instanz-Variable zugegriffen wird, ist das nicht der Fall. Konkurrierende Zugriffe auf die Variable currentSystem kann zu Seiten-Effekte führen. Auch die Parallelisierung des Streams ist deshalb nicht möglich. Ich habe allerdings keinen anderen Weg gefunden.

Es bleibt aber festzuhalten, dass die Stream-API kompakten und verständlichen Code ermöglicht, der auch recht effizient ist. Im konkreten Beispiel wird das File nicht komplett in den Speicher geladen, sondern zeilenweise eingelesen. Das forEach-Statement wird nur für die Zeilen ausgeführt, die den Filter-Kriterien entsprechen. Es können so also auch sehr große Files effizient verarbeitet werde.

Aus meiner Sicht gibt es noch eine Schwäche. Streams basieren immer auf fixe Collections. Wird einer Collection, oder einem File ein Eintrag hinzugefügt, nach dem der Stream erzeugt wurde, taucht dieser Eintrag nicht im Stream auf. Streams sind im Sinne der Daten statisch und nicht dynamisch.
Im konkreten Beispiel wäre aber ein Observer interessant, der immer dann, wenn im Log-File ein neuer Eintrag hinzugefügt wird, diesen Eintrag dem Stream zuführt. Also ein Stream, der auf einer endlosen Datenquelle basiert.
Dazu muss ich mir mal Reactive Streams und ReactiveX genauer anschauen.

Sourcen

Die Sourcen der Beispiele stehen bei GitHub zur Verfügung:
https://github.com/MarcoMichel/Labor/tree/master/Java8-Test

Schreibe einen Kommentar

Anmelden um einen Kommentar abzugeben.

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.

*