wandres.dev
MÁQUINAS EN OTROS LENGUAJES · Swift, Kotlin, Rust

Swift: el enum con valores asociados ya es una máquina de estado

Swift no incluye ninguna librería de statecharts en su biblioteca estándar y en la mayoría de los casos no la necesita, porque el sistema de tipos trae de serie la pieza esencial: el tipo suma con carga útil. Esta lección muestra cómo un enum con valores asociados hace imposible por construcción el cuarteto de booleanos contradictorios, cómo la exhaustividad del switch convierte al compilador en verificador mecánico de la tabla de transiciones, y dónde termina exactamente lo que el lenguaje regala: en la jerarquía, las regiones paralelas y la historia, que siguen siendo trabajo tuyo.

⏱ 18 min

Llevamos veinte niveles hablando de estados y transiciones con la sintaxis de una librería concreta, y conviene decir ahora lo que esa sintaxis ocultaba: createMachine no inventó ninguna idea que el sistema de tipos de Swift no supiera expresar desde su primera versión. Un enum con valores asociados es una unión discriminada, esto es, exactamente la estructura que impide que un valor esté en dos estados a la vez y que un estado cargue datos que no le pertenecen. Lo que en JavaScript exige una librería con intérprete propio, aquí son siete líneas de declaración que el compilador verifica antes de que exista el binario. Esta lección examina qué parte del statechart te regala el lenguaje, qué parte sigues escribiendo a mano, y por qué la frontera entre ambas cae justo donde cae.

🎯 Al terminar esta lección sabrás
  • Modelar el estado con un enum de valores asociados donde cada caso transporte solo los datos que le corresponden.
  • Usar la exhaustividad del switch como verificación mecánica de la cobertura de la tabla de transiciones.
  • Escribir la transición como función total sobre el par estado y evento, y decidir conscientemente qué ocurre con los pares ilegales.
  • Reconocer el límite del enum puro: jerarquía, regiones ortogonales e historia no vienen de serie.

Cada caso transporta solo lo suyo

El punto de partida de casi cualquier pantalla escrita sin criterio es un struct con un booleano de carga, un valor opcional y un error opcional. Tres campos independientes producen ocho combinaciones y solo cuatro tienen sentido: la representación admite un estado que carga y a la vez tiene error, u otro que ni carga ni tiene datos ni ha fallado sin que nadie sepa si es que aún no ha empezado. El enum con valores asociados elimina esas combinaciones de raíz porque el dato deja de pertenecer al agregado y pasa a pertenecer al caso.

enum EstadoDePerfil {
    case inactivo
    case cargando(id: Perfil.ID, intentos: Int, tarea: Task<Perfil, Error>)
    case listo(perfil: Perfil, recibidoEn: Date)
    case fallo(id: Perfil.ID, error: Error, intentos: Int)
}

Léelo como una afirmación sobre el dominio y no como una estructura de datos. El manejador de la tarea existe únicamente mientras se está cargando, de modo que es imposible retener por descuido el descriptor de una operación ya terminada. La marca temporal existe únicamente en el caso con datos, así que nunca hay que preguntarse qué significa la fecha de recepción de un perfil que aún no llegó. El contador de intentos aparece en los dos casos donde tiene sentido y desaparece en los otros dos, y ese detalle es justamente el que un struct plano no sabe expresar: en la versión con campos sueltos, intentos sería un entero permanentemente presente cuyo valor en el caso con datos no significa nada y, por eso mismo, acabará significando cualquier cosa.

ℹ️
Este enum no es el enum de C

Conviene desactivar la intuición heredada de otros lenguajes. El enum de C es un conjunto de nombres para enteros, y por eso allí el patrón exige un struct aparte con la carga útil y la disciplina manual de no leer el campo equivocado. El enum de Swift es un tipo suma verdadero: cada caso puede llevar una tupla de valores distinta y el compilador impide leerlos sin haber comprobado antes en qué caso estás. Esa comprobación obligatoria es el mecanismo completo, y no un adorno sintáctico.

La exhaustividad como verificador de cobertura

La segunda mitad del regalo llega al consumir el valor. Un switch sobre un enum declarado en tu propio módulo debe cubrir todos los casos o el programa no compila, y esa regla convierte una propiedad de diseño en una herramienta de refactorización. El día que el dominio crezca y aparezca un quinto estado, el compilador enumerará, uno por uno, todos los lugares del código que decidían según el estado y todavía no contemplan el caso nuevo. Esa lista es la deuda exacta que introduce el cambio, calculada por la máquina en lugar de por tu memoria.

Representación Estados representables Datos fuera de lugar Quién detecta un caso nuevo
tres booleanos sueltos ocho en todos los estados nadie
enum sin valores asociados cuatro en todos los estados el compilador
enum con valores asociados cuatro en ninguno el compilador
enum de otro módulo con evolución cuatro en ninguno el compilador, con aviso

