Iteradores 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)

Mi primera clase Iterable funcionó a la primera. La metí en un for-each, salió la lista entera y me sentí un genio durante casi un minuto. Después quise comparar cada elemento con todos los demás y anidé dos for-each sobre la misma colección. El de fuera dio una vuelta… y el de dentro no imprimió nada. Ni error, ni excepción: silencio. Tardé una tarde entera en darme cuenta de que había guardado el iterador en un atributo de la clase, así que los dos bucles compartían el mismo cursor, y el de dentro lo dejaba siempre al final.

Esa es la gracia de este tema: un iterador es un cursor, y lo que pasa con él es muy fácil de entender… en cuanto lo ves moverse. Por eso hoy practicamos con tres programas en los que el cursor lo vas a ver, y a romper, tú mismo.

Si ya has leído la teoría de iteradores, este artículo es para aplicarla: borrar de una lista sin que salte la excepción, construir una colección propia que funcione en un for-each y recorrer una playlist en los dos sentidos. Son programas de dificultad media. Cada uno trae el código comentado, la salida y las dudas que suelen surgir.

Antes de empezar

Conviene tener claro:

  • Que el for-each es un Iterator disfrazado: pide un iterador al Iterable y repite hasNext() y next().
  • Que para borrar mientras recorres hay que hacerlo con it.remove() (una vez por cada next()), no con el remove de la lista.
  • Que un Iterable devuelve un iterador nuevo en cada llamada a iterator(), y que cada iterador lleva su propio cursor.
  • Que ListIterator permite ir hacia atrás y modificar la lista con set() y add().

Si alguno te suena dudoso, repasa la teoría antes de seguir.

Los tres programas son independientes. Crea una carpeta para cada uno y compílalos por separado. Los programas 1 y 3 comparten la misma clase Cancion; el 2 no la necesita.

Programa 1: borrar con Iterator sin excepción

Una playlist con cinco canciones, de las cuales tres son demasiado cortas (menos de dos minutos y medio). Queremos quitarlas de tres maneras distintas y ver cuál se porta bien.

La idea

Vamos a hacer lo mismo tres veces: borrar las canciones de menos de 150 segundos. Primero con un for-each y el remove de la lista (para ver la excepción y, sobre todo, cómo queda la lista después). Luego con Iterator.remove(), la forma segura. Y, por último, con el atajo removeIf.

En la primera parte capturamos la ConcurrentModificationException con un try/catch para poder seguir. No lo harías en código real: es solo para enseñarte el estado en el que queda la lista.

Antes de ejecutar, piensa cuántas canciones borrará la primera versión. ¿Las tres? ¿Una? Después lo comprobamos.

El código

Cancion.java

public record Cancion(String titulo, int segundos) {
    @Override
    public String toString() {
        return titulo + " (" + segundos / 60 + ":" + String.format("%02d", segundos % 60) + ")";
    }
}

Borrar.java

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

public class Borrar {
    static List<Cancion> cargar() {
        return new ArrayList<>(List.of(
                new Cancion("Amanecer", 215), new Cancion("Intro", 45),
                new Cancion("Carretera", 180), new Cancion("Faro", 135),
                new Cancion("Silencio", 20)));
    }

    public static void main(String[] args) {
        // 1. for-each + remove(): peligro
        List<Cancion> a = cargar();
        try {
            for (Cancion c : a) {
                if (c.segundos() < 150) {
                    a.remove(c);
                }
            }
        } catch (ConcurrentModificationException e) {
            System.out.println("1. for-each: " + e.getClass().getSimpleName());
        }
        System.out.println("   Se quedó a medias: " + a);

        // 2. Iterator.remove(): la forma segura
        List<Cancion> b = cargar();
        Iterator<Cancion> it = b.iterator();
        int borradas = 0;
        while (it.hasNext()) {
            if (it.next().segundos() < 150) {
                it.remove();
                borradas++;
            }
        }
        System.out.println("2. Iterator: borradas " + borradas + " -> " + b);

        // 3. El atajo: removeIf
        List<Cancion> c = cargar();
        c.removeIf(x -> x.segundos() < 150);
        System.out.println("3. removeIf: " + c);
    }
}

Salida

1. for-each: ConcurrentModificationException
   Se quedó a medias: [Amanecer (3:35), Carretera (3:00), Faro (2:15), Silencio (0:20)]
