Zum Hauptinhalt springen

Iteration

In der Programmierung haben Sie bisher zwei grundlegende Möglichkeiten kennengelernt, wie Sie Anweisungen wiederholen (iterieren) können: Schleifen und Rekursion. Von Rekursion wissen Sie auch schon, dass diese in Java sehr sparsam eingesetzt werden sollte, da z. B. der Stack sehr schnell voll werden kann.

Neben den Schleifen gibt es in Java aber noch eine viel verbreitetere Art, z. B. Elemente einer Liste durchzugehen (zu iterieren). Diese wollen wir Ihnen hier nach einer Wiederholung der bekannten Schleifenarten vorstellen.

Idiomatische Verwendung von Schleifen

In Java gibt es folgende Arten von Schleifen:

  • while-Schleife
  • do-while-Schleife
  • for-Schleife
  • for-each-Schleife (enhanced for)

Sie wissen, dass die Schleifenarten grundsätzlich alle ineinander umformbar sind. Allerdings werden bestimmte Schleifen von Java-Profis für bestimmte Zwecke bevorzugt genutzt. Das Sprachkonstrukt, was von Profis in einer Sprache typischweise zum Lösen einer Aufgabe benutzt wird, bezeichnet man als idiomatisch.

Warum sollte Code idiomatisch sein?

Gute Frage! Schauen wir uns dazu einmal ein Beispiel zu sprachlichen Idiomen an. Angenommen, Sie erhalten folgende E-Mail:

Sehr geehrte Damen und Herren,

ich hoffe, diese Nachricht erreicht Sie wohlauf. Ich kontaktiere Sie, da ich ein Problem beim Freischalten der Softwarelizenz haben.

[…]

Erstmal ist grundsätzlich klar, was die Person möchte. Wenn aber eine Person, die tagtäglich mit (deutschen) förmlichen E-Mails zu tun hat, den Satz „ich hoffe, diese Nachricht erreicht Sie wohlauf“ liest, wird sie erstmal darüber stolpern. Es ist zwar klar, dass das Gegenüber hier nett sein möchte. Diese Wendung ist aber im Deutschen unüblich, weshalb man beim Lesen erstmal kurz hängenbleibt und kurz nachdenken muss, was der Satz zu bedeuten hat. (Im Englischen wäre allerdings „I hope this message finds you well.“ eine vollkommen übliche Wendung.)

Genau das gleiche passiert, wenn wir beim Programmieren eine funktionierende, aber unübliche Konstruktion benutzen: Erfahrene Programmierer:innen stolpern über unseren Code und müssen sich erstmal fragen, was da passiert. („Ist i hier irgendwie noch für eine Berechnung innerhalb der Schleife relevant, oder wird das nur zum Durchzählen benutzt?“)

Beim Programmieren kommt dann noch dazu, dass wir möglichst wenig veränderlichen Zustand haben wollen, um einfacher Schlüsse über die Programmlogik ziehen zu können und weniger potentielle Fehler zu haben. Sie sehen wahrscheinlich schon selbst, dass eine foreach-Schleife zum Durchgehen eines Arrays weniger fehleranfällig ist als eine klassische for-Schleife mit Zählvariable, da Sie keine Out-of-Bounds-Fehler machen können.

Sie haben schon gelernt, wenn welche Schleifenart idiomatisch ist. Zur Wiederholung können Sie sich nochmal die Übersicht über die Schleifenarten, inkl. dem Folgeabschnitt Schleifen im Vergleich II, durchlesen. Beantworten Sie dann die folgenden Fragen.

Welche Schleifenart ist in den jeweiligen Situation idiomatischer?

forEach-Methode

Wie Sie vielleicht schon gemerkt haben: Jedes Element einer Liste anschauen und damit etwas tun, ist eine der häufigsten Dinge, die wir mit Schleifen machen. Daher haben Sie bisher meistens Fälle gesehen, in denen eine for-each-Schleife die idiomatische Wahl ist.