La última fila merece explicación porque es donde el modelo se vuelve sutil. Un enum publicado por un módulo compilado con evolución de biblioteca activada no es congelado: sus autores se reservan el derecho de añadir casos en versiones futuras sin romper el código ya compilado. Al consumirlo desde fuera, Swift exige una cláusula @unknown default que se dispara solo ante casos que aún no existían, y degrada el error de compilación a un aviso cuando la biblioteca crece. Es un compromiso deliberado entre exhaustividad y estabilidad binaria, y la lectura correcta es que la exhaustividad total solo es gratis dentro de la frontera que tú controlas.

⚠️
Un default cualquiera destruye la propiedad entera

La cláusula default en un switch sobre un estado propio silencia para siempre la comprobación que estabas comprando. A partir de ahí, añadir un caso al enum compila sin decir nada y el comportamiento nuevo cae en la rama comodín, que casi nunca es lo que quieres. La regla práctica es no escribir default al decidir según el estado y reservar @unknown default para los tipos que vienen de fuera. Cuando el switch tiene diez casos y solo dos te importan, la respuesta no es el comodín sino agrupar los casos irrelevantes escribiéndolos por su nombre, que cuesta una línea y conserva la verificación.

La transición como función total sobre el par

El enum describe los estados; la máquina, sin embargo, no son los estados sino la relación legal entre ellos, y esa relación es una función. Escribirla como función libre sobre el par estado y evento la vuelve trivialmente comprobable, porque no toca red, ni reloj, ni pantalla: recibe dos valores y devuelve uno.

enum Evento {
    case cargar(id: Perfil.ID)
    case llego(Perfil)
    case rompio(Error)
    case reintentar
}

func siguiente(_ estado: EstadoDePerfil, ante evento: Evento) -> EstadoDePerfil {
    switch (estado, evento) {
    case let (.inactivo, .cargar(id)):
        return .cargando(id: id, intentos: 1, tarea: cargaDe(id))
    case let (.cargando, .llego(perfil)):
        return .listo(perfil: perfil, recibidoEn: .now)
    case let (.cargando(id, intentos, _), .rompio(error)):
        return .fallo(id: id, error: error, intentos: intentos)
    case let (.fallo(id, _, intentos), .reintentar) where intentos < 3:
        return .cargando(id: id, intentos: intentos + 1, tarea: cargaDe(id))
    default:
        return estado
    }
}

Aquí aparece el default que la sección anterior desaconsejaba, y aparece por una razón que conviene entender en lugar de imitar. El switch ya no recorre un enum de cuatro casos sino el producto cartesiano de cuatro estados por cuatro eventos: dieciséis pares de los cuales apenas cuatro son legales. Enumerar los doce restantes uno a uno no aporta ninguna seguridad y sí mucho ruido, de modo que el comodín deja de ser una omisión y pasa a ser una afirmación semántica precisa: un evento que no tiene transición declarada en el estado actual se descarta sin efecto. Esa es, literalmente, la semántica de una máquina de estados finita, y es la misma que aplica cualquier intérprete de statecharts cuando ningún manejador coincide. Si tu dominio prefiere que un evento imposible sea un fallo de programación y no un no-op, el comodín es también el lugar natural donde poner una aserción que solo grite en compilaciones de depuración.

flowchart LR
I[inactivo] -->|cargar| C[cargando]
C -->|llego| L[listo]
C -->|rompio| F[fallo]
F -->|reintentar con intentos menores que tres| C
F -->|reintentar sin intentos restantes| F
style L fill:#a6e3a1,color:#11111b
style F fill:#f38ba8,color:#11111b

Como la función no toca nada exterior, la tabla de transiciones se comprueba entera en un test que no arranca ni la aplicación ni el simulador, y ese test es la única documentación del grafo que no puede quedarse desactualizada. Conviene escribirlo enumerando pares y resultados esperados en lugar de un caso por método, porque así la tabla del test tiene la misma forma que la tabla del dominio y las omisiones saltan a la vista al leerla.

@Test("los pares ilegales no mueven la maquina", arguments: [
    (EstadoDePerfil.inactivo, Evento.reintentar),
    (EstadoDePerfil.inactivo, Evento.llego(.ejemplo)),
    (EstadoDePerfil.listo(perfil: .ejemplo, recibidoEn: .now), Evento.rompio(ErrorFalso())),
])
func paresIlegales(estado: EstadoDePerfil, evento: Evento) {
    #expect(caso(de: siguiente(estado, ante: evento)) == caso(de: estado))
}

Merece mención aparte la forma de leer un solo caso sin escribir un switch entero, porque es lo que más se usa en el día a día y lo que peor se explica. Las construcciones if case let y guard case let extraen los valores asociados de un caso concreto y siguen adelante o salen si el valor está en otro, lo que permite escribir código local a un estado sin renunciar a nada. La disciplina que conviene mantener es una sola: usar esas formas para leer, nunca para decidir el estado siguiente. En el momento en que una cadena de if case empieza a asignar estados, la tabla de transiciones se ha escapado de la función que la centralizaba y vuelve a estar repartida por el archivo, que es justo la situación de la que este modelado venía huyendo.

