Streams

Aus Programmieren-Wiki
🚧 Diese Seite befindet sich in Bearbeitung 🚧

Java Streams: Eine kurze Einführung

Java Streams sind ein leistungsfähiges Werkzeug der funktionalen Programmierung. Obwohl sie Teil der Java Standardbibliothek sind, sollten sie im Programmieren-Kurs vorsichtig verwendet werden, da sie primär aus dem Bereich der Funktionalen Programmierung stammen und nicht Teil der gelehrten Objekt-Orientierung sind.

Warum sollten Streams im Programmieren-Kurs eher vermieden werden?

  1. Komplexität: Java Streams können für Anfänger schwer verständlich sein, besonders wenn sie noch nicht mit Konzepten der funktionalen Programmierung vertraut sind.
  2. Seiteneffekte vermeiden: Eines der funktionalen Paradigmen ist, dass Funktionen keine Seiteneffekte haben sollten. Dies bedeutet, dass der Zustand außerhalb der Funktion nicht geändert werden sollte. Das schließt eigentlich das Verwenden von Methoden wie Stream::forEach aus, da es typischerweise für Seiteneffekte verwendet wird. Das Abwägen, wie Streams verwendet werden sollten, hängt also insbesondere auch damit zusammen, wie groß das Wissen im Bereich der funktionalen Programmierung ist.

Einführung in Java Streams

Java Streams bieten eine Methode, um große Datenmengen effizient zu verarbeiten, indem sie eine Reihe von aufeinanderfolgenden Operationen auf Daten durchführen. Sie sind Bestandteil von Java seit Version 8 und gehören zum java.util.stream-Paket.

Hier ist ein einfaches Beispiel, das die Grundkonzepte von Streams zeigt:

import java.util.Arrays;
import java.util.List;

public class StreamExample {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");

        // Beispiel eines einfachen Streams
        long count = names.stream()
                          .filter(name -> name.startsWith("A"))
                          .count();

        System.out.println("Anzahl der Namen, die mit 'A' beginnen: " + count);
    }
}

In diesem Beispiel wird ein Stream aus einer Liste von Namen erstellt. Der Stream filtert die Namen, die mit "A" beginnen, und zählt dann die Anzahl dieser Namen. Beachten Sie, dass keine Seiteneffekte auftreten, da der Zustand außerhalb des Streams nicht verändert wird.

Funktionale Paradigmen einhalten

Beim Einsatz von Streams ist es wichtig, die Prinzipien der funktionalen Programmierung einzuhalten, insbesondere:

  1. Keine Seiteneffekte: Vermeiden Sie das Verwenden von forEach, um den Zustand außerhalb des Streams zu ändern.
  2. Immutability: Arbeiten Sie mit unveränderlichen Datenstrukturen.
  3. Pure Functions: Verwenden Sie reine Funktionen, die nur von ihren Eingaben abhängen und keine externen Zustände verändern.

Fazit

Java Streams bieten eine leistungsstarke Möglichkeit zur Datenverarbeitung im funktionalen Stil. Allerdings sollten sie im Programmieren-Kurs mit Vorsicht verwendet werden, da sie nicht zur gelehrten objektorientierten Programmierung gehören und Konzepte beinhalten, die Anfänger überfordern können. Wenn sie jedoch verwendet werden, ist es wichtig, die funktionalen Paradigmen einzuhalten und Seiteneffekte zu vermeiden.

Siehe dazu auch: https://docs.oracle.com/javase/8/docs/api/java/util/stream/package-summary.html

Verwenden von Streams (Details)

Im folgenden gehen wir nochmals im Detail auf das Konzept von Streams sein.

Vorab nochmals:

