wandres.dev
ESTABILIDAD Y SKIPPING · rendimiento en Compose

Tipos estables e inestables

El compilador de Compose infiere la estabilidad de la mayoría de los tipos sin ayuda, pero hay una familia entera que se le escapa por razones que no son un descuido sino una imposibilidad de demostración: las interfaces de colección de la biblioteca estándar de Kotlin. Esta lección explica por qué una lista declarada como List es inestable pese a su nombre, por qué el compilador no puede confiar en un tipo cuya implementación real desconoce, qué diferencia hay entre una vista de solo lectura y una estructura genuinamente inmutable, y cómo las colecciones persistentes de kotlinx.collections.immutable resuelven el problema de raíz aportando además compartición estructural. Se cubre también el caso de los tipos que vienen de módulos ajenos al compilador de Compose.

⏱ 18 min

Hay un momento en la vida de todo desarrollador de Compose en el que descubre, con incredulidad, que una lista declarada como List —esa que Kotlin llama “de solo lectura” y que uno lleva años tratando como sinónimo de inmutable— es un tipo inestable para el compilador, y que basta con tenerla en el estado de una pantalla para que el mecanismo de salto deje de funcionar en media jerarquía. La reacción inicial es pensar que se trata de un defecto de la herramienta. No lo es: es la conclusión correcta de un razonamiento impecable que descubre una mentira que Kotlin lleva años contándonos con la mejor intención. Entender esa mentira, y la solución que aportan las colecciones persistentes, es el paso donde la estabilidad deja de ser teoría y empieza a cambiar el código que escribes todos los días.

🎯 Al terminar esta lección sabrás
  • Enumerar qué tipos infiere el compilador como estables sin necesidad de ayuda.
  • Explicar por qué una List de la biblioteca estándar es inestable pese a llamarse de solo lectura.
  • Distinguir una vista de solo lectura de una estructura genuinamente inmutable.
  • Usar ImmutableList y PersistentList de kotlinx.collections.immutable y saber cuándo elegir cada una.

Lo que el compilador infiere solo

Antes de mirar los casos problemáticos conviene fijar el terreno firme. El compilador de Compose considera estables, sin que tengas que decirle nada, todos los tipos primitivos y sus envoltorios, String, las funciones —las lambdas— cuyas capturas son estables, y las clases cuyas propiedades son todas val de tipos a su vez estables. También los tipos de la propia biblioteca de Compose diseñados para esto, como MutableState, que son estables por la segunda cláusula: mutan, pero avisan.

// Estable: todo val, todos los tipos estables.
data class Usuario(val id: Long, val nombre: String, val activo: Boolean)

// Inestable: un var corriente puede mutar sin notificar a la composicion.
data class Sesion(var token: String, val expira: Long)

// Estable pese a mutar: MutableState notifica cada cambio.
class Contador { val valor = mutableStateOf(0) }

Conviene notar un detalle del segundo ejemplo que suele sorprender: Sesion no es inestable porque token vaya a cambiar —quizá no cambie nunca en la vida del programa— sino porque podría cambiar sin que nadie se enterase. El compilador razona sobre lo que el tipo permite, no sobre lo que tu código hace. Un var de tipo corriente es, para él, una puerta abierta, y la existencia de la puerta basta para invalidar la garantía aunque nadie la cruce jamás.

La inferencia es recursiva y estricta. Basta con que una sola propiedad pública sea de tipo inestable para que toda la clase lo sea, y basta con que una propiedad sea var de un tipo corriente para que la segunda cláusula —la de notificación— se incumpla. Esta severidad es deliberada: el compilador prefiere marcar de más y perder rendimiento antes que marcar de menos y producir una interfaz que muestre datos viejos.

Por qué una List es inestable

Y ahora el caso célebre. En Kotlin, List no es una clase: es una interfaz. Cuando escribes que una propiedad es de tipo List, no estás diciendo que su contenido no vaya a cambiar; estás diciendo que no tienes métodos para cambiarlo a través de esa referencia. La distinción es enorme. La instancia concreta que llega en tiempo de ejecución puede ser perfectamente una ArrayList que otra parte del programa conserva bajo su tipo mutable y a la que añade elementos cuando le apetece.

val respaldo = mutableListOf("a", "b")
val vista: List<String> = respaldo   // la misma instancia, otro tipo estatico

respaldo.add("c")                     // vista tambien "cambio"
// vista.equals seguiria devolviendo true frente a si misma

Ese fragmento no es un caso rebuscado de laboratorio: es exactamente lo que ocurre cuando un repositorio guarda una lista mutable como caché interna y la expone al exterior con el tipo de solo lectura, un patrón que durante años se enseñó como buena práctica precisamente porque parecía seguro.

