Part 10

Sammlungen (Collections) als Streams verarbeiten

Lassen Sie uns Collections (im Weiteren nur: Sammlungen), wie Listen, als Datenströme (Streams) von Werten kennenlernen. Ein Stream ist eine Möglichkeit, eine Sammlung von Daten zu durchlaufen, wobei die Programmierenden die Operationen festlegen, die auf jedem Wert ausgeführt werden. Es wird kein Protokoll über den Index oder die gerade verarbeitete Variable geführt.

Mit Streams definieren die Programmierenden eine Abfolge von Ereignissen, die für jeden Wert in einer Sammlung ausgeführt wird. Eine Ereigniskette kann das Verwerfen einiger Werte, das Konvertieren von Werten von einer Form in eine andere oder Berechnungen umfassen. Ein Stream ändert nicht die Werte in der ursprünglichen Datensammlung, sondern verarbeitet sie nur. Wenn Sie die Transformationen beibehalten möchten, müssen diese in einer anderen Datensammlung gespeichert werden.

Lassen Sie uns die Verwendung von Streams anhand eines konkreten Beispiels verstehen. Betrachten Sie folgendes Problem:

Schreiben Sie ein Programm, das Eingaben von einem Benutzer liest und Statistiken zu diesen Eingaben ausgibt. Wenn der Benutzer den String "end" eingibt, wird das Lesen gestoppt. Andere Eingaben sind Zahlen im String-Format. Wenn die Eingabe gestoppt wird, gibt das Programm die Anzahl der positiven ganzen Zahlen, die durch drei teilbar sind, und den Durchschnitt aller Werte aus.

// Wir initialisieren den Scanner und die Liste, in die die Eingabe gelesen wird
Scanner scanner = new Scanner(System.in);
List<String> inputs = new ArrayList<>();

// Eingaben lesen
while (true) {
    String row = scanner.nextLine();
    if (row.equals("end")) {
        break;
    }

    inputs.add(row);
}

// Anzahl der durch drei teilbaren Werte ermitteln
long numbersDivisibleByThree = inputs.stream()
    .mapToInt(s -> Integer.valueOf(s))
    .filter(number -> number % 3 == 0)
    .count();

// Durchschnitt berechnen
double average = inputs.stream()
    .mapToInt(s -> Integer.valueOf(s))
    .average()
    .getAsDouble();

// Statistiken ausgeben
System.out.println("Divisible by three " + numbersDivisibleByThree);
System.out.println("Average number: " + average);

Lassen Sie uns den Teil des obigen Programms genauer betrachten, in dem die Eingaben als Streams verarbeitet werden.

// Anzahl der durch drei teilbaren Werte ermitteln
long numbersDivisibleByThree = inputs.stream()
    .mapToInt(s -> Integer.valueOf(s))
    .filter(number -> number % 3 == 0)
    .count();
Ein Stream kann aus jedem Objekt gebildet werden, das das Collection-Interface implementiert (z.B. ArrayList, HashSet, HashMap, ...), mit der Methode stream(). Die String-Werte werden dann mit der Stream-Methode mapToInt(value -> conversion) in eine Ganzzahl umgewandelt („gemappt“). Die Umwandlung erfolgt durch die valueOf-Methode der Klasse Integer, die wir bereits in der Vergangenheit verwendet haben. Anschließend verwenden wir die Methode filter(value -> filter condition), um nur die Zahlen herauszufiltern, die durch drei teilbar sind, und diese weiter zu verarbeiten. Schließlich rufen wir die Methode count() des Streams auf, die die Anzahl der Elemente im Stream zählt und als long-Variable zurückgibt.

Lassen Sie uns nun den Teil des Programms betrachten, der den Durchschnitt der Listenelemente berechnet.

// Durchschnitt berechnen
double average = inputs.stream()
    .mapToInt(s -> Integer.valueOf(s))
    .average()
    .getAsDouble();
Das Berechnen des Durchschnitts ist von einem Stream möglich, auf den die Methode mapToInt angewendet wurde. Ein Stream von Ganzzahlen hat eine Methode average, die ein OptionalDouble-Objekt zurückgibt. Das Objekt verfügt über die Methode getAsDouble(), die den Durchschnitt der Listenwerte als double-Variable zurückgibt.