2. Iterator: borradas 3 -> [Amanecer (3:35), Carretera (3:00)]
3. removeIf: [Amanecer (3:35), Carretera (3:00)]

Compruébalo a mano:

  • **for-each + remove():** el recorrido llega a Intro (0:45), la borra con a.remove(c) y, en el siguiente next(), salta la excepción. La lista se queda a medias: Intro se fue, pero Faro (2:15) y Silencio (0:20) siguen ahí, cuando también debían borrarse.
  • **Iterator.remove():** borra las tres (Intro, Faro y Silencio) y deja solo Amanecer y Carretera. borradas cuenta 3.
  • **removeIf:** mismo resultado, en una línea.

x -> x.segundos() < 150 es una lambda: una función pequeña que removeIf aplica a cada canción para decidir si se borra. No hace falta que la domines ahora; piensa en ella como «la condición, escrita en corto».

Preguntas que te puedes hacer

**¿Por qué la excepción no salta nada más borrar, sino en el siguiente next()?** Porque quien comprueba el contador de modificaciones es el iterador, y solo lo hace cuando le pides el siguiente elemento. El borrado en sí (a.remove(c)) lo hace la lista, que no sabe nada de iteradores.

**¿Por qué it.remove() no provoca la excepción?** Porque es el propio iterador quien borra, y al hacerlo actualiza su cuenta de modificaciones. La lista y el iterador vuelven a estar de acuerdo.

¿Por qué no basta con capturar la excepción y seguir? Porque el estado en el que queda la lista es impredecible: aquí se quedó a medias, con dos canciones cortas sin borrar. La excepción no es un fallo que se pueda ignorar, es un aviso de que el recorrido ya no es fiable.

**¿Y si recorro con un for de índices?** Funciona, pero cuidado: al borrar el elemento i, el siguiente pasa a ocupar la posición i y el i++ te lo salta. Se arregla recorriendo de atrás hacia delante. Con un iterador, te ahorras ese problema.

Llamar a it.remove() sin haber llamado antes a it.next() en esa vuelta, o dos veces seguidas. Salta IllegalStateException. Cada remove() necesita su next().

Pruébalo tú

  1. Cambia la condición para borrar las canciones de más de 200 segundos en lugar de las cortas, con Iterator.remove().
  2. Modifica el programa para que, además de borrarlas, imprima el título de cada canción borrada ("Borrada: " + c.titulo()). Pista: necesitas guardar la canción que devuelve next() en una variable.

Programa 2: mi propia colección Iterable

Una lista enlazada hecha a mano, sin ArrayList: nodos que se apuntan unos a otros. Y lo importante: que funcione en un for-each como cualquier otra colección.

La idea

Una lista enlazada guarda sus elementos en nodos: cada nodo lleva un dato y una flecha al siguiente. La lista conoce solo el primer nodo (y, por comodidad, el último), y para llegar a cualquier otro hay que ir siguiendo flechas. Eso es justo lo que hace un iterador: su cursor es «el nodo que toca devolver».

Haremos la clase genérica (ListaEnlazada<T>) para que valga con cualquier tipo, y le daremos un iterador propio, como clase interna. Después la probamos con for-each, con dos for-each anidados (cada uno con su cursor), con un iterador agotado y con un método que acepta cualquier Iterable.

Dibuja en un papel tres cajas con flechas (FP2 → IC2 → AEDV) y mueve el dedo como lo haría el cursor. Cada vez que el dedo avanza, es un next().

El código

ListaEnlazada.java

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

public class ListaEnlazada<T> implements Iterable<T> {
    private class Nodo {
        T dato;
        Nodo siguiente;

        Nodo(T dato) {
            this.dato = dato;
        }
    }

    private Nodo primero;
    private Nodo ultimo;
    private int cuantos;

    public void anadir(T dato) {
        Nodo nodo = new Nodo(dato);
        if (primero == null) {
            primero = nodo;
        } else {
            ultimo.siguiente = nodo;
        }
        ultimo = nodo;
        cuantos++;
    }

    public int tamano() {
        return cuantos;
    }

    @Override
    public Iterator<T> iterator() {
        return new IteradorLista();
    }

    private class IteradorLista implements Iterator<T> {
        private Nodo actual = primero;          // cada iterador empieza en el primero

