wandres.dev
KOTLIN PARA MVI · data y sealed classes

data class: el estado como valor inmutable

La data class es la construccion con la que Kotlin expresa la semantica de valor: una entidad cuya identidad es su contenido y cuyo contenido no cambia. Esta leccion desmonta lo que el compilador genera al escribir data class -equals y hashCode estructurales, toString, componentN y copy- y explica por que ese paquete es exactamente el que MVI necesita para tratar la pantalla como una sucesion de instantes en vez de como un objeto que se edita. Muestra como copy produce el estado siguiente sin tocar el anterior, como encadenarlo cuando el estado esta anidado, por que el structural sharing hace barata la inmutabilidad, y cuales son las tres grietas -campos var, colecciones mutables y propiedades fuera del constructor primario- por las que la inmutabilidad se escapa sin que el compilador te avise.

⏱ 18 min

La primera pieza de Kotlin que MVI exige no es una biblioteca de concurrencia ni un contenedor de estado: es una construccion del lenguaje tan cotidiana que casi nadie se detiene a pensarla. La data class es el vehiculo con el que Kotlin expresa la idea de valor, es decir, una entidad cuya identidad es su contenido y cuyo contenido no cambia jamas. Frente a la clase ordinaria, que modela un objeto con biografia —algo que nace, muta y conserva su identidad a traves de los cambios—, la data class modela un instante. Y MVI es, mirado desde lo alto, la decision de representar una pantalla como una sucesion de instantes en lugar de como un objeto que se va editando. Entender con precision qué genera el compilador al escribir esas dos palabras, y sobre todo qué garantiza y qué no garantiza copy, es lo que separa usar el patron de sostenerlo cuando el estado crece.

🎯 Al terminar esta lección sabrás
  • Distinguir semantica de valor de semantica de referencia y ver por qué la data class encarna la primera.
  • Saber exactamente qué escribe el compilador por ti: equals, hashCode, toString, componentN y copy.
  • Producir el estado siguiente con copy, incluido el caso anidado, sin mutar nunca el anterior.
  • Reconocer las tres grietas por las que la inmutabilidad se escapa sin aviso del compilador.

Identidad por contenido: qué significa ser un valor

En la orientacion a objetos clasica, dos instancias distintas son cosas distintas aunque contengan lo mismo. Esa es la semantica de referencia: la identidad la da la direccion en memoria, no el contenido. Es la semantica correcta para modelar entidades del mundo —dos personas homonimas siguen siendo dos personas—, pero es exactamente la incorrecta para modelar el estado de una pantalla. Cuando dices “la pantalla esta en el estado X”, no te refieres a un objeto concreto que ocupa una direccion: te refieres a una configuracion de valores. Dos estados con los mismos campos son el mismo estado, y una arquitectura que no pueda afirmarlo no podra comparar, memoizar ni testear con comodidad.

La data class da la vuelta a la semantica: la identidad pasa a ser el contenido. Dos instancias con los mismos campos son iguales segun equals, tienen el mismo hashCode y son intercambiables a todos los efectos.

data class TareasState(
    val borrador: String = "",
    val tareas: List<Tarea> = emptyList(),
    val filtro: Filtro = Filtro.Todas,
)

val a = TareasState(borrador = "leer")
val b = TareasState(borrador = "leer")
a == b   // true  -> mismo contenido, mismo valor
a === b  // false -> instancias distintas, y da igual

Conviene detenerse en la eleccion de los valores por defecto, porque no son cosmetica. Un estado cuyo constructor se puede invocar sin argumentos declara cual es su configuracion inicial legitima, y eso resuelve de un plumazo dos problemas: el StateFlow exige un valor inicial y ya lo tiene, y cada test puede construir el escenario que le interesa nombrando solo los campos relevantes en vez de repetir el estado entero. Un estado sin defectos razonables suele delatar que se estan almacenando datos que no tienen valor inicial sensato y que probablemente pertenecen a un caso de un tipo suma, no a la raiz.