Eine kurze Zusammenfassung der Stream-Methoden, die wir bisher kennengelernt haben:

Zweck und Methode Voraussetzungen
Stream erstellen: stream() Die Methode wird für eine Sammlung aufgerufen, die das Collection-Interface implementiert, wie z.B. ein ArrayList-Objekt. Mit dem erstellten Stream wird etwas gemacht.
Einen Stream in einen Ganzzahl-Stream konvertieren: mapToInt(value -> another) Der Stream wird in einen Stream, der Ganzzahlen enthält, umgewandelt. Ein Stream, der Strings enthält, kann beispielsweise mit der valueOf-Methode der Klasse Integer konvertiert werden. Mit dem Stream, der Ganzzahlen enthält, wird etwas gemacht.
Werte filtern: filter(value -> filter condition) Die Elemente, die die Filterbedingung nicht erfüllen, werden aus dem Stream entfernt. Auf der rechten Seite des Pfeils steht eine Anweisung, die einen booleschen Wert zurückgibt. Wenn der boolesche Wert true ist, wird das Element in den Stream aufgenommen. Wenn der boolesche Wert false ist, wird das Element nicht in den Stream aufgenommen. Mit den gefilterten Werten wird etwas gemacht.
Durchschnitt berechnen: average() Gibt ein OptionalDouble-Objekt zurück, das eine Methode getAsDouble() hat, die einen Wert vom Typ double zurückgibt. Die Methode average() wird für Streams aufgerufen, die Ganzzahlen enthalten – diese können mit der mapToInt-Methode erstellt werden.
Anzahl der Elemente in einem Stream zählen: count() Gibt die Anzahl der Elemente in einem Stream als long-Wert zurück.
Loading
Loading

Lambda-Ausdrücke

Stream-Werte werden durch Stream-Methoden verarbeitet. Methoden, die Werte verarbeiten, erhalten eine Funktion als Parameter, die bestimmt, was mit jedem Element gemacht wird. Was die Funktion tut, ist spezifisch für die jeweilige Methode. Beispielsweise wird der filter-Methode, die zum Filtern von Elementen verwendet wird, eine Funktion übergeben, die einen booleschen Wert true oder false zurückgibt, je nachdem, ob der Wert im Stream behalten wird oder nicht. Der mapToInt-Methode, die für die Umwandlung verwendet wird, wird hingegen eine Funktion übergeben, die den Wert in eine Ganzzahl umwandelt, und so weiter.

Warum werden die Funktionen in der Form value -> value > 5 geschrieben?

Der obige Ausdruck, d.h. ein Lambda-Ausdruck, ist eine Abkürzung, die Java für anonyme Methoden bereitstellt, die keinen "Besitzer" haben, d.h. sie sind nicht Teil einer Klasse oder eines Interface. Die Funktion enthält sowohl die Parameterdefinition als auch den Funktionskörper. Dieselbe Funktion kann auf mehrere verschiedene Arten geschrieben werden. Siehe unten.

// original
*stream*.filter(value -> value > 5).*furtherAction*

// ist dasselbe wie
*stream*.filter((Integer value) -> {
    if (value > 5) {
        return true;
    }

    return false;
}).*furtherAction*

Dasselbe kann auch explizit geschrieben werden, indem eine statische Methode im Programm definiert wird, die innerhalb der an den Stream übergebenen Funktion verwendet wird.

public class Screeners {
    public static boolean greaterThanFive(int value) {
        return value > 5;
    }
}
// original
*stream*.filter(value -> value > 5).*furtherAction*

// ist dasselbe wie
*stream*.filter(value -> Screeners.greaterThanFive(value)).*furtherAction*

Die Funktion kann auch direkt als Parameter übergeben werden. Die folgende Syntax Screeners::greaterThanFive bedeutet: "Verwenden Sie die statische Methode greaterThanFive, die in der Klasse Screeners enthalten ist".

// ist dasselbe wie
*stream*.filter(Screeners::greaterThanFive).*furtherAction*

