Enums con valores asociados: el tipo suma
El enum de Swift es un tipo suma de verdad: cada caso puede llevar su propio payload. Álgebra de tipos, cardinalidad, exhaustividad y la representación real en memoria.
Un struct combina datos con la conjunción “y”: tiene esto y esto y aquello. Un enum los combina con la disyunción “o”: es esto o esto o aquello. La segunda mitad del álgebra de tipos falta en muchos lenguajes, y su ausencia obliga a codificar la disyunción con banderas sueltas que nadie valida. Swift la tiene, la comprueba en compilación y la representa en memoria de forma sorprendentemente ajustada.
- Distinguir tipos producto de tipos suma y calcular su cardinalidad.
- Definir casos con payloads heterogéneos y extraer sus valores con seguridad.
- Explicar la representación en memoria: etiqueta, spare bits y extra inhabitants.
- Razonar sobre exhaustividad, evolución de la API y
@frozen.
Producto frente a suma
La teoría de tipos ofrece dos constructores fundamentales. El producto empareja: un struct con campos de tipos A y B tiene tantos valores como el producto de los valores de A y B. La suma elige: un enum con un caso que lleva A y otro que lleva B tiene tantos valores como la suma.
struct ParDeBooleanos { var a: Bool; var b: Bool } // 2 x 2 = 4 habitantes
enum UnoUOtro { // 2 + 2 = 4 habitantes
case izquierda(Bool)
case derecha(Bool)
}
La cardinalidad no es folclore académico: es la medida exacta de cuántos estados puede alcanzar tu programa. Cada bit de estado que declaras multiplica el espacio de configuraciones posibles, y la mayoría de esas configuraciones no significan nada. El modelado con enums existe para que la cardinalidad del tipo coincida con la cardinalidad del dominio real, ni un habitante de más.
Never es el tipo suma sin casos: cardinalidad cero, ningún valor puede existir, por eso una función que devuelve Never no retorna nunca. Void es el producto vacío: cardinalidad uno, un solo valor, la tupla sin elementos. Bool es la suma de dos casos vacíos. Todo el sistema de tipos se construye a partir de estas piezas.
Payloads: cada caso con su forma
Un caso puede transportar datos, y cada caso puede transportar datos distintos. Ahí está la diferencia con los enums de C, que son enteros disfrazados.
enum Peticion {
case get(ruta: String)
case post(ruta: String, cuerpo: Data)
case borrar(id: UUID)
case ninguna
}
let p = Peticion.post(ruta: "/usuarios", cuerpo: Data())
Internamente el payload de un caso es una tupla, con o sin etiquetas. Al hacer pattern matching se enlaza posicionalmente o por etiqueta, y let puede ir fuera del paréntesis para vincular todo el payload de golpe.
switch p {
case .get(let ruta):
print("GET \(ruta)")
case .post(let ruta, let cuerpo): // enlace posicional
print("POST \(ruta) con \(cuerpo.count) bytes")
case let .borrar(id): // el let cubre todo el payload
print("DELETE \(id)")
case .ninguna:
break
}
Los payloads pueden ser recursivos, genéricos o cerrarse sobre otros enums. Optional y Result de la biblioteca estándar no son magia del compilador: son enums de dos casos con payload, escritos en Swift.
enum MiOpcional<Wrapped> {
case none
case some(Wrapped)
}
Representación en memoria
Aquí el compilador se vuelve interesante. Swift no reserva un byte de etiqueta a ciegas: analiza los payloads y busca huecos donde esconder la información del caso.
Un enum sin payloads se representa con el entero más pequeño que quepa: hasta 256 casos ocupan un solo byte, y un enum de un único caso ocupa cero bytes, porque no hay información que almacenar.
Un enum de un solo payload intenta usar los extra inhabitants del payload: patrones de bits que el tipo interno nunca produce. Un puntero jamás vale cero, así que Optional de un puntero cabe en los mismos 8 bytes; un Bool solo usa los valores 0 y 1, así que los 254 patrones restantes están libres.
Un enum de varios payloads intenta los spare bits comunes a todos ellos; si no encuentra suficientes, añade un byte de etiqueta al final y paga alineación.
enum SinCarga { case a, b, c }
MemoryLayout<SinCarga>.size // 1
MemoryLayout<Bool>.size // 1
MemoryLayout<Bool?>.size // 1 gratis: usa un extra inhabitant
MemoryLayout<String>.size // 16
MemoryLayout<String?>.size // 16 gratis: String tiene huecos
MemoryLayout<Int>.size // 8
MemoryLayout<Int?>.size // 9 Int ocupa TODOS sus patrones
MemoryLayout<Int?>.stride // 16 la alineacion redondea
enum DosCargas { case entero(Int); case doble(Double) }
MemoryLayout<DosCargas>.size // 9, stride 16
flowchart TD
A[Enum a representar] --> B{Algun caso lleva payload}
B -- No --> C[Entero minimo: 1 byte hasta 256 casos]
B -- Un solo payload --> D{El payload deja extra inhabitants}
D -- Si --> E[Coste cero: mismo tamano que el payload]
D -- No --> F[Byte de etiqueta adicional]
B -- Varios payloads --> G{Hay spare bits comunes}
G -- Si --> H[Etiqueta escondida en los bits libres]
G -- No --> F
style E fill:#a6e3a1,color:#11111b
style C fill:#89b4fa,color:#11111b
style F fill:#f9e2af,color:#11111bSin payload
Solo la etiqueta. Un byte para hasta 256 casos, cero bytes si el enum tiene un único caso.
Un payload
Se intenta esconder el caso en patrones de bits que el payload nunca genera. String? es gratis.
Varios payloads
Se reutilizan los bits libres comunes; si no bastan, aparece un byte de etiqueta y su alineación.
Que String? ocupe exactamente lo mismo que String no es una curiosidad: es la razón por la que la seguridad frente a nulos en Swift no tiene coste. En un lenguaje con punteros nulos implícitos, la ausencia se codifica robando un valor al dominio y confiando en que todo el mundo lo compruebe. Swift eleva esa misma técnica a la categoría de regla del sistema de tipos: el compilador demuestra que el patrón de bits reservado no colisiona con ningún valor legítimo del payload, y a partir de ahí el chequeo de nulidad es una comparación de bits que el optimizador suele eliminar del todo. La consecuencia es profunda: puedes modelar el dominio con la precisión que quieras —envolviendo, sumando, anidando enums— sin pagar en bytes ni en ciclos lo que ganas en garantías. La expresividad y el rendimiento dejan de ser un intercambio.
Exhaustividad y evolución
El switch sobre un enum debe cubrir todos los casos. Esa obligación convierte cada caso nuevo en una lista de errores de compilación que señala exactamente los sitios que hay que revisar: refactorización guiada por el compilador, no por la memoria del programador.
Con la estabilidad de ABI aparece un matiz. Un enum público de un módulo con evolución de biblioteca puede ganar casos en versiones futuras, así que se considera non-frozen: el cliente debe escribir @unknown default para el futuro sin perder los avisos del presente.
switch codigo {
case .exito: manejar()
case .fallo: reintentar()
@unknown default: // avisa si el enum crece, pero compila
registrar("caso desconocido")
}
Marcar un enum con @frozen es una promesa pública e irrevocable de no añadir casos jamás, a cambio de que el cliente pueda hacer un switch cerrado y de que el compilador conozca el tamaño exacto en tiempo de compilación.
- Calcula la cardinalidad de un
structcon dosBooly una de unenumcon cuatro casos vacíos. Explica por qué solo uno de los dos modela bien un semáforo de tres luces. - Define un enum
Medidacon casos para metros, pies y grados, cada uno con su payloadDouble. Escribe unswitchque convierta todo a metros. - Imprime
MemoryLayoutdeInt?,Bool?,String?y de un enum con dos payloadsInt. Justifica cada tamaño con extra inhabitants o etiquetas. - Reescribe
Resultde la biblioteca estándar desde cero como enum genérico de dos casos. - Explica qué se gana y qué se pierde al marcar un enum público como
@frozen.