        @Override
        public boolean hasNext() {
            return actual != null;
        }

        @Override
        public T next() {
            if (actual == null) {
                throw new NoSuchElementException();
            }
            T dato = actual.dato;
            actual = actual.siguiente;
            return dato;
        }
    }
}

Enlazada.java

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

public class Enlazada {
    static int contar(Iterable<?> coleccion) {
        int n = 0;
        for (Object o : coleccion) {
            n++;
        }
        return n;
    }

    public static void main(String[] args) {
        ListaEnlazada<String> asignaturas = new ListaEnlazada<>();
        asignaturas.anadir("FP2");
        asignaturas.anadir("IC2");
        asignaturas.anadir("AEDV");

        System.out.println("Con for-each:");
        for (String a : asignaturas) {
            System.out.println("  " + a);
        }

        System.out.println("Dos for-each anidados (cada uno con su cursor):");
        for (String a : asignaturas) {
            for (String b : asignaturas) {
                if (!a.equals(b)) {
                    System.out.println("  " + a + " + " + b);
                }
            }
        }

        Iterator<String> it = asignaturas.iterator();
        for (int i = 0; i < asignaturas.tamano(); i++) {
            it.next();
        }
        try {
            it.next();
        } catch (NoSuchElementException e) {
            System.out.println("Iterador agotado: " + e.getClass().getSimpleName());
        }

        ArrayList<Integer> numeros = new ArrayList<>();
        numeros.add(7);
        numeros.add(8);
        System.out.println("contar(mi lista) = " + contar(asignaturas));
        System.out.println("contar(ArrayList) = " + contar(numeros));
    }
}

Salida

Con for-each:
  FP2
  IC2
  AEDV
Dos for-each anidados (cada uno con su cursor):
  FP2 + IC2
  FP2 + AEDV
  IC2 + FP2
  IC2 + AEDV
  AEDV + FP2
  AEDV + IC2
Iterador agotado: NoSuchElementException
contar(mi lista) = 3
contar(ArrayList) = 2

Compruébalo a mano:

  • **for-each:** las tres asignaturas, en el orden en que se añadieron.
  • **Dos for-each anidados:** son 6 parejas (3 × 3 menos las 3 en las que a y b son la misma). Cada for-each pide su propio iterador, y los dos recorren la lista a la vez sin estorbarse.
  • Iterador agotado: tras tres next(), el cursor está en null y el cuarto lanza NoSuchElementException.
  • **contar:** el mismo método sirve para mi lista (3) y para un ArrayList (2), porque los dos son Iterable.

Iterable<?> significa «un Iterable de lo que sea». Es la forma de escribir un método que acepta cualquier colección recorrible sin importar el tipo de sus elementos.

Preguntas que te puedes hacer

**¿Por qué IteradorLista es una clase interna y no static?** Porque necesita ver el campo primero de la lista que la creó (private Nodo actual = primero;). Una clase interna (sin static) pertenece a un objeto concreto y accede a sus campos; una static no.

**¿Por qué dos for-each anidados funcionan aquí y a mí no me funcionó?** Porque iterator() devuelve un new IteradorLista() cada vez. Si hubiera guardado un único iterador en un atributo, los dos bucles compartirían cursor, y el de dentro lo dejaría siempre agotado. (Es justo lo que cuenta la anécdota del principio.)

**¿Por qué next() comprueba actual == null si ya existe hasNext()?** Porque nadie te obliga a llamar a hasNext() antes. El contrato dice que next() debe lanzar NoSuchElementException cuando no quedan elementos, y eso es lo que hace.

**¿Y remove()?** No lo hemos implementado. Como en Iterator es una operación opcional (por defecto lanza UnsupportedOperationException), nuestro iterador es de solo lectura. Para soportarlo habría que guardar también el nodo anterior y reenlazar, como en el programa de Python Tutor.

Olvidar implements Iterable<T> en la clase. Si solo escribes un método iterator() pero no declaras que implementas Iterable, el for-each no compila: lo comprobarás en los ejercicios.

Pruébalo tú

  1. Añade a ListaEnlazada un método existe(T dato) que use un for-each para decir si el dato está en la lista.
  2. Haz que el iterador soporte remove(): guarda el nodo anterior y reenlaza. Empieza por el caso fácil (borrar un nodo que no sea el primero).