Streams sind nicht Teil des Vorlesungsstoffes und werden daher als nicht trivial angesehen. Das heißt, dass die Benutzung zwar erlaubt ist, allerdings entsprechend diese Stellen im Quellcode genau zu dokumentieren sind. Zudem sollte beachtet werden, dass Programme nicht ausschließlich durch die Benutzung von Streams gelöst werden sollten. Ist in einer Aufgabenstellung das Vermeiden von Streams vorgeschrieben, so sind diese nicht vollständig verboten, sollten aber insbesondere in diesen Aufgaben nur sehr spärlich verwendet werden und nur, wo dringend notwendig. Andere Lösungsansätze sollten bevorzugt werden.

Was sind Streams?

Eine kurze Definition von Oracle: "A sequence of elements from a source that supports aggregate operations"

Bröseln wir das mal auf:

  • Sequence of elements
    Ein Stream stellt eine Schnittstelle zu einer Menge an Werten mit spezifiziertem Typ bereit. Allerdings speichern sie die Elemente nicht, sondern berechnen die Ergebnisse wenn sie gebraucht werden.
  • Source
    Streams erhalten Elemente durch eine datenbereitstellende Quelle wie zum Beispiel Collections, Arrays oder I/O Ressourcen
  • Aggregate operations
    Streams unterstützen SQL-ähnliche Operationen und Operationen aus funktionalen Programmiersprachen wie filter, map, reduce, find, match, sorted, ... Diese Operationen sind auch ein Grund dafür, weshalb Streams nur in Maßen verwendet werden sollen und nicht Teil der Vorlesung sind. Ihre funktionale Natur entsprechen nicht dem objektorientierten Ansatz der in dieser Lehrveranstaltung im Fokus liegt.

Stream-Operationen haben außerdem folgende zwei Charakteristiken, welche sie von Collection-Operationen stark abheben:

  • Pipelining
    Viele Stream-Operationen geben selbst auch wieder einen Stream zurück. Das erlaubt es, mehrere dieser Operationen aneinander zu ketten um dadurch eine Pipeline zu bilden. Dadurch kann einiges optimiert werden, wie zum Beispiel laziness und short-circuiting
  • Internal iteration
    Im Gegensatz zu Collections, welche explizit iteriert werden, iterierten Stream-Operationen implizit ("Hinter den Kulissen")

Was ist die Syntax von Streams?

Um einen Stream zu erhalten gibt es mehrere Methoden:

  • Von einer Collection mit der ::stream() Methode
  • Von einem Array mit der Arrays::stream(Object[]) Methode
  • Von statischen Factory-Methoden aus den Stream Klassen wie z.B. Stream::of(Object[]), IntStream::range(int, int), ...
  • Zeilen einer Datei können mithilfe von BufferedReader::lines() ausgelesen werden (gibt einen Stream<String> zurück)
  • Streams von Dateipfaden mithilfe Methoden aus der Files-Klasse
  • Streams aus (Pseudo-)Zufallszahlen mithilfe der Methode Random::ints()
  • ...

Wie baue ich eine Stream-Pipeline?

Der genaue Aufbau einer Pipeline ist je nach Verwendungszweck unterschiedlich. Allerdings gibt es wiederkehrende Bausteine in einer Stream-Pipeline die sehr hilfreich sein können. Die im Folgenden vorgestellten Bausteine können in (theoretisch) jeder beliebigen Reihenfolge aneinander gekettet werden um eine Pipeline zu erstellen, da sieselbst wieder einen Stream zurückgeben:

  • distinct()
    Der zurückgegebene Stream enthält jedes Element des originalen Streams exakt einmal. Sprich: Sind Elemente doppelt vorhanden, wird eins davon verworfen. Ob Elemente doppelt sind wird mit der jeweiligen Object::equals(Object) Methode entschieden
  • filter(Predicate<? super T> predicate)
    Der Stream wird mit einer vorgegebenen Regel untersucht und Werte die dem gegebenen Predicate (Filter) entsprechen, werden als Stream weitergegeben.
  • limit(long maxSize)
    Gibt einen Stream zurück, der maximal die gegebene Anzahl Elemente des originalen Streams enthält. Die Menge wird am Ende abgeschnitten. Es werden also die ersten n Elemente ausgewählt.
  • peek(Consumer <? super T> action)
    Gibt den Stream zurück wie er war, kann allerdings jedes Element nach einer gegebenen Regel modifizieren bevor es wieder verwendet wird.
  • skip(long n)
    Die ersten n Elemente des gegebenen Streams werden ignoriert, alle anderen werden als Stream weitergegeben
  • sorted()
    Sortiert den gegebenen Stream nach natürlicher Ordnung (in der Regel Alphanumerisch).