Funktionen, die Stream-Elemente verarbeiten, können keine Werte von Variablen außerhalb der Funktion ändern. Dies hat damit zu tun, wie statische Methoden funktionieren – während eines Methodenaufrufs gibt es keinen Zugriff auf Variablen außerhalb der Methode. Mit Funktionen können die Werte von Variablen außerhalb der Funktion gelesen werden, sofern sich diese Werte im Programm nicht ändern.

Das folgende Programm zeigt eine Situation, in der versucht wird, eine Variable außerhalb der Funktion zu verwenden. Es funktioniert nicht.

// Initialisieren eines Scanners und einer Liste, in die die Werte gelesen werden
Scanner scanner = new Scanner(System.in);
List<String> inputs = new ArrayList<>();

// Eingaben lesen
while (true) {
    String row = scanner.nextLine();
    if (row.equals("end")) {
        break;
    }

    inputs.add(row);
}

int numberOfMappedValues = 0;

// Anzahl der durch drei teilbaren Werte ermitteln
long numbersDivisibleByThree = inputs.stream()
    .mapToInt(s -> {
        // Variablen, die außerhalb einer anonymen Funktion deklariert wurden, können nicht verwendet werden, daher funktioniert dies nicht
        numberOfMappedValues++;
        return Integer.valueOf(s);
    }).filter(value -> value % 3 == 0)
    .count();

Stream-Methoden

Stream-Methoden können grob in zwei Kategorien unterteilt werden: (1) Zwischenoperationen, die zur Verarbeitung von Elementen gedacht sind, und (2) Terminaloperationen, die die Verarbeitung von Elementen abschließen. Sowohl die filter- als auch die mapToInt-Methoden, die im vorherigen Beispiel gezeigt wurden, sind Zwischenoperationen. Zwischenoperationen geben einen Wert zurück, der weiterverarbeitet werden kann – theoretisch könnten unendlich viele Zwischenoperationen hintereinander (durch einen Punkt getrennt) aufgerufen werden. Andererseits ist die average-Methode, die im vorherigen Beispiel gesehen wurde, eine Terminaloperation. Eine Terminaloperation gibt einen zu verarbeitenden Wert zurück, der beispielsweise aus den Stream-Elementen gebildet wird.

Die Abbildung unten veranschaulicht, wie ein Stream funktioniert. Der Ausgangspunkt (1) ist eine Liste mit Werten. Wenn die stream()-Methode für eine Liste aufgerufen wird, (2) wird ein Stream der Listenwerte erstellt. Die Werte werden dann einzeln verarbeitet. Die Stream-Werte können (3) mit der filter-Methode gefiltert werden, die Werte, die die Bedingung nicht erfüllen, aus dem Stream entfernt. Die map-Methode des Streams (4) kann verwendet werden, um Werte in einem Stream von einer Form in eine andere zu übertragen. Die collect-Methode (5) sammelt die Werte in einem Stream in einer angegebenen Sammlung, wie z.B. einer Liste.

Die oben schriftlich erläuterte Funktionsweise eines Streams als Bild.

 

Unten ist ein Programm des Beispiels dargestellt, das im Bild oben dargestellt ist. In diesem Beispiel-Stream wird eine neue ArrayList erstellt, in die Werte eingefügt werden. Dies geschieht in der letzten Zeile .collect(Collectors.toCollection(ArrayList::new));.

List<Integer> list = new ArrayList<>();
list.add(3);
list.add(7);
list.add(4);
list.add(2);
list.add(6);

ArrayList<Integer> values = list.stream()
    .filter(value -> value > 5)
    .map(value -> value * 2)
    .collect(Collectors.toCollection(ArrayList::new));
Loading

Terminaloperationen

Werfen wir einen Blick auf vier Terminaloperationen: die count-Methode zum Zählen der Anzahl der Werte in einer Liste, die forEach-Methode zum Durchgehen der Listenwerte, die collect-Methode zum Sammeln der Listenwerte in einer Datenstruktur und die reduce-Methode zum Kombinieren der Listenelemente.

Die count-Methode gibt die Anzahl der Werte im Stream als long-Variable an.

List<Integer> values = new ArrayList<>();
values.add(3);
values.add(2);
values.add(17);
values.add(6);
values.add(8);

System.out.println("Values: " + values.stream().count());
Beispielausgabe

Values: 5

