Para conservar valores anidados simples con un enum recursivo de Swift, implementa la codificación y decodificación personalizadas para cada tipo de valor. Este recorrido crea un modelo sintético de una app para Mac que realiza un viaje de ida y vuelta mediante objetos de Foundation íntegramente en memoria, sin añadir envoltorios para los casos del enum.

¿Por qué la implementación sintetizada de Codable cambia la representación?

Supongamos que una app de escritorio para Mac tiene una pequeña vista previa del diseño que contiene etiquetas, valores numéricos, interruptores, listas y grupos anidados. Un enum recursivo puede describir esos valores con precisión. La tarea concreta aquí consiste en convertir ese enum en un grafo de objetos simples de Foundation y reconstruirlo conservando los tipos de valor admitidos.

La conformidad automática no proporciona la representación necesaria. El diseño implementado de Swift para sintetizar enums coloca el nombre del caso en un contenedor de claves externo y luego coloca los valores asociados dentro de otro contenedor. Los valores asociados sin etiqueta reciben claves generadas como _0. Por consiguiente, un caso de cadena adquiere envoltorios estructurales en vez de convertirse en una cadena simple. Estas reglas se documentan en Síntesis de Codable para enums con valores asociados.

Cambiar CodingKeys puede renombrar esos envoltorios, pero conservar la síntesis mantiene su modelo estructural. La solución admitida es implementar por tu cuenta init(from:) y encode(to:). Apple documenta explícitamente la conformidad manual para tipos cuya estructura en Swift difiere de su estructura codificada en Codificación y decodificación de tipos personalizados.

La recursión y la representación son decisiones independientes. El modificador indirect de Swift permite el almacenamiento de enums recursivos; los métodos de codificación personalizados determinan la forma codificada. La guía del lenguaje explica que se debe colocar indirect antes de la declaración del enum para aplicar indirección a sus casos con valores asociados en Enumeraciones.

¿Qué debe conservar exactamente la representación simple?

Para este ejemplo, usa el siguiente contrato. Admite deliberadamente un vocabulario reducido de valores adecuado para una vista previa sintética del diseño. Las reglas de conversión documentadas de Foundation permiten cadenas, números, arreglos, diccionarios y objetos nulos, exigen claves de diccionario de tipo cadena y excluyen los números no finitos. Consulta JSONSerialization.

Caso del enum Valor simple Requisito de compatibilidad
null NSNull Conservar un valor nulo explícito
bool Valor booleano Distinguirlo de los valores numéricos cero y uno
number Valor Double finito Aceptar los límites de precisión de Double
string Cadena Conservar el texto sin convertirlo a número
array Arreglo de valores simples Conservar el orden de los elementos y los arreglos vacíos
object Diccionario con claves de tipo cadena Conservar las claves, los valores anidados y los objetos vacíos

En este caso, la compatibilidad significa conservar esas categorías y sus valores dentro de los límites numéricos indicados. No exige conservar una subclase concreta de número de Foundation ni el orden en que aparecen las claves del diccionario. También debe sobrevivir una entrada nula explícita: un objeto que contiene "subtitle": NSNull() tiene un significado distinto en el modelo que un objeto sin la clave subtitle.

Antes de adoptar este contrato, decide si los números realmente pueden compartir un único caso Double. Apple documenta Double como un tipo de coma flotante de 64 bits y proporciona inicializadores de conversión exacta porque las conversiones normales pueden redondear. Si tu modelo necesita enteros grandes exactos, define una política numérica explícita antes de convertirlos. Añadir un caso entero también requiere una regla de decodificación, porque los valores numéricos simples no llevan identidades de casos del enum de Swift. Consulta Double.

¿Cómo se implementa el enum recursivo?

Usa un entorno Swift actual para Mac con Foundation disponible. Los siguientes tres bloques de código, en orden, forman un ejemplo completo de nivel superior: el enum, las funciones de conversión y una rutina de comprobación ejecutable. Todos los valores de ejemplo se crean en memoria. Swift no está disponible en el entorno de investigación, así que se examinó el ejemplo, pero no se ejecutó.

El decodificador primero comprueba si el valor es nulo y luego intenta decodificar los tipos primitivos admitidos. SingleValueDecodingContainer de Apple proporciona estas operaciones, incluida decodeNil(). El auxiliar local solo trata una incompatibilidad de tipos como motivo para probar otro tipo primitivo; los demás errores de decodificación se propagan.

import Foundation

