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

Propiedades: calculadas, didSet y lazy

Al terminar: Usar propiedades calculadas, didSet y lazy.

Al terminar tendrás en el torneo de Swift dos cosas nuevas: una caché que guarda la tabla de posiciones y la borra sola cuando se registra un partido, y una foto del torneo que calcula esa tabla una sola vez, la primera vez que alguien la lee. Y verás en la consola cuántas veces se calculó.

Importa porque calcular la tabla recorre todos los partidos, y en una pantalla que se redibuja muchas veces no conviene repetir ese trabajo si nada ha cambiado. Para guardar un resultado y saber cuándo ya no vale, Swift tiene tres tipos de propiedad con nombre propio. Temas: propiedades calculadas, observadores (didSet y willSet) y propiedades lazy.

Tres formas de escribir una propiedad

Una propiedad es un dato que pertenece a un tipo, como los goles de un marcador. Las más comunes son las guardadas: tienen un sitio en la memoria donde está el valor, como los goles. Ya has escrito también algunas calculadas sin llamarlas así (diferencia, capitanONinguno, puntosPorDerrota, enEuros, esGrave, description); hoy les pones nombre. Y hay dos más: la que lleva un observador (didSet) y la perezosa (lazy). Cada una resuelve un problema distinto.

FormaGuarda un valorCuándo corre el códigoKotlin
Calculada: var x: Int { a - b }noen cada lecturaval x: Int get() = a - b
Con observador: var x = 0 { didSet { … } }sídespués de cada cambioDelegates.observable o un setter propio
Perezosa: lazy var x = …síen la primera lecturaval x by lazy { … }

Un ejemplo con las tres formas, con datos ajenos al torneo. Pruébalo en tu Playground:

ejemplo mínimo: propiedades (con comentarios)
struct Fila {
    var golesAFavor: Int
    var golesEnContra: Int
    // Calculada: no guarda nada; se evalúa en cada lectura.
    var diferencia: Int { golesAFavor - golesEnContra }
}
var fila = Fila(golesAFavor: 3, golesEnContra: 1)
print(fila.diferencia)        // 2
fila.golesEnContra = 5
print(fila.diferencia)        // -2: se recalcula sola
 
final class Contador {
    var goles = 0 {
        // Observador: corre DESPUÉS de cada cambio; `oldValue` es el valor anterior.
        didSet { print("de \(oldValue) a \(goles)") }
    }
    // Perezosa: el bloque corre en la primera lectura; los `()` del final lo llaman.
    lazy var resumen: String = {
        print("calculando")
        return "goles: \(goles)"
    }()
}
let contador = Contador()
contador.goles = 1            // de 0 a 1
contador.goles += 1           // de 1 a 2
print("antes")
print(contador.resumen)       // calculando, y luego goles: 2
print(contador.resumen)       // solo goles: 2

Lo que imprime:

2
-2
de 0 a 1
de 1 a 2
antes
calculando
goles: 2
goles: 2

Tres detalles:

  • lazy var x = expresión (sin el { … }() del ejemplo) también vale cuando el valor se calcula con una sola expresión, como la goleadores de la foto, más abajo; el { … }() solo hace falta si el cálculo lleva varias líneas.
  • willSet corre justo antes del cambio y trae newValue. didSet no corre durante el init del propio tipo.
  • lazy exige var, porque leerla por primera vez cambia la propiedad (de «sin calcular» a «calculada»). Se usa sobre todo en una class, como en el ejemplo de arriba y en la lección de hoy (struct frente a class explica la diferencia).

Una caché que se borra sola

Una caché es un resultado guardado para no repetir un cálculo. Tiene un problema: si el dato del que salió cambia, el resultado guardado ya no vale y hay que borrarlo. lazy no sirve para eso, porque calcula una vez y no se entera de los cambios. Para una caché que se puede invalidar se usa una propiedad opcional (vacía significa «hay que calcularlo») y un didSet que la vacía cada vez que cambia el dato.

El patrón, en una cesta de la compra:

ejemplo mínimo: una caché opcional que didSet invalida (con comentarios)
final class Cesta {
    // didSet borra el total guardado cada vez que cambian los precios.
    private(set) var precios: [Int] = [] {
        didSet { totalEnCache = nil }
    }
    private var totalEnCache: Int?                 // nil = «hay que calcularlo»
    private(set) var calculos = 0                  // para ver cuántas veces se suma de verdad
 
