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.
| Kotlin | Swift | |
|---|---|---|
| Cómo se activa | @Serializable + plugin de Gradle + librería | : Codable (sin plugin ni dependencia) |
| Objeto → texto | Json.encodeToString(x) | JSONEncoder().encode(x) (devuelve Data) |
| Texto → objeto | json.decodeFromString<T>(texto) | JSONDecoder().decode(T.self, from: datos) |
Opcional que vale nil al escribir | no se escribe si tiene valor por defecto (= null) | no se escribe |
| Opcional que falta al leer | se lee como null solo si tiene valor por defecto | se lee como nil |
| Campos desconocidos al leer | error, salvo ignoreUnknownKeys = true | se ignoran |
Variantes de un sealed / enum | @SerialName + classDiscriminator | a 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.
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:
CodingKeyes 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: Stringhace que cada caso valga el texto de su nombre, así que.tipoes 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) oencoder.container(keyedBy: Claves.self)(al escribir), y con él lees o escribes un campo condecode(…, forKey: .tipo)oencode(…, forKey: .tipo).keyedBy:significa «con estas claves». init(from:)yencode(to:)son las dos funciones queCodablegenera solo y que aquí escribes tú: la primera construye el valor leyendo el contenedor y la segunda lo escribe en él. Ambasthrows, 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:
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 partidaLo que imprime:
[{"mensaje":"hola","tipo":"texto"},{"cantidad":5,"tipo":"minutos"}]
trueCada 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 0Para 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
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.
Los ficheros viven en ~/Marcador-Swift/Torneo.
- En el Project navigator (el panel de la izquierda de Xcode; se muestra u oculta con Cmd+1), despliega
Sources, clic derecho sobre la carpetaTorneo(la de dentro deSources) → New File from Template… → Swift File → Next, escribeGuardadoy pulsa Create. Si el menú de tu versión de Xcode se llama distinto, crea el fichero desde la Terminal, en~/Marcador-Swift/Torneo, contouch Sources/Torneo/Guardado.swift. - Comprueba que el fichero quedó dentro de
Sources/Torneo. - 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.ktde la versión de Kotlin. Intenta escribirlo tú:import Foundationy dos structs codificables y que se puedan comparar con==(Equatable):JugadorGuardado, con el nombre y el dorsal, que puede faltar; yEquipoGuardado, 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
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?
}- Compila: en la Terminal,
cd ~/Marcador-Swift/Torneoy luegoxcrun swift build. Debe terminar conBuild 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.
EventoDePartido.swift(Sources → Torneo): solo cambia la línea deColorDeTarjeta, que añadeCodablea sus protocolos.Guardado.swift: el bloque va debajo deEquipoGuardado.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
EventoDePartido.swift:
public enum ColorDeTarjeta: String, CaseIterable, Codable {Guardado.swift, el evento con tipo:
// 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 conBuild complete!.xcrun swift testtermina conTest 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:)yencode(to:), y por qué el JSON de Swift no lleva eldorsalde un jugador que no lo tiene. - Sabes decir por qué
torneo-clino veJugadorGuardado: esinternal.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Un
struct Jugador: Codabletienevar dorsal: Int?y valenil. ¿Qué escribeJSONEncoder?2.¿Por qué
EventoGuardadoescribe a manoinit(from:)yencode(to:)en lugar de adoptarCodablesin más?3.
struct JugadorGuardado: Codable, Equatable { … }está escrito sin ningún modificador de acceso. ¿Quién puede usarlo?4.Decodificas un JSON con un campo
arbitroque tustruct Marcador: Codableno tiene. ¿Qué haceJSONDecoder?5.En
init(from decoder:)de un enum escrito a mano, ¿qué esdecoder.container(keyedBy: Claves.self)?