Ahí está la ruptura del contrato en su forma más pura. La referencia no cambió, así que la comparación de parámetros diría “iguales, saltamos”, pero el contenido que se va a pintar es distinto. Compose mostraría dos elementos donde hay tres. El compilador no puede saber qué implementación concreta llegará —eso se decide en tiempo de ejecución, potencialmente en otro módulo— y por tanto no puede demostrar ninguna de las dos primeras cláusulas. Su única salida honesta es declarar la interfaz inestable. Lo mismo vale para Set, Map y Collection.

⚠️
Solo lectura no es inmutable

Kotlin distingue mal dos conceptos que el ecosistema confunde a diario. Una vista de solo lectura limita lo que puedes hacer a través de una referencia concreta; no promete nada sobre lo que otros hagan por otras referencias a la misma instancia. Una estructura inmutable garantiza que nadie, por ninguna vía, podrá alterar su contenido después de construida. List es lo primero. ImmutableList es lo segundo. Toda la diferencia de estabilidad nace de ahí.

flowchart TD
DECL[Propiedad declarada como List] --> RT{Que instancia llega en tiempo de ejecucion}
RT -->|ArrayList compartida| MUT[Alguien puede mutarla sin avisar]
RT -->|Copia defensiva| OK1[Se comporta bien pero nadie lo garantiza]
MUT --> INEST[El compilador la marca inestable]
OK1 --> INEST
DECL2[Propiedad declarada como ImmutableList] --> GAR[El tipo prohibe la mutacion por contrato]
GAR --> EST[El compilador la trata como estable]
style INEST fill:#f38ba8,color:#11111b
style EST fill:#a6e3a1,color:#11111b

ImmutableList y las colecciones persistentes

La biblioteca kotlinx.collections.immutable existe justamente para cerrar ese agujero, y ofrece dos niveles que conviene no mezclar. ImmutableList es la promesa mínima: esta colección no cambiará nunca después de creada. PersistentList extiende esa promesa con operaciones que devuelven una colección nueva a partir de la anterior —add, remove, set— compartiendo internamente la estructura que no cambió, de modo que añadir un elemento a una lista de diez mil no copia diez mil referencias.

// build.gradle.kts
// implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8")

data class BandejaState(
    val mensajes: ImmutableList<Mensaje> = persistentListOf(),
    val filtro: String = "",
)

// Producir un estado nuevo sin copiar toda la estructura.
fun marcarLeido(state: BandejaState, id: Long) = state.copy(
    mensajes = state.mensajes.mutate { lista ->
        val i = lista.indexOfFirst { it.id == id }
        if (i >= 0) lista[i] = lista[i].copy(leido = true)
    }.toImmutableList(),
)

El compilador de Compose reconoce estos tipos como estables porque el contrato de la interfaz prohíbe la mutación: no hay forma legítima de obtener una referencia mutable a la misma instancia. Con eso, las dos primeras cláusulas quedan demostradas y la comparación de parámetros vuelve a significar lo que debe significar.

La objeción habitual llega en este punto: si cada actualización crea una colección nueva, ¿no es eso más caro que mutar la existente? La respuesta es que depende de qué se compare. Una copia ingenua de una lista de diez mil elementos sí lo sería; una colección persistente no copia la lista, comparte el árbol interno y solo reconstruye el camino desde la raíz hasta el nodo modificado, un coste logarítmico con factores muy pequeños. Y frente a ese coste hay que poner en el otro plato de la balanza el trabajo que se ahorra: cada recomposición evitada es un subárbol entero de funciones que no se ejecutan. En pantallas reales el balance es favorable con holgura, y además la comparación correcta nunca es “copiar frente a mutar” sino “copiar frente a mutar y perder el salto en toda la jerarquía”.

// Conversion en el borde: el dominio habla su idioma, la pantalla el suyo.
fun List<MensajeDominio>.aEstado(): ImmutableList<Mensaje> =
    map { Mensaje(id = it.id, texto = it.cuerpo, leido = it.visto) }
        .toImmutableList()

Ese toImmutableList en el mapeo es el punto donde la garantía entra en el sistema. A partir de ahí, todo lo que viaje hacia la interfaz lo hace con la promesa ya firmada por el tipo, y ningún composable aguas abajo tiene que preocuparse de nada.

📋

List — inestable

Interfaz de solo lectura sobre una instancia potencialmente compartida y mutable. Útil en dominios y capas de datos; venenosa dentro del estado de una pantalla.

🔒

ImmutableList — estable

Contrato de no mutación. Es la elección por defecto para cualquier colección que viva dentro del estado que observa la interfaz.

🌳

PersistentList — estable y barata

