Java 8 Streams - Tutorial & Beispiele

In diesem Tutorial beschreibe ich die Funktionsweise der Java 8 Streams API und zeige anhand von Beispielen den Unterschied zu klassischen Java Implementierung.
Java 8 Streams Tutorial

Java 8 Streams Tutorial

Einleitung

Collections, Listen oder Sets gehören zum Berufsalltag eines Java Programmierer. Es gibt keine Software, die ohne einer diese Datenstrukturen implementiert ist. Die klassische Art um mit diesen Datenstrukturen umzugehen sind Schleifen.

Schleifen haben leider den Nachteil, dass sie den Code ein wenig unübersichtlich machen. Der Programmierer muss quasi Zeile für Zeile durch den Quellcode analysieren um zu verstehen was implementiert wurde.

Mit Java 8 wurde die Streams API vorgestellt. Streams erlauben es dem Programmierer Quellcode zu schreiben, welches lessbarer ist und somit auch wartbarer.

In diesem Tutorial werde ich euch zeigen wie ihr euren Quellcode besser strukturieren könnt. Hierfür werde ich euch im Vergleich zeigen, wie Ihr ein Problem auf die klassische Java Art lösen könnt und wie es mit Streams gelöst wird

Voraussetzung

  • Java Kenntnisse

Stream vs ParallelStream

Es gibt zwei Arten von Streams in Java. Zum einen haben wir die klassischen Streams und zum anderen die ParallelStreams. Beide Streams können aus jeder Collection, List oder Set heraus erstellt werden. Was genau die unterschiede sind werden wir an einem Beispiel sehen.

Stream

Im Kontext von Java 8 ist ein Stream eine Sequenz von Objekten auf die gewisse Methoden ausgeführt werden können. Welche Methode das genau sind werden wir weiter unten kennen lernen.

Die “klassischen” Streams in Java 8, welche mit der Methode .stream() erzeugt. Diese kann man wie oben schon erwähnt aus einer Collection, List oder Set erzeugen. Die Elemente der “klassischen” Streams werden nacheinander abgearbeitet. Im Grunde kann man das vergleichen mit einer Iteration. Nur mit dem Unterschied, dass man nicht in dem Iterationsschritt implementiert was mit dem aktuellen Element passieren soll.

ParallelStream

Zusätzlich zu den “klassischen” Streams bietet Java 8 auch die ParallelStreams an. Wie der Name schon sagt sind ParallelStreams Streams die parallel abgearbeitet werden. Dies hat den großen Vorteil, dass man nicht selber sich um das Multithreading kümmern muss. Die Stream API übernimmt das für uns.

Folgender Quellcode zeigt die Nutzung von Streams & ParallelStreams.

List list = Arrays.asList("Customer 1", "Customer 2", "Customer 3", "Customer 4", "Customer 5");

list.stream().forEach(s -> System.out.println("Stream: " + s + " - " + Thread.currentThread()));

list.parallelStream().forEach(s -> System.out.println("ParallelStream: " + s + " - " + Thread.currentThread()));

Eigene Streams erzeugen

Natürlich ist es auch möglich eigene Streams zu erzeugen. Hierfür bietet die Streams API mehrere Möglichkeiten an.

Folgender Quellcode zeigt wie Streams Objekte erzeugt werden können.

Stream myStream = Stream.of("Customer 1", "Customer 2", "Customer 3", "Customer 4", "Customer 5");

myStream.forEach(s -> System.out.println("My Stream: " + s));

Java Streams Filter

Nachdem wir nun wissen welche Arten es von Stream in Java 8 existieren, schauen wir uns nun die Filter Methode genauer an. Wie der Name schon sagt, kann man mit der Filter Methode die Objekte in dem Stream Filtern. Die Rückgabe der Lampda Ausdruckes der Filter Methode muss ein true zurückliefern, wenn das Objekt in dem Stream weiterhin bleiben soll. Bei einem false wird das aktuelle Objekt aus dem Stream entfernt.

Einfache Filter Funktion

Um das Filtern in Streams zu veranschaulichen werden wir uns zwei Implementierungen anschauen. Das erste wird den meisten Java Programmierern bekannt vorkommen. Hierbei handelt es sich um eine klassische for-Schleife mit einer if-Abfrage.

Folgender Quellcode zeigt wie mit einer for-Schleife und einer if-Abfrage Objekte aus einer Liste selektiert und diesen dann als Liste wieder zurück gibt.

