Saltar al contenido
K
Swift: el lenguajePaso a paso30 min de lectura · 10 min de máquina Básico

Codable: el enum con tipo

Al terminar: Leer con Codable el JSON que escribió tu versión de Kotlin.

Al terminar, tu paquete Torneo tendrá Guardado.swift con tres tipos que se convierten a JSON: JugadorGuardado, EquipoGuardado y EventoGuardado, un enum con valores asociados cuyo init(from:) y encode(to:) escribes tú, con el campo tipo que usa también la versión de Kotlin. Todavía no guarda ningún torneo: eso es la lección siguiente, Codable: guardar y cargar el torneo.

Importa porque un formato de datos es un contrato: si dos programas lo entienden, cualquiera puede sustituir al otro. El JSON de Marcador es ese contrato entre la versión de Kotlin y la de Swift. Temas: Codable, escribir init(from:) y encode(to:) a mano para un enum con valores asociados y el control de acceso (internal frente a public).

Codable: dos protocolos en uno

Un protocolo dice qué sabe hacer un tipo (Protocolos y extensiones). Encodable significa «sé convertirme a un formato externo» y Decodable, «sé construirme desde uno». Codable es las dos cosas juntas.

Si quieres leer más sobre Codable, está en la documentación de Apple.

KotlinSwift
Cómo se activa@Serializable + plugin de Gradle + librería: Codable (sin plugin ni dependencia)
Objeto → textoJson.encodeToString(x)JSONEncoder().encode(x) (devuelve Data)
Texto → objetojson.decodeFromString<T>(texto)JSONDecoder().decode(T.self, from: datos)
Opcional que vale nil al escribirno se escribe si tiene valor por defecto (= null)no se escribe
Opcional que falta al leerse lee como null solo si tiene valor por defectose lee como nil
Campos desconocidos al leererror, salvo ignoreUnknownKeys = truese ignoran
Variantes de un sealed / enum@SerialName + classDiscriminatora mano: init(from:) y encode(to:)

JSONEncoder y JSONDecoder viven en Foundation, la librería base de Apple (fechas, ficheros, Data, URL, Bundle…). Hay que escribir import Foundation para usarlos. Data es un bloque de bytes: es lo que produce el codificador y lo que consume el decodificador.

ejemplo mínimo: Codable (con comentarios)
import Foundation
 
struct Jugador: Codable { var nombre: String; var dorsal: Int? }
 
let codificador = JSONEncoder()
// Un conjunto de opciones: `.sortedKeys` escribe las claves siempre en el mismo orden.
codificador.outputFormatting = [.sortedKeys]
 
let datos = try codificador.encode([Jugador(nombre: "Ana", dorsal: 9), Jugador(nombre: "Luis", dorsal: nil)])
// `String(decoding: datos, as: UTF8.self)` convierte los bytes (`Data`) en texto.
print(String(decoding: datos, as: UTF8.self))
// [{"dorsal":9,"nombre":"Ana"},{"nombre":"Luis"}]   ← Luis no lleva "dorsal"
 
// `#"…"#` es una cadena en bruto: las comillas del JSON no hay que escaparlas.
let texto = #"[{"nombre":"Marta","dorsal":1,"extra":true},{"nombre":"Iván"}]"#
// `[Jugador].self` es el tipo mismo como valor: le dice al decodificador qué construir
// (en Kotlin, el `<T>` de `decodeFromString<T>`). `Data(texto.utf8)`: de texto a bytes.
let leidos = try JSONDecoder().decode([Jugador].self, from: Data(texto.utf8))
// `\.dorsal` es una ruta de propiedad (key path): «de cada jugador, su dorsal».
// Equivale a la closure `{ $0.dorsal }` de Closures.
print(leidos.map(\.dorsal))                       // [Optional(1), nil]

Lo que imprime:

[{"dorsal":9,"nombre":"Ana"},{"nombre":"Luis"}]
[Optional(1), nil]

