Un HashMap de Java encuentra las claves con hashCode y equals, así que un contrato roto o una clave que cambia después de put hace desaparecer entradas. Aprende las reglas, por qué TreeSet usa compareTo y qué colección elegir.
Un HashMap o un HashSet confía en dos métodos de cada clave: hashCode para decidir dónde buscar, y equals para decidir qué cuenta como coincidencia. Si uno de los dos está mal, o si cambias una clave después de guardarla, la colección deja de encontrar en silencio cosas que siguen dentro. Sin excepción, sin aviso, solo null.
Este post cubre los contratos de equals y hashCode, cómo encuentra un HashMap una clave, por qué se pierde una clave que cambió, por qué TreeSet usa compareTo en su lugar, y cómo elegir una lista, un deque, un mapa o una colección inmutable. Cada programa de abajo se ejecutó en Java 25, y su salida está copiada de esa ejecución. Para ejecutar uno tú mismo, guárdalo como Main.java y ejecuta java Main.java.
El contrato de equals, y un bug de simetría
Un método equals tiene que comportarse como una igualdad de verdad, o las colecciones dan respuestas que dependen de qué lado preguntó. La parte sobre valores y referencias cubre == frente a equals, y la parte sobre records muestra el equals que los records escriben por ti. Esta sección trata de escribir uno a mano.
Aquí hay un wrapper que intenta ser útil. Compara sin distinguir mayúsculas y minúsculas con otros wrappers, y también con strings simples:
final class CaseInsensitive {
private final String key;
CaseInsensitive(String text) {
this.key = text.toLowerCase(Locale.ROOT);
}
@Override
public boolean equals(Object other) {
if (other instanceof CaseInsensitive c) {
return key.equals(c.key);
}
if (other instanceof String s) {
return key.equals(s.toLowerCase(Locale.ROOT));
}
return false;
}
@Override
public int hashCode() {
return key.hashCode();
}
}
void main() {
var name = new CaseInsensitive("Ana");
IO.println("name.equals(\"ana\"): " + name.equals("ana"));
IO.println("\"ana\".equals(name): " + "ana".equals(name));
var wrappers = new ArrayList<Object>();
wrappers.add(name);
IO.println("wrappers contains \"ana\": " + wrappers.contains("ana"));
var strings = new ArrayList<Object>();
strings.add("ana");
IO.println("strings contains name: " + strings.contains(name));
}
Imprime:
name.equals("ana"): true
"ana".equals(name): false
wrappers contains "ana": false
strings contains name: true
CaseInsensitive conoce String, pero String nunca oyó hablar de CaseInsensitive. Así que a.equals(b) y b.equals(a) no coinciden. ArrayList.contains(x) llama a x.equals(element), lo que significa que la respuesta cambia según qué objeto está en la lista y cuál estás buscando.
Eso rompe la regla de simetría. El contrato de equals en la documentación de Object tiene cinco reglas, para cualquier x, y y z que no sean null:
- Reflexiva:
x.equals(x)es true. - Simétrica:
x.equals(y)es true exactamente cuandoy.equals(x)es true. - Transitiva: si
x.equals(y)yy.equals(z), entoncesx.equals(z). - Consistente: llamarlo otra vez da la misma respuesta, mientras ningún objeto haya cambiado.
- Null:
x.equals(null)es false, no una excepción.
La solución es comparar solo con tu propio tipo. Aquí importa que la clase sea final: si una subclase pudiera agregar campos y su propio equals, el mismo problema de simetría volvería entre padre e hija.
final class CaseInsensitive {
private final String key;
CaseInsensitive(String text) {
this.key = text.toLowerCase(Locale.ROOT);
}
@Override
public boolean equals(Object other) {
return other instanceof CaseInsensitive c && key.equals(c.key);
}
@Override
public int hashCode() {
return key.hashCode();
}
}
void main() {
var a = new CaseInsensitive("Ana");
var b = new CaseInsensitive("ANA");
var c = new CaseInsensitive("ana");
IO.println("reflexive: " + a.equals(a));
IO.println("symmetric: " + (a.equals(b) == b.equals(a)));
IO.println("transitive: " + (a.equals(b) && b.equals(c) && a.equals(c)));
IO.println("null: " + a.equals(null));
IO.println("vs String: " + a.equals("ana") + " " + "ana".equals(a));
}
Imprime:
reflexive: true
symmetric: true
transitive: true
null: false
vs String: false false
Ahora un wrapper y un string nunca son iguales, desde ningún lado. Es menos ingenioso, y es correcto. instanceof además resuelve null gratis, porque null instanceof CaseInsensitive es false.
Los objetos iguales deben tener hash codes iguales
El contrato de hashCode tiene una regla que importa más que las demás: si a.equals(b) es true, a.hashCode() debe ser igual a b.hashCode(). Los objetos distintos pueden compartir un hash code. Los iguales no pueden tenerlo diferente.
El bug clásico es sobrescribir equals y olvidar hashCode. La compilación que usa esta serie, javac -Xlint:all -Werror, lo atrapa:
class Email {
private final String address;
Email(String address) {
this.address = address;
}
@Override
public boolean equals(Object other) {
return other instanceof Email e && address.equals(e.address);
}
}
void main() {
var subscribers = new HashSet<Email>();
subscribers.add(new Email("ana@example.com"));
IO.println(subscribers.contains(new Email("ana@example.com")));
}
La compilación falla con:
Main.java:1: warning: [overrides] Class Main.Email overrides equals, but neither it nor any superclass overrides hashCode method
error: warnings found and -Werror specified
Esto nos sorprendió: la comprobación existe, pero está apagada por defecto. Un javac Main.java simple y java Main.java aceptan este archivo sin decir nada. El lint overrides solo se ejecuta cuando lo pides con -Xlint. El mensaje dice Main.Email porque un archivo fuente compacto envuelve sus clases en una clase oculta Main.
Entonces, esto es lo que pasa cuando nadie lo pide. @SuppressWarnings("overrides") apaga la comprobación, para que puedas ver el bug en ejecución:
@SuppressWarnings("overrides")
class Email {
private final String address;
Email(String address) {
this.address = address;
}
@Override
public boolean equals(Object other) {
return other instanceof Email e && address.equals(e.address);
}
}
void main() {
var a = new Email("ana@example.com");
var b = new Email("ana@example.com");
IO.println("a.equals(b): " + a.equals(b));
IO.println("same hashCode: " + (a.hashCode() == b.hashCode()));
var subscribers = new HashSet<Email>();
subscribers.add(a);
IO.println("contains b: " + subscribers.contains(b));
subscribers.add(b);
IO.println("size: " + subscribers.size());
var list = new ArrayList<Email>();
list.add(a);
IO.println("list has b: " + list.contains(b));
}
Imprime:
a.equals(b): true
same hashCode: false
contains b: false
size: 2
list has b: true
a y b son iguales, pero cada uno sigue con el hash code de Object, que se basa en la identidad del objeto. El HashSet busca b en el lugar equivocado y dice que no está. Después agrega tan tranquilo un “duplicado”, así que un set guarda dos elementos iguales. El ArrayList nunca usa hash codes, así que encuentra b. Por eso este bug se esconde: el código que usa listas funciona, y la misma clase se rompe en cuanto entra en un set o se vuelve clave de un mapa.
La solución es un hashCode construido con los mismos campos que compara equals. Objects.hash lo hace en una línea:
class Email {
private final String user;
private final String domain;
Email(String user, String domain) {
this.user = user;
this.domain = domain;
}
@Override
public boolean equals(Object other) {
return other instanceof Email e && user.equals(e.user) && domain.equals(e.domain);
}
@Override
public int hashCode() {
return Objects.hash(user, domain);
}
}
void main() {
var a = new Email("ana", "example.com");
var b = new Email("ana", "example.com");
IO.println("same hashCode: " + (a.hashCode() == b.hashCode()));
var subscribers = new HashSet<Email>();
subscribers.add(a);
subscribers.add(b);
IO.println("contains b: " + subscribers.contains(b));
IO.println("size: " + subscribers.size());
}
Imprime:
same hashCode: true
contains b: true
size: 1
Usa cada campo que usa equals, y ningún campo que ignore. Un record hace todo esto por ti a partir de sus componentes, como muestra la parte sobre records. Si Email fuera record Email(String user, String domain) {}, no habría nada que olvidar.
Cómo encuentra un HashMap una clave
Un HashMap guarda sus entradas en un array de buckets (cubetas), y el hash code de cada clave decide en qué bucket vive. Una búsqueda no recorre todo el mapa. Calcula el hash code de la clave, va directo a un bucket y revisa con equals solo las entradas de ese bucket.
Dos claves pueden caer en el mismo bucket, e incluso pueden tener el mismo hash code. equals las distingue:
void main() {
IO.println("\"Aa\".hashCode() = " + "Aa".hashCode());
IO.println("\"BB\".hashCode() = " + "BB".hashCode());
var map = new HashMap<String, Integer>();
map.put("Aa", 1);
map.put("BB", 2);
IO.println("get Aa: " + map.get("Aa"));
IO.println("get BB: " + map.get("BB"));
IO.println("size: " + map.size());
}
Imprime:
"Aa".hashCode() = 2112
"BB".hashCode() = 2112
get Aa: 1
get BB: 2
size: 2
"Aa" y "BB" chocan exactamente, así que comparten un bucket. El mapa igual los mantiene separados, porque "Aa".equals("BB") es false. Una colisión cuesta un poco de tiempo. Nunca cuesta que el resultado sea incorrecto.
Explicado como si tuvieras diez años
Un HashMap es un guardarropa. Cuando entregas tu abrigo, el encargado lee la etiqueta que lleva y elige una barra según esa etiqueta. Eso es hashCode. El encargado cuelga tu abrigo en esa barra, junto a otros pocos abrigos.
Cuando vuelves, muestras la misma etiqueta. El encargado camina hasta esa única barra y revisa las fichas de los abrigos colgados ahí, una por una, hasta que una coincide. Eso es equals. Nadie busca en todo el cuarto.
Ahora imagina que vuelves a escondidas y cambias la etiqueta de tu abrigo después de entregarlo. Cuando lo pides, la etiqueta nueva manda al encargado a otra barra. Tu abrigo no está ahí. Sigue en el cuarto, en la barra vieja, pero nadie lo va a buscar ahí.
La versión precisa
Un HashMap tiene un array de buckets cuya longitud es una potencia de dos, 16 por defecto cuando entra la primera entrada. Para elegir un bucket, toma el hashCode() de la clave, mezcla los bits altos con los bajos con h ^ (h >>> 16) y se queda con los bits bajos con (n - 1) & hash, donde n es el número de buckets. Cada entrada guarda el hash con el que se insertó.
get(key) vuelve a calcular el hash, va a ese bucket y, para cada entrada, comprueba que el hash guardado sea igual y que las claves sean == o equals. La primera entrada que pasa es la respuesta. Cuando el mapa supera el 75% de ocupación, el array se duplica y las entradas se reparten entre los buckets nuevos. Si un bucket junta demasiadas entradas (la constante de umbral del JDK es 8) y la tabla tiene al menos 64 buckets, ese bucket se convierte en un pequeño árbol, así que ni siquiera una mala función hash hace que las búsquedas recorran una cadena larga.
Dónde falla la analogía: un encargado real notaría la etiqueta nueva y buscaría. HashMap nunca vuelve a leer una clave después de put. Confía en el hash que guardó, así que una clave que cambió no se mueve, no se marca ni se recalcula su hash. Además, la barra no se elige con toda la etiqueta. Solo los bits bajos del hash mezclado eligen el bucket, y por eso dos hash codes muy distintos pueden compartir uno.
Cambiar una clave después de put pierde la entrada
Una clave que cambia después de guardarla es la forma más común en que un HashMap “pierde” datos. La entrada sigue en el mapa. Solo que ya no puedes llegar a ella.
class Coat {
String tag;
Coat(String tag) {
this.tag = tag;
}
@Override
public boolean equals(Object other) {
return other instanceof Coat c && tag.equals(c.tag);
}
@Override
public int hashCode() {
return tag.hashCode();
}
}
void main() {
var owners = new HashMap<Coat, String>();
var coat = new Coat("blue-17");
owners.put(coat, "Ana");
IO.println("before: " + owners.get(coat));
coat.tag = "red-42";
IO.println("after, same object: " + owners.get(coat));
IO.println("after, old tag: " + owners.get(new Coat("blue-17")));
IO.println("containsKey: " + owners.containsKey(coat));
IO.println("remove: " + owners.remove(coat));
IO.println("size: " + owners.size());
IO.println("values: " + owners.values());
coat.tag = "blue-17";
IO.println("tag put back: " + owners.get(coat));
}
Imprime:
before: Ana
after, same object: null
after, old tag: null
containsKey: false
remove: null
size: 1
values: [Ana]
tag put back: Ana
Coat tiene un equals y un hashCode correctos. El bug es que los dos dependen de tag, y tag cambió mientras el abrigo era una clave. Lee los resultados de a pares:
- El mismo objeto, después del cambio: su hash nuevo manda a
geta otro bucket, así que no encuentra nada.containsKeyyremovefallan de la misma forma. - Un
Coat("blue-17")nuevo: su hash coincide con el guardado, así quegetllega al bucket correcto. Despuésequalslo compara con la clave guardada, cuya etiqueta ahora dicered-42, y no coinciden. sizeyvalues: Ana sigue ahí dentro. Recorrer el mapa la encuentra. Solo las búsquedas no.
Volver a poner la etiqueta vieja hace que la entrada sea alcanzable otra vez, lo que prueba que no se borró nada. En código real nadie recuerda el valor viejo, así que la entrada queda atascada, y un mapa de larga vida pierde memoria de esta forma.
La solución es usar claves que no puedan cambiar: records con componentes inmutables, String, Integer o clases con campos final. Si una clave de verdad tiene que cambiar, sácala primero, cámbiala y vuelve a ponerla.
Una búsqueda, y después una clave perdida
La animación es un dibujo simplificado con números inventados: 8 buckets y hash codes inventados. Muestra owners.get(coat) encontrando a Ana, y después la misma llamada tras cambiar la etiqueta:
Un HashMap simplificado con 8 buckets y hash codes inventados. El hash de la clave elige el bucket 3, equals rechaza la primera entrada y acepta la segunda, y get devuelve Ana. Después de cambiar la etiqueta, el hash nuevo elige el bucket 6, que está vacío, así que get devuelve null mientras la entrada sigue en el bucket 3.
Aquí están esos pasos en palabras, por si la animación no se reproduce:
owners.get(coat)empieza con un abrigo cuya etiqueta esblue-17.getllama acoat.hashCode(). En este dibujo eso da 1283, y 1283 elige el bucket 3 de 8.- El bucket 3 tiene dos entradas.
getllama aequalscon la clave de la primera,pear-05, y da false, así que sigue. equalscon la clave de la segunda entrada,blue-17, da true. Esa es la entrada.getdevuelve el valor guardado con ella,"Ana".- Ahora la etiqueta del abrigo cambia a
red-42. La entrada no se mueve: se queda en el bucket 3 con su hash guardado de 1283. El siguienteget(coat)calcula un hash nuevo, 5078, que elige el bucket 6. El bucket 6 está vacío, así quegetdevuelvenull.
TreeSet y TreeMap usan compareTo, no equals
Un TreeSet o un TreeMap mantiene sus claves ordenadas, y decide si dos claves son la misma comparándolas, no llamando a equals. Si compare devuelve 0, el árbol trata a las dos como una sola clave. Un comparador que devuelve 0 para cosas que no son iguales hace que el set las descarte:
void main() {
var byLength = new TreeSet<String>(Comparator.comparingInt(String::length));
for (var fruit : List.of("fig", "kiwi", "pear", "plum", "apple")) {
IO.println("add " + fruit + ": " + byLength.add(fruit));
}
IO.println(byLength);
IO.println("contains pear: " + byLength.contains("pear"));
IO.println("contains lime: " + byLength.contains("lime"));
var prices = List.of(new BigDecimal("1.0"), new BigDecimal("1.00"));
IO.println("equals: " + prices.get(0).equals(prices.get(1)));
IO.println("compareTo: " + prices.get(0).compareTo(prices.get(1)));
IO.println("HashSet size: " + new HashSet<>(prices).size());
IO.println("TreeSet size: " + new TreeSet<>(prices).size());
}
Imprime:
add fig: true
add kiwi: true
add pear: false
add plum: false
add apple: true
[fig, kiwi, apple]
contains pear: true
contains lime: true
equals: false
compareTo: 0
HashSet size: 2
TreeSet size: 1
pear y plum tienen cuatro letras, como kiwi, así que el set los rechazó. Peor aún, contains("lime") dice true para una palabra que nunca se agregó. Con tener cuatro letras basta.
BigDecimal muestra lo mismo dentro del propio JDK. 1.0 y 1.00 tienen escalas distintas, así que equals dice que son diferentes y un HashSet guarda los dos. Su compareTo es 0, así que un TreeSet guarda uno. Cuando el orden natural de una clase coincide con equals, la documentación dice que es coherente con equals (consistent with equals), y eso es lo que quieres para las claves.
Una clase obtiene un orden natural implementando Comparable. Un Comparator da un orden desde fuera de la clase. Comparator.comparing(...).thenComparing(...) construye uno campo por campo, y los criterios de desempate son lo que lo mantiene coherente con equals:
record Version(int major, int minor) implements Comparable<Version> {
private static final Comparator<Version> ORDER =
Comparator.comparingInt(Version::major).thenComparingInt(Version::minor);
@Override
public int compareTo(Version other) {
return ORDER.compare(this, other);
}
}
record Person(String last, String first, int age) {}
void main() {
var versions = new TreeSet<>(
List.of(new Version(21, 0), new Version(8, 2), new Version(17, 1)));
IO.println(versions);
var people = new ArrayList<>(List.of(
new Person("Silva", "Rui", 41),
new Person("Okafor", "Ada", 29),
new Person("Silva", "Ana", 35),
new Person("Okafor", "Ada", 52)));
people.sort(Comparator.comparing(Person::last)
.thenComparing(Person::first)
.thenComparing(Comparator.comparingInt(Person::age).reversed()));
people.forEach(IO::println);
}
Imprime:
[Version[major=8, minor=2], Version[major=17, minor=1], Version[major=21, minor=0]]
Person[last=Okafor, first=Ada, age=52]
Person[last=Okafor, first=Ada, age=29]
Person[last=Silva, first=Ana, age=35]
Person[last=Silva, first=Rui, age=41]
Version 8.2 va antes que 17.1 porque la comparación es numérica. Como strings, "17" iría primero. people se ordena por apellido, después por nombre, y después del mayor al menor. Las dos Ada Okafor solo difieren en la edad, y el último thenComparing las pone en orden. Sin él, un TreeSet que usara este comparador guardaría solo una de ellas.
Elegir una lista, una pila o una cola
ArrayList es la lista correcta casi siempre, y la razón es cómo guardan sus elementos las dos listas. Un ArrayList guarda las referencias en un solo array. get(i) va directo a la posición i. Agregar al final escribe en la siguiente posición libre y, de vez en cuando, copia todo a un array más grande.
Un LinkedList envuelve cada elemento en su propio objeto nodo, con enlaces a sus vecinos. get(i) tiene que caminar desde un extremo, nodo por nodo, y cada elemento cuesta un objeto extra. Insertar en el medio es barato solo cuando un iterador ya caminó hasta ahí, lo que casi nunca compensa el resto.
Para una pila o una cola, usa ArrayDeque. La vieja clase Stack extiende Vector, bloquea en cada llamada, y su propia documentación recomienda usar un Deque en su lugar.
void main() {
var stack = new ArrayDeque<String>();
stack.push("open file");
stack.push("read line");
stack.push("parse number");
IO.println("stack pop: " + stack.pop());
IO.println("stack peek: " + stack.peek());
var queue = new ArrayDeque<String>();
queue.offer("Ana");
queue.offer("Ben");
queue.offer("Cy");
IO.println("queue poll: " + queue.poll());
IO.println("queue now: " + queue);
IO.println("empty poll: " + new ArrayDeque<String>().poll());
}
Imprime:
stack pop: parse number
stack peek: read line
queue poll: Ana
queue now: [Ben, Cy]
empty poll: null
push y pop trabajan al frente, así que lo último que entró es lo primero que sale. offer agrega atrás y poll saca del frente, así que la cola es primero en entrar, primero en salir. poll sobre un deque vacío devuelve null en vez de lanzar una excepción. ArrayDeque no acepta elementos null, así que un null de poll siempre significa que el deque estaba vacío.
Elegir un mapa
Los tres mapas de uso general difieren en una cosa que puedes ver: el orden en que te devuelven los elementos cuando los recorres. Elige según lo que necesitas:
| Necesitas | Usa | Orden al recorrer |
|---|---|---|
| Búsqueda rápida, y el orden no importa | HashMap |
Ninguno en el que debas confiar |
| Búsqueda rápida, en el orden en que se agregaron las claves | LinkedHashMap |
Orden de inserción |
| Claves ordenadas, o rangos como “todas las claves antes de Lagos” | TreeMap |
Ordenado por compareTo o un comparador |
HashMap y LinkedHashMap buscan claves en tiempo constante en promedio, suponiendo hash codes decentes. TreeMap garantiza tiempo log(n). Las mismas tres opciones existen para sets: HashSet, LinkedHashSet y TreeSet.
void main() {
var cities = List.of("Porto", "Lagos", "Delhi", "Accra");
var inserted = new LinkedHashMap<String, Integer>();
var sorted = new TreeMap<String, Integer>();
for (var i = 0; i < cities.size(); i++) {
inserted.put(cities.get(i), i + 1);
sorted.put(cities.get(i), i + 1);
}
IO.println("LinkedHashMap: " + inserted);
IO.println("TreeMap: " + sorted);
IO.println("first key: " + sorted.firstKey());
IO.println("before Lagos: " + sorted.headMap("Lagos"));
}
Imprime:
LinkedHashMap: {Porto=1, Lagos=2, Delhi=3, Accra=4}
TreeMap: {Accra=4, Delhi=3, Lagos=2, Porto=1}
first key: Accra
before Lagos: {Accra=4, Delhi=3}
Aquí no se imprime ningún HashMap a propósito. Su orden viene de los buckets, así que puede cambiar cuando el mapa crece o cuando usas otro JDK. Si el orden de la salida importa, elige uno de los otros dos. Cuando las claves son un enum, EnumMap es la mejor opción: guarda los valores en un array indexado por el ordinal del enum, y recorre en el orden de declaración.
Colecciones inmutables y vistas no modificables
List.of, Set.of y Map.of crean colecciones que no pueden cambiar en absoluto, y List.copyOf hace una copia inmutable de una existente. Collections.unmodifiableList es distinto. Es una ventana de solo lectura sobre una lista que todavía puede cambiar por debajo:
void main() {
var names = new ArrayList<String>(List.of("Ana", "Ben"));
List<String> view = Collections.unmodifiableList(names);
List<String> copy = List.copyOf(names);
names.add("Cy");
IO.println("names: " + names);
IO.println("view: " + view);
IO.println("copy: " + copy);
try {
view.add("Dee");
} catch (UnsupportedOperationException e) {
IO.println("view.add threw " + e.getClass().getSimpleName());
}
try {
Map.of("Ana", 31, "Ben", null);
} catch (NullPointerException e) {
IO.println("Map.of with a null value threw " + e.getClass().getSimpleName());
}
}
Imprime:
names: [Ana, Ben, Cy]
view: [Ana, Ben, Cy]
copy: [Ana, Ben]
view.add threw UnsupportedOperationException
Map.of with a null value threw NullPointerException
Cy se agregó a names, y la vista lo muestra, porque la vista no tiene elementos propios. La copia se tomó antes, así que no lo muestra. No puedes cambiar una lista a través de la vista, pero cualquiera que tenga names sí puede. Entrega List.copyOf cuando quieras que quien llama vea una lista fija.
Los métodos of y copyOf rechazan null en cualquier lugar. Map.of también rechaza la misma clave dos veces, lo que atrapa un error de copiar y pegar en el momento en que se construye el mapa:
void main() {
var ok = Map.of("Ana", 31, "Ben", 27);
IO.println("size: " + ok.size());
IO.println("Ana: " + ok.get("Ana"));
var typo = Map.of("Ana", 31, "Ben", 27, "Ana", 40);
IO.println(typo.size());
}
Imprime y se detiene:
size: 2
Ana: 31
Exception in thread "main" java.lang.IllegalArgumentException: duplicate key: Ana
Un HashMap habría guardado el segundo valor sin quejarse. Set.of lanza la misma excepción con un elemento duplicado.
Quitar elementos mientras recorres
Un bucle for-each sobre un ArrayList usa un iterador, y ese iterador falla si la lista cambia a sus espaldas. Quita un elemento dentro del bucle y el siguiente paso lanza una excepción:
void main() {
var names = new ArrayList<>(List.of("Ana", "Ben", "Cy", "Dee"));
for (var name : names) {
IO.println("checking " + name);
if (name.startsWith("B")) {
names.remove(name);
}
}
IO.println(names);
}
Imprime y se detiene:
checking Ana
checking Ben
Exception in thread "main" java.util.ConcurrentModificationException
El nombre confunde: hay un solo hilo. “Concurrent” significa que la lista cambió mientras un iterador estaba a mitad de recorrerla.
La excepción no está garantizada, y ese es el caso peor. Aquí está el mismo bucle sobre una lista de tres elementos:
void main() {
var names = new ArrayList<>(List.of("Ana", "Ben", "Cy"));
for (var name : names) {
IO.println("checking " + name);
if (name.startsWith("B")) {
names.remove(name);
}
}
IO.println(names);
}
Imprime:
checking Ana
checking Ben
[Ana, Cy]
Sin excepción, y Cy nunca se revisó. Quitar Ben redujo la lista a dos, y el iterador ya había entregado dos elementos, así que decidió que había terminado antes de comprobar si hubo cambios. Si Cy hubiera empezado con “B”, seguiría en la lista. La documentación llama a esta comprobación “best-effort” (de mejor esfuerzo), y esto es lo que significa.
Hay dos formas correctas. removeIf hace todo el trabajo en una llamada. Un Iterator explícito te deja quitar el elemento en el que estás con it.remove(), y el iterador se entera:
void main() {
var names = new ArrayList<>(List.of("Ana", "Ben", "Cy", "Bea", "Dee"));
names.removeIf(name -> name.startsWith("B"));
IO.println("removeIf: " + names);
var guests = new ArrayList<>(List.of("Ana", "Ben", "Cy", "Bea", "Dee"));
var it = guests.iterator();
while (it.hasNext()) {
var name = it.next();
if (name.startsWith("B")) {
it.remove();
IO.println("removed " + name);
}
}
IO.println("iterator: " + guests);
}
Imprime:
removeIf: [Ana, Cy, Dee]
removed Ben
removed Bea
iterator: [Ana, Cy, Dee]
Usa removeIf, salvo que necesites hacer algo con cada elemento que quitas, como hace aquí el bucle con iterador.
getOrDefault, merge y computeIfAbsent
Tres métodos de Map reemplazan la mayor parte del código de “comprobar y después poner” que se escribía antes. getOrDefault devuelve un valor alternativo para una clave que no está. merge combina un valor nuevo con uno existente. computeIfAbsent crea un valor la primera vez que aparece una clave.
void main() {
var text = "the quick fox saw the lazy dog and the dog saw the fox";
var counts = new TreeMap<String, Integer>();
for (var word : text.split(" ")) {
counts.merge(word, 1, Integer::sum);
}
IO.println(counts);
IO.println("the: " + counts.getOrDefault("the", 0));
IO.println("cat: " + counts.getOrDefault("cat", 0));
var byLength = new TreeMap<Integer, List<String>>();
for (var word : counts.keySet()) {
byLength.computeIfAbsent(word.length(), k -> new ArrayList<>()).add(word);
}
IO.println(byLength);
}
Imprime:
{and=1, dog=2, fox=2, lazy=1, quick=1, saw=2, the=4}
the: 4
cat: 0
{3=[and, dog, fox, saw, the], 4=[lazy], 5=[quick]}
counts.merge(word, 1, Integer::sum) pone 1 para una palabra nueva, y suma 1 a una palabra que ya está. computeIfAbsent crea una lista vacía la primera vez que ve una longitud, devuelve la lista del mapa en cualquier caso, y add pone la palabra en ella. Los dos mapas son TreeMap, así que el orden impreso está ordenado y es el mismo en cada ejecución.
Qué recordar
equalsdebe ser reflexiva, simétrica, transitiva, consistente, y dar false paranull. Compara solo con tu propio tipo, o la respuesta depende de qué lado pregunta.- Los objetos iguales deben tener hash codes iguales. Sobrescribe
hashCodejunto conequals, con los mismos campos, por ejemplo conObjects.hash.-Xlintavisa cuando lo olvidas, pero solo si lo activas. Los records hacen las dos cosas por ti. - Un
HashMapusahashCodepara elegir un bucket, y despuésequalsdentro de él. Nunca cambies un campo que usahashCodemientras el objeto es una clave, o la entrada se vuelve inalcanzable. TreeSetyTreeMaptratancompare(a, b) == 0como la misma clave. Agrega criterios de desempate conthenComparingpara que el orden coincida conequals.- Usa
ArrayListpara listas,ArrayDequepara pilas y colas, y eligeHashMap,LinkedHashMapoTreeMapsegún el orden de recorrido que necesites. List.ofyList.copyOfno pueden cambiar.Collections.unmodifiableListes una vista que muestra los cambios de la lista que tiene detrás.- No quites elementos de una lista dentro de un bucle for-each. Usa
removeIfoIterator.remove.
Una colección basada en hash solo funciona si el
equalsy elhashCodede una clave coinciden y no cambian mientras está dentro.