indirect enum PlainValue: Codable, Equatable {
    case null
    case bool(Bool)
    case number(Double)
    case string(String)
    case array([PlainValue])
    case object([String: PlainValue])

    init(from decoder: Decoder) throws {
        let scalar = try decoder.singleValueContainer()

        if scalar.decodeNil() {
            self = .null
            return
        }

        func matching<T: Decodable>(_ type: T.Type) throws -> T? {
            do {
                return try scalar.decode(type)
            } catch DecodingError.typeMismatch(_, _) {
                return nil
            }
        }

        if let value = try matching(Bool.self) {
            self = .bool(value)
            return
        }
        if let value = try matching(String.self) {
            self = .string(value)
            return
        }
        if let value = try matching(Double.self) {
            guard value.isFinite else {
                throw DecodingError.dataCorrupted(.init(
                    codingPath: decoder.codingPath,
                    debugDescription: "Expected a finite number."
                ))
            }
            self = .number(value)
            return
        }

        var sequence: UnkeyedDecodingContainer?
        do {
            sequence = try decoder.unkeyedContainer()
        } catch DecodingError.typeMismatch(_, _) {
            sequence = nil
        }

        if var items = sequence {
            var values: [PlainValue] = []
            while !items.isAtEnd {
                values.append(try items.decode(PlainValue.self))
            }
            self = .array(values)
            return
        }

        self = .object(try scalar.decode([String: PlainValue].self))
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()

        switch self {
        case .null:
            try container.encodeNil()
        case .bool(let value):
            try container.encode(value)
        case .number(let value):
            guard value.isFinite else {
                throw EncodingError.invalidValue(value, .init(
                    codingPath: encoder.codingPath,
                    debugDescription: "Expected a finite number."
                ))
            }
            try container.encode(value)
        case .string(let value):
            try container.encode(value)
        case .array(let values):
            try container.encode(values)
        case .object(let values):
            try container.encode(values)
        }
    }
}

Cada rama de codificación escribe directamente su carga útil. La rama nula llama explícitamente a encodeNil(), y las ramas de colecciones delegan en las implementaciones de codificación de los arreglos y diccionarios. Apple documenta tanto las operaciones de codificación primitivas como las genéricas en SingleValueEncodingContainer, y la conformidad de las colecciones en Codificación y decodificación de tipos personalizados.

La detección de arreglos está separada deliberadamente de la decodificación de sus elementos. Solicitar un contenedor sin claves puede generar una incompatibilidad de tipos cuando el valor actual no es un arreglo. Una vez reconocido el arreglo, cada elemento se decodifica con try como de costumbre, por lo que cualquier error al decodificar un elemento se propaga en vez de activar un intento ajeno de decodificar un objeto. El comportamiento documentado se describe en unkeyedContainer() y UnkeyedDecodingContainer.

Esta implementación está dirigida a la ruta de codificación JSON de Foundation y al vocabulario de valores indicado. Prueba cualquier otro codificador o decodificador con el mismo contrato antes de usarlo; la conformidad con Codable por sí sola no garantiza un comportamiento idéntico en todos los formatos de representación.

¿Cómo se convierten objetos de Foundation íntegramente en memoria?

Foundation ofrece dos puntos de conexión útiles: JSONEncoder produce Data a partir de un valor codificable y JSONDecoder reconstruye un valor decodificable a partir de Data. JSONSerialization convierte entre esa representación intermedia y los objetos de Foundation. La combinación de estas operaciones documentadas proporciona el siguiente puente en memoria. Consulta JSONEncoder, JSONDecoder y JSONSerialization.

func foundationObject(from value: PlainValue) throws -> Any {
    let bytes = try JSONEncoder().encode(value)
    return try JSONSerialization.jsonObject(
        with: bytes,
        options: [.fragmentsAllowed]
    )
}

func plainValue(from object: Any) throws -> PlainValue {
    let bytes = try JSONSerialization.data(
        withJSONObject: object,
        options: [.fragmentsAllowed]
    )
    return try JSONDecoder().decode(PlainValue.self, from: bytes)
}

El Data intermedio permanece en memoria. Estas funciones realizan tareas de serialización; no son conversiones sin copia entre el enum y las colecciones de Foundation. Mantén la conversión en el punto donde la funcionalidad de escritorio realmente necesite un grafo de objetos simples y conserva el enum para las operaciones con modelos tipados.

Ambas opciones .fragmentsAllowed son importantes porque un valor recursivo puede tener un escalar como raíz. Una cadena, un booleano, un número o un valor nulo en el nivel superior necesita el mismo tratamiento que un valor anidado. Foundation documenta este comportamiento por separado en ReadingOptions.fragmentsAllowed y WritingOptions.fragmentsAllowed.

La función inversa acepta Any porque ese es el tipo de entrada de la API de Foundation, pero su dominio admitido sigue siendo el indicado en el contrato. Los errores de conversión se propagan al llamador. No sustituyas una conversión fallida por un objeto vacío ni elimines los elementos que no se puedan convertir; hacerlo modificaría el modelo silenciosamente.

¿Qué comprobaciones demuestran la compatibilidad?

Una prueba de ida y vuelta por sí sola puede pasar por alto el problema original: la codificación sintetizada del enum puede completar correctamente el viaje de ida y vuelta de su propia estructura con envoltorios. Comprueba tanto la igualdad del enum como la forma simple. La siguiente rutina crea un grafo independiente de Foundation, verifica el modelo de enum esperado, comprueba las claves reales del objeto raíz y prueba cada categoría de raíz.

func runExample() throws {
    let plain: [String: Any] = [
        "title": "Tile preview",
        "enabled": true,
        "spacing": 8.5,
        "subtitle": NSNull(),
        "tiles": [
            ["label": "Circle", "size": 24.0],
            ["label": "Square", "size": 32.0]
        ]
    ]

    let expected: PlainValue = .object([
        "title": .string("Tile preview"),
        "enabled": .bool(true),
        "spacing": .number(8.5),
        "subtitle": .null,
        "tiles": .array([
            .object([
                "label": .string("Circle"),
                "size": .number(24)
            ]),
            .object([
                "label": .string("Square"),
                "size": .number(32)
            ])
        ])
    ])

    let model = try plainValue(from: plain)
    precondition(model == expected)

    let output = try foundationObject(from: model)
    guard let object = output as? [String: Any] else {
        preconditionFailure("Expected a plain object root.")
    }
    precondition(Set(object.keys) == Set(plain.keys))
    precondition(object["title"] as? String == "Tile preview")
    precondition(object["subtitle"] is NSNull)
    precondition((object["tiles"] as? [Any])?.count == 2)

    let restored = try plainValue(from: output)
    precondition(restored == expected)

    let roots: [PlainValue] = [
        .null, .bool(false), .bool(true),
        .number(0), .number(1), .number(8.5),
        .string("1"), .array([]), .object([:]),
        expected
    ]
    for root in roots {
        let graph = try foundationObject(from: root)
        let decoded = try plainValue(from: graph)
        precondition(decoded == root)
    }

    do {
        _ = try foundationObject(from: .number(.infinity))
        preconditionFailure("Expected nonfinite number rejection.")
    } catch EncodingError.invalidValue(_, _) {
        // Expected: this model requires finite numbers.
    }

    print("All compatibility checks passed.")
}

try runExample()

El mensaje de éxito esperado es Todas las comprobaciones de compatibilidad se completaron correctamente. La comprobación de las claves raíz detecta si se ha añadido un envoltorio externo para el caso. El modelo esperado e independiente detecta una selección incorrecta de casos, incluida la confusión entre booleanos y números y entre texto que parece numérico y números. La comprobación del valor nulo confirma que subtitle sigue presente, mientras que las comprobaciones de la raíz abarcan los fragmentos escalares y distinguen los arreglos vacíos de los objetos vacíos.

Para un modelo de escritorio existente, adapta esta secuencia de diagnóstico a sus valores sintéticos: primero verifica el enum esperado, después inspecciona la forma simple, luego reconstruye el enum y, por último, prueba los casos límite. Asigna nombres a los resultados intermedios para que una comprobación fallida indique qué conversión cambió el valor.

¿Cuáles son los límites de este enfoque?

El contrato numérico es la principal limitación. Un único caso Double no puede garantizar la conservación exacta de todas las entradas numéricas de Foundation. Cuando sea importante convertir enteros con exactitud, valida que sean representables antes de incluirlos en este modelo o rediseña los casos numéricos y sus reglas de decodificación. Que la serialización termine correctamente no demuestra que los valores numéricos se hayan conservado con exactitud. La documentación de Double de Apple distingue las conversiones con redondeo de las conversiones exactas.

El ejemplo también rechaza explícitamente los números no finitos en ambas direcciones de codificación. Así, el límite del enum queda alineado con las restricciones documentadas de la representación simple de Foundation. La estructura recursiva no implica una capacidad de anidamiento ilimitada, así que mantén los árboles del modelo sintético en un tamaño razonable y deja que los errores de conversión lleguen a la funcionalidad que los llama.

Para la tarea de la vista previa del diseño, el resultado final es un enum recursivo tipado cuya codificación personalizada conserva cadenas simples, valores booleanos, números finitos, valores nulos, arreglos y objetos con claves de tipo cadena. Usa las comprobaciones de forma junto con las comprobaciones de ida y vuelta siempre que cambies los casos o la lógica de conversión; juntas hacen que la representación prevista sea concreta y fácil de revisar.

Fuentes