La jerarquía de colecciones: solo lectura no es inmutable
El árbol completo de tipos de la biblioteca estándar, desde Iterable hasta Map, con lo que añade exactamente cada nivel y por qué Set no añade ninguna firma. Esta lección separa la jerarquía de solo lectura de su gemela mutable, explica la varianza de cada una, demuestra con código por qué una referencia de solo lectura no garantiza que nadie modifique la colección detrás, y fija el criterio para elegir tipos en parámetros y retornos y para copiar defensivamente en las fronteras.
Casi todo el código que escribes manipula colecciones, y casi todo el mundo aprende su jerarquía por ósmosis: listOf devuelve algo que no se puede modificar y mutableListOf devuelve algo que sí. Esa formulación basta para escribir código que compila y es, precisamente en el punto donde importa, falsa. La distinción que Kotlin introdujo no es entre colecciones inmutables y colecciones mutables, sino entre interfaces que ofrecen operaciones de escritura e interfaces que no las ofrecen; toda la diferencia entre una garantía real y una convención de buena educación cabe en esa preposición. Esta lección reconstruye el árbol completo de tipos, señala el punto exacto donde termina lo que el compilador comprueba y empieza lo que solo sostiene la disciplina, y deja claro qué hay que hacer cuando lo que necesitas de verdad es una colección que nadie pueda cambiar a tu espalda.
- Describir la jerarquía de
Iterable,Collection,List,SetyMapy qué aporta cada nivel. - Distinguir la interfaz de solo lectura de su gemela mutable y justificar la varianza de cada una.
- Demostrar con código por qué una referencia de solo lectura no implica inmutabilidad.
- Elegir el tipo correcto en firmas públicas y copiar defensivamente en las fronteras.
El árbol: qué añade exactamente cada nivel
La raíz es la interfaz más pequeña que puede existir: Iterable<out T> declara un único método que devuelve un iterador. No sabe cuántos elementos hay, no sabe si puede recorrerse dos veces y no sabe si contiene algo concreto sin recorrerlo entero. Sobre ella, Collection<out E> añade el tamaño conocido y la pertenencia; List<out E> añade el acceso posicional; y Set<out E> no añade absolutamente ninguna firma nueva.
interface Iterable<out T> {
operator fun iterator(): Iterator<T>
}
interface Collection<out E> : Iterable<E> {
val size: Int
fun isEmpty(): Boolean
operator fun contains(element: @UnsafeVariance E): Boolean
fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
}
interface List<out E> : Collection<E> {
operator fun get(index: Int): E
fun indexOf(element: @UnsafeVariance E): Int
fun subList(fromIndex: Int, toIndex: Int): List<E>
}
Ese último dato sobre Set<E> es el más instructivo de la jerarquía y conviene detenerse en él: la diferencia entre un conjunto y una colección cualquiera no está en el sistema de tipos sino en el contrato escrito en la documentación, que promete que no hay elementos duplicados y que no se garantiza ningún orden posicional. El compilador no puede comprobar esa promesa, de modo que un Set<T> mal implementado compila perfectamente y falla en tiempo de ejecución. Es el ejemplo canónico de una invariante semántica que la firma no captura, y explica por qué la biblioteca estándar te da fábricas en vez de invitarte a implementar la interfaz a mano.
Map vive fuera de esta jerarquía. No es una Collection, no se puede recorrer directamente con un iterador propio y su declaración es Map<K, out V>: la clave es invariante porque aparece como parámetro de entrada en get, mientras que el valor sí es covariante. Su relación con el resto del árbol es por composición, a través de tres vistas que sí son colecciones.
interface Map<K, out V> {
val size: Int
val keys: Set<K>
val values: Collection<V>
val entries: Set<Map.Entry<K, V>>
operator fun get(key: K): V?
}
Que values sea una Collection<V> y no un Set<V> no es un descuido: dos claves distintas pueden compartir valor, así que la vista de valores admite duplicados y solo el conjunto de claves puede prometer que no los tiene. Y que entries sea un conjunto de un tipo anidado con dos propiedades es lo que permite que el bucle sobre un mapa desestructure la entrada en clave y valor, porque Map.Entry declara los operadores de componente necesarios. Las tres vistas son vivas: reflejan el contenido actual del mapa y no copias tomadas en el momento del acceso.
flowchart TD A[Iterable de solo lectura] --> B[Collection con size y contains] B --> C[List con acceso por indice] B --> D[Set con contrato de no duplicados] E[Map fuera de la jerarquia] --> F[keys es un Set] E --> G[values es una Collection] E --> H[entries es un Set de entradas]
Dos jerarquías paralelas, no una con extras
Junto a cada interfaz de solo lectura existe una interfaz mutable que la extiende y añade las operaciones de escritura. MutableCollection<E> hereda de Collection<E> y de MutableIterable<E>; MutableList<E> hereda de List<E> y de MutableCollection<E>. No hay ningún mecanismo mágico: la interfaz de solo lectura simplemente carece de los métodos que modifican, y por eso llamarlos no compila.
val solo: List<Int> = listOf(1, 2, 3)
val mut: MutableList<Int> = mutableListOf(1, 2, 3)
mut.add(4) // compila: el metodo existe en MutableList
// solo.add(4) // no compila: el metodo no existe en List
La varianza es la consecuencia estructural más importante de esta separación. List<out E> es covariante, así que un List<String> puede usarse donde se espera un List<Any> sin ningún riesgo: solo se pueden sacar elementos, y todo lo que sale de una lista de cadenas es un Any legítimo. MutableList<E> es invariante porque add recibe un E en posición de entrada, y permitir la conversión abriría la puerta a insertar un entero en una lista de cadenas. La misma razón explica las anotaciones @UnsafeVariance del listado anterior: contains recibe un E, lo cual sería ilegal en un tipo covariante, y la biblioteca lo permite explícitamente porque ese parámetro solo se compara y nunca se almacena.
Cuando declaras un parámetro como List<Empleado> en lugar de MutableList<Empleado>, no solo estás prohibiendo la escritura: estás haciendo que tu función acepte también List<Directivo> sin escribir un solo genérico adicional. La ergonomía de la covarianza es la razón por la que declarar los parámetros con la interfaz de solo lectura mejora la reutilización de tu código incluso en el caso, mucho más raro de lo que parece, en el que de verdad ibas a modificar la colección.
Por qué solo lectura no es inmutable
Aquí está la afirmación que da título a la lección, y se demuestra en cinco líneas. Asignar una lista mutable a una referencia de tipo List<T> no copia nada: es una conversión ascendente que produce otra vista del mismo objeto. Quien conserve la referencia mutable puede seguir modificándolo, y quien tenga la referencia de solo lectura verá esos cambios aparecer bajo sus pies.
val fuente = mutableListOf("a", "b")
val vista: List<String> = fuente // conversion, no copia
println(vista.size) // 2
fuente.add("c")
println(vista.size) // 3: es el mismo objeto visto por otra interfaz
Hay un segundo agujero, más grosero y más peligroso. Las interfaces de solo lectura de Kotlin no tienen representación propia en tiempo de ejecución: kotlin.collections.List se proyecta sobre java.util.List, de modo que el objeto que hay detrás de tu referencia es casi siempre un ArrayList corriente. La comprobación de solo lectura es enteramente estática, y una conversión descendente la elude.
fun sabotear(lista: List<String>) {
(lista as? MutableList<String>)?.add("intruso") // funciona si por debajo es mutable
}
El tercer agujero es la frontera con Java, donde no existe ninguna de estas distinciones: cualquier código Java que reciba tu lista ve un java.util.List con todos sus métodos disponibles. La conclusión operativa es que la interfaz de solo lectura documenta intención y evita el error accidental, que es muchísimo, pero no constituye una barrera frente a código hostil ni frente a un alias que tú mismo hayas dejado vivo.
Solo lectura es ausencia de métodos
List no es una lista congelada: es la misma lista mirada por una interfaz que no declara add. La protección la da el compilador, no el objeto.
`toList` copia, la conversión no
Asignar a un List<T> no cuesta nada porque no hace nada. Si necesitas independencia real frente al origen, la operación es toList y sí cuesta una copia.
Java no ve la diferencia
En el artefacto compilado hay un java.util.List. Cualquier consumidor Java, y cualquier conversión descendente, tiene acceso completo a la escritura.
Inmutable de verdad: persistentes
Cuando la garantía tiene que ser estructural y no de convención, la respuesta es una colección persistente de kotlinx.collections.immutable, que devuelve una versión nueva en cada modificación.
Elegir el tipo en la firma
De todo lo anterior se deduce una regla de diseño con dos mitades independientes. En los parámetros, pide el tipo más amplio que te sirva: si solo vas a recorrer, Iterable<T>; si necesitas el tamaño, Collection<T>; si necesitas el índice, List<T>. En los retornos, devuelve el tipo de solo lectura más informativo, nunca la interfaz mutable, y decide conscientemente si esa lista puede compartir identidad con tu estado interno.
class Carrito {
private val items = mutableListOf<Linea>()
// Fuga: el llamante recibe la misma instancia que muta por dentro
val lineas: List<Linea> get() = items
// Copia defensiva: independiente, a cambio de una asignacion por llamada
fun lineasEstables(): List<Linea> = items.toList()
}
Ninguna de las dos versiones es incorrecta en abstracto; son contratos distintos y hay que elegir uno a sabiendas. La propiedad devuelve una vista viva, que es exactamente lo que quiere una interfaz de usuario que observa el carrito y lo que arruina a un consumidor que guarda la referencia y espera un valor estable. El método devuelve una instantánea independiente, que es lo correcto en una frontera de módulo y un desperdicio si se llama dentro de un bucle.
Merece la pena entender por qué el lenguaje se detuvo justo aquí, porque la decisión parece tibia y en realidad es la más pragmática del diseño de su biblioteca estándar. Kotlin nació obligado a interoperar sin fricción con la plataforma Java y con los millones de líneas que ya devolvían ArrayList y HashMap por todas partes. Introducir colecciones genuinamente inmutables habría exigido tipos nuevos, conversiones en cada frontera y una penalización de copia en cada llamada a una biblioteca existente; el lenguaje habría sido más puro y muchísimo menos adoptado. Lo que hizo en su lugar fue una jugada de sistema de tipos casi gratuita: mantener las mismas clases en tiempo de ejecución y partir en dos la superficie de interfaces, de modo que el permiso de escritura viaje en el tipo estático y desaparezca de la vista de quien no debe tenerlo. El resultado es que la mayoría absoluta de los errores reales de mutación compartida, que no son sabotajes sino descuidos, se convierten en errores de compilación sin coste alguno en tiempo de ejecución. Y el precio, que hay que conocer para no engañarse, es que esta garantía es de compilador y no de objeto: se evapora en cuanto alguien conserva un alias mutable, ejecuta una conversión descendente o cruza la frontera hacia Java. Por eso la pregunta correcta al diseñar una API nunca es si devolver una lista mutable o de solo lectura, cosa que ya está decidida, sino si el consumidor necesita observar los cambios o necesita una instantánea estable; y en el segundo caso, si esa estabilidad puede sostenerse en el pacto de no guardar alias o exige el coste explícito de una copia. Los tipos te dicen quién puede escribir. Sobre qué significa que algo no cambie tienes que responder tú, y la respuesta es una decisión de arquitectura, no de sintaxis.
- Escribe una clase con una lista privada mutable expuesta como propiedad
List<T>y demuestra desde fuera que los cambios internos son visibles para quien guardó la referencia. - Convierte esa misma referencia de solo lectura a su interfaz mutable con una conversión descendente y explica por qué la operación tiene éxito en tiempo de ejecución.
- Sustituye la propiedad por un método con copia defensiva y mide cuántas asignaciones añades si el consumidor lo llama dentro de un bucle.
- Declara una función que reciba
Iterable<T>y otra que recibaList<T>, e identifica qué llamadas admite la primera y rechaza la segunda. - Justifica con la firma de
containspor quéList<out E>necesita@UnsafeVarianceyMutableList<E>no puede ser covariante.