    func meter(_ precio: Int) { precios.append(precio) }
 
    func total() -> Int {
        if let guardado = totalEnCache { return guardado }   // ya estaba: no se recalcula
        calculos += 1
        let suma = precios.reduce(0, +)
        totalEnCache = suma                        // se guarda para la próxima vez
        return suma
    }
}
 
let cesta = Cesta()
cesta.meter(4)
cesta.meter(6)
print(cesta.total(), cesta.total(), cesta.calculos)   // 10 10 1: la segunda vez no sumó
cesta.meter(5)                                         // didSet vacía la caché
print(cesta.total(), cesta.calculos)                   // 15 2

Lo que imprime:

10 10 1
15 2

La primera total() suma y guarda; la segunda encuentra el resultado y no suma (los calculos se quedan en 1). Al meter un precio, didSet vacía la caché y la siguiente total() vuelve a sumar (2).

Reto · Ejercicio: un termómetro

En tu Playground, escribe una clase final, Termometro, con unos grados que empiezan en 20. Cada vez que cambian, imprime el cambio con el valor de antes y el de ahora. Tiene además unos grados Fahrenheit que se calculan en cada lectura (los grados por 9, entre 5, más 32, con enteros) y una ficha de texto, «Termómetro a N grados», que se calcula la primera vez que se lee e imprime «creando ficha» al hacerlo.

Crea un termómetro, pon los grados a 23 y luego a 25, imprime los Fahrenheit, lee la ficha dos veces, pon los grados a 30 y lee la ficha otra vez. Lo que debe salir:

Cambio: 20 -> 23
Cambio: 23 -> 25
77
creando ficha
Termómetro a 25 grados
Termómetro a 25 grados
Cambio: 25 -> 30
Termómetro a 25 grados

Para pensar: ¿por qué la última ficha sigue diciendo 25?

Ver solución
solución: un termómetro
final class Termometro {
    var grados = 20 {
        didSet { print("Cambio: \(oldValue) -> \(grados)") }
    }
    var enFahrenheit: Int { grados * 9 / 5 + 32 }
    lazy var ficha: String = {
        print("creando ficha")
        return "Termómetro a \(grados) grados"
    }()
}
 
let termometro = Termometro()
termometro.grados = 23
termometro.grados = 25
print(termometro.enFahrenheit)
print(termometro.ficha)
print(termometro.ficha)
termometro.grados = 30
print(termometro.ficha)

La respuesta: lazy calcula una vez y guarda; no se entera de los cambios posteriores. Por eso Torneo usa didSet para invalidar su caché, y lazy solo vale en la foto, cuyos partidos ya no cambian.

Paso 1 · La foto del torneo y la caché

Objetivo. InstantaneaDelTorneo calcula la tabla una sola vez con lazy var, y Torneo guarda la suya con didSet para invalidarla cuando cambian los partidos. Compila, y todavía no se imprime.

Torneo
Sources
Torneo
Tabla.swiftse completa
Torneo.swiftse modifica
Lo que tocas en este paso: un fichero que ya creaste y otro que cambia, en el Project navigator de Xcode con el paquete Torneo abierto.

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

Piezas nuevas de este paso:

  • InstantaneaDelTorneo es una «foto» del torneo: sus partidos ya no cambian, así que aquí sí conviene calcular la tabla una sola vez con lazy var. calculosDeLaTabla solo sirve para que las pruebas vean cuántas veces se calculó.
  • didSet en partidos borra la tabla guardada (tablaEnCache) tras cada cambio.

Intenta escribirlo tú. Los tests de más abajo exigen estos nombres:

  • InstantaneaDelTorneo, una clase pública de la que nadie hereda, con sus partidos, equipos y reglamento; calculosDeLaTabla, que se lee desde fuera pero solo ella cambia y cuenta cuántas veces se calculó la tabla; y tabla y goleadores, que se calculan la primera vez que se leen y se guardan. Solo se crea desde dentro del paquete.
  • En Torneo: cada cambio de partidos borra la tabla guardada, tablaEnCache (privada; vacía significa «hay que calcularla»); cantidadDePartidos; tablaDePosiciones, que calcula la tabla la primera vez y la reutiliza hasta el siguiente partido; e instantanea, que devuelve la foto del torneo.

