Iteradores en Java — teoría con una playlist, Iterator, Iterable y ListIterator (y el for-each que no me dejaba borrar)

Estaba preparando la playlist para un viaje en coche y quise quitar las canciones de menos de dos minutos y medio. Un for-each, un if con un remove dentro, y a otra cosa. Java me contestó esto:

¡Alto ahí!: ConcurrentModificationException
Quedan: [Amanecer (3:35), Carretera (3:00), Marea (3:20)]

*»¿Alto ahí? ¡Pero si ha borrado Faro!»* Y es verdad: la canción desapareció de la lista y, aun así, Java se quejó. (En mi programa real no había try/catch, así que el programa se paró en seco; aquí lo capturo solo para poder enseñarte cómo quedó la lista.) La explicación está en algo que usas todos los días sin verlo: el for-each no es magia, es un iterador trabajando por detrás.

Hoy abrimos el capó. Verás qué son Iterator e Iterable y en qué se diferencian, por qué borrar mientras recorres exige hacerlo a través del iterador, cómo hacer que tu propia clase funcione en un for-each y qué añade ListIterator para ir y volver. Si has leído la teoría de Comparable y Cloneable, ya conoces el truco: son interfaces pequeñas con un contrato claro. Te vendrá bien repasar también las interfaces y las excepciones, porque aquí aparecen tres excepciones nuevas. Y cuando termines, la práctica de iteradores te espera con tres programas completos.

Iterator: el cursor que recorre una colección

Una playlist es una lista de canciones. Para recorrerla, hasta ahora hacías esto:

for (Cancion c : lista) {
    System.out.println(c);
}

Pero ese for-each es solo una forma cómoda de escribir otra cosa. Mira los dos programas juntos, el de siempre y el que el compilador entiende por debajo:

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class DosFormas {
    public static void main(String[] args) {
        List<Cancion> lista = new ArrayList<>();
        lista.add(new Cancion("Amanecer", 215));
        lista.add(new Cancion("Carretera", 180));
        lista.add(new Cancion("Tormenta", 240));

        System.out.println("Con for-each:");
        for (Cancion c : lista) {
            System.out.println("  " + c);
        }

        System.out.println("Con Iterator:");
        Iterator<Cancion> it = lista.iterator();
        while (it.hasNext()) {
            Cancion c = it.next();
            System.out.println("  " + c);
        }
    }
}
Con for-each:
  Amanecer (3:35)
  Carretera (3:00)
  Tormenta (4:00)
Con Iterator:
  Amanecer (3:35)
  Carretera (3:00)
  Tormenta (4:00)

Sale exactamente lo mismo. Lo que hace el for-each es pedirle a la lista un iterador (lista.iterator()) y repetir «¿hay siguiente? dame el siguiente». Ese iterador es un objeto de tipo Iterator, una interfaz con tres métodos:

public interface Iterator<E> {
    boolean hasNext();   // ¿quedan elementos por visitar?
    E next();            // avanza y devuelve el siguiente
    void remove();       // opcional: borra el último que devolvió next()
}
MétodoQué haceDetalle
hasNext()Dice si quedan elementosNo avanza: puedes llamarlo mil veces seguidas
next()Avanza una posición y devuelve ese elementoLanza NoSuchElementException si ya no quedan
remove()Borra de la colección el último elemento que devolvió next()Es opcional: algunas colecciones no lo permiten

Cada vez que llamas a lista.iterator() te devuelven un iterador nuevo, con su propio cursor empezando desde el principio. Dos iteradores sobre la misma lista no se molestan entre sí (mientras no modifiques la lista, como verás).

hasNext() y next(): el compás del recorrido

Imagínate el iterador como un cursor que se coloca entre dos canciones, no encima de una. Al empezar está antes de la primera. next() salta por encima de una canción y te la devuelve; hasNext() solo mira si hay otra canción delante, sin moverse. Veámoslo con una playlist de dos canciones:

import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;

public class Agotado {
    public static void main(String[] args) {
        List<Cancion> lista = List.of(new Cancion("Faro", 135), new Cancion("Marea", 200));
        Iterator<Cancion> it = lista.iterator();

        System.out.println("¿Hay siguiente? " + it.hasNext());
        System.out.println("next() -> " + it.next());
        System.out.println("¿Hay siguiente? " + it.hasNext());
        System.out.println("next() -> " + it.next());
        System.out.println("¿Hay siguiente? " + it.hasNext());
        try {
            it.next();
        } catch (NoSuchElementException e) {
            System.out.println("Ya no quedan canciones: " + e.getClass().getSimpleName());
        }
    }
}
¿Hay siguiente? true
next() -> Faro (2:15)
¿Hay siguiente? true
next() -> Marea (3:20)
¿Hay siguiente? false
Ya no quedan canciones: NoSuchElementException

