Stable e Immutable: prometer y el riesgo de mentir
Cuando el compilador no puede demostrar por sí mismo que un tipo se comporta bien, existen dos anotaciones para decírselo: una promete que las mutaciones se anuncian y que la igualdad es consistente; la otra, más fuerte, promete que el objeto no cambia jamás. Esta lección precisa qué obliga exactamente cada contrato, en qué se diferencian sus consecuencias para la comparación de parámetros, y qué ocurre cuando la promesa es falsa: no un aviso ni un error, sino una interfaz que muestra datos obsoletos sin que nada falle. Se cubren los usos legítimos, los antipatrones habituales, la alternativa del fichero de configuración de estabilidad y el criterio para decidir entre anotar un tipo ajeno o envolverlo en un modelo propio.
Hay un punto en el que la inferencia automática se queda corta: un tipo que tú sabes que se comporta impecablemente, pero cuya buena conducta el compilador no puede deducir de su forma. Para ese hueco existen dos anotaciones, @Stable e @Immutable, y su naturaleza es distinta a la de casi todo lo que has escrito hasta ahora. No son configuración ni optimización: son aserciones. Con ellas no le pides nada al compilador, le juras algo, y él te cree sin verificar nada y empieza a generar código apoyándose en tu palabra. Esa asimetría —tú afirmas, él confía, y nadie comprueba— convierte estas dos anotaciones en las herramientas más potentes y más peligrosas del capítulo de estabilidad, porque el castigo por mentir no es un error de compilación ni una excepción: es una interfaz que enseña datos viejos y un bug que no sabrás reproducir.
- Precisar el contrato exacto que exige
@Stabley en qué se diferencia del de@Immutable. - Reconocer los casos legítimos donde anotar es la respuesta correcta y no un parche.
- Anticipar qué falla —y cómo se manifiesta— cuando la promesa que hiciste es falsa.
- Elegir con criterio entre anotar, envolver en un modelo propio o usar el fichero de configuración de estabilidad.
Dos promesas de distinta fuerza
Las dos anotaciones se aplican a tipos y ambas los hacen estables a ojos del compilador, pero el contrato que firmas es diferente y esa diferencia importa.
@Immutable es la promesa fuerte: ninguna propiedad de este tipo cambiará jamás después de construido el objeto, ni las públicas ni las privadas, ni directamente ni a través de estructuras que contenga. Es la garantía que ya cumple, sin anotación alguna, una clase de datos con todos los campos val de tipos estables; la anotación se usa cuando la forma del tipo no permite deducirlo. Bajo @Immutable, dos instancias iguales son intercambiables para siempre, y el runtime puede saltarse la recomposición con total tranquilidad.
@Stable es la promesa débil, y por eso mismo la más delicada: el tipo puede cambiar, pero si lo hace notificará a la composición, y mientras tanto equals se comportará de forma consistente. Es el contrato de MutableState, que muta constantemente y sin embargo es perfectamente seguro porque cada escritura invalida los scopes que leyeron el valor.
La relación entre ambas es de implicación: todo lo inmutable es estable, pero no todo lo estable es inmutable. Por eso @Immutable habilita optimizaciones adicionales —el runtime puede tratar el valor como una constante y cachear resultados derivados de él sin volver a mirarlo—, mientras que @Stable solo garantiza que la comparación es fiable en el instante en que se hace.
// Promesa fuerte: nada cambia nunca.
@Immutable
data class Tema(val primario: Long, val secundario: Long, val oscuro: Boolean)
// Promesa debil: cambia, pero avisa a la composicion.
@Stable
class EstadoScroll {
var desplazamiento by mutableStateOf(0f)
private set
fun mover(delta: Float) { desplazamiento += delta }
}
Immutable
Nada cambia después de la construcción. Para modelos de datos y configuraciones. Habilita el salto de forma incondicional.
Stable
Puede cambiar, pero cada cambio se anuncia mediante estado observable. Para holders de interfaz e interfaces con implementaciones que tú controlas.
Como elegir
Si el objeto realmente no muta, usa la fuerte: da más margen al runtime. Reserva la débil para cuando el objeto guarde estado vivo y observable.
Cuándo anotar es la respuesta correcta
Hay tres situaciones en las que anotar no es un parche sino el uso previsto de la herramienta. La primera es una interfaz o clase abstracta que aparece como tipo de un parámetro: el compilador no puede inspeccionar sus implementaciones futuras, y si tú controlas todas ellas y garantizas su buena conducta, anotar la interfaz transmite esa garantía al lugar donde se compara.
@Stable
interface FormatoPrecio {
fun formatear(centimos: Long): String
}
La segunda es un holder de estado de interfaz: una clase que agrupa varios mutableStateOf y expone métodos para manipularlos. Todos sus cambios pasan por estado observable, así que cumple el contrato débil aunque su forma —propiedades var delegadas— no lo delate.
La tercera es un tipo cuyos campos son inestables por accidente y no por naturaleza: una clase de datos con una List interna que se construye una vez y no se toca. Aquí conviene detenerse: casi siempre es mejor cambiar el tipo del campo a ImmutableList que anotar la clase contenedora. Anotar el contenedor esconde el problema; corregir el campo lo elimina.
Hay además un caso frecuente que parece pedir anotación y no la necesita: una interfaz que se usa como parámetro pero cuyas implementaciones son todas objetos sin estado o clases de datos con campos val. Si esas implementaciones viven en el mismo módulo compilado con el plugin, la jerarquía sellada resuelve el problema sin anotar nada, porque el compilador conoce el conjunto cerrado de subtipos.
// Sin anotaciones y perfectamente estable: el conjunto de subtipos es cerrado.
sealed interface EstadoCarga {
data object Inicial : EstadoCarga
data object Cargando : EstadoCarga
data class Error(val mensaje: String) : EstadoCarga
}
La lección implícita es que buena parte de las anotaciones que se ven en proyectos reales son cicatrices de un modelo de datos que se podía haber cerrado mejor. Antes de firmar una promesa, pregúntate si lo que en realidad te falta es una jerarquía sellada.
Antes de escribir una anotación, recorre esta escalera: convierte los var en val, sustituye List por ImmutableList, aplica el plugin de Compose al módulo que define el tipo, o mapea el modelo de dominio a un modelo de presentación propio. Cada uno de esos pasos hace que el compilador demuestre la estabilidad. La anotación no demuestra nada: la asume. Reserva la asunción para cuando ninguna demostración sea posible.
El riesgo de mentir
Aquí está la parte que hay que grabar a fuego. Si anotas un tipo y la promesa es falsa, no ocurre nada visible: no hay error de compilación, no hay aviso en tiempo de ejecución, no hay excepción. El compilador genera la comparación de parámetros, la comparación devuelve “iguales” porque la referencia no cambió, y el runtime se salta la recomposición. La pantalla se queda mostrando el valor anterior. Para siempre, o hasta que algún otro cambio no relacionado provoque casualmente una recomposición y el dato aparezca de golpe, actualizado y sin explicación.
// MENTIRA: la lista puede mutar por detras y nadie avisa.
@Immutable
data class CarritoState(val lineas: List<Linea>)
// Verdad demostrable: el tipo prohibe la mutacion.
data class CarritoState(val lineas: ImmutableList<Linea>)
flowchart TD
A[Anotas un tipo como Immutable] --> B{La promesa es cierta}
B -->|Si| C[El runtime salta y la interfaz es correcta y rapida]
B -->|No| D[El objeto muta en silencio]
D --> E[La comparacion sigue diciendo iguales]
E --> F[Se salta la recomposicion]
F --> G[La pantalla muestra datos obsoletos sin ningun error]
style C fill:#a6e3a1,color:#11111b
style G fill:#f38ba8,color:#11111bHay una asimetría de riesgo entre las dos anotaciones que conviene tener presente al elegir. Mentir con @Stable suele producir un fallo acotado: el objeto muta y no avisa, la pantalla se queda atrás hasta la siguiente invalidación por otra vía, y el usuario ve un parpadeo tardío. Mentir con @Immutable produce un fallo permanente: has afirmado que el valor no cambia nunca, así que el runtime tiene licencia para no volver a mirarlo jamás. Cuando dudes entre las dos, la promesa débil es la elección conservadora.
El perfil de este fallo es especialmente cruel. No es determinista desde el punto de vista del usuario: depende de qué otros estados cambien y en qué orden, así que se manifiesta de forma intermitente y desaparece al intentar reproducirlo. No deja rastro en los registros. Y como la anotación suele estar en un fichero distinto del que se está depurando, nadie sospecha de ella. Se buscará el error durante horas en el reductor, en el repositorio, en la capa de red, y el culpable llevaba meses en una línea de tres palabras encima de una clase de datos.
Existe un agravante organizativo que rara vez se menciona. La anotación la escribe quien conoce la invariante, pero la mentira la comete, meses después, quien no sabe que la invariante existe: alguien añade un método que muta un campo, la compilación pasa sin una queja, y la promesa queda rota sin que nadie haya tomado la decisión de romperla. Una garantía que depende de que todos los futuros colaboradores recuerden un contrato tácito no es una garantía, es una esperanza con sintaxis.
Anotar en el borde, no en el centro
Queda el caso de los tipos que no son tuyos: modelos de una biblioteca externa que llegan inestables y que no puedes anotar porque no controlas su código. Existe un mecanismo de configuración de estabilidad —un fichero de texto donde declaras patrones de clases que el compilador debe considerar estables— y es útil para bibliotecas de terceros cuya buena conducta conoces.
Ese fichero tiene una limitación de diseño que conviene conocer antes de apoyarse en él: al declarar patrones de clases por nombre, la promesa deja de estar junto al código que la debe cumplir. Si la biblioteca cambia de versión y una de esas clases pasa a exponer un campo mutable, nada te avisa; el patrón sigue coincidiendo y la mentira empieza a producirse en silencio. Es la misma trampa de la anotación, agravada por la distancia.
Una anotación sin comentario es una deuda anónima. Escribe siempre, en la línea de arriba, por qué el compilador no puede deducir la estabilidad y qué invariante concreta garantiza que la promesa se cumple. Quien la lea dentro de dos años necesita saber si sigue siendo cierta, y solo puede saberlo si le explicaste sobre qué se apoyaba.
Pero la alternativa suele ser mejor y no es una alternativa técnica sino arquitectónica: no dejes entrar tipos ajenos al estado de tu pantalla. Define modelos de presentación propios, estables por construcción, y mapea en el borde. Ganas estabilidad demostrable, dejas de acoplar tu interfaz al esquema de la red o de la base de datos, y el mapeo se convierte en el sitio natural donde ocurre la traducción del lenguaje del dominio al lenguaje de la pantalla.
La forma correcta de entender estas dos anotaciones no es como configuración del compilador sino como lógica. Un compilador de tipos es una máquina de demostración: parte de unos axiomas y deriva teoremas, y el teorema que aquí nos interesa es “esta llamada se puede saltar sin alterar el resultado observable”. Cuando escribes @Immutable, no estás dándole una pista ni activando un modo: estás añadiendo un axioma al sistema, una premisa que se acepta sin prueba y sobre la que se construirán conclusiones. Y de ahí se sigue todo lo demás, incluida la asimetría del castigo. Si el axioma es verdadero, cada teorema derivado es verdadero y obtienes rendimiento gratis y correcto. Si el axioma es falso, el sistema no se detiene ni protesta —un sistema formal no puede detectar que una de sus premisas no corresponde a la realidad—: sigue derivando, con perfecta corrección interna, conclusiones falsas sobre el mundo. Esa es la razón exacta de que el bug se manifieste como una pantalla congelada y no como una excepción: la máquina no ha fallado, ha razonado impecablemente a partir de algo que tú le juraste y no era cierto. Por eso la regla profesional no es “usa las anotaciones con cuidado”, que es un consejo vacío, sino esta otra, mucho más operativa: prefiere siempre las estructuras cuya estabilidad el compilador pueda derivar antes que las que necesitan que tú la postules. Cada axioma que introduces es una porción de corrección que retiras del compilador y te asignas a ti mismo, para siempre, incluidos todos los refactors futuros que hará gente que no sabe que ese axioma existe.
- Busca en tu código todas las apariciones de
@Stablee@Immutabley escribe, para cada una, la frase exacta que estás jurando. - Encuentra una que se pueda eliminar convirtiendo un campo a un tipo demostrablemente estable, y hazlo.
- Construye a propósito un ejemplo con una promesa falsa y provoca el fallo: describe la secuencia mínima de interacciones que deja la pantalla congelada.
- Justifica por qué anotar una interfaz es más arriesgado que anotar una clase de datos final.
- Elige un modelo de una biblioteca externa presente en tu estado y decide, argumentando, entre el fichero de configuración y un modelo de presentación propio.