Programa 3: ListIterator en ambos sentidos

La playlist vuelve. Esta vez la recorremos de principio a fin y de fin a principio con el mismo iterador, y la modificamos sobre la marcha con set() y add().

La idea

ListIterator tiene un cursor que se mueve entre dos elementos y puede ir hacia delante (next) y hacia atrás (previous). Vamos a probar tres cosas: ir y volver mostrando las posiciones con nextIndex() y previousIndex(); modificar con set() (sustituir la canción devuelta) y add() (insertar una nueva); y reutilizar el mismo iterador, que al terminar está al final, para recorrerlo a la inversa.

add() inserta antes del elemento que devolvería next(), es decir, justo después de la canción que acabas de pasar. Por eso el siguiente next() sigue devolviendo la canción de siempre y no la nueva.

El código

Usa la misma Cancion.java del programa 1.

Ambos.java

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

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

        // 1. Ida y vuelta con el mismo iterador
        ListIterator<Cancion> it = lista.listIterator();
        System.out.println("Hacia delante:");
        while (it.hasNext()) {
            int i = it.nextIndex();
            System.out.println("  " + i + ": " + it.next());
        }
        System.out.println("Hacia atrás:");
        while (it.hasPrevious()) {
            int i = it.previousIndex();
            System.out.println("  " + i + ": " + it.previous());
        }

        // 2. set() y add() mientras recorremos
        it = lista.listIterator();
        while (it.hasNext()) {
            Cancion c = it.next();
            if (c.titulo().equals("Carretera")) {
                it.add(new Cancion("Interludio", 30));
            } else if (c.titulo().equals("Tormenta")) {
                it.set(new Cancion("Tormenta (remix)", 250));
            }
        }
        System.out.println("Resultado: " + lista);

        // 3. El mismo iterador, ahora desde el final
        System.out.print("Del final al principio:");
        while (it.hasPrevious()) {
            System.out.print(" " + it.previous().titulo() + ";");
        }
        System.out.println();
    }
}

Salida

Hacia delante:
  0: Amanecer (3:35)
  1: Carretera (3:00)
  2: Tormenta (4:00)
  3: Faro (2:15)
Hacia atrás:
  3: Faro (2:15)
  2: Tormenta (4:00)
  1: Carretera (3:00)
  0: Amanecer (3:35)
Resultado: [Amanecer (3:35), Carretera (3:00), Interludio (0:30), Tormenta (remix) (4:10), Faro (2:15)]
Del final al principio: Faro; Tormenta (remix); Interludio; Carretera; Amanecer;

Compruébalo a mano:

  • Hacia delante: las posiciones 0 a 3, con nextIndex() antes de cada next().
  • Hacia atrás: de la 3 a la 0. Aquí se usa previousIndex() antes de cada previous(): devuelve la posición del elemento que previous() va a entregar.
  • Resultado: Interludio (0:30) se ha insertado justo después de Carretera, y Tormenta ha sido sustituida por Tormenta (remix) (4:10). El resto queda igual.
  • Del final al principio: el iterador de la segunda parte terminó con el cursor al final, así que hasPrevious() es true y previous() entrega las canciones desde la última.

Preguntas que te puedes hacer

**¿Por qué tras un add() el programa no vuelve a visitar la canción nueva?** Porque add() coloca el cursor detrás del elemento insertado. El siguiente next() devuelve Tormenta, no Interludio. Por eso no hay riesgo de bucle infinito.

**¿A qué canción afecta set()?** A la última que devolvió next() o previous(). Por eso va justo después de la llamada que la devolvió. Aquí, Tormenta es la devuelta por next() cuando el if la reconoce.

**¿Por qué nextIndex() y previousIndex() van antes de next() y previous()? Porque indican la posición de lo que va a** devolverse. Si los llamas después, te dan la posición del siguiente elemento, no la del que acabas de recibir.

**¿Puedo usar add() o set() con un Iterator normal?** No: solo ListIterator los tiene. El Iterator simple permite como mucho remove().

Llamar a set() o a remove() justo después de un add(), o dos set() seguidos tras un add(). Tras un add() no hay «última devuelta» y salta IllegalStateException.

Pruébalo tú

  1. Recorre la playlist hacia atrás desde el final (lista.listIterator(lista.size())) y borra con remove() las canciones de menos de 150 segundos.
  2. Inserta un Interludio antes de cada canción en lugar de después. Pista: ¿qué tienes que hacer con previous()?