Lo que la función pura no puede hacer es cancelar la tarea que quedó viva al abandonar el estado de carga, y esa carencia no es un defecto sino el reparto correcto: la función decide, y quien la invoca ejecuta. El envoltorio observable compara el estado anterior con el nuevo, dispara los efectos de salida del que se abandona y los de entrada del que se ocupa, y publica el valor para que la vista se redibuje. La vista, por su parte, vuelve a ser un switch exhaustivo sobre el mismo enum, con lo que un estado nuevo obliga a decidir explícitamente cómo se pinta antes de poder compilar la aplicación.

@MainActor @Observable
final class TiendaDePerfil {
    private(set) var estado: EstadoDePerfil = .inactivo

    func enviar(_ evento: Evento) {
        let anterior = estado
        let nuevo = siguiente(anterior, ante: evento)
        salir(de: anterior, hacia: nuevo)
        estado = nuevo
        entrar(en: nuevo)
    }
}

Queda por decir qué no te da el lenguaje, porque el entusiasmo con los tipos suma suele terminar en una máquina plana de treinta casos. Swift no ofrece jerarquía: anidar un enum dentro de otro la simula, pero el switch se vuelve anidado y las transiciones que cruzan niveles hay que escribirlas a mano. Tampoco ofrece historia, ni acciones de entrada y salida declarativas, ni la posibilidad de dibujar el grafo sin ejecutarlo. Y de las regiones paralelas hay que decir algo más interesante que su ausencia: un struct con dos enum dentro es exactamente un par de regiones ortogonales, porque el producto de dos tipos suma tiene por cardinalidad el producto de sus casos y por semántica la simultaneidad. La estructura estaba ahí; lo que falta es el motor que la interprete.

💡
Igualdad, persistencia y depuración salen casi gratis

Si todos los valores asociados son comparables, marcar el enum como Equatable permite escribir aserciones de transición en una línea y comparar estados en los tests sin escribir código de igualdad. Si además son codificables, Codable sintetizado serializa el estado completo con su discriminante, lo que convierte guardar y restaurar una sesión en un problema resuelto. Añadir CustomStringConvertible con el nombre del caso da trazas legibles. Ninguna de esas tres cosas es específica de máquinas de estado, y las tres son exactamente lo que un intérprete de statecharts tiene que construir a mano.

El álgebra de tipos ya contenía los statecharts

La conclusión profunda de esta lección no es que Swift sea cómodo para las máquinas, sino que los statecharts de Harel y los tipos algebraicos son la misma idea descubierta dos veces desde dos culturas que apenas se hablaban. El tipo suma es el estado exclusivo: sus casos son mutuamente excluyentes y su cardinalidad es la suma de las cardinalidades, que es precisamente lo que significa estar en un estado y no en los otros. El tipo producto es la región ortogonal: un struct de dos enum está en un caso de cada uno a la vez y su cardinalidad es el producto, que es la explosión combinatoria que Harel domesticó dibujando las regiones en paralelo en lugar de multiplicar los estados. Y la recursión mediante indirect es la jerarquía: un estado que contiene estados. Lo único que el álgebra no aporta es la función de transición, es decir, el conjunto de pares legales y el efecto que acompaña a cada uno, y eso es exactamente lo que tú escribes y lo que una librería te ejecutaría. De ahí se sigue el criterio que gobernará todo este nivel: en un lenguaje con tipos suma verificados, la mitad estructural del statechart la aporta el compilador de forma gratuita y estática, y lo que queda por decidir es si la mitad dinámica —jerarquía, historia, paralelismo, cancelación estructurada, visualización— pesa lo bastante en tu problema como para justificar un intérprete. Cuando la respuesta es que no, escribir el grafo a mano en Swift no es la versión pobre de una máquina de estados: es una máquina de estados a la que le sobraba el intérprete.

⚔️ Convierte una pantalla de booleanos en un enum verificado
  1. Elige en tu código una vista con al menos tres campos de estado independientes y calcula cuántas combinaciones admite frente a cuántas son legales.
  2. Reescribe ese estado como un enum con valores asociados donde cada dato aparezca únicamente en los casos que lo justifican.
  3. Elimina todos los default de los switch que deciden sobre ese estado y comprueba cuántos lugares tenía la lógica repartida.
  4. Extrae la transición a una función libre sobre el par estado y evento, y decide de forma explícita qué significa el comodín en tu dominio.
  5. Escribe cuatro tests que solo llamen a esa función y verifiquen pares legales e ilegales sin arrancar la interfaz.
  6. Añade un quinto estado al enum y anota cuántos errores de compilación aparecen: esa cifra es la cobertura real que acabas de comprar.