Iteradores, Ordenamiento y Contrato equals/hashCode

En la lección anterior quedaron tres deudas: por qué un HashSet a veces guarda duplicados, cómo se ordena una colección de objetos propios, y qué es esa ConcurrentModificationException que aparece al borrar mientras recorrés.

Las tres tienen la misma raíz: las colecciones de Java hacen preguntas sobre tus objetos, y si tus objetos contestan mal, todo falla en silencio.


1. Qué hay realmente detrás de un for-each

Este bucle que venís usando desde la lección Arrays y Manejo de Strings en Java:

for (String nombre : nombres) {
    System.out.println(nombre);
}

El compilador lo traduce a esto:

Iterator<String> it = nombres.iterator();
while (it.hasNext()) {
    String nombre = it.next();
    System.out.println(nombre);
}

Un Iterator es un objeto con solo tres métodos: hasNext(), next() y remove(). Y una colección es recorrible con for-each únicamente si implementa Iterable, que exige exactamente un método: iterator().

Por eso podés recorrer un ArrayList, un HashSet y un ArrayDeque con la misma sintaxis aunque por dentro no se parezcan en nada. El for-each no habla con la colección: habla con su iterador.


2. ConcurrentModificationException: por qué pasa

Este código parece razonable y falla siempre:

List<String> tareas = new ArrayList<>(List.of("estudiar", "descansar", "practicar"));

for (String t : tareas) {
    if (t.startsWith("d")) {
        tareas.remove(t);      // ← ConcurrentModificationException
    }
}
Por qué modificar una colección durante un for-each lanza ConcurrentModificationException Modificar la colección durante el for-each 1. El for-each crea un Iterator, que memoriza el modCount actual de la lista: 0 2. tareas.remove("descansar") modifica la lista por fuera: su modCount pasa a 1 3. En la vuelta siguiente, next() compara el modCount memorizado (0) con el actual (1) 4. No coinciden → ConcurrentModificationException. El iterador se declara desactualizado. Las dos formas correctas de borrar mientras recorrés it.remove() → borra a través del propio iterador, que sincroniza su modCount y sigue válido lista.removeIf(t -> t.startsWith("d")) → una línea, sin iterador explícito. La forma moderna.
La excepción no protege la colección: te protege a vos de recorrer una estructura que cambió debajo de tus pies y de saltearte elementos sin darte cuenta.
// Opción 1: el iterador explícito
Iterator<String> it = tareas.iterator();
while (it.hasNext()) {
    if (it.next().startsWith("d")) {
        it.remove();          // el iterador borra Y se mantiene consistente
    }
}

// Opción 2: removeIf — desde Java 8, y es la que vas a usar el 95 % de las veces
tareas.removeIf(t -> t.startsWith("d"));

El nombre de la excepción confunde: no tiene nada que ver con hilos ni con concurrencia. Pasa igual en un programa de un solo hilo. Se llama así porque la colección fue modificada “concurrentemente” respecto del recorrido en curso.


3. Comparable: el orden natural de una clase

Si intentás ordenar una lista de objetos propios, Java te frena:

List<Libro> libros = new ArrayList<>();
Collections.sort(libros);   // ERROR: Libro no es Comparable

Y tiene razón: ¿ordenar por qué? ¿Título, autor, páginas, año? Java no puede adivinarlo. Se lo tenés que decir.

Comparable define el orden natural: el orden por defecto, el que tiene sentido cuando nadie pide otra cosa.

public class Libro implements Comparable<Libro> {
    private final String titulo;
    private final String autor;
    private final int paginas;

    @Override
    public int compareTo(Libro otro) {
        return this.titulo.compareTo(otro.titulo);   // orden natural: por título
    }
}
Los tres valores de retorno de compareTo y su significado para el ordenamiento Qué significa lo que devuelve a.compareTo(b) negativo a va ANTES que b a es "menor" cero son equivalentes para este orden positivo a va DESPUÉS que b a es "mayor" La trampa clásica: return this.edad - otro.edad; Con valores grandes esa resta desborda el int y devuelve un signo equivocado: la lista sale mal ordenada y no hay ninguna excepción que te avise. Usá siempre Integer.compare(this.edad, otro.edad).
El valor exacto no importa, solo el signo. Por eso devolver la resta parece funcionar — hasta el día en que los números son grandes.

Con Comparable implementado, todo el ecosistema de Java funciona solo: Collections.sort(), lista.sort(null), TreeSet, TreeMap y Arrays.sort().


4. Comparator: todos los otros órdenes

El problema de Comparable es que solo podés tener uno. ¿Y si a veces querés ordenar por páginas y otras por autor?

Para eso está Comparator: un orden que vive fuera de la clase.

Comparable define un único orden interno; Comparator permite muchos órdenes externos Comparable — el orden natural, escrito DENTRO de la clase class Libro implements Comparable<Libro> public int compareTo(Libro otro) { ... } Solo puede haber UNO por clase. Lo usan Collections.sort(lista), TreeSet y TreeMap por defecto. Comparator — órdenes externos, tantos como necesites Libro sin tocar la clase Comparator.comparing(Libro::getTitulo) Comparator.comparingInt(Libro::getPaginas).reversed() comparing(Libro::getAutor).thenComparing(Libro::getTitulo) Comparator sirve incluso para clases que no escribiste vos y no podés modificar. Comparable, no.
El orden natural es una propiedad de la clase; un comparador es una decisión de quien ordena. Por eso pueden coexistir muchos.
// Ordenar por páginas, de menor a mayor
libros.sort(Comparator.comparingInt(Libro::getPaginas));

// De mayor a menor
libros.sort(Comparator.comparingInt(Libro::getPaginas).reversed());

// Por autor y, a igual autor, por título
libros.sort(
    Comparator.comparing(Libro::getAutor)
              .thenComparing(Libro::getTitulo)
);

// Con nulos al final, sin que reviente
libros.sort(Comparator.comparing(Libro::getAutor,
            Comparator.nullsLast(Comparator.naturalOrder())));

Fijate que sort ordena la lista original en el lugar. Si necesitás conservar el orden original, copiá primero: new ArrayList<>(libros).sort(...).


5. El contrato equals: cinco reglas

Por defecto, Object.equals() compara referencias: devuelve true solo si son literalmente el mismo objeto en el Heap. Para casi cualquier clase de dominio, eso está mal:

Libro a = new Libro("1984", "Orwell", 328);
Libro b = new Libro("1984", "Orwell", 328);

System.out.println(a == b);        // false — son dos objetos distintos, obvio
System.out.println(a.equals(b));   // false — pero ESTO debería ser true

Sobrescribir equals significa firmar un contrato de cinco cláusulas:

ReglaQué significa
Reflexivaa.equals(a) siempre es true.
SimétricaSi a.equals(b), entonces b.equals(a).
TransitivaSi a.equals(b) y b.equals(c), entonces a.equals(c).
ConsistenteLlamarlo diez veces devuelve lo mismo, si nada cambió.
Contra nulla.equals(null) es false, nunca lanza excepción.
@Override
public boolean equals(Object o) {
    if (this == o) return true;                    // atajo: mismo objeto
    if (o == null || getClass() != o.getClass()) return false;
    Libro otro = (Libro) o;
    return paginas == otro.paginas
        && Objects.equals(titulo, otro.titulo)     // tolera null en ambos lados
        && Objects.equals(autor, otro.autor);
}

El parámetro es Object o, no Libro o. Si escribís public boolean equals(Libro o) estás sobrecargando, no sobrescribiendo, y las colecciones —que llaman a equals(Object)— van a seguir usando la versión heredada. Es exactamente el error que @Override detecta, tal cual lo vimos en la lección Arrays de Objetos: Guardar y Recorrer Muchas Instancias.


6. hashCode: el que rompe todo cuando falta

Acá está el problema real. equals solo no alcanza:

Qué pasa cuando dos objetos son equals pero tienen hashCode distinto equals() bien, hashCode() sin sobrescribir libro1 = new Libro("1984") hashCode() heredado de Object = 366712642 libro2 = new Libro("1984") hashCode() heredado de Object = 1829164700 equals() → true bucket 2 bucket 11 cajones distintos set.add(libro1); set.add(libro2); → set.size() == 2 El Set nunca los comparó: como cayeron en cajones distintos, equals() jamás llegó a ejecutarse. LA REGLA: si a.equals(b) es true, entonces a.hashCode() == b.hashCode() TIENE que ser true. Al revés no hace falta: dos objetos distintos pueden compartir hashCode. Eso es una colisión, y es normal.
El HashSet no recorre todo comparando: primero calcula el cajón. Si el cajón está mal, la comparación nunca ocurre.

Por eso la regla es absoluta: si sobrescribís equals, sobrescribí hashCode. No es opcional ni una buena práctica: es una condición para que las colecciones funcionen.

@Override
public int hashCode() {
    return Objects.hash(titulo, autor, paginas);   // los MISMOS campos que equals
}

Objects.hash(...) combina los valores con una fórmula ya probada. Usá exactamente los mismos campos en equals y en hashCode: si equals compara tres campos y hashCode usa dos, el contrato sigue cumpliéndose; si usa uno que equals ignora, se rompe.

El atajo: record

Si tu clase es un simple portador de datos, un record genera equals, hashCode y toString correctos automáticamente:

public record Libro(String titulo, String autor, int paginas) implements Comparable<Libro> {
    @Override
    public int compareTo(Libro otro) {
        return this.titulo.compareTo(otro.titulo);
    }
}

Tres líneas y el contrato está garantizado por el compilador. Es la razón por la que los record de la lección Constructores, Modificadores de Acceso y Getters/Setters aparecen tanto en código moderno.


7. Errores frecuentes

ErrorQué pasaCómo se arregla
Sobrescribir equals y no hashCodeLos HashSet guardan duplicados y HashMap.get() devuelve null con la clave correcta.Sobrescribir siempre los dos, con los mismos campos.
Escribir equals(Libro o) en vez de equals(Object o)Es una sobrecarga, no una sobrescritura. Las colecciones siguen usando la comparación por referencia.Firma equals(Object o) y anotar con @Override.
compareTo devolviendo a - bCon valores grandes el int desborda y el orden sale invertido, sin ninguna excepción.Integer.compare(a, b).
Usar == para comparar StringCompara referencias; funciona con literales por el pool de strings y falla con strings construidos..equals(), o Objects.equals() si puede haber null.
Modificar un objeto ya guardado en un HashSetCambia su hashCode, queda en el cajón viejo y contains() devuelve false sobre un objeto que está adentro.Claves y elementos de Set inmutables.
Borrar con lista.remove(x) dentro de un for-eachConcurrentModificationException.removeIf(...) o iterator.remove().
compareTo inconsistente con equalsUn TreeSet descarta elementos que equals considera distintos, porque para él compareTo == 0 significa duplicado.Que compareTo devuelva 0 exactamente cuando equals sea true.

8. Ejercicio práctico guiado

Desafío: la clase Libro

  1. Creá Libro con titulo, autor y paginas, todos inmutables.
  2. Implementá equals y hashCode usando los tres campos.
  3. Implementá Comparable<Libro> con orden natural por título.
  4. Demostrá que un HashSet descarta el duplicado.
  5. Ordená una lista por el orden natural y después con tres Comparator distintos.
  6. Mostrá qué pasa con un TreeSet cuyo compareTo solo mira el título.
Ver solución sugerida
import java.util.*;

public final class Libro implements Comparable<Libro> {
    private final String titulo;
    private final String autor;
    private final int paginas;

    public Libro(String titulo, String autor, int paginas) {
        if (titulo == null || titulo.isBlank()) {
            throw new IllegalArgumentException("El título es obligatorio");
        }
        if (paginas <= 0) {
            throw new IllegalArgumentException("Las páginas deben ser positivas");
        }
        this.titulo = titulo;
        this.autor = autor;
        this.paginas = paginas;
    }

    public String getTitulo() { return titulo; }
    public String getAutor() { return autor; }
    public int getPaginas() { return paginas; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Libro otro = (Libro) o;
        return paginas == otro.paginas
            && Objects.equals(titulo, otro.titulo)
            && Objects.equals(autor, otro.autor);
    }

    @Override
    public int hashCode() {
        return Objects.hash(titulo, autor, paginas);   // los mismos tres campos
    }

    @Override
    public int compareTo(Libro otro) {
        return this.titulo.compareTo(otro.titulo);     // orden natural: por título
    }

    @Override
    public String toString() {
        return String.format("%-28s %-18s %4d p.", titulo, autor, paginas);
    }

    public static void main(String[] args) {
        Libro a = new Libro("1984", "Orwell", 328);
        Libro b = new Libro("1984", "Orwell", 328);   // idéntico a 'a'

        System.out.println("a == b        → " + (a == b));        // false
        System.out.println("a.equals(b)   → " + a.equals(b));     // true
        System.out.println("mismo hash    → " + (a.hashCode() == b.hashCode()));  // true

        // 4. El HashSet descarta el duplicado gracias al par equals/hashCode
        Set<Libro> sinDuplicados = new HashSet<>(List.of(a, b));
        System.out.println("\nHashSet size  → " + sinDuplicados.size());   // 1

        List<Libro> libros = new ArrayList<>(List.of(
            new Libro("Rayuela",       "Cortázar", 736),
            new Libro("El Aleph",      "Borges",   146),
            new Libro("1984",          "Orwell",   328),
            new Libro("Ficciones",     "Borges",   174)
        ));

        // 5a. Orden natural: usa compareTo
        Collections.sort(libros);
        System.out.println("\nPor título (orden natural):");
        libros.forEach(l -> System.out.println("  " + l));

        // 5b. Por páginas, descendente
        libros.sort(Comparator.comparingInt(Libro::getPaginas).reversed());
        System.out.println("\nPor páginas (descendente):");
        libros.forEach(l -> System.out.println("  " + l));

        // 5c. Por autor y, a igual autor, por título
        libros.sort(Comparator.comparing(Libro::getAutor)
                              .thenComparing(Libro::getTitulo));
        System.out.println("\nPor autor, después por título:");
        libros.forEach(l -> System.out.println("  " + l));

        // 6. La trampa del TreeSet
        Set<Libro> arbol = new TreeSet<>(libros);
        System.out.println("\nLibros en la lista: " + libros.size());
        System.out.println("Libros en el TreeSet: " + arbol.size());
        System.out.println("(iguales, porque no hay dos títulos repetidos)");

        Libro otroConMismoTitulo = new Libro("1984", "Otro Autor", 500);
        arbol.add(otroConMismoTitulo);
        System.out.println("\nDespués de agregar otro libro titulado \"1984\": " + arbol.size());
        System.out.println("El TreeSet lo RECHAZÓ: para él, compareTo == 0 significa duplicado,");
        System.out.println("aunque equals() diga que son libros distintos.");
    }
}

Lo más importante de este ejercicio es el punto 6.

equals compara título, autor y páginas. compareTo solo mira el título. Son inconsistentes, y eso no molesta a nadie hasta que el objeto entra en un TreeSet o un TreeMap: esas estructuras ignoran equals por completo y deciden duplicados con compareTo == 0.

Resultado: un libro que equals considera distinto desaparece del conjunto sin error, sin excepción y sin aviso.

La solución cuando el orden natural tiene que ser consistente:

@Override
public int compareTo(Libro otro) {
    int porTitulo = this.titulo.compareTo(otro.titulo);
    if (porTitulo != 0) return porTitulo;
    int porAutor = Objects.compare(this.autor, otro.autor,
                                   Comparator.nullsFirst(Comparator.naturalOrder()));
    if (porAutor != 0) return porAutor;
    return Integer.compare(this.paginas, otro.paginas);   // nunca la resta
}

Ahora compareTo devuelve 0 exactamente cuando equals devuelve true, y las dos familias de colecciones coinciden.


Para llevarte

  • El for-each no habla con la colección: usa su Iterator por detrás.
  • ConcurrentModificationException no tiene nada que ver con hilos: es el iterador detectando que la colección cambió por fuera.
  • Para borrar mientras recorrés: removeIf(...), o iterator.remove().
  • Comparable = un orden natural, adentro de la clase. Comparator = muchos órdenes, afuera y para clases que no controlás.
  • En compareTo importa solo el signo, y nunca uses a - b: usá Integer.compare(a, b).
  • Si sobrescribís equals, sobrescribí hashCode. Con los mismos campos. Sin excepciones.
  • La firma correcta es equals(Object o). Con Libro o estás sobrecargando y las colecciones no lo usan.
  • TreeSet y TreeMap ignoran equals y deciden duplicados con compareTo == 0. Mantenelos consistentes.
  • Un record genera equals y hashCode correctos gratis.