Aus demselben Grund gibt es aber in Java auch noch eine kürzere Variante, um Schleifen der Art

List<Integer> zahlen = new LinkedList<>();
zahlen.add(4);
zahlen.add(-5);
for(Integer zahl: zahlen) {
System.out.println(zahl * zahl);
}

kompakter zu fassen. Jede Collection hat eine Methode forEach, der ein Objekt übergeben werden kann, das sagt, was mit jedem Element getan werden soll (bei uns hier: quadrieren und ausgeben). Dieses Objekt muss das Interface Consumer<T> implementieren. Das Interface schreibt eine Methode void accept(T e) vor, in der implementiert wird, was mit einem Element e passieren soll.

Okay, das klingt alles sehr theoretisch. Schauen wir uns ein konkretes Beispiel an:

import java.util.function.Consumer;

class SquarePrinter implements Consumer<Integer> {
@Override
public void accept(Integer n) {
System.out.println(n * n);
}
}
List<Integer> zahlen = new LinkedList<>();
zahlen.add(4);
zahlen.add(-5);
zahlen.forEach(new SquarePrinter()); // gibt 16 und 25 aus

Jetzt werden Sie anmerken, dass das viel mehr Code ist, als die for-each-Schleife oben. Da haben Sie Recht, es gibt aber noch einen Trick, den wir Ihnen gleich verraten.

Zunächst wollen wir aber noch verstehen, wie forEach funktioniert. Angenommen, wir wollten die forEach-Methode für eine Listen-Klasse selbst implementieren. Wie könnte sie aussehen?

Lambda-Ausdrücke

Damit die forEach-Methode eine wirklich gute Alternative zur for-each-Schleife ist, gibt es eine kompaktere Schreibweise für unseren SquarePrinter. Tatsächlich müssen wir gar nicht die Klasse SquarePrinter komplett hinschreiben. Wir können uns stattdessen auf das beschränken, was relevant ist: Der SquarePrinter nimmt eine Zahl n und führt dann System.out.println(n * n) aus.

Und wir können das ziemlich genau so hinschreiben:

List<Integer> zahlen = new LinkedList<>();
zahlen.add(4);
zahlen.add(-5);
zahlen.forEach(n -> System.out.println(n * n));

Probieren Sie das gerne einmal aus.

Was passiert da?

Das Konstrukt mit dem -> ist ein sogenannter Lambda-Ausdruck. Ein Lambda-Ausdruck repräsentiert eine Methodenimplementierung, hat aber keinen Methodennamen, weshalb ein solcher Ausdruck auch anonyme Funktion genannt wird.

Ein Lambda-Ausdruck kann überall dort eingesetzt werden, wo Java eine Implementierung für ein Interface erwartet, das genau eine abstrakte Methode vorschreibt. Bei Consumer<T> ist das beispielsweise der Fall: In diesem Interface gibt es nur eine abstrakte Methoden, nämlich accept.

Java weiß hier, dass forEach einen Consumer erwartet. Der Lambda-Ausdruck wird von Java dann quasi in ein Objekt umgewandelt, das Consumer implementiert, und zwar so, dass die accept-Methode das tut, was im Lmabda-Ausdruck steht.

Mehr Details?

In diesem Video haben wir Lambda-Ausdrücke und die forEach-Methode genauer erklärt. Alles zu Lambda-Ausdrücken, insbesondere syntaktische Besonderheiten, lernen Sie aber auch noch im Programmierpraktikum.

Aufgaben

Vervollständigen Sie den Code, sodass jeweils passiert, was in den Kommentaren steht:

Zwischenoperationen

Mit den Lambda-Ausdrücken haben Sie jetzt ein Sprachkonstrukt kennengelernt, das in modernem, idiomatischem Java-Code sehr häufig anzutreffen ist. Abschließend wollen wir Ihnen die Lambda-Ausdrücke noch in einem anderen Zusammenhang zeigen.