Fijate ahora en la asimetria entre las dos ultimas lineas del ejemplo: es el corazon del asunto. Cuando el estado es un valor, a === b deja de importar y a == b pasa a ser la unica pregunta interesante. Compose se apoya en eso para decidir si recompone; las pruebas se apoyan en eso para afirmar resultados sin inspeccionar campo por campo; el StateFlow se apoya en eso para no emitir cuando el estado nuevo es indistinguible del anterior.

Lo que el compilador escribe por ti

Al marcar una clase como data, Kotlin genera cinco cosas a partir de las propiedades declaradas en el constructor primario, y esa restriccion, que parece burocratica, es la fuente de la sorpresa mas comun del capitulo.

data class Usuario(val id: String, val nombre: String) {
    var visitas: Int = 0   // NO participa en equals, hashCode, copy ni toString
}

Que la generacion mire solo al constructor primario tiene una logica interna coherente: el compilador entiende ese constructor como la declaracion de qué es el valor, y todo lo demas como maquinaria auxiliar. Aceptar esa lectura convierte una regla que parece caprichosa en una guia de diseño: si un dato define la identidad del estado, va en el constructor; si no la define, probablemente no deberia estar almacenado.

Lo generado es: equals y hashCode estructurales, que comparan y mezclan campo a campo; toString, que imprime el contenido y convierte cualquier log en algo legible; las funciones component1, component2, etc., que habilitan la desestructuracion; y copy, con un parametro por propiedad y todos con valor por defecto igual al actual. La propiedad visitas del ejemplo queda fuera de las cinco: dos usuarios con distinto numero de visitas seran iguales, y copy no la propagara. Ese es un bug esperando en el estado de una pantalla.

val (id, nombre) = usuario          // desestructuracion posicional
val siguiente = usuario.copy(nombre = "Ada")

La desestructuracion merece una advertencia de diseño: es posicional, no nominal. Si un dia reordenas los campos del constructor primario, todo el codigo que desestructuraba seguira compilando y empezara a asignar valores cruzados en silencio. En estados de mas de dos o tres campos, prefiere el acceso por nombre; la desestructuracion es comoda para pares y triples efimeros, no para el UiState de una pantalla que llevara años cambiando.

Un segundo matiz, menos conocido y con consecuencias practicas, es que la generacion es superficial. equals compara campo a campo delegando en el equals de cada campo, de modo que la igualdad estructural del todo depende de que las partes tambien la tengan. Si uno de los campos es una clase ordinaria sin equals propio, la comparacion de ese campo vuelve a ser por referencia y contamina la del estado entero: dos estados con contenido identico se declararan distintos, tu StateFlow emitira de mas y Compose recompondra sin motivo. La propiedad de valor no se hereda por decreto, se construye desde las hojas hacia la raiz.

copy: el estado siguiente sin tocar el anterior

copy es la operacion que convierte la inmutabilidad en algo practicable. Su firma generada asigna a cada parametro el valor actual como defecto, de modo que nombrar un campo significa “cambia este” y omitir el resto significa “conserva aquellos”. No es azucar sintactico sobre una mutacion: construye un objeto nuevo y deja el anterior exactamente como estaba, disponible para quien lo tuviera en la mano.

val v0 = TareasState()
val v1 = v0.copy(borrador = "leer a Bachelard")
val v2 = v1.copy(tareas = v1.tareas + Tarea("leer a Bachelard"), borrador = "")

// v0 y v1 siguen intactos: cada version es un instante permanente

Que v0 sobreviva a la creacion de v1 no es un efecto colateral simpatico: es la propiedad de la que cuelgan el historial, el viaje en el tiempo, la comparacion barata y la reproducibilidad de un test. El coste, ademas, es mucho menor de lo que sugiere la palabra “copia”: copy construye un objeto nuevo pero comparte las referencias de todo lo que no cambio. Cambiar el borrador de un estado con diez mil tareas no clona la lista; el estado nuevo apunta a la misma lista de siempre. Eso es el structural sharing, y es la razon economica de que la inmutabilidad sea viable en una pantalla real.

flowchart LR
V0[Estado v0 borrador vacio] -->|copy borrador| V1[Estado v1]
V1 -->|copy tareas mas una| V2[Estado v2]
V0 -.->|sigue vivo e intacto| H[Historial reproducible]
V1 -.-> H
L[Lista de tareas en memoria] --- V0
L --- V1
style V0 fill:#89b4fa,color:#11111b
style V2 fill:#a6e3a1,color:#11111b
style L fill:#f9e2af,color:#11111b

El caso incomodo llega con el anidamiento. Si el estado contiene otra data class, cambiar un campo profundo exige encadenar copias, porque cada nivel debe producir su propia version nueva. Kotlin no tiene lentes ni actualizacion profunda en la biblioteca estandar, asi que el encadenamiento se escribe a mano y crece con la profundidad.

data class Perfil(val nombre: String, val direccion: Direccion)
data class Direccion(val calle: String, val ciudad: String)

val nuevo = perfil.copy(
    direccion = perfil.direccion.copy(ciudad = "Granada")
)

Dos niveles se toleran; tres empiezan a doler y suelen ser un sintoma. Si necesitas encadenar cuatro copy para cambiar un booleano, el problema no es la sintaxis sino la forma del estado: probablemente estas modelando una jerarquia de datos de dominio donde deberia haber un estado plano de presentacion. La leccion tercera de este nivel vuelve sobre ello.

Hay ademas una sutileza de rendimiento que conviene conocer antes de que alguien la use como argumento contra la inmutabilidad. Actualizar un elemento dentro de una lista si copia la lista, porque las colecciones de la biblioteca estandar no comparten estructura: reemplazar una tarea de mil recorre y reconstruye las mil referencias. Para listas de tamaño de pantalla eso es irrelevante y optimizarlo es perder el tiempo. Para colecciones grandes que se actualizan en cada pulsacion existen los tipos persistentes de kotlinx.collections.immutable, que comparten la mayor parte del arbol interno entre versiones y ademas son estables a ojos de Compose.

// La lista se reconstruye entera; el resto del estado se comparte
val v3 = v2.copy(
    tareas = v2.tareas.map { if (it.id == id) it.copy(hecha = true) else it }
)

Lo importante no es memorizar cual estructura copia qué, sino tener claro el principio: copy comparte lo que no toca y reconstruye lo que si toca, y el coste real de la inmutabilidad se mide contando cuanto hay dentro de lo tocado.

Las tres grietas de la inmutabilidad

Escribir data class no te hace inmutable. El modificador data genera funciones, no impone restricciones: no comprueba que los campos sean val, no exige que los tipos contenidos sean a su vez inmutables y no impide declarar propiedades fuera del constructor. Hay tres fugas por las que el estado se vuelve mutable sin que el compilador levante una ceja, y conviene reconocerlas de un vistazo.

🤖

Campos var

Un var en el constructor primario participa en equals y en copy, pero permite reasignarse despues. El estado deja de ser un instante y vuelve a ser un objeto con biografia: dos referencias al mismo estado pueden ver cosas distintas en momentos distintos.

🧊

Colecciones mutables

Un campo val de tipo MutableList es una contradiccion: la referencia es fija, el contenido no. Alguien puede añadir elementos sin producir un estado nuevo, y ni equals ni StateFlow se enteraran del cambio a tiempo.

🚪

Propiedades fuera del constructor

Lo declarado en el cuerpo de la clase queda excluido de equals, hashCode, toString y copy. Sirve para propiedades derivadas con get(), que se recalculan; es veneno para datos almacenados, que se pierden en cada copia.