Die forEach-Methode definiert, was mit jedem Listenwert gemacht wird, und beendet die Stream-Verarbeitung. Im folgenden Beispiel erstellen wir zunächst eine Liste von Zahlen, danach drucken wir nur die Zahlen, die durch zwei teilbar sind.

List<Integer> values = new ArrayList<>();
values.add(3);
values.add(2);
values.add(17);
values.add(6);
values.add(8);

values.stream()
    .filter(value -> value % 2 == 0)
    .forEach(value -> System.out.println(value));
Beispielausgabe

2 6 8

Sie können die collect-Methode verwenden, um Stream-Werte in eine andere Sammlung zu sammeln. Im folgenden Beispiel wird eine neue Liste erstellt, die nur positive Werte enthält. Der collect-Methode wird ein Collectors-Objekt als Parameter übergeben, in das die Stream-Werte gesammelt werden – zum Beispiel erstellt der Aufruf Collectors.toCollection(ArrayList::new) ein neues ArrayList-Objekt, das die gesammelten Werte enthält.
List<Integer> values = new ArrayList<>();
values.add(3);
values.add(2);
values.add(-17);
values.add(-6);
values.add(8);

ArrayList<Integer> positives = values.stream()
    .filter(value -> value > 0)
    .collect(Collectors.toCollection(ArrayList::new));

positives.stream()
    .forEach(value -> System.out.println(value));
Beispielausgabe

3 2 8

Quiz

Loading...

Loading

Die reduce-Methode ist nützlich, wenn Sie Stream-Elemente in eine andere Form kombinieren möchten. Die von der Methode akzeptierten Parameter haben folgendes Format: reduce(*initialState*, (*previous*, *object*) -> *actions on the object*).

Beispielsweise können Sie die Summe einer Ganzzahlliste mithilfe der reduce-Methode wie folgt berechnen.

ArrayList<Integer> values = new ArrayList<>();
values.add(7);
values.add(3);
values.add(2);
values.add(1);

int sum = values.stream()
    .reduce(0, (previousSum, value) -> previousSum + value);
System.out.println(sum);
Beispielausgabe

13

Auf ähnliche Weise können wir aus einer Liste von Strings einen kombinierten, zeilengetrennten String erstellen.

ArrayList<String> words = new ArrayList<>();
words.add("First");
words.add("Second");
words.add("Third");
words.add("Fourth");

String combined = words.stream()
    .reduce("", (previousString, word) -> previousString + word + "\n");
System.out.println(combined);
Beispielausgabe

First Second Third Fourth

Zwischenoperationen

Zwischenoperationen im Stream (intermediate stream operations) sind Methoden, die einen Stream zurückgeben. Da der zurückgegebene Wert ein Stream ist, können wir Zwischenoperationen nacheinander aufrufen. Typische Zwischenoperationen umfassen das Umwandeln eines Wertes von einer Form in eine andere mit map und dessen spezifischere Form mapToInt, die verwendet wird, um einen Stream in einen Ganzzahl-Stream umzuwandeln. Weitere Beispiele sind das Filtern von Werten mit filter, das Identifizieren eindeutiger Werte mit distinct und das Sortieren von Werten mit sorted (wenn möglich).

Schauen wir uns diese Methoden anhand einiger Probleme an. Angenommen, wir haben die folgende Person-Klasse.

public class Person {
    private String firstName;
    private String lastName;
    private int birthYear;

    public Person(String firstName, String lastName, int birthYear) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.birthYear = birthYear;
    }

    public String getFirstName() {
        return this.firstName;
    }

    public String getLastName() {
        return this.lastName;
    }

    public int getBirthYear() {
        return this.birthYear;
    }
}

Problem 1: Sie erhalten eine Liste von Personen. Geben Sie die Anzahl der Personen aus, die vor dem Jahr 1970 geboren wurden.

Wir verwenden die filter-Methode, um nur diejenigen Personen zu filtern, die vor dem Jahr 1970 geboren wurden. Anschließend zählen wir deren Anzahl mit der Methode count.

// Angenommen, wir haben eine Liste von Personen
// ArrayList<Person> persons = new ArrayList<>();

long count = persons.stream()
    .filter(person -> person.getBirthYear() < 1970)
    .count();
