10.6 Iterator, Iterable *
In Programmen spielen nicht nur einzelne Daten eine Rolle, sondern auch Sammlungen dieser Daten. Arrays sind zum Beispiel solche Sammlungen, aber auch Standarddatenstrukturen wie ArrayList oder HashSet oder Dateien. Eine Sammlung zeichnet sich hauptsächlich durch Methoden aus, die Daten hinzufügen, wieder entfernen und das Vorhandensein von Elementen prüfen. Natürlich hat jede dieser veränderbaren Datenstrukturen eine bestimmte API, doch im Sinne der guten objektorientierten Modellierung ist es wünschenswert, dieses Verhalten in Schnittstellen zu beschreiben. Zwei Schnittstellen treten besonders hervor:
Iterator: Bietet eine Möglichkeit, Schritt für Schritt durch Sammlungen zu laufen.
Iterable: Liefert so einen Iterator, ist also ein Iterator-Produzent.
10.6.1 Die Schnittstelle Iterator
Ein Iterator ist ein Datengeber, der über eine Methode verfügen muss, die das nächste Element liefert. Dann muss es eine zweite Methode geben, die Auskunft darüber gibt, ob der Datengeber noch weitere Elemente zur Verfügung stellt. Genau dafür deklariert die Java-API eine Schnittstelle Iterator mit zwei Operationen:
Hast du mehr? | Gib mir das Nächste! | |
---|---|---|
Iterator | hasNext() | next() |
[zB] Beispiel
Das Ablaufen (auf Neudeutsch »iterieren«) sieht immer gleich aus:
while ( iterator.hasNext() )
process( iterator.next() );
Die Methode hasNext() ermittelt, ob es überhaupt ein nächstes Element gibt, und wenn ja, ob next() das nächste Element erfragen darf. Bei jedem Aufruf von next() erhalten wir ein weiteres Element der Datenstruktur. So kann der Iterator einen Datengeber (in der Regel eine Datenstruktur) Element für Element ablaufen. Wahlfreien Zugriff haben wir nicht. Übergehen wir ein false von hasNext() und fragen trotzdem mit next() nach dem nächsten Element, bestraft uns eine NoSuchElementException.
interface java.util.Iterator<E>
boolean hasNext()
Liefert true, falls die Iteration weitere Elemente bietet.E next()
Liefert das nächste Element in der Aufzählung und setzt die Position weiter. Es gibt eine NoSuchElementException, wenn keine weiteren Elemente mehr vorhanden sind.
Prinzipiell könnte die Methode, die das nächste Element liefert, auch per Definition null zurückgeben und so anzeigen, dass es keine weiteren Elemente mehr gibt, und auf hasNext() könnte verzichtet werden. Allerdings kann null dann kein gültiger Iterator-Wert sein, und das wäre ungünstig.
Die Schnittstelle Iterator erweitert selbst keine weitere Schnittstelle.[ 203 ](Konkrete Enumeratoren (und Iteratoren) können nicht automatisch serialisiert werden; die realisierenden Klassen müssen hierzu die Schnittstelle Serializable implementieren. ) Die Deklaration ist generisch, da das, was der Iterator liefert, immer von einem bekannten Typ ist.
Beim Iterator geht es immer nur vorwärts
Im Gegensatz zum Index eines Arrays können wir beim Iterator ein Objekt nicht noch einmal auslesen (next() geht automatisch zum nächsten Element), nicht vorspringen oder hin- und herspringen. Ein Iterator lässt sich an einem Datenstrom veranschaulichen: Wollten wir ein Element zweimal besuchen, zum Beispiel eine Datenstruktur von rechts nach links noch einmal durchwandern, dann müssten wir wieder ein neues Iterator-Objekt erzeugen oder uns die Elemente zwischendurch merken. Nur bei Listen und sortierten Datenstrukturen ist die Reihenfolge der Elemente vorhersehbar. Grundsätzlich entscheidet die Implementierung der Datenstruktur und des Iterators, in welcher Reihenfolge die Elemente herausgegeben werden. Sind die Daten sortiert, so wird auch der Iterator die Ordnung achten. (Die Dokumentation beschreibt die Details.)
[»] Hinweis
In Java steht der Iterator nicht auf einem Element, sondern zwischen Elementen. Die Methode hasNext() sagt, ob es ein nächstes Element gibt, und next() liefert es und setzt die interne Position weiter. Es gibt kein Konzept vom aktuellen Element, das immer wieder erfragbar ist; next() ist zustandsbehaftet. Zu Beginn der Iteration steht der Iterator vor dem ersten Element.
Code auf verbleibenden Elementen eines Iterators ausführen
In der Schnittstelle Iterator gibt es die Default-Methode forEachRemaining(Consumer<? super E> action), die ein beliebiges Stückchen Code – transportiert über einen Consumer – auf jedem Element ausführt. Die Implementierung der Default-Methode ist ein Dreizeiler:
default void forEachRemaining( Consumer<? super E> action ) {
Objects.requireNonNull( action );
while ( hasNext() )
action.accept( next() );
}
Mithilfe dieser Methode lässt sich eine externe Iteration über eine selbst gebaute Schleife in eine interne Iteration umbauen, und Lambda-Ausdrücke machen die Implementierung der Schnittstelle kurz. (Mehr zu Lambda-Ausdrücken folgt in Kapitel 12, »Lambda-Ausdrücke und funktionale Programmierung«.)
[zB] Beispiel
Gibt jedes Argument der Konsoleneingabe aus:
new Scanner( System.in ).forEachRemaining( System.out::println );
interface java.util.Iterator<E>
default void forEachRemaining(Consumer<? super E> action)
Führt action auf jedem kommenden Element des Iterators bis zum letzten Element aus.
[»] Hinweis
Jede Collection-Datenstruktur liefert mit iterator() einen Iterator, auf dem dann wiederum ein Aufruf von forEachRemaining(…) möglich ist. Allerdings gibt es mit der Stream-API eine flexiblere Alternative zum Abarbeiten von Programmcode, die sich auch anbietet, wenn der Stream aus anderen Quellen kommt, z. B. aus Arrays.
Optional: Elemente über Iterator löschen
Die Iterator-Methode next() ist eine reine Lesemethode und verändert die darunterliegende Datenstruktur nicht. Doch bietet die Schnittstelle Iterator auch eine Methode remove(), die das zuletzt von next() gelieferte Objekt aus der Datensammlung entfernen kann. Da diese Operation nicht immer Sinn ergibt (etwa bei immutablen Datenstrukturen oder wenn ein Iterator zum Beispiel Dateien Zeile für Zeile ausliest), ist sie in der API-Dokumentation als optional gekennzeichnet. Das heißt, dass ein konkreter Iterator keine Löschoperation unterstützen muss und etwa einfach nichts macht oder eine UnsupportedOperationException auslösen könnte.
interface java.util.Iterator<E>
default void remove()
Löscht das zuletzt von next() gelieferte Objekt aus der darunterliegenden Sammlung. Die Operation muss nicht zwingend von Iteratoren angeboten werden und löst, falls nicht anderweitig überschrieben, eine UnsupportedOperationException("remove") aus.
10.6.2 Wer den Iterator liefert
Iteratoren spielen in Java eine sehr große Rolle und kommen im JDK tausendfach vor. Die Frage ist nur: Woher kommt ein Iterator, sodass sich eine Sammlung ablaufen lässt? Datengeber müssen dazu eine Methode anbieten.
1. Beispiel
iterator() von Path liefert ein Iterator<Path> über die Pfad-Elemente. Der Ausdruck
Iterator<Path> iterator = Paths.get( "/chris/brain/java/9" ).iterator();
while ( iterator.hasNext() )
System.out.println( iterator.next() );
liefert vier Zeilen mit »chris«, »brain«, »java« und »9«.
2. Beispiel
Datenstrukturen wie Listen und Mengen deklarieren ebenfalls eine iterator()-Methode:
Iterator<Integer> iter = new TreeSet<>( Arrays.asList( 4, 2, 9 ) ).iterator();
Die Ausgabe wäre mit der while-Schleife von oben sortiert 2, 4 und 9.
3. Beispiel
Die Klasse Scanner implementiert Iterator<String>, sodass das bekannte Paar von hasNext()/next() über die Tokens laufen kann:
Iterator<String> iterator = new Scanner( "Hund Katze Maus" );
Die Ausgabe besteht mit der oben gezeigten Schleife aus drei Zeilen.
Im ersten und zweiten Fall ist es also der Aufruf von iterator(), der uns einen Iterator verschafft, im dritten Fall ist es eine Klasse, die selbst ein Iterator mit Konstruktor ist.
10.6.3 Die Schnittstelle Iterable
Eine Methode iterator(), die einen Iterator liefert, ist häufig bei Datenstrukturen anzutreffen. Das hat einen Grund: Die Klassen mit iterator()-Methode implementieren eine Schnittstelle java.lang.Iterable, und die schreibt die Operation iterator() vor. Das TreeSet, das wir im Beispiel verwendet haben, implementiert genauso Iterable, wie Path es auch implementiert.
interface java.lang.Iterable<T>
Iterator<T> iterator()
Liefert einen java.util.Iterator, der über alle Elemente vom Typ T iteriert.
10.6.4 Erweitertes for und Iterable
Bisher haben wir das erweiterte for für kleine Beispiele eingesetzt, in denen es darum ging, ein Array von Elementen abzulaufen:
for ( String s : new String[]{ "T. Noah", "S. Colbert", "J. Oliver" } )
System.out.printf( "%s ist toll.%n", s );
Die erweiterte for-Schleife läuft nicht nur Arrays ab, sondern alles, was vom Typ Iterable ist. Da insbesondere viele Datenstrukturklassen diese Schnittstelle implementieren, lässt sich mit dem erweiterten for praktisch durch Ergebnismengen iterieren:
for ( String s : Arrays.asList( "T. Noah", "S. Colbert", "J. Oliver" ) )
System.out.printf( "%s ist toll.%n", s );
10.6.5 Interne Iteration
Die Schnittstelle Iterable bietet zwei Methoden mit Default-Implementierung. Die interessante Methode ist forEach(…), die Methode spliterator() ist an dieser Stelle dagegen nicht interessant.
default void forEach(Consumer<? super T> action)
Holt den Iterator, läuft über alle Elemente und ruft dann den Konsumenten auf, der das Element übergeben bekommt.
Die Methode forEach(…) realisiert eine sogenannte interne Iteration. Das heißt, nicht wir müssen eine Schleife mit dem Paar hasNext()/next() formulieren, sondern das macht forEach(…) für uns.
[zB] Beispiel
Laufe über die ersten Primzahlen und gib sie aus:
Arrays.asList( 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31 )
.forEach( e -> System.out.printf( "Primzahl: %d%n", e ) );
Der Aufruf Arrays.asList(…) liefert eine java.util.List und ist Iterable.
10.6.6 Eine eigene Iterable implementieren *
Damit unsere eigenen Objekte rechts hinter dem Doppelpunkt vom erweiterten for stehen können, muss die entsprechende Klasse die Schnittstelle Iterable implementieren und somit eine iterator()-Methode anbieten. iterator() muss einen passenden Iterator zurückgeben. Der wiederum muss die Methoden hasNext() und next() implementieren, die das nächste Element in der Aufzählung angeben und das Ende anzeigen. Zwar schreibt der Iterator auch remove() vor, doch das wird leer implementiert.
Unser Beispiel soll aus einer Klasse bestehen, die Iterable implementiert, und Wörter eines Satzes zerlegen kann. Als grundlegende Implementierung dient der StringTokenizer, der über nextToken() die nächsten Teilfolgen liefert und über hasMoreTokens() meldet, ob weitere Tokens ausgelesen werden können.
Beginnen wir mit dem ersten Teil, der Klasse WordIterable, die erst einmal Iterable implementieren muss, um auf der rechten Seite vom Punkt stehen zu können. Dann muss dieses Exemplar über iterator() einen Iterator zurückgeben, der über alle Wörter läuft. Dieser Iterator kann als eigene Klasse implementiert werden, doch wir implementieren die Klasse WordIterable so, dass sie Iterable und Iterator gleichzeitig verkörpert; daher ist nur ein Exemplar nötig. Der Nachteil ist, dass es für ein WordIterable nicht mehrere unterschiedliche Iterator-Exemplare geben kann.
class WordIterable implements Iterable<String>, Iterator<String> {
private StringTokenizer st;
public WordIterable( String s ) {
st = new StringTokenizer( s );
}
// Method from interface Iterable
@Override public Iterator<String> iterator() {
return this;
}
// Methods from interface Iterator
@Override public boolean hasNext() {
return st.hasMoreTokens();
}
@Override public String next() {
if ( hasNext() )
return st.nextToken();
throw new NoSuchElementException();
}
}
Im Beispiel:
String s = "Am Anfang war das Wort, am Ende die Phrase. (Stanislaw Jerzy Lec)";
for ( String word : new WordIterable(s) )
System.out.println( word );
Die erweiterte for-Schleife baut der Java-Compiler intern um zu: