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
}
}
// 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
}
}
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.
// 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:
| Regla | Qué significa |
|---|---|
| Reflexiva | a.equals(a) siempre es true. |
| Simétrica | Si a.equals(b), entonces b.equals(a). |
| Transitiva | Si a.equals(b) y b.equals(c), entonces a.equals(c). |
| Consistente | Llamarlo diez veces devuelve lo mismo, si nada cambió. |
Contra null | a.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, noLibro o. Si escribíspublic boolean equals(Libro o)estás sobrecargando, no sobrescribiendo, y las colecciones —que llaman aequals(Object)— van a seguir usando la versión heredada. Es exactamente el error que@Overridedetecta, 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:
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
| Error | Qué pasa | Cómo se arregla |
|---|---|---|
Sobrescribir equals y no hashCode | Los 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 - b | Con valores grandes el int desborda y el orden sale invertido, sin ninguna excepción. | Integer.compare(a, b). |
Usar == para comparar String | Compara 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 HashSet | Cambia 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-each | ConcurrentModificationException. | removeIf(...) o iterator.remove(). |
compareTo inconsistente con equals | Un 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
- Creá
Librocontitulo,autorypaginas, todos inmutables. - Implementá
equalsyhashCodeusando los tres campos. - Implementá
Comparable<Libro>con orden natural por título. - Demostrá que un
HashSetdescarta el duplicado. - Ordená una lista por el orden natural y después con tres
Comparatordistintos. - Mostrá qué pasa con un
TreeSetcuyocompareTosolo 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-eachno habla con la colección: usa suIteratorpor detrás. ConcurrentModificationExceptionno tiene nada que ver con hilos: es el iterador detectando que la colección cambió por fuera.- Para borrar mientras recorrés:
removeIf(...), oiterator.remove(). Comparable= un orden natural, adentro de la clase.Comparator= muchos órdenes, afuera y para clases que no controlás.- En
compareToimporta solo el signo, y nunca usesa - 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). ConLibro oestás sobrecargando y las colecciones no lo usan. TreeSetyTreeMapignoranequalsy deciden duplicados concompareTo == 0. Mantenelos consistentes.- Un
recordgeneraequalsyhashCodecorrectos gratis.