Customer customer1 = new Customer("1", "Albert", "Einstein");
Customer customer2 = new Customer("2", "Otto", "Hahn");
Customer customer3 = new Customer("3", "Isaac", "Newton");
Customer customer4 = new Customer("4", "Stephen", "Hawking");
Customer customer5 = new Customer("5", "Werner", "Heisenberg");

List customerList = Arrays.asList(customer1, customer2, customer3, customer4, customer5);
List resultList = new ArrayList<>();
for (Customer customer : customerList) {
    if (Integer.valueOf(customer.getId()) > 3 ) {
        resultList.add(customer);
    }
}

System.out.println("Size of ResultList: " + resultList.size());

Folgender Quellcode zeigt die Implementierung mit Java Streams.

List resultListStream = customerList.stream()
    .filter(customer -> Integer.valueOf(customer.getId()) > 3)
    .collect(Collectors.toList());


System.out.println("Size of Stream ResultList: " + resultListStream.size());

Wie wir sehen können ist die Implementierung mit Java Streams leserlicher und übersichtlicher. Als Entwickler fällt es einem einfacher den Quellcode zu verstehen. Zudem ist es nicht mehr notwendig eine zusätzliche Liste zu erzeugen und sich die Objekte zu merken, die man selektiert hat. Was die collect() Methode macht werden wir im Abschnitt Collectoren kennen lernen.

Komplexe Filter Funktionen & Predicate

Bei der täglichen Arbeit wird es leider nicht ausreichen, dass man nur nach einem Kriterium Filtern muss. Unter Umständen muss man nach zwei oder mehreren Kriterien eine Liste Filtern. Auch hier bietet uns die Filter Funktion von Java Streams eine elegante und einfache Möglichkeit.

Die Verkettung der Filter Funktion

Nehmen wir unser Beispiel von oben und erweitern diese so, dass wir jetzt nach dem Vornamen und nach dem Nachnamen filtern möchten.

Auf die klassische Art würden wir den Code wie folgt implementieren.

Customer customer1 = new Customer("1", "Albert", "Einstein");
Customer customer2 = new Customer("2", "Otto", "Hahn");
Customer customer3 = new Customer("3", "Isaac", "Newton");
Customer customer4 = new Customer("4", "Stephen", "Hawking");
Customer customer5 = new Customer("5", "Werner", "Heisenberg");

List customerList = Arrays.asList(customer1, customer2, customer3, customer4, customer5);
List resultList = new ArrayList<>();
for (Customer customer : customerList) {
    if (customer.getFirstname().equals("Isaac") &&  customer.getFirstname().equals("Newton")) {
        resultList.add(customer);
    }
}

System.out.println("Size of ResultList: " + resultList.size());

Im Grunde ist die Implementierung fast identisch mit der Implementierung der einfachen Filterung. Auch hier haben wir eine ArrayList verwendet um die Objekte zu merken, die wir anhand der if Abfrage uns gemerkt haben.

Auch diese Implementierung können wir mit Java Streams und der Filter Funktion vereinfacht implementieren. Hierfür können wir die Filter Funktion zweimal aufrufen

Folgender Quellcode zeigt die Implementierung mit der Verkettung der Filter Funktion mit Java Streams.

List resultListStream = customerList.stream()
        .filter(customer -> customer.getFirstname().equals("Isaac"))
        .filter(customer -> customer.getLastname().equals("Newton"))
        .collect(Collectors.toList());

System.out.println("Size of Stream ResultList: " + resultListStream.size())

Auch hier sehen wir, dass unser Quellcode viel übersichtlicher und aufgeräumter wirkt.

Filtern mit Predicate

Es ist nicht nur möglich mit der Verkettung der Filter Funktion komplexere Filter Funktionen zu schreiben. Wir haben die Möglichkeit Predicate zu nutzen um komplexere Filter Funktionen zu erstellen. Predicate Objekte können mit der and(…) Methode miteinander verkettet werden. Somit kann der Entwickler mehrere Predicate schreiben und diese dann später verketten. Predicate bieten noch einen weiteren Vorteil! Predicate werden der Filter Funktion als Lambda Ausdruck übergeben und sind somit zur Laufzeit ersetzbar. Das heißt im Klartext, dass je nach Anforderung es möglich ist mit einer Filterfunktion unterschiedliche Filter Kriterien zu nutzen