Entre líneas:

  • Mientras queden canciones, hasNext() da true. Tras la segunda llamada a next(), el cursor está al final y pasa a false.
  • Si llamas a next() con el cursor ya al final, Java no devuelve null ni se inventa nada: lanza NoSuchElementException, una excepción no comprobada (no hace falta try/catch, pero el programa se para si nadie la captura).
  • Por eso el patrón correcto siempre es while (it.hasNext()) y una sola llamada a next() dentro del bucle.

Llamar a next() sin comprobar antes hasNext(), o llamarlo dos veces en la misma vuelta del bucle. Con un número impar de elementos, la segunda llamada se encuentra el final y salta NoSuchElementException. Si necesitas el elemento varias veces, guárdalo en una variable.

Iterable: lo que necesita un for-each

Si Iterator es «el recorrido», Iterable es «algo que sabe darte un recorrido». Es otra interfaz, y mucho más pequeña todavía:

public interface Iterable<T> {
    Iterator<T> iterator();
}

Un único método: dame un iterador. Y aquí está la clave de todo el tema: el for-each solo exige que lo que va a la derecha de los dos puntos sea un array o un Iterable. Cuando escribes

for (Cancion c : lista) { ... }

el compilador lo convierte en esto:

Iterator<Cancion> it = lista.iterator();
while (it.hasNext()) {
    Cancion c = it.next();
    ...
}
Iterable<T>Iterator<T>
Qué esAlgo que se puede recorrerEl recorrido en sí (el cursor)
Método principaliterator()hasNext(), next(), remove()
Cuántos tiene una listaEs la propia listaUno nuevo cada vez que lo pides
¿Sirve para un for-each?SíNo (no se puede escribir for (x : it))

Todas las colecciones de Java (listas, conjuntos…) son Iterable, y por eso todas funcionan en un for-each. (Las veremos con calma en el tema de colecciones.) Los arrays también, aunque por una vía especial del compilador: no usan iterador, sino un bucle con índice.

Iterable y Iterator se parecen tanto en el nombre que es fácil mezclarlos. Truco para recordarlo: -able = «capaz de» (algo que se puede iterar); -or = «el que hace» (el que itera).

Borrar mientras recorres: remove() del Iterator

Volvamos al programa del principio. Esto es lo que hacía:

import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.List;

public class Concurrente {
    public static void main(String[] args) {
        List<Cancion> lista = new ArrayList<>(List.of(
                new Cancion("Amanecer", 215), new Cancion("Faro", 135),
                new Cancion("Carretera", 180), new Cancion("Marea", 200)));

        try {
            for (Cancion c : lista) {
                if (c.segundos() < 150) {
                    lista.remove(c);    // modificamos la lista por fuera del iterador
                }
            }
        } catch (ConcurrentModificationException e) {
            System.out.println("¡Alto ahí!: " + e.getClass().getSimpleName());
        }
        System.out.println("Quedan: " + lista);
    }
}

Cuando empieza el for-each, la lista le entrega un iterador. Por dentro, un ArrayList lleva la cuenta de cuántas veces se ha modificado su estructura (un contador llamado modCount), y el iterador se apunta ese contador al empezar. En cada next() comprueba que no haya cambiado. Yo llamé a lista.remove(c) por mi cuenta, el contador cambió y el iterador, al ver que su cuenta ya no coincidía con la de la lista, lanzó ConcurrentModificationException en el siguiente next(). No es que el borrado fallara: es que Java prefiere parar antes que seguir con un recorrido que ya no es de fiar.

La forma correcta de borrar es pedírselo al iterador: él sabe qué ha devuelto y, al borrar, actualiza su propia cuenta para no enfadarse.

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class Quitar {
    public static void main(String[] args) {
        List<Cancion> lista = new ArrayList<>(List.of(
                new Cancion("Amanecer", 215), new Cancion("Faro", 135),
                new Cancion("Carretera", 180), new Cancion("Marea", 200)));

        Iterator<Cancion> it = lista.iterator();
        while (it.hasNext()) {
            Cancion c = it.next();
            if (c.segundos() < 150) {
                it.remove();            // borra la última canción que devolvió next()
            }
        }
        System.out.println("Quedan: " + lista);

        Iterator<Cancion> otro = lista.iterator();
        try {
            otro.remove();              // ¡todavía no hemos llamado a next()!
        } catch (IllegalStateException e) {
            System.out.println("remove() sin next(): " + e.getClass().getSimpleName());
        }
    }
}
Quedan: [Amanecer (3:35), Carretera (3:00), Marea (3:20)]
remove() sin next(): IllegalStateException