Errores típicos en esta práctica

  • **Borrar con lista.remove(...) dentro de un for-each**: ConcurrentModificationException y lista a medias. Usa it.remove().
  • **Llamar a it.remove() sin next() previo** (o dos veces seguidas): IllegalStateException.
  • **Olvidar implements Iterable<T>** al escribir una colección propia: el for-each no compila.
  • Guardar el iterador en un atributo y devolver siempre el mismo desde iterator(): el segundo recorrido no encuentra nada.
  • Confundir la posición del cursor con la del elemento: nextIndex() es la del que va a salir con next(), no la del que acaba de salir.
  • **Usar add() y esperar que el siguiente next() devuelva lo añadido**: el cursor ya está detrás.

Míralo paso a paso en Python Tutor

Python Tutor ejecuta el código línea a línea y dibuja variables, objetos y referencias. Elige Java. Aquí tienes el borrado de un nodo en una lista enlazada, que es lo que hace remove() por dentro: no hay ningún «hueco» que tapar, solo una flecha que se desvía.

public class Tutor {
    static class Nodo {
        String dato;
        Nodo siguiente;

        Nodo(String dato, Nodo siguiente) {
            this.dato = dato;
            this.siguiente = siguiente;
        }
    }

    public static void main(String[] args) {
        Nodo lista = new Nodo("FP2", new Nodo("IC2", new Nodo("AEDV", null)));
        Nodo anterior = null;
        Nodo actual = lista;
        while (actual != null) {
            if (actual.dato.equals("IC2")) {
                anterior.siguiente = actual.siguiente;   // saltamos el nodo: así "borra" un iterador
            } else {
                anterior = actual;
            }
            actual = actual.siguiente;
        }
        for (Nodo n = lista; n != null; n = n.siguiente) {
            System.out.println(n.dato);
        }
    }
}

Salida esperada:

FP2
AEDV

Qué observar paso a paso:

  1. Antes del while, verás tres cajas Nodo encadenadas por flechas: FP2 → IC2 → AEDV. lista apunta a la primera, actual también, y anterior es null.
  2. En la primera vuelta, FP2 no es IC2, así que entra el else: anterior pasa a apuntar a FP2 y actual avanza a IC2.
  3. En la segunda vuelta se cumple el if. Fíjate en la flecha siguiente de FP2: **salta por encima de IC2** y apunta directamente a AEDV. La caja IC2 sigue en el dibujo, pero ya nada de la lista llega hasta ella.
  4. actual = actual.siguiente todavía puede avanzar desde IC2 (conserva su flecha a AEDV). Por eso el recorrido no se pierde. Al final, el for imprime solo FP2 y AEDV.

Resumen

  • El **for-each** pide un iterador a un Iterable y repite hasNext() / next().
  • Para borrar mientras recorres, usa it.remove() (una vez por next()); con lista.remove() obtienes ConcurrentModificationException y una lista a medias.
  • removeIf es el atajo para borrar por condición.
  • Una colección propia necesita implements Iterable<T> y un iterator() que devuelva un iterador nuevo con su propio cursor.
  • Una clase interna (sin static) es la forma natural de escribir ese iterador, porque ve los campos de la colección.
  • ListIterator añade previous(), nextIndex(), previousIndex(), set() y add(), y su cursor siempre está entre dos elementos.

Checklist: ¿lo has practicado?

  • [ ] He compilado y ejecutado los tres programas.
  • [ ] He comprobado a mano al menos una salida de cada programa.
  • [ ] He visto la ConcurrentModificationException y el estado en el que deja la lista.
  • [ ] He recorrido mi ListaEnlazada con dos for-each anidados.
  • [ ] He insertado y sustituido canciones con un ListIterator.
  • [ ] He mirado el programa de Python Tutor y he visto cómo una flecha se salta un nodo.

Y ahora, a por los ejercicios: ejercicios de iteradores, con tres niveles, tres fragmentos para depurar y un chuletario al final. ¡Nos vemos allí!

Siguiente paso: ejerciciosIteradores en Java — ejercicios con playlists, títulos repetidos y un rango con paso (y el «Faro» que nunca se iba)

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

Sigue aprendiendo

Deja una respuesta

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