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?
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.
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.
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?
.stream()
verwandelt unsere Liste in einen „Datenstrom“, auf dem wir bestimmte Operationen nacheinander ausführen können..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 demfilter
-Schritt sind also nur noch das Staubsauger-Objekt und das Ofen-Objekt im Stream..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 keineProdukt
-Objekte mehr im Stream, sondern die beidenString
-Objekte"Staubsauger"
und"Ofen"
..forEach(consumer)
kennen wir schon: Auf jedes Element im Stream wird die angegebenevoid
-Funktion angewendet. Dadurch wird der Stream vorbraucht und hat seinen Zweck erfüllt.
Zusammengefasst passiert also folgendes:
In diesem Video können Sie sich ein weiteres Beispiel zu Streams anschauen.
Aufgaben
- Vervollständigen Sie den Code, sodass jeweils passiert, was in den Kommentaren steht.
- Was gibt folgende Methode aus?
- Versuchen Sie, den vorherigen Stream-Ausdruck mit Schleifen und Verzweigungen nachzubauen. Welche Variante finden Sie übersichtlicher?
Lösungsvorschlag
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.
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>)
?