wandres.dev
RECORDS · datos inmutables

Actualización inmutable: copiar con un campo distinto y no mutar jamás

Elm no tiene ninguna operación que cambie un campo de un record, y esta lección explica qué se pone en su lugar y por qué la sustitución no es un rodeo sino una mejora. Se estudia la sintaxis de actualización con barra vertical, que produce un valor nuevo idéntico al anterior salvo en los campos enumerados, sus restricciones deliberadas —el operando izquierdo debe ser un nombre, no se pueden añadir ni eliminar campos, y el tipo del resultado es necesariamente el mismo que el del origen— y la lectura correcta de una expresión que parece una asignación y no lo es. Después se desmonta la intuición de que copiar es caro mediante la compartición estructural: los campos no tocados no se duplican, se comparten, porque siendo inmutables ninguna referencia compartida puede volverse peligrosa. La lección cierra examinando qué desaparece cuando desaparece la mutación: el aliasing hostil, las condiciones de carrera lógicas, la invalidación silenciosa de invariantes y la imposibilidad de razonar localmente sobre un valor que otro fragmento del programa puede haber cambiado a tus espaldas.

⏱ 17 min

En casi cualquier lenguaje, cambiar el estado de un dato consiste en apuntar a él y sobrescribir una de sus partes. La operación es tan barata, tan universal y tan temprana en el aprendizaje de cualquiera que cuesta verla como lo que es: una decisión de diseño, y no una ley de la naturaleza. Elm la rechaza sin matices. No existe ninguna forma de modificar un record ya construido, ni siquiera dentro de la función que lo creó, ni siquiera si nadie más lo está mirando. Lo que existe en su lugar es una sintaxis que produce un valor nuevo a partir de uno viejo, cambiando lo que le digas y copiando lo demás, y que se escribe de una manera tan compacta que resulta fácil confundirla con la asignación que sustituye. Esa confusión hay que deshacerla desde el primer minuto, porque de la diferencia entre modificar y derivar cuelgan casi todas las garantías del lenguaje: la pureza de update, la posibilidad de guardar la historia entera de estados, el viaje en el tiempo del depurador y la capacidad de leer una función sabiendo que nada de lo que recibió puede haber cambiado mientras la leías.

🎯 Al terminar esta lección sabrás
  • Escribir actualizaciones inmutables con la sintaxis de barra vertical y leerlas como creación de un valor nuevo, no como mutación.
  • Enumerar las restricciones de esa sintaxis y explicar qué garantía de tipos aporta cada una.
  • Razonar sobre el coste real de copiar mediante la compartición estructural de los campos no modificados.
  • Identificar qué clases de error desaparecen cuando el lenguaje elimina la mutación como operación disponible.

Derivar un valor nuevo, no cambiar el viejo

La sintaxis de actualización toma un record, una barra vertical y una lista de campos con sus valores nuevos. El resultado es un record distinto: mismos campos, mismos tipos, mismo alias, y los valores que indicaste sustituidos. El original no se ve afectado de ninguna manera, sigue vivo y sigue siendo utilizable inmediatamente después.

type alias Usuario =
    { nombre : String
    , edad : Int
    , activo : Bool
    }

-- Produce un record NUEVO; ada sigue teniendo 36
cumplirAnios : Usuario -> Usuario
cumplirAnios u =
    { u | edad = u.edad + 1 }

-- Varios campos a la vez, separados por comas
renombrarYDesactivar : String -> Usuario -> Usuario
renombrarYDesactivar nuevo u =
    { u | nombre = nuevo, activo = False }

-- El original permanece intacto y ambos coexisten
ada : Usuario
ada =
    Usuario "Ada" 36 True

adaMayor : Usuario
adaMayor =
    cumplirAnios ada        -- edad 37, mientras ada sigue en 36

Es útil leer la expresión en voz alta con las palabras correctas. No dice cambia la edad de este usuario, dice dame un usuario como este pero con esta edad. La diferencia parece retórica y no lo es: en la primera lectura hay un sujeto que sufre una acción, en la segunda hay una función que devuelve un valor. Toda la Arquitectura Elm se sostiene sobre la segunda, porque update no altera el modelo que recibe, sino que construye el siguiente.

⚠️
Tres restricciones que no son caprichos

La sintaxis exige que a la izquierda de la barra haya un nombre, no una expresión: no puedes actualizar el resultado de una llamada directamente y tendrás que ligarlo antes con let. Tampoco puedes añadir campos que el record no tuviera ni eliminar los que tiene, y el tipo de cada campo debe seguir siendo el mismo. Las tres restricciones apuntan al mismo objetivo: garantizar que el tipo del resultado es idéntico al del origen. Eso es exactamente lo que permite que update en la Arquitectura Elm devuelva siempre un Model bien formado por construcción, sin comprobar nada y sin posibilidad de producir un estado con la forma equivocada.

Copiar no es caro: la compartición estructural

La objeción inmediata de quien viene de un lenguaje imperativo es económica: si cada cambio produce una copia, un modelo grande costará una fortuna por cada tecla que pulse el usuario. La objeción sería correcta si la copia fuese profunda, y no lo es. Lo que se construye es un contenedor nuevo cuyos campos apuntan a los mismos valores de antes, salvo los que has cambiado. El trabajo es proporcional al número de campos del record, no al tamaño de los datos que contiene.

type alias Modelo =
    { usuarios : List Usuario     -- puede tener diez mil elementos
    , filtro : String
    , pagina : Int
    }

-- Esta actualizacion NO copia la lista de usuarios: la comparte
irAPagina : Int -> Modelo -> Modelo
irAPagina n modelo =
    { modelo | pagina = n }

El diagrama que sigue lo muestra con claridad: dos versiones del modelo coexisten, ambas apuntan a la misma lista y al mismo filtro, y solo el campo que cambió tiene un valor propio en la versión nueva. La memoria adicional que consume la segunda versión es un puñado de referencias, no una copia de los datos, y esa proporción se mantiene por muy grande que sea el contenido.

La razón por la que compartir es seguro es precisamente la inmutabilidad, y aquí las dos ideas se sostienen mutuamente. En un lenguaje con mutación, compartir una referencia es peligroso porque alguien podría escribir a través de ella y afectar a todos los que la comparten; por eso las copias defensivas son una práctica obligada y por eso son caras. Si nadie puede escribir, compartir deja de tener riesgo y la copia defensiva pierde su motivo de ser. La inmutabilidad no hace que copiar sea barato: hace que casi nunca haga falta copiar.

flowchart LR
M1[Modelo v1] --> L[Lista de usuarios]
M1 --> F1[Filtro]
M1 --> P1[Pagina 1]
M2[Modelo v2] --> L
M2 --> F1
M2 --> P2[Pagina 2]
style L fill:#a6e3a1,color:#11111b
style P2 fill:#f9e2af,color:#11111b
style M2 fill:#89b4fa,color:#11111b
🧊

Copia, no cambio

La barra vertical produce un valor nuevo. El original sobrevive intacto y puede seguir usándose en la misma expresión.

🪢

Mismo tipo garantizado

No se añaden ni se quitan campos ni cambian sus tipos. El resultado es del mismo tipo que el origen, siempre.

🔗

Compartición estructural

Los campos no tocados no se duplican: se comparten. Compartir es seguro porque nadie puede escribir en ellos.

🕰️

Historia gratis

Cada estado anterior sigue siendo un valor válido. Guardar la secuencia entera no exige ninguna maquinaria adicional.

Qué desaparece cuando desaparece la mutación

La ausencia de mutación no se nota tanto por lo que hay que escribir como por lo que deja de poder ocurrir. Una función que recibe un record sabe que ese record no cambiará mientras dure su ejecución, ni durante las llamadas que haga, ni después. Esa certeza es lo que permite razonar localmente sobre un fragmento de código sin conocer el programa entero.

-- Encadenar transformaciones es seguro: cada paso recibe un valor estable
procesar : Usuario -> Usuario
procesar =
    cumplirAnios
        >> renombrarYDesactivar "anonimo"

-- Guardar la historia no requiere nada especial: son valores
type alias Historial =
    { actual : Modelo
    , anteriores : List Modelo
    }

registrar : Modelo -> Historial -> Historial
registrar nuevo h =
    { actual = nuevo
    , anteriores = h.actual :: h.anteriores
    }

El bloque de historial es el ejemplo más elocuente. En un lenguaje con mutación, conservar los estados anteriores obliga a clonarlos en profundidad en cada paso, porque de lo contrario todas las entradas del historial acabarían apuntando al mismo objeto mutado y mostrarían el mismo contenido. En Elm no hay nada que clonar: cada modelo anterior es un valor, y guardar valores es lo único que el lenguaje sabe hacer. El depurador con viaje en el tiempo no es una hazaña de ingeniería, es una consecuencia trivial de esta propiedad.

💡
Actualiza con funciones pequeñas y con nombre

Cuando la lógica de un cambio ocupa más de una línea, conviene extraerla a una función con nombre que reciba el record y devuelva el record. Ese hábito produce piezas componibles con el operador de composición, mantiene update legible como un índice de intenciones en lugar de un muro de sintaxis, y hace que cada transformación sea comprobable por separado. Es también la preparación natural para la cuarta lección, donde las funciones auxiliares dejarán de ser una comodidad para convertirse en la única defensa razonable frente a los records anidados.

La mutación no es una optimización: es una renuncia a poder razonar

Merece la pena preguntarse qué se pierde exactamente cuando un lenguaje permite escribir sobre un dato existente, porque la respuesta no es una lista de errores concretos sino algo más profundo: se pierde la posibilidad de que el valor de una expresión dependa solo de lo que se ve. Un nombre en un lenguaje mutable no designa un valor, designa un lugar donde ahora mismo hay un valor, y esa palabra ahora arrastra consigo la noción de tiempo hasta el corazón del programa. En cuanto el tiempo entra, entra también la pregunta de quién más tiene la dirección de ese lugar, y esa pregunta no tiene respuesta local: para contestarla honestamente habría que examinar cada punto del programa donde esa referencia pudo copiarse, incluidos los que se escribirán el año que viene. De ahí sale la familia entera de patologías que todo desarrollador reconoce y que rara vez atribuye a su causa común: el aliasing hostil en el que dos módulos creen tener datos propios y tienen el mismo; la copia defensiva que se escribe por si acaso y que en un noventa por ciento de los casos es trabajo puro desperdiciado; el invariante que se cumple al construir el objeto y deja de cumplirse tres capas más abajo sin que nadie lo advierta; el equals y el hashCode que se vuelven traicioneros porque la identidad del dato cambia mientras está dentro de una colección; el estado compartido entre hilos que exige cerrojos para restaurar a mano una consistencia que el lenguaje regaló por defecto y luego rompió. Elm cierra esa puerta entera con una decisión sola, y lo notable es que el precio resulta ser negativo. No solo no se paga en rendimiento, gracias a la compartición estructural, sino que se cobra un dividendo: si el estado es una sucesión de valores en lugar de un lugar que se sobrescribe, entonces registrar la sucesión es gratis, reproducir un fallo es replicar una lista, comparar dos versiones es comparar dos valores y deshacer una acción es quedarse con el valor anterior en vez de intentar invertir la operación que lo produjo. La lección que trasciende a Elm es que la inmutabilidad no consiste en prohibir el cambio, porque el cambio es el asunto de cualquier programa útil; consiste en representar el cambio como una función entre estados en lugar de como una escritura sobre memoria. Y una función entre estados es algo sobre lo que se puede razonar, se puede probar y se puede componer, mientras que una escritura sobre memoria es solo algo que ocurre.

⚔️ Cambia sin cambiar nada
  1. Escribe tres funciones de actualización sobre el mismo record y comprueba en cada caso que el valor original sigue disponible después.
  2. Intenta usar la sintaxis de actualización sobre el resultado de una llamada, lee el error del compilador y arréglalo ligando con let.
  3. Intenta añadir con esa sintaxis un campo que el record no tiene y explica por qué el rechazo protege el tipo del resultado.
  4. Construye un modelo con una lista grande, actualiza un campo escalar y argumenta por qué la lista no se ha copiado.
  5. Implementa un historial con deshacer usando solo una lista de modelos, sin ninguna operación de clonado.
  6. Traduce a Elm un fragmento de otro lenguaje que mute un objeto en tres puntos distintos y señala qué error de aliasing se vuelve imposible.