Inmutable más operaciones que devuelven versiones nuevas con compartición estructural. La opción cuando el estado se actualiza con frecuencia.

Queda por decir dónde poner la frontera. No hay que convertir el proyecto entero a colecciones persistentes: dentro de un repositorio, de un caso de uso o de una función de cálculo, una List corriente es la herramienta adecuada y nadie la va a comparar. La regla operativa es de superficie: todo lo que forme parte del estado que observa la interfaz debe ser inmutable; lo que quede detrás de esa frontera puede ser lo que convenga.

Tipos de otros módulos

Queda un último caso que desconcierta: una clase perfectamente modelada, con todos sus campos val y primitivos, que aun así aparece como inestable. Suele deberse a que vive en un módulo compilado sin el plugin del compilador de Compose —un módulo de dominio puro, o una dependencia externa—. Sin ese plugin no se emiten los metadatos de estabilidad, y desde el módulo de interfaz el compilador no tiene forma de inspeccionar la clase, así que asume lo peor.

El detalle técnico importa para no perder el tiempo buscando en el sitio equivocado: la información de estabilidad viaja como metadatos que el plugin escribe junto a las clases que compila. Si una clase se compiló sin él, esos metadatos no existen, y el compilador que la encuentra desde otro módulo no puede distinguir “no tiene metadatos porque nadie los generó” de “no tiene metadatos porque es inestable”. Ante esa ambigüedad, aplica de nuevo su regla de oro y asume lo peor.

ℹ️
El sintoma tipico del modulo sin plugin

Si en el informe de estabilidad ves una clase de datos con todos los campos val de tipos primitivos marcada como inestable y sin ninguna línea que señale al campo culpable, no busques más en su definición: casi con seguridad vive en un módulo compilado sin el plugin de Compose. El informe no puede explicarte lo que no puede leer.

Hay tres salidas, en orden de preferencia: aplicar el plugin de Compose también al módulo de dominio, definir en la capa de interfaz un modelo propio y estable al que mapees el del dominio, o —último recurso— declarar la estabilidad a mano mediante el fichero de configuración de estabilidad que acepta el compilador. La segunda opción no es solo un truco de rendimiento: obliga a separar el modelo de dominio del modelo de presentación, que es exactamente lo que MVI quiere que hagas. Un modelo de dominio y un modelo de pantalla responden a preguntas distintas —uno describe la realidad del negocio, el otro describe lo que hay que pintar— y confundirlos acopla la interfaz a decisiones que no le incumben. Que además arregle la estabilidad es la señal de que ambas presiones apuntan al mismo diseño.

La estabilidad es una propiedad del tipo, no del valor

La lección más profunda de este capítulo es una que trasciende Compose por completo. Cuando escribes List y piensas “esta lista no va a cambiar”, estás razonando sobre el valor que tienes en la mano en este instante, y probablemente tengas razón: nadie la va a mutar, porque tú sabes cómo funciona tu código. Pero el compilador no razona sobre valores, razona sobre tipos, y un tipo es una afirmación universal sobre todos los valores posibles que puede tomar esa posición, ahora y en cualquier futuro refactor. Cuando declaras List, la afirmación que estás firmando no es “esto no cambia” sino “esto es cualquier cosa que sepa dar sus elementos, incluida una lista mutable que otro módulo conserva”. La incomodidad que sientes al cambiar List por ImmutableList es exactamente la fricción de reemplazar una creencia privada por una garantía pública, y esa fricción es el precio del razonamiento mecánico. Un compilador solo puede optimizar lo que puede demostrar, y solo puede demostrar lo que le has dicho en el tipo. Todo lo que sabes de tu programa y no está codificado en un tipo es conocimiento que muere en tu cabeza y no llega a la máquina. Este es el mismo principio que gobierna el estado sellado de MVI, la exhaustividad de when y las clases de datos con todos los campos val: no se trata de ser puristas, se trata de que cada verdad que subes al sistema de tipos se convierte en una verdad que otro puede usar para trabajar por ti.

⚔️ Audita las colecciones de tu estado
  1. Busca en tu estado todas las propiedades de tipo List, Set o Map y anota cuántos composables las reciben directa o indirectamente.
  2. Escribe el ejemplo mínimo de la lista compartida que se muta por detrás y explica en qué momento exacto la interfaz mostraría datos viejos.
  3. Convierte una de esas propiedades a ImmutableList y describe qué cambios de compilación te ha obligado a hacer aguas arriba.
  4. Justifica cuándo merece la pena PersistentList frente a ImmutableList en términos de coste de las actualizaciones.
  5. Elige un tipo de tu módulo de dominio que aparezca como inestable y decide, argumentando, entre aplicar el plugin o crear un modelo de presentación propio.