Um einen Stream zu beenden oder gesuchte/gefilterte Elemente zu erhalten müssen wir ihn jetzt noch terminieren. Auch dafür gibt es verschiedene Operationen um verschiedene Ergebnisse zu erhalten. Diese können nicht aneinander gekettet werden:

  • boolean allMatch(Predicate<? super T> predicate)
    Gibt zurück ob alle Elemente des Streams einer Regel übereinstimmen
  • boolean anyMatch(Predicate<? super T> predicate)
    Gibt zurück ob ein Element des Streams einer Regel übereinstimmt
  • long count()
    Gibt die Anzahl der Elemente des Streams zurück

Ordnung hat, wird diese eingehalten.

  • Optional<T> findAny()
    Gibt ein Element aus dem Stream zurück falls eins existiert. Wenn nicht, wird ein leeres Optional zurückgegeben.
  • Optional<T> findFirst()
    Gibt das erste Element des Streams zurück, falls es existiert. Wenn nicht, wird ein leeres Optional zurückgegeben
  • Optional<T> min(Comparator<? super T> comparator)
    Gibt das kleinste Element des Streams zurück. Verglichen wird mit dem gegebenen Comparator.
  • Optional<T> max(Comparator<? super T> comparator)
    Gibt das größte Element des Streams zurück. Verglichen wird mit dem gegebenen Comparator.
  • Object[] toArray()
    Gibt ein Array zurück, in dem alle Elemente des Streams enthalten sind.
  • <R, A> R collect(Collector<? super T, A, R> collector)
    Führt "mutable reduction" Operationen auf dem Stream, mithilfe eines gegebenen Collectors aus. In der Regel verwenden wir diese Methode um eine Liste der Elemente zu erhalten.

Ein Beispiel

List<Integer> numbers = List.of(4, 3, 1, 5, 2); 
List<Integer> result = numbers.stream() 
                          .filter(n -> n % 2 == 0) 
                          .map(n -> n * 2) 
                          .sorted() 
                          .collect(Collectors.toList());

Wir gehen die Zeilen einzeln durch:

  1. Wir erstellen eine Liste an Zahlen, die wir manipulieren wollen
  2. Diese Liste wird in einen Stream umgewandelt
  3. Jedes Element wird geprüft, ob es eine gerade Zahl ist und weitergegeben in diesem Fall
  4. Jede zuvor gefundene, gerade Zahl wird jetzt mit 2 multipliziert
  5. Die Zahlen werden jetzt in ihrer natürlichen Ordnung sortiert (also Alphanumerisch, aufsteigend)
  6. Der Stream wird jetzt wieder in eine Liste umgeformt und damit terminiert

Im Folgenden Diagram, wird das Vorgehen des Beispiels bildlich darstellt:

Streams Diagram.png


Streams können sehr komplex sein und das Thema ist hier auch nur angeschnitten. Es kann also weiterhin sehr informativ sein, weitere Seiten zu durchstöbern. Hier gibt es noch weitere Ressourcen zu Streams wie Methodenlisten, Dokumentation und Beispiele:

  1. https://docs.oracle.com/javase/8/docs/api/?java/util/stream/Stream.html
  2. https://www.oracle.com/technical-resources/articles/java/ma14-java-se-8-streams.html
  3. https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/stream/package-summary.html