De las tres, la segunda es la mas insidiosa porque el compilador la bendice: declarar val historial: MutableList produce un campo de solo lectura cuya lista se puede vaciar entera desde cualquier sitio, y ni equals ni el StateFlow notaran que la fotografia acaba de cambiar por debajo. La regla que cierra la grieta es sencilla y no admite excepciones utiles: dentro del estado solo entran tipos de solo lectura, y si el tipo declara metodos de mutacion, no entra.

💡
Propiedades derivadas: la unica cosa que debe vivir fuera del constructor

La regla practica se enuncia en una linea: en el constructor primario van los hechos; en el cuerpo, solo lo que se deduce de ellos. Una propiedad como val puedeEnviar: Boolean get() = borrador.isNotBlank() no almacena nada, se recalcula en cada lectura y por tanto no puede desincronizarse. Si en cambio guardas puedeEnviar como campo, has duplicado una verdad que ya existia y has creado la posibilidad de que las dos copias discrepen. La derivacion es gratis y siempre coherente; el almacenamiento redundante es la forma mas silenciosa de introducir un estado imposible.

La data class no es una comodidad sintactica: es un cambio de ontologia

Es tentador leer la data class como una taquigrafia, una manera de ahorrarse el equals y el hashCode que uno escribiria a mano. Esa lectura funciona hasta que el estado crece, y entonces revela lo que ocultaba: la data class no automatiza un trabajo, cambia la categoria de la cosa que estas modelando. Una clase ordinaria describe una entidad, algo que persiste a traves del cambio y cuya identidad sobrevive a sus mutaciones; por eso tiene sentido preguntarle qué le paso, en qué orden y quien se lo hizo. Una data class describe un valor, algo que no tiene historia porque no dura: el numero siete no cambia, y tampoco cambia el estado de una pantalla, simplemente es sustituido por otro. Cuando aceptas esa reclasificacion, la pregunta central del desarrollo de interfaces se reformula. Deja de ser “¿como llevo la pantalla desde donde esta hasta donde quiero?”, que es una pregunta sobre procesos, sobre secuencias de mutaciones y sobre quien tiene permiso para ejecutarlas —una pregunta que se responde mal, porque exige razonar sobre el tiempo, sobre el orden y sobre los entrelazados de la concurrencia—. Y pasa a ser “¿cual es el valor que describe la pantalla ahora?”, que es una pregunta sobre datos y se responde mirando un objeto. Todo lo que MVI promete cuelga de ese giro. La comparacion es barata porque comparar valores es comparar contenido. El test es trivial porque una funcion de valores a valores no tiene contexto que montar. El historial es gratuito porque los instantes anteriores no fueron destruidos por los posteriores, solo sucedidos. Y la concurrencia se vuelve manejable porque un valor compartido entre hilos no necesita cerrojo: nadie puede corromper aquello que nadie puede modificar. copy no es entonces un atajo para mutar comodamente, sino su exacto contrario: la operacion que permite que exista un despues sin destruir el antes.

⚔️ Convierte un objeto con biografia en una sucesion de instantes
  1. Toma una clase de estado tuya que use var y reescribela con val en el constructor primario; anota cada sitio donde el compilador te obligue a introducir un copy.
  2. Localiza cualquier campo de tipo mutable —MutableList, ArrayList, HashMap— y sustituyelo por su equivalente de solo lectura; explica qué invariante acabas de recuperar.
  3. Identifica un campo almacenado que sea deducible de otros, muevelo al cuerpo de la clase como propiedad con get() y razona qué desincronizacion has vuelto imposible.
  4. Escribe una secuencia de tres copy encadenados y comprueba, con ===, que las versiones anteriores siguen existiendo y que la coleccion no clonada es literalmente la misma referencia.
  5. Construye un caso de estado anidado a tres niveles, escribe el copy encadenado que lo actualiza y argumenta si el dolor de esa expresion es un problema de sintaxis o un sintoma de que el estado esta mal modelado.