Folgender Quellcode zeigt die Implementierung mit Predicate.

Predicate firstnamePredicate = (Customer customer) -> customer.getFirstname().equals("Isaac");
Predicate lastnamePredicate = (Customer customer) -> customer.getLastname().equals("Newton");

List resultListStream2 = customerList.stream()
        .filter(firstnamePredicate.and(lastnamePredicate))
        .collect(Collectors.toList());

System.out.println("Size of Stream ResultList 2: " + resultListStream2.size());

Auf dem ersten Blick wirkt die Implementierung mit Predicate recht Umfangreich und komplex. Zumal es eine Zeit braucht bis man sich an die Predicate bzw Funktionalen Interfaces gewöhnt ha

Collectors

Mit Java Streams kann man nicht nur Objekte aus einer Collections, Liste oder Set filtern, sondern auch die Objekte die man gefiltert hat direkt als List, Set oder Map zurück geben.

Bleiben wir bei unseren Beispiel von oben. Da haben wir gesehen, dass wir nach dem Filtern stets eine collect(…) Funktion aufrufen. Die collect Funktion dient dazu, dass die im Stream verbliebenen Objekte gesammelt und dann zurück gegeben werden.

Collectors.toList()

Als erstes schauen wir uns den Collector für List an. Dieser sammelt die Objekte im Stream und gibt sie dann in einer List zurück.

Folgender Quellcode zeigt die Nutzung des Collectors.toList().

Customer customer1 = new Customer("1", "Albert", "Einstein");
Customer customer2 = new Customer("2", "Otto", "Hahn");
Customer customer3 = new Customer("3", "Isaac", "Newton");
Customer customer4 = new Customer("4", "Stephen", "Hawking");
Customer customer5 = new Customer("5", "Werner", "Heisenberg");

List customerList = Arrays.asList(customer1, customer2, customer3, customer4, customer5);

List resultList = customerList.stream()
        .filter(customer -> Integer.valueOf(customer.getId()) > 3)
        .collect(Collectors.toList());

Collectors.toSet()

Als nächstes schauen wir uns den Collector für Set an. Dieser macht das gleiche wie der Collector für List. Hier ist der Rückgabewert ein Set und keine Liste.

Folgender Quellcode zeigt die Nutzung des Collectors.toSet().

Set resultSet = customerList.stream()
        .filter(customer -> Integer.valueOf(customer.getId()) > 3)
        .collect(Collectors.toSet());