System.out.println("Count: " + count);

Problem 2: Sie erhalten eine Liste von Personen. Wie viele Personen haben einen Vornamen, der mit dem Buchstaben "A" beginnt?

Wir verwenden die filter-Methode, um Personen zu filtern, deren Vorname mit dem Buchstaben "A" beginnt. Anschließend berechnen wir die Anzahl der Personen mit der Methode count.

// Angenommen, wir haben eine Liste von Personen
// ArrayList<Person> persons = new ArrayList<>();

long count = persons.stream()
    .filter(person -> person.getFirstName().startsWith("A"))
    .count();
System.out.println("Count: " + count);

Problem 3: Sie erhalten eine Liste von Personen. Geben Sie die Anzahl der eindeutigen Vornamen in alphabetischer Reihenfolge aus.

Zuerst verwenden wir die map-Methode, um einen Stream von Personenobjekten in einen Stream von Vornamen zu ändern. Danach rufen wir die Methode distinct auf, die einen Stream zurückgibt, der nur eindeutige Werte enthält. Anschließend rufen wir die Methode sorted auf, die die Strings sortiert. Schließlich rufen wir die Methode forEach auf, die zum Ausgeben der Strings verwendet wird.

// Angenommen, wir haben eine Liste von Personen
// ArrayList<Person> persons = new ArrayList<>();

persons.stream()
    .map(person -> person.getFirstName())
    .distinct()
    .sorted()
    .forEach(name -> System.out.println(name));

Die oben beschriebene distinct-Methode verwendet die equals-Methode, die in allen Objekten vorhanden ist, um zu vergleichen, ob zwei Strings gleich sind. Die sorted-Methode hingegen kann Objekte sortieren, die eine Art von Ordnung enthalten – Beispiele für solche Objekte sind beispielsweise Zahlen und Strings.

Loading
Loading
Loading

Objekte und Streams

Die Verarbeitung von Objekten mit Stream-Methoden ist naheliegend. Jede Stream-Methode, die sich mit den Werten des Streams befasst, ermöglicht es Ihnen auch, Methoden aufzurufen, die mit den Werten verbunden sind. Schauen wir uns ein Beispiel an, bei dem wir Bücher mit Autoren haben. Die Klassen Person und Book sind unten angegeben.

public class Person {
    private String name;
    private int birthYear;

    public Person(String name, int birthYear) {
        this.name = name;
        this.birthYear = birthYear;
    }

    public String getName() {
        return this.name;
    }

    public int getBirthYear() {
        return this.birthYear;
    }

    public String toString() {
        return this.name + " (" + this.birthYear + ")";
    }
}
public class Book {
    private Person author;
    private String name;
    private int pages;

    public Book(Person author, String name, int pages) {
        this.author = author;
        this.name = name;
        this.pages = pages;
    }

    public Person getAuthor() {
        return this.author;
    }

    public String getName() {
        return this.name;
    }

    public int getPages() {
        return this.pages;
    }
}

Angenommen, wir haben eine Liste von Büchern. Die Berechnung des Durchschnitts der

Geburtsjahre der Autoren kann auf eine Weise erfolgen, die sich mit Stream-Methoden natürlich anfühlt. Zuerst konvertieren wir den Stream der Bücher in einen Stream von Personen, und dann konvertieren wir den Stream der Personen in einen Stream von Geburtsjahren. Schließlich fragen wir den (Ganzzahl-)Stream nach einem Durchschnitt.

// Angenommen, wir haben eine Liste von Büchern
// List<Book> books = new ArrayList<>();

double average = books.stream()
    .map(book -> book.getAuthor())
    .mapToInt(author -> author.getBirthYear())
    .average()
    .getAsDouble();

System.out.println("Durchschnitt der Geburtsjahre der Autoren: " + average);

// die Zuordnung eines Buches zu einem Autor könnte auch mit einem einzigen map-Aufruf erfolgen
// double average = books.stream()
//     .mapToInt(book -> book.getAuthor().getBirthYear())
//     ...

Ebenso werden die Namen der Autoren von Büchern mit dem Wort „Potter“ im Titel wie folgt ausgegeben.

// Angenommen, wir haben eine Liste von Büchern
// List<Book> books = new ArrayList<>();