Las reglas de remove():

  • Borra el elemento que devolvió **la última llamada a next()**. Por eso va después de él, normalmente dentro de un if.
  • Solo se puede llamar **una vez por cada next()**. Dos remove() seguidos, o uno antes del primer next(), lanzan IllegalStateException (la segunda parte del programa).
  • Es una operación opcional: las listas inmutables (como las de List.of(...)) lanzan UnsupportedOperationException si intentas borrar. Por eso en el programa uso new ArrayList<>(List.of(...)), que sí se puede modificar.

Modificar la colección (con add o remove de la propia lista) mientras la recorres con un for-each o con un iterador. Lo correcto es it.remove(). Para añadir mientras recorres, necesitarás un ListIterator, que verás más abajo.

Si solo quieres borrar según una condición, hay un atajo que ya hace todo esto por dentro: lista.removeIf(c -> c.segundos() < 150). Lo verás funcionando en la práctica.

Crear tu propio Iterable

Aquí está la parte que más cuesta y la que más recompensa. Si tu clase guarda cosas, puedes hacer que funcione en un for-each con solo dos ingredientes: que **implemente Iterable** y que su método iterator() devuelva un objeto que sepa recorrerla. Una playlist hecha a mano con un array:

import java.util.Iterator;
import java.util.NoSuchElementException;

public class Playlist implements Iterable<Cancion> {
    private final Cancion[] canciones = new Cancion[10];
    private int total = 0;

    public void anadir(Cancion c) {
        canciones[total++] = c;
    }

    @Override
    public Iterator<Cancion> iterator() {
        return new IteradorPlaylist();
    }

    private class IteradorPlaylist implements Iterator<Cancion> {
        private int posicion = 0;                  // el cursor de este recorrido

        @Override
        public boolean hasNext() {
            return posicion < total;
        }

        @Override
        public Cancion next() {
            if (!hasNext()) {
                throw new NoSuchElementException();
            }
            return canciones[posicion++];
        }
    }
}

Tres piezas:

  1. implements Iterable<Cancion>: la etiqueta que le dice al for-each «a mí se me puede recorrer».
  2. iterator() devuelve un objeto nuevo de IteradorPlaylist: cada recorrido tiene su propio cursor (posicion).
  3. IteradorPlaylist es una clase interna (declarada dentro de Playlist, sin static): por eso puede ver canciones y total de la playlist que la creó, sin que se las pases.

Fíjate también en que next() comprueba hasNext() y lanza NoSuchElementException si no quedan canciones: es lo que promete el contrato de la interfaz. Y ahora, lo bonito:

public class MiPlaylist {
    public static void main(String[] args) {
        Playlist lista = new Playlist();
        lista.anadir(new Cancion("Amanecer", 215));
        lista.anadir(new Cancion("Carretera", 180));
        lista.anadir(new Cancion("Marea", 200));

        for (Cancion c : lista) {
            System.out.println(c);
        }
    }
}
Amanecer (3:35)
Carretera (3:00)
Marea (3:20)

Una clase hecha por mí, en un for-each como cualquier lista de Java. Yo no he escrito ningún bucle con índices fuera de la clase: quien usa Playlist no necesita saber que dentro hay un array.

Guardar el iterador en un atributo y devolver siempre ese mismo objeto desde iterator(). El segundo for-each encuentra el cursor ya al final y no recorre nada. Cada llamada a iterator() tiene que crear un iterador nuevo.

ListIterator: ir y volver

El Iterator solo avanza. Las listas ofrecen una versión mejorada, ListIterator, que se obtiene con lista.listIterator() y permite ir hacia atrás, saber en qué posición está el cursor y modificar la lista mientras recorres:

MétodoQué hace
hasPrevious() / previous()Como hasNext() / next(), pero hacia atrás
nextIndex() / previousIndex()Posición del elemento que devolvería next() / previous()
set(x)Sustituye el último elemento devuelto por next() o previous()
add(x)Inserta x justo donde está el cursor

Y su comportamiento se entiende mejor con el cursor «entre canciones» del que hablábamos:

import java.util.List;
import java.util.ListIterator;

public class IdaYVuelta {
    public static void main(String[] args) {
        List<Cancion> lista = List.of(
                new Cancion("Amanecer", 215), new Cancion("Carretera", 180), new Cancion("Faro", 135));
        ListIterator<Cancion> it = lista.listIterator();

        System.out.println("next()     -> " + it.next());
        System.out.println("next()     -> " + it.next());
        System.out.println("previous() -> " + it.previous());
        System.out.println("next()     -> " + it.next());
        System.out.println("nextIndex: " + it.nextIndex() + ", previousIndex: " + it.previousIndex());
    }
}
next()     -> Amanecer (3:35)
next()     -> Carretera (3:00)
previous() -> Carretera (3:00)
next()     -> Carretera (3:00)
nextIndex: 2, previousIndex: 1