Collectors.toMap(

Als letztes Beispiel schauen wir uns den Collector für Map an. Dieser ist ein wenig umfangreicher als die vorherigen Collectoren, was aber nicht heißt das er komplizierter ist.

Wir wissen, dass wir für eine Map immer eine Key und einen Value brauchen. Genau diese müssen wir bei der Nutzung des Collectors angeben. In unseren Beispiel werden wir die id des Customer Objektes als Key verwenden und das Customer Objekt an sich als Value.

Folgender Quellcode zeigt die Nutzung des Collectors.toMap().

Map<String, Customer> resultMap = customerList.stream()
        .filter(customer -> Integer.valueOf(customer.getId()) > 3)
        .collect(Collectors.toMap(o -> o.getId(), Function.identity()));

Am Ende sehen wir folgenden Aufruf “Function.identity()”. Dieser Aufruf gibt uns das Customer Objekt zurück. Anstatt Function.identity() hätten wir auch o -> o schreiben können.
Nur finde ich, dass die erste Schreibweise viel leserlicher und ordentlicher.

Java Streams map() Methode

Die nächste Stream Methode die wir uns anschauen werden ist die map(…) Methode. Diese dient dazu die Objekte im Stream in eine andere Datenstruktur umzuwandeln. Nach dem mappen stehen dann auch nur die Objekte zur Verfügung in die gemappt wurde.

Folgender Quellcode zeigt die Nutzung des map Methode.

Customer customer1 = new Customer("1", "Albert", "Einstein");
Customer customer2 = new Customer("2", "Otto", "Hahn");
Customer customer3 = new Customer("3", "Isaac", "Newton");
Customer customer4 = new Customer("4", "Stephen", "Hawking");
Customer customer5 = new Customer("5", "Werner", "Heisenberg");

List customerList = Arrays.asList(customer1, customer2, customer3, customer4, customer5);

List firstnameList = customerList.stream()
        .map(Customer::getFirstname)
        .collect(Collectors.toList());

firstnameList.forEach(s -> System.out.println("Firstname: " + s));

Wie ihr bestimmt schon bemerkt habt steht da folgender Ausdruck „.map(Customer::getFirstname)“. Die Schreibweise mit den zwei Doppelpunkte ist die vereinfachte Version von „.map(customer -> customer.getFirstname())“.

Am Ende wird wie schon oben im Abschnitt Collectoren beschreiben das Ergebnis in einer Liste von Strings zusammengefasst und zurückgegeben.

Optional

Als nächstes werden wir uns das Optional Objekt anschauen. Mit dem Optional Objekt ist der Entwickler in der Lage die Schreibweise die er von den Streams kennt weiter zu nutzen.

Man stelle sich vor die find Methode einer Klasse gibt ein Customer Objekt zurück wenn diese gefunden wird. Sollte die Suchanfrage nicht erfolgreich sein, dann wird ein null zurück gegeben. In fast 100% der klassischen Implementierungen würde dann die Abfrage ob ein Ergebnis gefunden wurde wie folgt aussehen.

Folgender Quellcode zeigt die klassische Implementierung.

Customer foundCustomer = customerRepository.findById("someId");
if (foundCustomer == null) {
    throw new RuntimeException("Customer not found");
}

//Do something with the customer object

Empty

Das Ergebnis eines Optional Objektes kann empty sein. In dem Fall gibt es kein Customer Objekt mit dem man weiterarbeiten könnte. Hier muss man dann als Entwickler entscheiden wie man weiter implementiert.

Optional.ifPresent

Für den Fall, dass ein Objekt im Optional Objekt zurückgegeben wird kann man auf diesen in der Methode ifPresent zugreifen. Das kommt einem klassischen if (foundCustomer != null) gleich.

Folgender Quellcode zeigt wie man die ifPresent-Methode nutzt.

customerRepository.findByIdReturnEmptyOptional("someId")
        .ifPresent(customer -> System.out.println("Customer is found"));

Optional.orElse

Nachdem ein Optional Objekt zurückgegeben wurde kann man mit der orElse Methode das Customer Objekt welches sich im Optional Objekt befindet zurück geben oder das Objekt welches man der Methode orElse übergeben hat.

Folgender Quellcode zeigt die Nutzung der orElse Methode Implementierung.

Customer defaultCustomer = new Customer("", "", "");
Customer customerOfElse = customerRepository.findByIdOptionalImplementation("someId")
        .orElse(defaultCustomer);

Optional.orElseGet

Das Optional Objekt bietet zusätzlich die Methode orElseGet an. Viele werden sich jetzt fragen was ist der Unterschied zwischen orElse und dem orElseGet. Beide geben in unserem Fall ein Customer Objekt zurück. Beide Methoden geben erst das Objekt im Optional zurück und wenn nicht das welches im orElse oder orElseGet übergeben wurde.

Der Unterschied ist, dass orElseGet den Codeblock der übergeben wurde erst dann ausführt, wenn das Optional Objekt leer ist. Man kann das vergleichen mit dem Lazy Loading Prinzip.

Folgender Quellcode zeigt die Nutzung der orElseGet Methode Implementierung.

Customer customerOfElseGet = customerRepository.findByIdOptionalImplementation("someId")
        .orElseGet(() -> new Customer("", "", ""));

Optional.orElseThrow

Möchte man in dem Fall, dass das Optional Objekt kein Wert zurück gibt eine Exception werfen, dann ist es am sinnvollsten die orElseThrow Methode zu verwenden.

Folgender Quellcode zeigt die Nutzung der orElseThrow Methode Implementierung.

Customer cutomerOrElseThrow = customerRepository.findByIdReturnEmptyOptional("someId")
        .orElseThrow(() -> new RuntimeException("Customer not found"));

Der Schluss

In diesem Tutorial haben wir anhand von Beispielen gesehen wie die Java 8 Streams API genutzt werden kann. Wir haben auch gesehen, dass man mit Java Streams seinen Quellcode lesbarer schreiben kann. Am Anfang ist die Schreibweise einen klassischen Java Entwickler fremd. Mit der Zeit werdet auch ihr die Java Streams lieben lernen.

Verwandte Beiträge

Comments (2)

Da steht:
if (customer.getFirstname().equals(„Isaac“) && customer.getFirstname().equals(„Newton“)) {

}

Leave a comment