Colecciones no modificables en Java
Crea colecciones inmutables en Java con List.of, Set.of, Map.of y los wrappers Collections.unmodifiable*.
Una colección modificable permite que cualquiera con una referencia cambie su contenido. Una colección no modificable no lo permite: llamar a add, remove, put, clear o set lanza UnsupportedOperationException. Java tiene dos formas complementarias de construir una: las factorías .of(...) introducidas en Java 9 (List.of, Set.of, Map.of, Map.ofEntries) y los antiguos wrappers Collections.unmodifiable*. Se parecen en el punto de llamada, pero se comportan de manera diferente en dos aspectos importantes, y la herramienta correcta depende de lo que realmente necesitas.
Este capítulo cierra la parte del framework de colecciones dándote una receta moderna y clara para "dame una constante" y "dame una instantánea".
Por qué la inmutabilidad
Tres ventajas concretas que justifican el patrón:
- Compartición segura. Entrega una lista no modificable a un constructor, un hilo de trabajo o un consumidor de eventos y no tienes que preocuparte de que muten tu estado. El tipo a nivel de compilador no dice "solo lectura", pero el runtime sí.
- Hashable de forma segura. Poner una
Listmutable en unHashSetes un error: si el contenido de la lista cambia, suhashCodecambia y el conjunto pierde el elemento. Las colecciones no modificables evitan esto por completo. - Mejor diseño de API. Devolver una vista no modificable desde un getter dice "esto es mío, léelo, no lo cambies". Sin eso, cada caller tiene que decidir si hacer una copia defensiva.
Las dos estrategias
List.of, Set.of, Map.of, Map.ofEntries — colecciones verdaderamente inmutables
Añadidas en Java 9. Construyen una nueva colección con su propio almacenamiento interno. Nada más tiene una referencia a ella:
List<String> roles = List.of("admin", "editor", "viewer");
Set<Integer> primes = Set.of(2, 3, 5, 7, 11);
Map<String, Integer> ages = Map.of("alice", 30, "bob", 25);
Map<String, Integer> many = Map.ofEntries(
Map.entry("alice", 30),
Map.entry("bob", 25),
Map.entry("carol", 28)
);Úsalas para constantes y literales — colecciones pequeñas y fijas que escribes en el código. El JIT las compila a representaciones muy compactas y de bajo coste (a menudo un único array en línea). El coste es cero por asignación más allá de lo que el literal en sí ocupa.
Tres restricciones a recordar:
- No hay elementos
null, no hay clavesnull, no hay valoresnull.List.of("a", null)lanzaNullPointerExceptionen la construcción. Si necesitas representar "ausente", usaOptionalu omite la clave del mapa. - No hay duplicados para
Set.ofyMap.of.Set.of("a", "a")lanzaIllegalArgumentException. Están diseñadas para datos literales que tú controlas. Map.oftiene sobrecargas solo hasta 10 entradas. Para 11 o más, usaMap.ofEntries(Map.entry(...), Map.entry(...), ...).
Collections.unmodifiableList(coll) etc. — vistas de una colección existente
Envuelve una colección en una vista de solo lectura. El original sigue siendo mutable, y los cambios a través del original son visibles a través de la vista:
List<String> mutable = new ArrayList<>(List.of("a", "b", "c"));
List<String> view = Collections.unmodifiableList(mutable);
view.add("d"); // throws UnsupportedOperationException
mutable.add("d"); // legal — and the view sees the change
System.out.println(view); // [a, b, c, d]Úsalas cuando quieras exponer una colección interna sin copiarla y sin dar permiso a los callers para mutarla. El patrón clásico es un getter:
public List<String> getNames() {
return Collections.unmodifiableList(this.names);
}El caller no puede cambiar this.names a través de la vista devuelta. Tú sí puedes. Si también quieres prohibírtelo a ti mismo, copia:
return List.copyOf(this.names);…que es la tercera estrategia.
List.copyOf, Set.copyOf, Map.copyOf — instantánea y luego congela
Un atajo para "copiar el contenido actual en una nueva colección inmutable":
List<String> snapshot = List.copyOf(mutable);Después de esta llamada, snapshot es completamente independiente de mutable. Los cambios posteriores a mutable son invisibles a través de snapshot. También existe una optimización inteligente: si la fuente ya es una colección no modificable producida por List.of / List.copyOf, la llamada devuelve la fuente misma, sin ninguna asignación.
copyOf rechaza elementos null, igual que of. Si tu fuente puede contener null, usa Collections.unmodifiableList(new ArrayList<>(source)) en su lugar.
Los tres patrones en resumen
| Patrón | ¿Independiente de la fuente? | ¿Permite null? | Úsalo cuando |
|---|---|---|---|
List.of("a", "b") | n/a (sin fuente) | No | Constantes literales |
List.copyOf(source) | Sí — almacenamiento propio | No | Instantánea en un momento concreto |
Collections.unmodifiableList(source) | No — vista | Sí | Exponer estado interno de solo lectura |
Cuando el punto de llamada dice "¿son estos datos literalmente codificados en duro?", usa of. Cuando dice "quiero una instantánea congelada de lo que hay ahora mismo", usa copyOf. Cuando dice "quiero que el contenido actual sea observable pero no modificable a través de esta referencia", usa unmodifiableList.
Superficial, no profundo
Las tres estrategias son superficiales — congelan la estructura de la colección, no los elementos que contiene.
List<int[]> arrays = List.of(new int[]{1, 2}, new int[]{3, 4});
arrays.add(new int[]{5}); // UnsupportedOperationException
arrays.get(0)[0] = 99; // OK — and now the list contains {99, 2}Si quieres inmutabilidad profunda, necesitas elegir tipos de elementos que sean a su vez inmutables. Los records con campos primitivos o de tipo String lo son. Los records con campos mutables no lo son. Esta es la misma advertencia que se aplica a las referencias final en general: el vínculo es fijo, el destino puede que no lo sea.
Set.of y Map.of tienen un orden de iteración no especificado
Dos decisiones de diseño intencionales que sorprenden a la gente:
Set.ofyMap.ofaleatorizan deliberadamente el orden de iteración entre ejecuciones de la misma JVM. Si escribes código que depende de un orden específico de estas, verás pruebas inestables. UsaList.of(que conserva el orden del literal) o unLinkedHashSet/LinkedHashMapenvuelto conCollections.unmodifiable*cuando realmente necesites orden.Set.of(a, b)ySet.of(b, a)pueden iterar de forma diferente incluso en la misma ejecución si los valores tienen distinto hash. No compares por toString.
Esto es por diseño — Java te impide depender accidentalmente del orden para que la implementación sea libre de cambiarlo.
Lo que la no modificabilidad no te da
- No es segura para hilos en lecturas de campos de elementos mutables. Si los elementos son mutables y otro hilo los está cambiando, necesitas sincronización de todos modos.
- No hace que la colección subyacente sea thread-safe.
Collections.unmodifiableList(arrayList)es una vista de una lista no thread-safe; si otro hilo haceaddsobrearrayList, la lectura a través de la vista puede ver un estado corrupto. Para inmutabilidad thread-safe,List.copyOf(oList.of) es la herramienta correcta — tienen almacenamiento privado propio. - No hace que
.equalssea independiente del orden. UnaListdevuelta porList.ofsigue siendo igual-por-posición respecto a otras listas, no por contenido.
Un ejemplo práctico: literales, instantáneas, vistas y la trampa de la superficialidad
El programa siguiente muestra las tres estrategias lado a lado, demuestra la sorpresa de "la vista ve las mutaciones", la promesa de "la copia es independiente", y la trampa de la superficialidad que atrapa a todos la primera vez.
Lo que se puede extraer de la ejecución:
List.ofyList.copyOfproducen ambas una colección verdaderamente inmutable — rechazan toda mutación. Solo se diferencian en si suministraste los datos de forma literal o los copiaste de otro lugar.- La vista
Collections.unmodifiableListrechazóview.addpero aceptóbacking.adda través de la referencia original. Los cambios a través de la lista de respaldo se hicieron visibles a través de la vista. Esa es la característica definitoria de una vista, y la razón por la que esta estrategia no es un sustituto decopyOfen código no confiable. - La trampa de la superficialidad es real: los elementos
int[]de unaList<int[]>inmutable son a su vez mutables, y editar uno reescribe la lista "congelada". Si quieres inmutabilidad profunda, tus elementos ya deben ser inmutables. Set.ofrechazó el duplicado yMap.ofrechazó el valornull— ambos en la construcción. Estas colecciones fallan rápido y con ruido; eso es una característica.List.copyOfde una lista ya inmutable devolvió la misma instancia sin asignar memoria. Esa es la optimización del JDK, y es la razón por la que "siempre copia a la salida" es barato cuando la fuente ya es inmutable.
Qué viene después — y hacia la Parte 12
Esto cierra la parte del Collections Framework. Ahora conoces cada implementación (ArrayList, LinkedList, HashMap, TreeMap, las colas, los deques y el resto), cada interfaz (Collection, List, Set, Map, Queue, Deque), los cursores de iteración (Iterator, ListIterator), las interfaces de ordenación (Comparable, Comparator), la caja de herramientas estática (Collections) y la historia de la inmutabilidad.
La siguiente parte — Programación Funcional — cambia de marcha. En lugar de cómo almacenar datos, trata cómo expresar transformaciones sobre datos. El primer capítulo, Functional Programming in Java, introduce el modelo mental: funciones como valores, inmutabilidad, funciones puras y composición. A partir de ahí, la parte desarrolla lambdas, referencias a métodos, las interfaces funcionales integradas (Function, Predicate, Consumer, Supplier), Optional y los streams, que usan las colecciones que acabas de aprender como fuente y destino.
La mayoría de los patrones en esta parte — list.sort(Comparator.comparing(Person::name)), map.getOrDefault(k, 0), stream().filter(...).toList() — ya tienen sabor funcional. La Parte 12 hace explícito ese sabor y te muestra cómo usarlo para todo lo demás.