Después de dos next(), el cursor está entre Carretera y Faro. previous() salta hacia atrás por encima de Carretera y la devuelve; el siguiente next() la vuelve a devolver, porque el cursor se quedó justo antes de ella. Por eso nextIndex() es 2 y previousIndex() es 1 al final. Los usos más interesantes (modificar y añadir) los tienes en la práctica.

ListIterator solo existe para listas. Otras colecciones (conjuntos, por ejemplo) tienen Iterator, porque en ellas «hacia atrás» no tiene sentido: no hay posiciones.

Errores típicos, de un vistazo

ConcurrentModificationException. Has modificado la colección por fuera del iterador mientras la recorrías. Usa it.remove().

NoSuchElementException. Llamaste a next() sin comprobar hasNext(), o dos veces en la misma vuelta.

IllegalStateException al borrar. remove() antes del primer next(), o dos remove() seguidos sin un next() entre medias.

Devolver siempre el mismo iterador desde iterator(). Cada recorrido necesita el suyo.

Confundir Iterable (la colección) con Iterator (el cursor). El for-each pide un Iterable.

Míralo paso a paso en Python Tutor

Un iterador es un objeto con una variable de posición, y esa posición es justo lo que cuesta imaginar. Python Tutor ejecuta el código línea a línea y dibuja variables, objetos y referencias. Elige Java, pega el programa y pulsa Visualize Execution. Aquí tienes un iterador «casero» con los mismos métodos que el de Java, para que puedas ver su cursor moverse y comprobar que dos iteradores sobre el mismo array no se pisan:

public class Tutor {
    static class Cursor {
        private final String[] datos;
        private int posicion = 0;

        Cursor(String[] datos) { this.datos = datos; }

        boolean hasNext() { return posicion < datos.length; }

        String next() { return datos[posicion++]; }
    }

    public static void main(String[] args) {
        String[] titulos = {"Amanecer", "Carretera", "Faro"};
        Cursor it = new Cursor(titulos);
        Cursor otro = new Cursor(titulos);

        System.out.println("it   -> " + it.next());
        System.out.println("it   -> " + it.next());
        System.out.println("otro -> " + otro.next());
        while (it.hasNext()) {
            System.out.println("it   -> " + it.next());
        }
    }
}

Salida esperada:

it   -> Amanecer
it   -> Carretera
otro -> Amanecer
it   -> Faro

Qué observar paso a paso:

  1. Tras crear it y otro, verás un array con tres títulos y dos objetos Cursor. Los dos apuntan al mismo array y tienen posicion igual a 0.
  2. Con las dos primeras llamadas a it.next(), la posicion de it pasa de 0 a 1 y luego a 2. Cada next() devuelve el elemento que había antes de incrementar.
  3. En otro.next() fíjate en que solo cambia posicion de otro (pasa a 1). El cursor de it sigue en 2: cada iterador lleva su propia posición.
  4. En el while, it.hasNext() es true porque 2 < 3. Tras el último next(), posicion vale 3 y hasNext() pasa a false: así termina el bucle.

Resumen y tabla rápida

Quiero…EscriboDetalle
Recorrer sin índicesfor (Cancion c : lista)Funciona con todo lo que sea Iterable
Recorrer a manoIterator<Cancion> it = lista.iterator();Con while (it.hasNext())
Pasar al siguiente elementoCancion c = it.next();Una llamada por vuelta
Borrar mientras recorroit.remove();Tras next(), una vez por next()
Borrar con una condiciónlista.removeIf(condicion)Atajo, ya lo hace por dentro
Que mi clase funcione en un for-eachimplements Iterable<T>Y iterator() devuelve un iterador nuevo
Ir y volver en una listaListIterator<T> it = lista.listIterator();previous(), set(), add()

Tres ideas para quedarte:

  1. **El for-each es un Iterator disfrazado:** pide un iterador al Iterable y repite hasNext() / next().
  2. **Para borrar mientras recorres, hazlo con it.remove()**, no con la lista: si no, ConcurrentModificationException.
  3. **Un Iterable fabrica iteradores nuevos:** cada recorrido lleva su propio cursor, y por eso tu clase propia puede usarse en un for-each.

Y ahora, a practicar

Con esto tienes la teoría completa. En la práctica de iteradores borramos canciones sin excepción, construimos una lista enlazada propia que funciona en un for-each y recorremos una playlist en los dos sentidos con ListIterator. Y si quieres repasar antes lo anterior, vuelve a la teoría de Comparable y Cloneable.

Siguiente paso: prácticaIteradores en Java — práctica con borrados seguros, una lista enlazada propia y una playlist de ida y vuelta (y el segundo for-each que no imprimía nada)

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

2 comentarios

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *