wandres.dev
DATA CLASSES · datos sin ceremonia

copy y la inmutabilidad: actualizar sin mutar

El patrón de actualización de estado inmutable con copy y argumentos con nombre, el muro que aparece con estructuras anidadas, la superficialidad de la copia y el coste real de asignar una instancia nueva en cada cambio.

⏱ 17 min

La función copy es el único miembro generado de una data class que cambia cómo escribes el resto del programa. equals y toString te resuelven una molestia; copy te ofrece una arquitectura: la de un estado que nunca se modifica, solo se sustituye. Es la base de casi todo el Kotlin moderno de interfaz y de casi todo el manejo de estado concurrente sin cerrojos. Y también es el punto donde la comodidad se agota más rápido, porque copy es superficial, porque anida fatal y porque cada llamada es una asignación de memoria que alguien tiene que pagar.

🎯 Al terminar esta lección sabrás
  • Dominar el patrón de actualización inmutable con copy y argumentos con nombre.
  • Entender por qué la copia es superficial y qué comparte realmente con el original.
  • Reconocer el muro de la anidación y las tres estrategias para no chocar con él.
  • Estimar el coste de copiar y saber cuándo deja de ser despreciable.

El patrón de actualización inmutable

La idea es sencilla y su fuerza está en lo que elimina. En vez de mutar campos, produces una instancia nueva que difiere de la anterior en lo mínimo, y sustituyes la referencia:

data class EstadoPantalla(
    val cargando: Boolean = false,
    val consulta: String = "",
    val resultados: List<String> = emptyList(),
    val error: String? = null,
)

val inicial = EstadoPantalla()
val buscando = inicial.copy(cargando = true, consulta = "kotlin")
val resuelto = buscando.copy(cargando = false, resultados = listOf("a", "b"))

Los argumentos con nombre no son un adorno estilístico: son parte del mecanismo. Como copy declara todos sus parámetros con el valor actual como defecto, nombrar solo los que cambian expresa literalmente un delta. La declaración se lee como una frase, y el compilador garantiza que las demás propiedades viajan intactas aunque mañana añadas cinco más.

El patrón brilla cuando el estado vive detrás de una referencia atómica, porque convierte una actualización compuesta en una operación puntual sin cerrojos:

private val _estado = MutableStateFlow(EstadoPantalla())
val estado: StateFlow<EstadoPantalla> = _estado.asStateFlow()

fun alEscribir(texto: String) {
    _estado.update { actual -> actual.copy(consulta = texto, error = null) }
}

Aquí conviene una precisión que casi nunca se dice: copy no es un clonador de bajo nivel, sino una llamada normal al constructor primario. Eso significa que los bloques init se ejecutan y las validaciones de invariantes se aplican en cada copia, lo cual es una buena noticia; y significa también que cualquier propiedad declarada en el cuerpo de la clase vuelve a su valor de inicialización, lo cual es una mala noticia que solo descubres en depuración.

⚠️
La visibilidad de copy fue un agujero durante años

Si declarabas el constructor primario como private para forzar la construcción a través de una factoría que validaba, el copy generado seguía siendo público y permitía saltarse esa factoría desde fuera. Kotlin lo señala desde la serie 2.0 y ofrece las anotaciones @ConsistentCopyVisibility, que alinea la visibilidad de copy con la del constructor, y @ExposedCopyVisibility, que documenta el comportamiento antiguo cuando lo quieres a propósito.

La copia es superficial y anida fatal

copy construye un objeto nuevo con las mismas referencias que el original. Cambia el continente, no el contenido. Si una propiedad apunta a una estructura mutable, el original y la copia comparten esa estructura y la ilusión de inmutabilidad se rompe entera:

data class Cesta(val lineas: MutableList<String>)

val a = Cesta(mutableListOf("pan"))
val b = a.copy()
b.lineas.add("leche")
println(a.lineas)  // [pan, leche]: nunca hubo dos listas

La cura no es un copy más inteligente, sino tipos inmutables en las propiedades: List en vez de MutableList, y colecciones persistentes cuando el volumen lo justifique. La inmutabilidad de una data class es exactamente tan profunda como la de sus propiedades, ni un nivel más.

El segundo problema aparece con la anidación de datos perfectamente inmutables. Para cambiar una hoja del árbol hay que reconstruir toda la rama que lleva hasta ella:

data class Direccion(val ciudad: String, val cp: String)
data class Perfil(val nombre: String, val direccion: Direccion)
data class Cuenta(val id: Long, val perfil: Perfil)

val cambiada = cuenta.copy(
    perfil = cuenta.perfil.copy(
        direccion = cuenta.perfil.direccion.copy(ciudad = "Bilbao")
    )
)
flowchart LR
A[Cuenta original] -->|copy id igual| B[Cuenta nueva]
A --> C[Perfil original]
C -->|copy nombre igual| D[Perfil nuevo]
C --> E[Direccion original]
E -->|copy ciudad Bilbao| F[Direccion nueva]
B --> D
D --> F

Ese diagrama explica el coste real: modificar una hoja a profundidad n asigna n objetos nuevos y deja el resto del árbol compartido. Es barato en memoria, porque las ramas no tocadas se reutilizan, pero es carísimo en legibilidad, y el ruido crece de forma cuadrática con la profundidad porque cada nivel repite la ruta completa.

Hay tres salidas honestas. La primera es aplanar el estado: muchas anidaciones profundas existen porque se calcó la forma del JSON del servidor en el modelo de dominio, y una jerarquía de tres niveles suele poder ser un solo objeto con propiedades bien nombradas. La segunda es encapsular la actualización en un método de la clase raíz o en una función de extensión, de modo que la cadena de copy se escriba una vez y se lea por su intención. La tercera son las ópticas, la abstracción funcional de lentes que compone accesos anidados en un objeto de primera clase; en Kotlin llegan a través de bibliotecas con generación de código, y solo rentan cuando la profundidad y la frecuencia lo justifican.

🧯

Aplanar el estado

Muchas anidaciones profundas existen porque se calcó la forma del JSON del servidor en el modelo de dominio. Una jerarquía de tres niveles suele poder ser un solo objeto con propiedades bien nombradas, y entonces el problema desaparece en vez de resolverse.

🎯

Encapsular la actualización

Escribe la cadena una sola vez, dentro de un método de la raíz o de una función de extensión con nombre de intención. El código de llamada pasa a leerse por lo que quiere hacer y no por cómo reconstruye el árbol.

🔭

Ópticas y lentes

La abstracción funcional que convierte un acceso anidado en un objeto componible de primera clase. Potente y correcta, pero exige generación de código y solo renta cuando la profundidad y la frecuencia lo justifican de verdad.

// Encapsular es la opcion que casi siempre gana
fun Cuenta.conCiudad(ciudad: String): Cuenta =
    copy(perfil = perfil.copy(direccion = perfil.direccion.copy(ciudad = ciudad)))

El coste de copiar

Una instancia de data class en la JVM es una asignación en el bloque local del hilo: un desplazamiento de puntero, la escritura de la cabecera del objeto y la copia de los campos. Para un objeto de una decena de propiedades el coste es del orden de decenas de nanosegundos, y la mayoría de esos objetos mueren jóvenes, que es exactamente el caso que mejor trata un recolector generacional. Por eso el patrón es viable: copiar el estado de una pantalla en cada pulsación de tecla no se nota en ningún perfilador honesto.

Deja de ser despreciable en tres situaciones concretas. La primera es la copia dentro de un bucle cerrado sobre colecciones grandes, donde el número de asignaciones se multiplica por el tamaño de los datos y la presión sobre el recolector se vuelve medible. La segunda es la copia de objetos muy anchos, porque el coste crece con el número de campos y, sobre todo, porque cada copy con argumentos por defecto pasa por el método sintético con máscara de bits y no siempre se integra bien en el punto de llamada. La tercera es la copia en cascada de árboles profundos y anchos, donde el número de objetos por actualización se dispara sin que nadie lo esté midiendo.

