Colecciones y genéricos

Las colecciones son la parte de la biblioteca estándar que se usa todos los días. Esta lección va de las tres que importan, de por qué una clave mal hecha hace desaparecer datos de un Map, y de un error al borrar que unas veces avisa y otras no.

Tres formas de guardar varias cosas

Admite repetidos Orden Buscar
List sí el de inserción recorriendo: lento
Set no ninguno (en HashSet) instantáneo
Map claves no, valores sí ninguno (en HashMap) instantáneo por clave

Esos tres nombres son interfaces. Lo que se crea con new es una implementación concreta, y lo idiomático es declarar por la interfaz:

List<String> compra = new ArrayList<>();
Set<String>  unicos = new HashSet<>();
Map<String,Integer> stock = new HashMap<>();

List

salida[pan, uva, pan]   size=3  get(1)=uva
  contains(uva)=true  indexOf(pan)=0
  tras remove("pan"): [uva, pan]

Al contrario que un array, crece sola. Y ojo con remove: quita la primera aparición, no todas. Otra trampa clásica es que remove(int) y remove(Object) son métodos distintos: en una List<Integer>, remove(2) borra la posición 2, y remove(Integer.valueOf(2)) borra el valor 2.

Set

salidaHashSet          [uva, pan]
  LinkedHashSet    [c, a, b]
  TreeSet (ordena) [a, b, c]

Las tres variantes sirven para lo mismo y se diferencian en el orden: HashSet no garantiza ninguno, LinkedHashSet conserva el de inserción y TreeSet ordena. Si el orden importa, elígelo a propósito; no confíes en el que salga.

Map

stock.put("pan", 3);
stock.put("pan", 5);                       // sustituye, no añade
IO.println(stock.getOrDefault("kiwi", 0));
stock.merge("pan", 2, Integer::sum);       // suma 2 a lo que haya, o pone 2 si no hay nada
stock.computeIfAbsent("kiwi", k -> 1);
for (var e : stock.entrySet()) IO.println(e.getKey() + " -> " + e.getValue());
salida{uva=10, pan=5}
  get(pan)=5  get(kiwi)=null
  getOrDefault(kiwi,0)=0
  tras merge(+2): 7
  computeIfAbsent: {uva=10, kiwi=1, pan=7}

get de una clave que no existe devuelve null, y de ahí salen la mitad de los NullPointerException del mundo. getOrDefault, merge y computeIfAbsent existen justo para no tener que escribir el «si no está, pon esto» a mano, y hacen el código bastante más corto.

Por qué un Map necesita equals y hashCode

Este experimento explica de golpe por qué la lección anterior insistía tanto:

record Clave(String a) { }                                 // equals y hashCode hechos
class Mala { final String a; Mala(String a){this.a=a;} }   // sin equals ni hashCode
salidacon record: valor
  sin equals: null

Mismo contenido, misma operación, resultado distinto. Un HashMap busca la clave calculando su hashCode y comparando con equals; si la clase no los define, usa los heredados, que comparan identidad. Resultado: metes algo y no lo vuelves a encontrar nunca.

Regla sin excepciones: todo lo que vaya a ser clave de un Map o elemento de un Set necesita equals y hashCode. En la práctica eso significa: usa un record, o un String, o un enum.

La trampa de borrar mientras se recorre

Lo que todo el mundo intenta alguna vez:

for (String s : lista) if (s.equals(quitar)) lista.remove(s);

Lo probé quitando cada uno de los tres elementos de [a, b, c]:

salidaquitando "a": ConcurrentModificationException
quitando "b": NO salta nada, queda [a, c]
quitando "c": ConcurrentModificationException
Esto es peor que si fallara siempre. Quitando el penúltimo elemento no salta nada, porque al acortarse la lista el recorrido se da por terminado antes de llegar a la comprobación. O sea: el mismo error, con los mismos datos, unas veces revienta y otras pasa desapercibido. Si alguna vez te ha fallado algo «solo con ciertas listas», puede ser esto.

La forma correcta es una línea:

lista.removeIf(s -> s.equals("b"));
salidacon removeIf: [a, c]

Ordenar

ps.sort(Comparator.comparingDouble(P::precio));
ps.sort(Comparator.comparing(P::n).reversed());
Collections.max(ps, Comparator.comparingDouble(P::precio));
salidapor precio:           [P[n=bufanda, precio=9.0], P[n=gorra, precio=12.5], P[n=mochila, precio=34.0]]
  por nombre, al revés: [P[n=mochila, precio=34.0], P[n=gorra, precio=12.5], P[n=bufanda, precio=9.0]]
  el más caro:          P[n=mochila, precio=34.0]

Comparator se construye por composición: comparing para extraer el campo, reversed() para invertir y thenComparing para el criterio de desempate. Esa sintaxis con :: es una referencia a método, y es lo que viene en la lección 9.

Genéricos

Los corchetes angulares de List<String> son el motivo de que no haga falta ningún cast al sacar elementos, y de que esto no compile:

List<String> solo = new ArrayList<>();
solo.add(42);   // error: incompatible types: int cannot be converted to String

También se pueden escribir métodos genéricos propios. La <T> delante del tipo de retorno declara el parámetro de tipo:

<T> T primero(List<T> l) { return l.isEmpty() ? null : l.get(0); }
Un detalle que explica rarezas: los genéricos de Java se borran al compilar. En tiempo de ejecución una List<String> es solo una List. Por eso no se puede preguntar si algo es una List<String>, ni crear un new T[]. Es el precio que se pagó por no romper el código anterior a 2004.

Lo esencial

Quieres… Usa
Una lista que crece List<T> x = new ArrayList<>()
Sin repetidos Set<T>; LinkedHashSet si el orden importa
Buscar por clave Map<K,V>
Evitar el null de get getOrDefault, merge, computeIfAbsent
Borrar según una condición removeIf, nunca dentro de un for-each
Que no se pueda modificar List.of, Set.of, Map.of
Ordenar por un campo Comparator.comparing(T::campo)

Todo el código de esta lección se compiló y se ejecutó con Java 25 LTS en un contenedor limpio antes de publicarla; las salidas y los errores del compilador están copiados de esa ejecución.