Qué se sustituye y dónde.

  1. Tabla.swift: el primer bloque va debajo de la extensión de Array.
  2. Torneo.swift (Sources → Torneo): la línea public private(set) var partidos: [Partido] = [] se sustituye por el bloque con didSet y tablaEnCache.
  3. Torneo.swift, justo debajo del init: cantidadDePartidos se añade, y la función tablaDePosiciones() de una sola línea que escribiste en la lección anterior se sustituye por la que guarda su resultado.
  4. Torneo.swift: la extension Torneo va al final, después de la llave que cierra la clase.

El resto no cambia.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código

Tabla.swift, debajo de la extensión:

Torneo/Sources/Torneo/Tabla.swift
// Una «foto» del torneo en un momento dado: sus partidos ya no cambian. Por eso
// aquí SÍ tiene sentido calcular la tabla una sola vez y guardarla.
//
// `lazy var`: una propiedad guardada cuyo valor se calcula la PRIMERA vez que se
// lee (el `by lazy` de Kotlin). Hace falta `var` porque leerla por primera vez
// la modifica, y aquí va en una clase.
public final class InstantaneaDelTorneo {
    public let partidos: [Partido]
    public let equipos: [Equipo]
    public let reglamento: any Reglamento
 
    // Solo para que las pruebas vean cuántas veces se calculó de verdad.
    public private(set) var calculosDeLaTabla = 0
 
    public lazy var tabla: [FilaDePosicion] = {
        calculosDeLaTabla += 1
        return partidos.tablaDePosiciones(equipos: equipos, reglamento: reglamento)
    }()
 
    public lazy var goleadores: Ranking<Jugador> = partidos.goleadores()
 
    init(partidos: [Partido], equipos: [Equipo], reglamento: any Reglamento) {
        self.partidos = partidos
        self.equipos = equipos
        self.reglamento = reglamento
    }
}

Torneo.swift, el bloque que sustituye a la línea de partidos (didSet invalida la tabla guardada tras cada cambio):

Torneo/Sources/Torneo/Torneo.swift
    // `didSet` corre DESPUÉS de cada cambio de la propiedad: aquí invalida la
    // tabla guardada, que ya no vale. (`willSet` correría antes.)
    public private(set) var partidos: [Partido] = [] {
        didSet { tablaEnCache = nil }
    }
 
    private var tablaEnCache: [FilaDePosicion]?

Torneo.swift, justo debajo del init, sustituyendo a la tablaDePosiciones() de una línea (cantidadDePartidos es calculada; tablaDePosiciones() calcula la primera vez y reutiliza la tabla hasta el siguiente partido):

Torneo/Sources/Torneo/Torneo.swift
    // Propiedad calculada de solo lectura, sin campo propio.
    public var cantidadDePartidos: Int { partidos.count }
 
    // La tabla se calcula la primera vez y se reutiliza hasta el siguiente partido.
    public func tablaDePosiciones() -> [FilaDePosicion] {
        if let guardada = tablaEnCache { return guardada }
        let tabla = partidos.tablaDePosiciones(equipos: equipos, reglamento: reglamento)
        tablaEnCache = tabla
        return tabla
    }

Torneo.swift, al final del fichero, después de la llave que cierra la clase (los partidos son valores, así que la foto no cambia con lo que se registre después):

Torneo/Sources/Torneo/Torneo.swift
extension Torneo {
    // Los partidos son valores, así que la foto no se ve afectada por lo que se
    // registre después.
    public func instantanea() -> InstantaneaDelTorneo {
        InstantaneaDelTorneo(partidos: partidos, equipos: equipos, reglamento: reglamento)
    }
}

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

Paso 2 · Imprimir la foto

Objetivo. Ver en la consola cuántas veces se calcula la tabla de la foto. Al terminar, xcrun swift run torneo-cli muestra tres líneas más debajo de la tabla de Rayo FC, Toros y Lobos.