La regla práctica es que copy es la herramienta correcta para el estado de la aplicación y la herramienta equivocada para el bucle interno de un algoritmo. En el bucle interno lo que quieres es un acumulador mutable local, que es privado del algoritmo y por tanto no compromete la inmutabilidad de nada que se vea desde fuera:

// Caro: una instancia nueva por elemento
val total = lineas.fold(Resumen()) { acc, l -> acc.copy(suma = acc.suma + l.importe) }

// Barato: mutacion local, resultado inmutable
fun resumir(lineas: List<Linea>): Resumen {
    var suma = 0L
    for (l in lineas) suma += l.importe
    return Resumen(suma)
}

Hay un segundo matiz de concurrencia que conviene tener presente. El bloque que pasas a una actualización atómica del estado puede ejecutarse más de una vez si hay contención, porque el mecanismo subyacente es una comparación e intercambio que reintenta cuando otro hilo se le adelantó. Eso obliga a que la lambda sea pura: si dentro de ella haces algo más que construir el estado nuevo con copy, ese efecto se repetirá un número impredecible de veces. La disciplina de la actualización inmutable no consiste solo en no mutar el objeto, sino en que la función que deriva el estado nuevo no observe ni toque nada más que el estado anterior.

copy no te da inmutabilidad: te da una disciplina barata para simularla exactamente hasta donde llegan tus tipos

Es tentador leer copy como el mecanismo que convierte a Kotlin en un lenguaje de datos inmutables, y esa lectura es falsa en un sentido muy preciso que conviene interiorizar antes de construir una arquitectura entera encima. Lo que copy hace es una única cosa: llamar al constructor primario pasando los valores actuales para los parámetros que no nombraste. Nada más. No copia en profundidad, no congela, no protege, no valida más allá de lo que valide tu propio bloque init, y no sabe distinguir entre una propiedad que es un valor y una propiedad que es una referencia a un montón de estado mutable compartido con medio programa. La inmutabilidad, entonces, no vive en copy sino en el sistema de tipos que hay por debajo: una data class cuyas propiedades sean todas val de tipos inmutables es genuinamente inmutable y copy te da una forma cómoda de derivar valores nuevos; una data class cuyas propiedades sean var, o val apuntando a colecciones mutables, o val apuntando a objetos con identidad y ciclo de vida, es una fachada de inmutabilidad sobre estado compartido, y copy no solo no lo arregla sino que lo esconde, porque el código de llamada parece funcional cuando en realidad está repartiendo alias de la misma memoria a hilos distintos. El corolario operativo es doble. Por un lado, la calidad de tu inmutabilidad se decide al declarar los tipos de las propiedades, no al invocar copy: elegir List sobre MutableList o un tipo de valor sobre una entidad es la decisión de verdad, y todo lo demás es consecuencia. Por otro lado, la cadena de copy anidados que tanto se detesta no es un defecto del lenguaje sino un mensaje del diseño: cuando actualizar un campo obliga a reconstruir cuatro niveles, lo que el código te está diciendo es que has modelado como jerarquía profunda algo que tu programa trata como estado plano, y que la solución no es una biblioteca de lentes sino un modelo más honesto sobre cómo cambia realmente tu dominio.

⚔️ Mide y rompe tu propia inmutabilidad
  1. Busca en tu código una data class con una propiedad de tipo mutable y demuestra con dos líneas que copy no te está aislando de nada.
  2. Localiza la cadena de copy anidados más profunda de tu proyecto, cuenta cuántos objetos asigna cada actualización y reescríbela como una función de extensión con nombre de intención.
  3. Coge esa misma jerarquía y pregúntate qué se rompería si la aplanaras en un solo objeto: si la respuesta es nada, tienes un rediseño gratis.
  4. Escribe una data class con constructor private y comprueba qué te deja hacer su copy generado antes y después de anotarla con @ConsistentCopyVisibility.