Ves tres cosas: el nil no se escribe (igual que en Kotlin con = null, sin configurar nada); un campo que sobra en el JSON (extra) se ignora; y un campo opcional que falta se lee como nil. La ruta de propiedad \.dorsal es la de Colecciones y key paths.

El enum con valores asociados: init(from:) y encode(to:)

Con un enum con valores asociados, Codable a secas escribe otra forma. Con enum Evento: Codable { case gol(minuto: Int, jugador: String) }, el caso .gol(minuto: 12, jugador: "Ana") sale así:

{"gol":{"jugador":"Ana","minuto":12}}

Kotlin escribe { "tipo": "gol", "minuto": 12, "jugador": "Ana", … }: un campo tipo al lado de los datos. Para leer y escribir esa forma se escriben dos funciones a mano. Antes de verlas, tres piezas que aparecen en ellas:

  • CodingKey es el protocolo de los nombres de los campos del JSON. Se usa con un enum pequeño cuyos casos son esos nombres: enum Claves: String, CodingKey { case tipo, mensaje }. El : String hace que cada caso valga el texto de su nombre, así que .tipo es el campo "tipo" del JSON.
  • El contenedor (container) es el objeto JSON visto por dentro: lo pides con decoder.container(keyedBy: Claves.self) (al leer) o encoder.container(keyedBy: Claves.self) (al escribir), y con él lees o escribes un campo con decode(…, forKey: .tipo) o encode(…, forKey: .tipo). keyedBy: significa «con estas claves».
  • init(from:) y encode(to:) son las dos funciones que Codable genera solo y que aquí escribes tú: la primera construye el valor leyendo el contenedor y la segunda lo escribe en él. Ambas throws, porque leer o escribir puede fallar.

Este es un enum de dos casos, completo, con datos ajenos al torneo. Lleva también Equatable, el protocolo que permite comparar con ==; el último print lo usa. Pruébalo en tu Playground:

ejemplo mínimo: un enum con tipo, a mano (con comentarios)
import Foundation
 
enum Aviso: Codable, Equatable {
    case texto(String)
    case minutos(Int)
 
    // `CodingKey`: los nombres de los campos del JSON.
    private enum Claves: String, CodingKey { case tipo, mensaje, cantidad }
 
    init(from decoder: any Decoder) throws {
        // El contenedor (container) es el objeto JSON visto por dentro.
        let contenedor = try decoder.container(keyedBy: Claves.self)
        // Se lee `tipo` primero y un `switch` elige qué otros campos leer.
        // `decode` falla si el campo no está o no es del tipo pedido.
        let tipo = try contenedor.decode(String.self, forKey: .tipo)
        switch tipo {
        case "texto":
            // En `init(from:)` se asigna a `self` el caso ya construido.
            self = .texto(try contenedor.decode(String.self, forKey: .mensaje))
        case "minutos":
            self = .minutos(try contenedor.decode(Int.self, forKey: .cantidad))
        default:
            // Un tipo desconocido lanza un `DecodingError`.
            throw DecodingError.dataCorruptedError(
                forKey: .tipo, in: contenedor, debugDescription: "Tipo de aviso desconocido: \(tipo)"
            )
        }
    }
 
    func encode(to encoder: any Encoder) throws {
        // Aquí el contenedor es `var`, porque cada `encode` lo modifica.
        var contenedor = encoder.container(keyedBy: Claves.self)
        switch self {
        case let .texto(mensaje):
            try contenedor.encode("texto", forKey: .tipo)
            try contenedor.encode(mensaje, forKey: .mensaje)
        case let .minutos(cantidad):
            try contenedor.encode("minutos", forKey: .tipo)
            try contenedor.encode(cantidad, forKey: .cantidad)
        }
    }
}
 
// Ida: dos avisos a JSON. Vuelta: ese JSON otra vez a avisos.
let codificador = JSONEncoder()
codificador.outputFormatting = [.sortedKeys]
let avisos: [Aviso] = [.texto("hola"), .minutos(5)]
let datos = try codificador.encode(avisos)
print(String(decoding: datos, as: UTF8.self))
let leidos = try JSONDecoder().decode([Aviso].self, from: datos)
print(leidos == avisos)    // true: el aviso leído es igual al de partida