En main.swift (Sources → torneo-cli) el bloque nuevo va en medio de dos líneas que ya tienes, y por eso el bloque de abajo las trae: la primera (print(copa.tablaComoTexto()), la que imprime la tabla en la lección anterior) y la última (let torneo = Torneo(…), de registrar lanza errores: throws tipado y Result) no se escriben, solo marcan dónde empieza y dónde acaba lo nuevo. Pega, o escribe, lo que hay entre ellas. Si lo escribes tú: saca la foto de la copa; imprime cuántas veces se calculó su tabla, el líder, y el líder otra vez con las veces que se calculó. Lo que debes ver, debajo de la tabla:

Tabla calculada 0 veces antes de leerla
Líder: Rayo FC
Líder otra vez: Rayo FC (calculada 1 vez)
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Torneo/Sources/torneo-cli/main.swift
print(copa.tablaComoTexto())
let foto = copa.instantanea()
print("Tabla calculada \(foto.calculosDeLaTabla) veces antes de leerla")
print("Líder: \(foto.tabla.first?.equipo.nombre ?? "nadie")")
print("Líder otra vez: \(foto.tabla.first?.equipo.nombre ?? "nadie") (calculada \(foto.calculosDeLaTabla) vez)")
 
let torneo = Torneo(nombre: nombreDelTorneo, equipos: [rayo, toros, lobos])

La foto calcula la tabla en la primera lectura (foto.tabla) y en la segunda reutiliza la guardada: por eso las veces calculadas pasan de 0 a 1 y se quedan en 1.

Paso 3 · Los tests

TablaTests.swift comprueba el orden, las columnas, el desempate, el didSet, el lazy y el texto de la tabla, sobre código que acabas de escribir. No enseña nada nuevo de Swift Testing, así que se copia, con el mismo orden de siempre: crear el fichero, pulsar el botón, seleccionar todo, pegar.

  1. En el Project navigator (el panel de la izquierda de Xcode; se muestra u oculta con Cmd+1), clic derecho sobre Tests → TorneoTests → New File from Template… → Swift File → Next, escribe TablaTests y pulsa Create. (Con la Terminal: touch Tests/TorneoTests/TablaTests.swift.)
  2. Pulsa el botón de abajo. Copia el contenido al portapapeles y no escribe nada en tu proyecto.
  3. Haz clic dentro de TablaTests.swift, selecciona todo con Cmd+A y pega con Cmd+V: sustituye lo que puso Xcode.
TablaTests.swift

Ahora compila, prueba y ejecuta, desde ~/Marcador-Swift/Torneo:

xcrun swift build
xcrun swift test
xcrun swift run torneo-cli

Punto de control

  • xcrun swift build (desde ~/Marcador-Swift/Torneo) termina sin errores.
  • xcrun swift run torneo-cli imprime, entre otras, estas líneas:
Pos Equipo    PJ  G  E  P  GF GC  DG Pts
 1  Rayo FC   2  2  0  0   3  1   2   6
 2  Toros     2  1  0  1   2  2   0   3
 3  Lobos     2  0  0  2   0  2  -2   0
Tabla calculada 0 veces antes de leerla
Líder: Rayo FC
Líder otra vez: Rayo FC (calculada 1 vez)
  • xcrun swift test termina con Test run with 78 tests in 10 suites passed: 9 más que en la lección anterior (69), los de TablaTests.
  • Provócalo: en Torneo.swift, deja vacío el didSet de partidos (didSet { }) y ejecuta xcrun swift test --filter didSetInvalidaLaTablaGuardada. La prueba falla con Expectation failed: sin el didSet, la tabla guardada no se borra al registrar un partido y sigue enseñando la de antes. Devuelve didSet { tablaEnCache = nil } y comprueba que pasa otra vez.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.En struct Fila { var a: Int; var b: Int; var diferencia: Int { a - b } }, ¿cuándo se calcula diferencia?

  2. 2.¿Por qué lazy se declara siempre con var y nunca con let?

  3. 3.Tienes var goles = 0 { didSet { print("de \(oldValue) a \(goles)") } } y escribes goles = 3 cuando valía 1. ¿Qué imprime?

  4. 4.Una propiedad lazy var ficha ya se leyó con los grados en 25, y después los grados pasan a 30. ¿Qué devuelve la siguiente lectura de ficha?

  5. 5.En Torneo, ¿para qué sirve el didSet { tablaEnCache = nil } de la propiedad partidos?

Faltan 5 de 5