Codable: guardar y cargar el torneo
Al terminar: Convertir el torneo a JSON, escribirlo en un fichero y volver a leerlo.
Al terminar, tu paquete Torneo sabrá convertir un torneo a JSON y volver a construirlo desde ese JSON: añadirás a Guardado.swift las conversiones largas y mecánicas (se pegan) y, desde main.swift, guardarás torneo (el de dos partidos, no la copa de tres) en un fichero, lo leerás de vuelta y comprobarás que es el mismo, con su líder de la tabla. En la lección siguiente, ese mismo código leerá el fichero que escribió tu versión de Kotlin.
Importa porque guardar y cargar es lo que hace que una app recuerde su trabajo al cerrarse, y porque el fichero que escribes hoy es el mismo formato que escribe la versión de Kotlin. Temas: pasar de objetos que se apuntan entre sí a un árbol de datos y volver, map con funciones que lanzan, Dictionary(uniqueKeysWithValues:) y leer y escribir un fichero.
Tres piezas para los pasos: decode con funciones que lanzan, diccionarios y ficheros
Los pasos de hoy usan tres cosas que no has escrito todavía: un map con una función que lanza, un diccionario creado desde pares y un fichero. Pruébalas aquí, con libros en vez de equipos:
guardados.map(libro(desde:)): amapse le pasa una función ya escrita, en vez de una closure, como.map(describir)en Closures. Si esa función puede lanzar, elmapllevatry.Dictionary(uniqueKeysWithValues: …): crea un diccionario desde pares(clave, valor); sirve para buscar por nombre de golpe. Las claves no pueden repetirse.- Una función dentro de otra (
buscar): solo existe mientras corre la de fuera, y por eso puede usar sus variables. - Un solo
trydelante de una expresión cubre todas las llamadas que lanzan dentro de ella, aunque sean dos:try a() + b().
import Foundation
struct LibroGuardado: Codable { var titulo: String; var paginas: Int }
struct Libro { let titulo: String; let paginas: Int }
enum ErrorDeLibro: Error { case sinPaginas(String), desconocido(String) }
// Una función que puede lanzar: convierte un guardado en un Libro del dominio.
func libro(desde guardado: LibroGuardado) throws -> Libro {
guard guardado.paginas > 0 else { throw ErrorDeLibro.sinPaginas(guardado.titulo) }
return Libro(titulo: guardado.titulo, paginas: guardado.paginas)
}
func paginasDe(_ titulo: String, en datos: Data) throws -> Int {
// Del JSON a los tipos guardados: [LibroGuardado].self dice qué construir.
let guardados = try JSONDecoder().decode([LibroGuardado].self, from: datos)
// map con el nombre de una función que lanza: el map también lleva try.
let libros = try guardados.map(libro(desde:))
// Pares (clave, valor) → diccionario: título → libro.
let porTitulo = Dictionary(uniqueKeysWithValues: libros.map { ($0.titulo, $0) })
// Función anidada: usa `porTitulo` sin recibirlo como parámetro.
func buscar(_ nombre: String) throws -> Libro {
guard let encontrado = porTitulo[nombre] else { throw ErrorDeLibro.desconocido(nombre) }
return encontrado
}
return try buscar(titulo).paginas
}
let datos = Data(#"[{"titulo":"Atlas","paginas":120},{"titulo":"Mapas","paginas":80}]"#.utf8)
// Un solo `try` cubre toda la expresión que tiene a su derecha: sirve para las dos llamadas.
let total = try paginasDe("Atlas", en: datos) + paginasDe("Mapas", en: datos)
print(total) // 200
do { _ = try paginasDe("Novela", en: datos) } catch { print(error) } // desconocido("Novela")Lo que imprime:
200
desconocido("Novela")Y para leer y escribir un fichero, con un texto cualquiera. Un fichero se escribe y se lee con Data, y se borra con FileManager; try? ignora el error si el fichero no existía:
import Foundation // URL, Data y FileManager vienen de aquí
do {
let datos = Data("hola".utf8) // unos bytes cualquiera
// Una dirección de fichero: aquí, dentro de la carpeta temporal del sistema.
let fichero = URL(fileURLWithPath: NSTemporaryDirectory() + "prueba.txt")
try datos.write(to: fichero) // escribe los bytes en el disco; puede fallar
let leidos = try Data(contentsOf: fichero) // los lee de vuelta
print(String(decoding: leidos, as: UTF8.self)) // hola
try? FileManager.default.removeItem(at: fichero) // borra; si no existiera, se ignora el error
print(FileManager.default.fileExists(atPath: fichero.path)) // false: ya no está
} catch {
print("No se pudo: \(error)")
}Imprime hola y false. (Es la carpeta temporal porque un Playground no siempre puede escribir en su propia carpeta; en main.swift usarás una dirección relativa a la carpeta donde ejecutas: URL(fileURLWithPath: "torneo-swift.json").)
Reto · Ejercicio: cargar préstamos de una biblioteca
En tu Playground, escribe un tipo codificable, PrestamoGuardado, con un título de texto y unos días enteros, y otro tipo, Prestamo, con los mismos dos datos, que es el del dominio. Escribe una función que convierta un guardado en un préstamo y que lance un error propio si los días son negativos (el error lleva el título). Escribe otra que reciba los datos de un JSON, lea la lista de guardados, convierta cada uno con la función anterior y devuelva un diccionario de préstamos por título. En un do/catch: escribe el texto [{"titulo":"Dune","dias":3},{"titulo":"Momo","dias":10}] en un fichero de la carpeta temporal, cárgalo, imprime los días de Momo, borra el fichero e imprime si sigue existiendo. Después intenta cargar [{"titulo":"Atlas","dias":-1}] e imprime el error. Lo que debe salir:
10
false
diasNegativos("Atlas")Para pensar: ¿por qué la llamada al map lleva try delante, y qué pasaría si dos préstamos tuvieran el mismo título?
Ver solución
import Foundation
struct PrestamoGuardado: Codable { var titulo: String; var dias: Int }
struct Prestamo { let titulo: String; let dias: Int }
enum ErrorDePrestamo: Error { case diasNegativos(String) }
func prestamo(desde guardado: PrestamoGuardado) throws -> Prestamo {
guard guardado.dias >= 0 else { throw ErrorDePrestamo.diasNegativos(guardado.titulo) }
return Prestamo(titulo: guardado.titulo, dias: guardado.dias)
}
func cargar(_ datos: Data) throws -> [String: Prestamo] {
let guardados = try JSONDecoder().decode([PrestamoGuardado].self, from: datos)
let prestamos = try guardados.map(prestamo(desde:))
return Dictionary(uniqueKeysWithValues: prestamos.map { ($0.titulo, $0) })
}
do {
let fichero = URL(fileURLWithPath: NSTemporaryDirectory() + "prestamos.json")
try Data(#"[{"titulo":"Dune","dias":3},{"titulo":"Momo","dias":10}]"#.utf8).write(to: fichero)
let porTitulo = try cargar(Data(contentsOf: fichero))
print(porTitulo["Momo"]?.dias ?? 0)
try? FileManager.default.removeItem(at: fichero)
print(FileManager.default.fileExists(atPath: fichero.path))
_ = try cargar(Data(#"[{"titulo":"Atlas","dias":-1}]"#.utf8))
} catch {
print(error)
}La respuesta: el map lleva try porque la función que le pasas lanza, y el map relanza el error. Con dos títulos iguales, Dictionary(uniqueKeysWithValues:) detendría el programa, porque exige claves únicas.
Paso 3 · De torneo a JSON
Objetivo. Los dos structs que faltan, la conversión del dominio a los tipos «guardados» y aJson(), que devuelve el torneo como JSON. Compila, y todavía no se llama.
Piezas nuevas de este paso:
private funcsuelta, fuera de cualquier tipo: solo se ve dentro de este fichero.equipos.map { guardado(de: $0) }: se transforma cada equipo del dominio en su gemelo «guardado».
Primero los dos structs, debajo de EventoGuardado. Intenta escribirlos tú: PartidoGuardado, con los nombres del local y del visitante y sus eventos guardados, y TorneoGuardado, con el nombre, los equipos guardados y los partidos guardados; los dos codificables y Equatable.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
struct PartidoGuardado: Codable, Equatable {
var local: String
var visitante: String
var eventos: [EventoGuardado]
}
struct TorneoGuardado: Codable, Equatable {
var nombre: String
var equipos: [EquipoGuardado]
var partidos: [PartidoGuardado]
}Las dos conversiones de dominio a guardado son largas y mecánicas (copiar cada dato a su gemelo): no enseñan nada nuevo, así que se pegan. Usa el botón de copiar del bloque y pégalo debajo de los structs:
// ---- Dominio -> guardado ----
private func guardado(de equipo: Equipo) -> EquipoGuardado {
EquipoGuardado(
nombre: equipo.nombre,
plantilla: equipo.plantilla.map { JugadorGuardado(nombre: $0.nombre, dorsal: $0.dorsal) },
capitan: equipo.capitan?.nombre
)
}
// `switch` exhaustivo sobre el evento del dominio: si aparece uno nuevo, esto no compila.
private func guardado(de evento: EventoDePartido) -> EventoGuardado {
switch evento {
case let .gol(minuto, jugador, equipo): .gol(minuto: minuto, jugador: jugador.nombre, equipo: equipo.nombre)
case let .tarjeta(minuto, jugador, color): .tarjeta(minuto: minuto, jugador: jugador.nombre, color: color)
case let .cambio(minuto, sale, entra): .cambio(minuto: minuto, sale: sale.nombre, entra: entra.nombre)
}
}Y aJson(), que sí escribes tú, debajo de lo anterior: una función pública de Torneo que puede fallar, convierte el torneo, sus equipos y sus partidos en sus gemelos guardados y los devuelve como JSON, con sangrías y con las claves siempre en el mismo orden (alfabético), para que el fichero no cambie entre ejecuciones.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
extension Torneo {
// `prettyPrinted`: sangrías y saltos de línea, para leer el fichero a ojo.
// `sortedKeys`: el orden de las claves no cambia entre ejecuciones (un
// diccionario de Swift no garantiza orden; sin esto el fichero variaría).
public func aJson() throws -> Data {
let torneoGuardado = TorneoGuardado(
nombre: nombre,
equipos: equipos.map { guardado(de: $0) },
partidos: partidos.map { partido in
PartidoGuardado(
local: partido.local.nombre,
visitante: partido.visitante.nombre,
eventos: partido.eventos.map { guardado(de: $0) }
)
}
)
let codificador = JSONEncoder()
codificador.outputFormatting = [.prettyPrinted, .sortedKeys]
return try codificador.encode(torneoGuardado)
}
}Compila con xcrun swift build: debe terminar con Build complete!.
Paso 4 · De JSON a torneo
Objetivo. ErrorDeCarga, las dos conversiones de guardado a dominio y torneoDesdeJson(_:), que lee un JSON y devuelve un Torneo. Compila, y todavía no se llama.
Piezas de este paso, ya vistas arriba: Dictionary(uniqueKeysWithValues: …) para buscar un equipo por su nombre, map con una función que lanza (equipo(desde:)) y funciones dentro de otra (buscar y jugador). Un try dentro de la closure de un map también obliga a poner try delante del map.
Primero el error de carga, debajo de TorneoGuardado. Intenta escribirlo tú: ErrorDeCarga, un error público, Equatable, con tres casos, cada uno con el nombre que falló: equipoInvalido, equipoDesconocido y jugadorDesconocido.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// Lo que puede salir mal al reconstruir el dominio a partir de un JSON que, aun
// siendo válido, no tiene sentido (un gol de un equipo que no existe…).
public enum ErrorDeCarga: Error, Equatable {
case equipoInvalido(String)
case equipoDesconocido(String)
case jugadorDesconocido(String)
}Las dos conversiones de guardado a dominio también se pegan; van debajo de aJson():
// ---- Guardado -> dominio ----
private func equipo(desde guardado: EquipoGuardado) throws -> Equipo {
let jugadores = guardado.plantilla.map { Jugador(nombre: $0.nombre, dorsal: $0.dorsal) }
guard let equipo = Equipo(nombre: guardado.nombre, plantilla: jugadores) else {
throw ErrorDeCarga.equipoInvalido(guardado.nombre)
}
if let nombreDelCapitan = guardado.capitan {
guard let capitan = jugadores.first(where: { $0.nombre == nombreDelCapitan }) else {
throw ErrorDeCarga.jugadorDesconocido(nombreDelCapitan)
}
equipo.nombrarCapitan(capitan)
}
return equipo
}
// Las referencias por nombre se resuelven aquí: si falta un equipo o un jugador,
// se lanza un `ErrorDeCarga`.
private func partido(desde guardado: PartidoGuardado, equipos: [String: Equipo]) throws -> Partido {
func buscar(_ nombre: String) throws -> Equipo {
guard let equipo = equipos[nombre] else { throw ErrorDeCarga.equipoDesconocido(nombre) }
return equipo
}
let local = try buscar(guardado.local)
let visitante = try buscar(guardado.visitante)
let jugadores = local.plantilla + visitante.plantilla
func jugador(_ nombre: String) throws -> Jugador {
guard let encontrado = jugadores.first(where: { $0.nombre == nombre }) else {
throw ErrorDeCarga.jugadorDesconocido(nombre)
}
return encontrado
}
let eventos: [EventoDePartido] = try guardado.eventos.map { evento in
switch evento {
case let .gol(minuto, nombre, equipo):
.gol(minuto: minuto, jugador: try jugador(nombre), equipo: try buscar(equipo))
case let .tarjeta(minuto, nombre, color):
.tarjeta(minuto: minuto, jugador: try jugador(nombre), color: color)
case let .cambio(minuto, sale, entra):
.cambio(minuto: minuto, sale: try jugador(sale), entra: try jugador(entra))
}
}
return Partido(local: local, visitante: visitante, eventos: eventos)
}Y torneoDesdeJson, que escribes tú, al final del fichero: una función pública que recibe los datos de un JSON sin etiqueta y puede fallar. Los lee como un TorneoGuardado, convierte sus equipos, crea el torneo con su nombre y esos equipos, y registra en él cada partido, buscando los equipos por su nombre. Devuelve el torneo.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// Leer un JSON puede fallar de muchas formas (texto roto, campo que falta,
// equipo que no existe…): la función `throws` y cada causa llega con su error
// (`DecodingError` de Foundation, `ErrorDeCarga` o `ErrorDeTorneo`).
public func torneoDesdeJson(_ datos: Data) throws -> Torneo {
let guardado = try JSONDecoder().decode(TorneoGuardado.self, from: datos)
let equipos = try guardado.equipos.map(equipo(desde:))
let porNombre = Dictionary(uniqueKeysWithValues: equipos.map { ($0.nombre, $0) })
let torneo = Torneo(nombre: guardado.nombre, equipos: equipos)
for partidoGuardado in guardado.partidos {
try torneo.registrar(partido(desde: partidoGuardado, equipos: porNombre))
}
return torneo
}En el for, un solo try cubre registrar y partido(desde:equipos:), porque va a la izquierda de toda la expresión. Compila con xcrun swift build: debe terminar con Build complete!.
Paso 5 · Pruébalo desde main
Objetivo. Guardar el torneo en un fichero, leerlo de vuelta y comprobar que es el mismo. Al ejecutar verás Cargado: Copa Barrio, 2 partidos, líder Rayo FC.
Piezas de este paso, ya vistas arriba: URL(fileURLWithPath:), datos.write(to:), Data(contentsOf:) y try? FileManager.default.removeItem(at:).
En main.swift (Sources → torneo-cli), import Foundation va en la primera línea, encima de import Torneo, y el bloque nuevo va al final del fichero. Intenta escribirlo tú: guarda el torneo como JSON en el fichero torneo-swift.json, léelo de vuelta, imprime su nombre, cuántos partidos tiene y el líder de la tabla, y borra el fichero. Si algo falla, «No se pudo guardar o cargar:» y el error. Lo que debes ver al final de la salida:
Cargado: Copa Barrio, 2 partidos, líder Rayo FCIntenta primero. Cuando termines, compara con la solución.Mostrar el código
main.swift, la primera línea:
import Foundationmain.swift, al final del fichero:
do {
let datos = try torneo.aJson()
let ficheroGuardado = URL(fileURLWithPath: "torneo-swift.json")
try datos.write(to: ficheroGuardado)
let cargado = try torneoDesdeJson(Data(contentsOf: ficheroGuardado))
print("Cargado: \(cargado.nombre), \(cargado.partidos.count) partidos, líder \(cargado.tablaDePosiciones().first?.equipo.nombre ?? "nadie")")
try? FileManager.default.removeItem(at: ficheroGuardado)
} catch {
print("No se pudo guardar o cargar: \(error)")
}Si algo no compila y no ves por qué, puedes sustituir tu fichero por el Guardado.swift completo, ya hecho. El botón copia el contenido al portapapeles y no escribe nada en tu proyecto:
- Abre
Guardado.swiftcon doble clic en el Project navigator. - Pulsa el botón de abajo.
- Haz clic dentro del fichero, selecciona todo con Cmd+A y pega con Cmd+V.
Compila y ejecuta, desde ~/Marcador-Swift/Torneo:
xcrun swift build
xcrun swift run torneo-cliLa última línea debe ser Cargado: Copa Barrio, 2 partidos, líder Rayo FC, y no debe quedar ningún torneo-swift.json en la carpeta (ls no lo lista).
Punto de control
xcrun swift build(desde~/Marcador-Swift/Torneo) termina sin errores.xcrun swift run torneo-clitermina conCargado: Copa Barrio, 2 partidos, líder Rayo FC, y no deja ningúntorneo-swift.jsonen la carpeta.xcrun swift testtermina conTest run with 78 tests in 10 suites passed: los mismos que en la lección anterior, porque hoy tampoco se añade ningún test. En la siguiente, los deCodableTestslos llevan a 89.- Sabes decir por qué se guarda con tipos «guardados» aparte, qué hace
torneoDesdeJsoncuando un equipo o un jugador del JSON no existe, y por qué eltrydelmapva delante.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Tienes
func equipo(desde guardado: EquipoGuardado) throws -> Equipo. ¿Cómo la usas conmapsobre un array deEquipoGuardado?2.
Dictionary(uniqueKeysWithValues: pares)recibe dos pares con la misma clave, por ejemplo dos equipos llamados"Toros". ¿Qué pasa?3.En
let total = try paginasDe("Atlas", en: datos) + paginasDe("Mapas", en: datos), las dos llamadas lanzan pero solo hay untry. ¿Es válido?4.Guardas el torneo y lo lees de vuelta en
main.swift. ¿Cuántos partidos tiene elTorneocargado?5.¿Para qué sirve
try? FileManager.default.removeItem(at: fichero)al final del bloque demain.swift?