Betrachten wir zunächst folgende Aufgabe: Wir haben in einer Liste eine Reihe von Produkten gespeichert. Für eine Inventur würden wir gerne wissen, welche Produkte in unserem Lager mehr als 100 € kosten.

public class Produkt {
private final String name;
private final int preis;

public Produkt(String name, int preis) {
this.name = name;
this.preis = preis;
}

public int getPreis() {
return preis;
}

public int getName() {
return preis;
}
}

Wie würden Sie mit den Mitteln, die Sie aus der Programmierung kennen, die Namen der Produkte, die mehr als 100 € kosten, ausgeben?

Dass wir die Produkte über 100 € herausgefiltert haben wollen und uns nur für deren Namen interessieren, können wir aber auch direkter in Java formulieren, ganz ohne Schleife und Verzweigung, nämlich wie folgt:

produkte.stream()
.filter(produkt -> produkt.getPreis() > 100)
.map(produkt -> produkt.getName())
.forEach(produkt -> System.out.println(produkt));

Was passiert hier?

  1. .stream() verwandelt unsere Liste in einen „Datenstrom“, auf dem wir bestimmte Operationen nacheinander ausführen können.
  2. .filter(predicate) behält alle Elemente aus dem Datenstrom, die das übergebene Prädikat (Bedingung) erfüllen. Hier prüft das Prädikat für jedes Element (produkt) im Stream, ob dessen Preis größer 100 ist. Nach dem filter-Schritt sind also nur noch das Staubsauger-Objekt und das Ofen-Objekt im Stream.
  3. .map(function) wendet die übergebene Funktion auf jedes Element im Stream an und ersetzt das jeweilige Element durch den Rückgabewert. Im Anschluss sind also keine Produkt-Objekte mehr im Stream, sondern die beiden String-Objekte "Staubsauger" und "Ofen".
  4. .forEach(consumer) kennen wir schon: Auf jedes Element im Stream wird die angegebene void-Funktion angewendet. Dadurch wird der Stream vorbraucht und hat seinen Zweck erfüllt.

Zusammengefasst passiert also folgendes:

Der Stream beinhaltet zunächst die vier Produkt-Objekte Nudeln 1, Staubsauger 250, Ofen 300, Mixer 30. Nach fiilter preis &gt; 100 sind nur noch die zwei Objekte Staubsauger 250 und Ofen 300 im Stream. Nach map getName sind die Strings Staubsauger und Ofen im Stream. Schließlich werden die Strings mit forEach println ausgegeben.

ein anderes Beispiel?

In diesem Video können Sie sich ein weiteres Beispiel zu Streams anschauen.

Aufgaben

  1. Vervollständigen Sie den Code, sodass jeweils passiert, was in den Kommentaren steht.
  1. Was gibt folgende Methode aus?
  1. Versuchen Sie, den vorherigen Stream-Ausdruck mit Schleifen und Verzweigungen nachzubauen. Welche Variante finden Sie übersichtlicher?
Lösungsvorschlag
List<Integer> numbers = new LinkedList<>();
numbers.add(-3);
numbers.add(-8);
numbers.add(7);
numbers.add(4);
numbers.add(10);
for(int n: numbers) {
if(n % 2 == 0 && Math.pow(n, 2) > 20) {
System.out.println(Math.pow(n, 2));
}
}

Ist das wesentlich unübersichtlicher als der Stream? Nicht unbedingt. Sie werden aber noch mehr über Streams lernen, sodass sie noch nützlicher für Sie werden.

Streams sind idiomatisch.

Wann immer eine übersichtliche Stream-Lösung möglich ist, ist dies ziemlich sicher die idiomatischste Wahl. Schleifen werden Ihnen in Zukunft in Java nur noch relativ selten begegnen.

Es gibt noch viel mehr

Streams können noch viel, viel mehr als filter, map und forEach. Wenn Sie wollen, können Sie mithilfe der IDE und der Dokumentation der Stream-Klasse weiter experimentieren: Was macht limit? Was ist toList? Wie funktioniert max(Comparator<T>)?