Semántica de valor: por qué TCA vive de structs y enums
Antes de la primera macro y del primer reducer hay una decisión de lenguaje que sostiene todo lo demás: en TCA el estado es un valor, no un objeto. Esta lección diseca qué significa exactamente eso en Swift —copia en vez de aliasing, copy-on-write como coste real, inout como transformación pura con buena ergonomía, e identidad modelada como dato en vez de como dirección de memoria— y muestra por qué las tres promesas de la arquitectura, testeo exhaustivo, reproducibilidad y composición, se apoyan sobre esa elección y se derrumban en cuanto una referencia compartida se cuela dentro del estado.
Hay una pregunta que conviene hacerse antes de escribir el primer reducer: ¿por qué TCA insiste en que el State sea un struct y la Action un enum, cuando el mundo de Cocoa lleva décadas modelando pantallas con clases? La respuesta no es estilística ni una preferencia funcional heredada de Elm: es que la semántica de valor cambia quién puede mutar qué y cuándo, y ese cambio es la condición técnica de todo lo que la arquitectura promete después. Con un struct, una copia del estado es un estado independiente que nadie puede alterar a tus espaldas; con una class, cualquiera que tenga la referencia puede escribir en él desde otro hilo, en otro archivo, tres capas más abajo. Esta lección examina qué garantiza exactamente cada semántica, por qué copiar sale mucho más barato de lo que temes, y qué se pierde —y por qué no importa— al renunciar a la identidad.
- Distinguir semántica de valor y de referencia, y precisar qué garantiza cada una sobre quién puede mutar un estado.
- Entender el copy-on-write para dejar de temer el coste de copiar un
Stategrande. - Leer
inoutcomo azúcar de una transformación de valor a valor, y no como una fuga hacia la mutación compartida. - Explicar por qué una referencia dentro del
Staterompe a la vez el diff, el testeo y la reproducibilidad.
Referencia contra valor: quién puede mutar qué
Swift parte sus tipos en dos familias con reglas de asignación opuestas. class y actor tienen semántica de referencia: asignar copia el puntero, no el contenido, así que dos nombres pueden apuntar al mismo objeto. struct, enum y las tuplas tienen semántica de valor: asignar copia el contenido, y a partir de ese instante las dos variables son historias separadas.
final class ContadorClase {
var cuenta = 0
}
struct ContadorValor {
var cuenta = 0
}
let a = ContadorClase()
let b = a
b.cuenta = 42
// a.cuenta también vale 42: a y b nombran el mismo objeto
var c = ContadorValor()
var d = c
d.cuenta = 42
// c.cuenta sigue valiendo 0: d es una copia independiente
La diferencia se llama aliasing, y es la raíz de una familia entera de bugs. Con la clase, a cambió sin que ninguna línea de código mencionara a; el causante estaba en otra parte del programa y no dejó rastro en el sitio donde se manifiesta el síntoma. Fíjate además en el let b = a: la constante no protege nada, porque lo constante es el puntero, no el objeto al que apunta. La inmutabilidad de una referencia es cosmética.
El patrón se vuelve endémico en cuanto una app crece, y casi siempre por una decisión razonable: compartir el modelo para que dos pantallas no se desincronicen.
final class ModeloUsuario {
var nombre = "Ada"
}
let compartido = ModeloUsuario()
let perfil = PantallaPerfil(modelo: compartido)
let ajustes = PantallaAjustes(modelo: compartido)
ajustes.guardarNombre("Grace") // perfil también lo ve, sin enterarse
El día que funciona parece elegante; el día que falla no hay forma de reconstruir qué escribió qué. La pregunta quién cambió este campo no tiene respuesta local, porque cualquiera con la referencia es sospechoso, y el conjunto de sospechosos crece con cada inyección. En un sistema de referencias compartidas, la respuesta correcta a esa pregunta es siempre el programa entero.
Lleva esto al State de una feature. Si fuera una clase, la vista podría escribirlo, un servicio en segundo plano también, y el reducer sería apenas uno de varios autores posibles. Al ser una struct, la premisa de la arquitectura se vuelve verificable por construcción: el estado solo cambia dentro del reducer y solo como respuesta a una acción, porque nadie más tiene un asa que apunte a la misma memoria. El flujo unidireccional deja de ser una convención que el equipo respeta por disciplina y pasa a ser una propiedad que el lenguaje impone.
Copy-on-write: el coste que casi nunca existe
La objeción llega enseguida: si el estado de la app entera es una struct, ¿no estamos copiando un objeto enorme en cada acción? La respuesta es no, y el motivo tiene nombre: copy-on-write. Los contenedores de la biblioteca estándar —Array, Dictionary, Set, String— y también el IdentifiedArray de Point-Free guardan sus elementos en un búfer con recuento de referencias. Copiar el valor copia el puntero al búfer e incrementa el recuento; el búfer solo se duplica de verdad cuando alguien intenta escribir mientras el recuento es mayor que uno.
@ObservableState
struct State: Equatable {
var items: IdentifiedArrayOf<Item> = [] // el buffer se comparte hasta escribir
var filtro = ""
var seleccion: Item.ID?
}
De ahí que una copia de esa struct cueste, en la práctica, copiar unos pocos campos escalares y ajustar un par de recuentos: trabajo proporcional al número de propiedades, no al de elementos. La mutación sí paga la copia real del búfer, pero solo la primera vez y solo si el valor está compartido en ese momento; dentro del reducer, donde el estado se muta en exclusiva, la escritura suele ocurrir en sitio sin copia alguna.
Conviene añadir el otro coste que la semántica de valor introduce y que se olvida al hablar solo de copias: la comparación. El State conforma a Equatable porque el TestStore necesita diferenciar dos instantáneas y la observación necesita saber si algo cambió, y comparar dos valores grandes recorre sus campos. También aquí el copy-on-write ayuda más de lo esperado, porque comparar dos colecciones que aún comparten búfer puede resolverse comprobando que el puntero es el mismo. La regla operativa que se deriva es sencilla y vale para todo el nivel: en el estado van datos, identificadores y banderas; los objetos pesados y las entidades con ciclo de vida propio viven detrás de una dependencia.
El caso patológico no es el State grande, sino el bucle que copia y muta un contenedor compartido miles de veces por fotograma. Si sospechas, no adivines: mide con Instruments y busca picos de asignación de memoria. Y si de verdad necesitas un blob pesado —una imagen decodificada, un búfer de audio—, ese dato no pertenece al State, sino detrás de una dependencia; el estado guarda su identificador, no sus bytes.
inout no es una fuga: es una ecuación con buena ergonomía
Al ver state.cuenta += 1 dentro de un reducer, la intuición sugiere que ahí hay mutación compartida. No la hay. El parámetro está marcado inout, y inout no es un puntero al estilo de C: es un préstamo exclusivo. El compilador aplica la ley de exclusividad —el mismo valor no puede estar accesible por dos caminos mientras dura el préstamo— y el efecto observable es copiar dentro, escribir, copiar fuera. Nadie más ve el valor a medio mutar.
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .incrementar:
state.cuenta += 1
return .none
}
}
Por eso (inout State, Action) -> Effect<Action> es, semánticamente, la misma función que (State, Action) -> (State, Effect<Action>), escrita de una forma que evita reconstruir a mano una struct de veinte campos para cambiar uno. Es azúcar sintáctico sobre una transformación pura, no una grieta en la pureza. Y trae una consecuencia práctica que reaparecerá al hablar de efectos: inout no puede escapar de la llamada, así que dentro de un Effect.run no tienes acceso al estado. Debes capturar por valor lo que necesites —[id = state.id]— y devolver el resto como acciones. Esa incomodidad aparente es la frontera entre lógica y mundo, dibujada por el sistema de tipos.
Lo que se gana renunciando a la identidad
Un valor no tiene identidad: dos struct con los mismos campos son indistinguibles, y no existe un === que aplicarles. Esa pérdida es exactamente lo que habilita el resto de la arquitectura.
El diff se vuelve posible
Comparar dos valores es comparar sus campos. De ahí sale el diff que el TestStore imprime cuando fallas una aserción, y la observación fina que redibuja solo lo que cambió.
La historia se puede rebobinar
Guardar un valor es guardar una foto completa e inalterable. Una lista de estados pasados es una máquina del tiempo; una lista de referencias serían todas la misma foto, la actual.
La composición no filtra
Un hijo que recibe una copia de su rebanada del estado no puede tocar al padre. Sin punteros compartidos no hay coordinación implícita: la única vía es la acción.
Cuando de verdad hace falta identidad —distinguir dos filas idénticas de una lista— TCA no la recupera con punteros: la modela como dato, con Identifiable y un ID estable dentro del propio valor. La identidad deja de ser una dirección de memoria accidental y pasa a ser una propiedad del dominio, comparable, serializable y testeable.
flowchart TD subgraph Referencia R1[variable a] --> OBJ[objeto compartido] R2[variable b] --> OBJ OBJ --> M[cualquiera muta y nadie se entera] end subgraph Valor V1[copia a] --> E1[estado independiente] V2[copia b] --> E2[estado independiente] E1 --> RED[solo el reducer escribe] E2 --> RED end style OBJ fill:#f38ba8,color:#11111b style RED fill:#a6e3a1,color:#11111b
Basta una propiedad de tipo clase dentro del State para perder las garantías: la copia del State copiará el puntero, dos features acabarán mirando el mismo objeto y el TestStore comparará dos instantáneas que ya son la misma. Si necesitas compartir estado de verdad entre features, existe una herramienta diseñada para ello, @Shared, que conserva la semántica de valor y es testeable. Meter una class es la vía rápida que parece funcionar y desactiva en silencio la mitad de la arquitectura.
Conviene ver hasta el fondo por qué esta lección abre el nivel y no es un apéndice sobre tipos de Swift. Todo lo que hace valiosa a TCA descansa sobre una única propiedad: que el estado no pueda cambiar sin que el reducer lo escriba. El testeo exhaustivo pide comparar el estado de antes con el de después, y esa comparación solo significa algo si el antes no muta bajo tus pies mientras la haces —con una referencia, el TestStore compararía un objeto consigo mismo y todos los tests pasarían siempre—. La reproducibilidad pide que la misma secuencia de acciones desde el mismo estado inicial produzca el mismo estado final, lo cual es falso en cuanto un tercero con un puntero puede intervenir entre dos acciones. La composición pide que un hijo no sepa nada de su padre, y un hijo con una referencia al modelo compartido sabe demasiado por definición. La observación fina de SwiftUI pide detectar qué campos cambiaron, y en un objeto mutado en sitio no hay dos versiones que enfrentar. Cuatro propiedades independientes en apariencia, un único cimiento común. Por eso la elección de struct y enum no es un gusto adquirido en Elm ni un tributo a la programación funcional, sino la contrapartida técnica de una decisión de diseño anterior: si quieres razonar sobre tu programa como una sucesión de estados, esos estados tienen que ser cosas que se puedan sostener quietas y comparar, y solo los valores lo son. Las clases modelan entidades que persisten y cambian de identidad estable —una conexión, un ciclo de vida, un recurso del sistema—, y ahí siguen siendo la herramienta correcta; por eso viven detrás de las dependencias, nunca dentro del estado. La regla que te llevas cabe en una frase: el estado es lo que se copia, las dependencias son lo que se referencia, y confundirlos es el error más caro que puedes cometer en este nivel.
- Escribe una
final class Perfilconvar nombre: String, mete una instancia en dos variables distintas y muta una. Confirma en un playground que la otra también cambió, y que declararletno lo impidió. - Convierte
Perfilenstructy repite el experimento. Anota exactamente qué línea deja de compilar y por qué la mutación ahora exigevar. - Modela un
Stateconvar items: IdentifiedArrayOf<Perfil>y escribe un reducer que renombre un elemento por suID. Verifica que en ningún punto necesitaste una referencia para localizarlo. - Intenta escribir un
Effect.runque mutestatedirectamente. Lee el error del compilador y explica con tus palabras qué garantizainoutque impide esa captura. - Cierra con un inventario de tu proyecto actual: lista qué tipos son clases hoy y clasifícalos en dos columnas, estado que debería ser valor y dependencia que legítimamente es una referencia. Esa tabla es el plan de migración de las lecciones siguientes.