Lo que imprime:

[{"mensaje":"hola","tipo":"texto"},{"cantidad":5,"tipo":"minutos"}]
true

Cada aviso lleva su campo tipo y, al lado, los datos de su caso. EventoGuardado, de Marcador, es exactamente esto con tres casos (gol, tarjeta, cambio).

Reto · Ejercicio: un marcador con penaltis que pueden faltar

En tu Playground, escribe un tipo, Marcador, que se pueda pasar a JSON y leer de JSON, con un local y un visitante de texto y unos penaltis enteros que pueden no estar. Codifica Rayo FC contra Toros sin penaltis, y Lobos contra Toros con 4, con las claves en orden alfabético, e imprime los dos textos. Luego lee el JSON {"local":"Rayo FC","visitante":"Toros","arbitro":"Marta"} e imprime el local y los penaltis (0 si no hay). Lo que debe salir:

{"local":"Rayo FC","visitante":"Toros"}
{"local":"Lobos","penaltis":4,"visitante":"Toros"}
Rayo FC 0

Para pensar: ¿qué pasó con los penaltis en la primera línea, y por qué no falla el campo arbitro, que tu tipo no tiene?

Ver solución
solución: marcador con penaltis
import Foundation
 
struct Marcador: Codable {
    var local: String
    var visitante: String
    var penaltis: Int?
}
 
let codificador = JSONEncoder()
codificador.outputFormatting = [.sortedKeys]
let sinPenaltis = Marcador(local: "Rayo FC", visitante: "Toros", penaltis: nil)
let conPenaltis = Marcador(local: "Lobos", visitante: "Toros", penaltis: 4)
print(String(decoding: try codificador.encode(sinPenaltis), as: UTF8.self))
print(String(decoding: try codificador.encode(conPenaltis), as: UTF8.self))
 
let texto = #"{"local":"Rayo FC","visitante":"Toros","arbitro":"Marta"}"#
let leido = try JSONDecoder().decode(Marcador.self, from: Data(texto.utf8))
print(leido.local, leido.penaltis ?? 0)

La respuesta: un opcional que vale nil no se escribe, y por eso la primera línea no tiene penaltis. Y JSONDecoder ignora los campos que su tipo no conoce, como arbitro; con kotlinx.serialization sí falla, salvo que actives ignoreUnknownKeys = true.

Paso 1 · Crea Guardado.swift

Objetivo. Un fichero nuevo en tu paquete Torneo con los dos primeros tipos que se convertirán a JSON. Compila, y todavía no lo usa nadie.

Torneo
Sources
Torneo
Guardado.swiftse crea
Lo que tocas ahora, en el Project navigator de Xcode con el paquete Torneo abierto.

Los ficheros viven en ~/Marcador-Swift/Torneo.

  1. En el Project navigator (el panel de la izquierda de Xcode; se muestra u oculta con Cmd+1), despliega Sources, clic derecho sobre la carpeta Torneo (la de dentro de Sources) → New File from Template… → Swift File → Next, escribe Guardado y pulsa Create. Si el menú de tu versión de Xcode se llama distinto, crea el fichero desde la Terminal, en ~/Marcador-Swift/Torneo, con touch Sources/Torneo/Guardado.swift.
  2. Comprueba que el fichero quedó dentro de Sources/Torneo.
  3. Escríbelo. El JSON habla de árboles de datos, no de objetos que se apuntan unos a otros (un gol apunta a un equipo), así que el fichero usa unos tipos «guardados» aparte, donde cada equipo o jugador se nombra por su nombre, y el dominio se reconstruye al cargar. Es la misma idea y el mismo formato que Guardado.kt de la versión de Kotlin. Intenta escribirlo tú: import Foundation y dos structs codificables y que se puedan comparar con == (Equatable): JugadorGuardado, con el nombre y el dorsal, que puede faltar; y EquipoGuardado, con el nombre, la plantilla de jugadores guardados y el capitán, que puede faltar. No llevan modificador de acceso.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Torneo/Sources/Torneo/Guardado.swift
import Foundation
 
// El JSON habla de árboles de datos, no de objetos con referencias (un Gol
// apunta a un Equipo). Por eso el fichero usa unos tipos «guardados» aparte,
// donde cada equipo o jugador se nombra por su nombre, y el dominio se
// reconstruye al cargar. Es EXACTAMENTE la misma idea (y el mismo formato) que
// `Guardado.kt` de la versión Kotlin: el fichero se puede leer desde las dos.
//
// `Codable` = `Encodable` + `Decodable`. Con solo escribirlo, el compilador
// genera el código que convierte el struct a JSON y de vuelta (en Kotlin lo
// hace el plugin de kotlinx.serialization al ver `@Serializable`). Los
// opcionales que valen `nil` NO se escriben, igual que en Kotlin.
//
// CONTROL DE ACCESO: estos tipos no llevan `public`, así que son `internal`:
// solo se ven dentro del módulo Torneo. Lo público es el par de funciones de abajo.
 
struct JugadorGuardado: Codable, Equatable {
    var nombre: String
    var dorsal: Int?
}
 
struct EquipoGuardado: Codable, Equatable {
    var nombre: String
    var plantilla: [JugadorGuardado]
    var capitan: String?
}
  1. Compila: en la Terminal, cd ~/Marcador-Swift/Torneo y luego xcrun swift build. Debe terminar con Build complete!. En Xcode, el mismo resultado es Product → Build (Cmd+B).

El control de acceso es el nivel por defecto de Swift, internal: lo que no lleva public se ve solo dentro del módulo Torneo. Por eso torneo-cli no verá JugadorGuardado, y solo las dos funciones públicas de la lección siguiente (aJson() y torneoDesdeJson(_:)) cruzarán esa frontera. En Kotlin el nivel por defecto es public.

Paso 2 · El evento con tipo

Objetivo. Escribir en Guardado.swift EventoGuardado, el enum con valores asociados que se codifica con un campo tipo, igual que lo escribe Kotlin. Para eso, ColorDeTarjeta pasa a Codable. Compila, y todavía no lo usa nadie.

ColorDeTarjeta pasa a Codable. Viste en Enums con valores asociados que su valor bruto (raw value) es el texto "AMARILLA". Codable usa ese valor: un enum con valores brutos se escribe y se lee por él, sin código extra, igual que lo escribe Kotlin. Hay que hacerlo antes de EventoGuardado, que guarda un color: sin ello el paquete no compila (requires that 'ColorDeTarjeta' conform to 'Decodable').

Intenta escribirlo tú. EventoGuardado es lo que enseña la lección: un enum, también Equatable, que se codifica a mano, como Aviso, con tres casos que guardan a las personas y los equipos por su nombre: un gol (minuto, jugador y equipo), una tarjeta (minuto, jugador y color) y un cambio (minuto, quién sale y quién entra). En el JSON, cada evento lleva un campo tipo con el texto gol, tarjeta o cambio, el minuto, y los campos de su caso con esos mismos nombres (jugador, equipo, color, sale, entra). Un tipo que no conoce falla con «Tipo de evento desconocido:» y el tipo. Cuando el torneo se guarde con aJson() (en la lección siguiente), un gol saldrá así:

{
  "equipo" : "Rayo FC",
  "jugador" : "Ana",
  "minuto" : 12,
  "tipo" : "gol"
}

Qué se sustituye y dónde.

  1. EventoDePartido.swift (Sources → Torneo): solo cambia la línea de ColorDeTarjeta, que añade Codable a sus protocolos.
  2. Guardado.swift: el bloque va debajo de EquipoGuardado.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código

EventoDePartido.swift:

Torneo/Sources/Torneo/EventoDePartido.swift
public enum ColorDeTarjeta: String, CaseIterable, Codable {

Guardado.swift, el evento con tipo:

Torneo/Sources/Torneo/Guardado.swift
// Un enum con valores asociados NO se puede codificar solo con `Codable` de la
// manera que espera Kotlin (un campo «tipo» y los datos al lado). Hay que
// escribir `init(from:)` y `encode(to:)` a mano: leer primero `tipo` y, según
// su valor, los demás campos.
enum EventoGuardado: Codable, Equatable {
    case gol(minuto: Int, jugador: String, equipo: String)
    case tarjeta(minuto: Int, jugador: String, color: ColorDeTarjeta)
    case cambio(minuto: Int, sale: String, entra: String)
 
    // `CodingKey`: los nombres de los campos del JSON.
    private enum Claves: String, CodingKey {
        case tipo, minuto, jugador, equipo, color, sale, entra
    }
 
    init(from decoder: any Decoder) throws {
        let contenedor = try decoder.container(keyedBy: Claves.self)
        let minuto = try contenedor.decode(Int.self, forKey: .minuto)
        let tipo = try contenedor.decode(String.self, forKey: .tipo)
        switch tipo {
        case "gol":
            self = .gol(
                minuto: minuto,
                jugador: try contenedor.decode(String.self, forKey: .jugador),
                equipo: try contenedor.decode(String.self, forKey: .equipo)
            )
        case "tarjeta":
            self = .tarjeta(
                minuto: minuto,
                jugador: try contenedor.decode(String.self, forKey: .jugador),
                color: try contenedor.decode(ColorDeTarjeta.self, forKey: .color)
            )
        case "cambio":
            self = .cambio(
                minuto: minuto,
                sale: try contenedor.decode(String.self, forKey: .sale),
                entra: try contenedor.decode(String.self, forKey: .entra)
            )
        default:
            throw DecodingError.dataCorruptedError(
                forKey: .tipo, in: contenedor, debugDescription: "Tipo de evento desconocido: \(tipo)"
            )
        }
    }
 
    func encode(to encoder: any Encoder) throws {
        var contenedor = encoder.container(keyedBy: Claves.self)
        switch self {
        case let .gol(minuto, jugador, equipo):
            try contenedor.encode("gol", forKey: .tipo)
            try contenedor.encode(minuto, forKey: .minuto)
            try contenedor.encode(jugador, forKey: .jugador)
            try contenedor.encode(equipo, forKey: .equipo)
        case let .tarjeta(minuto, jugador, color):
            try contenedor.encode("tarjeta", forKey: .tipo)
            try contenedor.encode(minuto, forKey: .minuto)
            try contenedor.encode(jugador, forKey: .jugador)
            try contenedor.encode(color, forKey: .color)
        case let .cambio(minuto, sale, entra):
            try contenedor.encode("cambio", forKey: .tipo)
            try contenedor.encode(minuto, forKey: .minuto)
            try contenedor.encode(sale, forKey: .sale)
            try contenedor.encode(entra, forKey: .entra)
        }
    }
}

Compila: xcrun swift build desde ~/Marcador-Swift/Torneo debe terminar con Build complete!.

Punto de control

  • xcrun swift build (desde ~/Marcador-Swift/Torneo) termina con Build complete!.
  • xcrun swift test termina con Test run with 78 tests in 10 suites passed: los mismos que en la lección anterior, porque hoy no se añade ningún test.
  • Sabes decir por qué un enum con valores asociados necesita init(from:) y encode(to:), y por qué el JSON de Swift no lleva el dorsal de un jugador que no lo tiene.
  • Sabes decir por qué torneo-cli no ve JugadorGuardado: es internal.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Un struct Jugador: Codable tiene var dorsal: Int? y vale nil. ¿Qué escribe JSONEncoder?

  2. 2.¿Por qué EventoGuardado escribe a mano init(from:) y encode(to:) en lugar de adoptar Codable sin más?

  3. 3.struct JugadorGuardado: Codable, Equatable { … } está escrito sin ningún modificador de acceso. ¿Quién puede usarlo?

  4. 4.Decodificas un JSON con un campo arbitro que tu struct Marcador: Codable no tiene. ¿Qué hace JSONDecoder?

  5. 5.En init(from decoder:) de un enum escrito a mano, ¿qué es decoder.container(keyedBy: Claves.self)?

Faltan 5 de 5