books.stream()
    .filter(book -> book.getName().contains("Potter"))
    .map(book -> book.getAuthor())
    .forEach(author -> System.out.println(author));

Streams können auch verwendet werden, um komplexere Zeichenfolgen-Darstellungen zu erstellen. Im folgenden Beispiel geben wir "Autor Name: Buch"-Paare in alphabetischer Reihenfolge aus.

// Angenommen, wir haben eine Liste von Büchern
// ArrayList<Book> books = new ArrayList<>();

books.stream()
    .map(book -> book.getAuthor().getName() + ": " + book.getName())
    .sorted()
    .forEach(name -> System.out.println(name));
Loading

Dateien und Streams

Streams sind auch sehr praktisch beim Umgang mit Dateien. Die Datei wird in Stream-Form mit der Java-eigenen Files-Klasse gelesen. Die Methode lines in der Files-Klasse ermöglicht es Ihnen, einen Eingabestream aus einer Datei zu erstellen, sodass Sie die Zeilen nacheinander verarbeiten können. Die Methode lines erhält einen Pfad als Parameter, der mit der Methode get in der Paths-Klasse erstellt wird. Der get-Methode wird eine Zeichenfolge übergeben, die den Dateipfad beschreibt.

Im folgenden Beispiel werden alle Zeilen in „file.txt“ gelesen und der Liste hinzugefügt.

List<String> rows = new ArrayList<>();

try {
    Files.lines(Paths.get("file.txt")).forEach(row -> rows.add(row));
} catch (Exception e) {
    System.out.println("Fehler: " + e.getMessage());
}

// tun Sie etwas mit den gelesenen Zeilen

Wenn die Datei gefunden und erfolgreich gelesen wird, befinden sich am Ende des Programms die Zeilen der Datei „file.txt“ in der Listenvariable rows. Wenn eine Datei jedoch nicht gefunden oder gelesen werden kann, wird eine Fehlermeldung angezeigt. Unten ist eine Möglichkeit:

Beispielausgabe

Fehler: file.txt (No such file or directory)

Loading

Stream-Methoden machen das Lesen von Dateien in vordefiniertem Format relativ unkompliziert. Schauen wir uns ein Szenario an, in dem eine Datei einige persönliche Informationen enthält. Die Details jeder Person befinden sich in ihrer eigenen Zeile: zuerst der Name der Person, dann ein Semikolon und schließlich das Geburtsjahr der Person. Das Dateiformat sieht wie folgt aus:

Beispielausgabe

Kaarlo Juho Ståhlberg; 1865 Lauri Kristian Relander; 1883 Pehr Evind Svinhufvud; 1861 Kyösti Kallio; 1873 Risto Heikki Ryti; 1889 Carl Gustaf Emil Mannerheim; 1867 Juho Kusti Paasikivi; 1870 Urho Kaleva Kekkonen; 1900 Mauno Henrik Koivisto; 1923 Martti Oiva Kalevi Ahtisaari; 1937 Tarja Kaarina Halonen; 1943 Sauli Väinämö Niinistö; 1948

Angenommen, die Datei heißt presidents.txt. Das Lesen der Details der Personen erfolgt wie folgt:

List<Person> presidents = new ArrayList<>();
try {
    // Lesen der Datei "presidents.txt" Zeile für Zeile
    Files.lines(Paths.get("presidents.txt"))
        // Aufteilen der Zeile in Teile am ";"-Zeichen
        .map(row -> row.split(";"))
        // Löschen der aufgeteilten Zeilen, die weniger als zwei Teile haben (wir möchten, dass die Zeilen immer sowohl den Namen als auch das Geburtsjahr enthalten)
        .filter(parts -> parts.length >= 2)
        // Erstellen von Personen aus den Teilen
        .map(parts -> new Person(parts[0], Integer.valueOf(parts[1])))
        // und schließlich die Personen zur Liste hinzufügen
        .forEach(person -> presidents.add(person));
} catch (Exception e) {
    System.out.println("Fehler: " + e.getMessage());
}

// jetzt sind die Präsidenten als Personenobjekte in der Liste
Loading
Sie haben das Ende dieses Abschnitts erreicht! Weiter zum nächsten Abschnitt: