Saltar al contenido
K
Swift: memoria y concurrenciaPaso a paso35 min de lectura · 10 min de máquina Básico

El marcador gol a gol

Al terminar: Emitir los goles de un partido como AsyncStream y recorrerlos.

Al terminar, el partido en vivo sabrá decir su marcador gol a gol: tendrás un struct Marcador, y PartidoEnVivo.marcadores será un stream que entrega 0-0, 1-0, 1-1 y 2-1 según van cayendo los goles. También tendrás ocho tests nuevos, entre ellos uno que comprueba que cancelar al consumidor para al productor.

Importa porque casi nunca quieres los eventos tal cual: quieres un estado que va cambiando con ellos, como un marcador, un saldo o un total. Y la librería estándar de Swift no trae una pieza que lo haga por ti, así que aprenderás a escribir un stream que lee de otro.

Temas: un stream que consume otro, Sendable, CustomStringConvertible y print sin salto de línea.

Un stream que lee de otro

Quieres un flujo con el marcador después de cada gol. Los datos ya los tienes: eventos entrega cada gol, tarjeta y cambio. Falta acumular: mantener el marcador de antes, sumarle el evento nuevo y entregar el resultado. En Kotlin eso era scan, un operador que guarda un valor y lo va actualizando con cada elemento. Swift no lo trae en su librería estándar (existe en un paquete aparte, swift-async-algorithms, que este curso no usa), así que se escribe a mano.

Escribirlo a mano es menos raro de lo que parece, porque ya tienes todas las piezas de la lección anterior. Un stream nuevo, AsyncStream { … }, cuyo productor es una Task que recorre el otro stream con for await, va actualizando una variable y entrega cada valor con yield. Dos detalles:

  • El stream de origen se crea fuera del AsyncStream { … }, y la closure lo captura como una constante. Así la tarea guarda solo ese stream y no el objeto que lo fabrica (en PartidoEnVivo, el propio partido): si lo pidiera dentro de la tarea, tendría que capturar self.
  • El productor sigue siendo una Task que lanzas tú, así que también hay que pararla en onTermination. Si el consumidor se va, la tarea deja de leer del origen; el origen, al quedarse sin nadie que lo lea, cancela también su propia tarea.

Para ver la forma sin el partido, un ejemplo con las ventas de una tienda. ventas() entrega lo que se vende, y acumulado() entrega el total después de cada venta, empezando en 0.

ejemplo mínimo: un stream que acumula otro (con comentarios)
// El stream de origen: tres ventas y se cierra.
func ventas() -> AsyncStream<Int> {
    AsyncStream { continuacion in
        for venta in [10, 5, 20] { continuacion.yield(venta) }
        continuacion.finish()
    }
}
 
// El stream derivado: entrega el total acumulado, empezando en 0.
func acumulado() -> AsyncStream<Int> {
    let origen = ventas()                          // se crea fuera, para capturarlo como constante
    return AsyncStream { continuacion in
        let tarea = Task {                         // el productor: lee del origen
            var total = 0                          // el estado que se va actualizando
            continuacion.yield(total)              // primero el valor de partida: 0
            for await venta in origen {            // espera cada venta del otro stream
                total += venta
                continuacion.yield(total)          // entrega el nuevo total
            }
            continuacion.finish()                  // el origen se acabó: este también
        }
        // Si el consumidor se va, el productor deja de leer.
        continuacion.onTermination = { _ in tarea.cancel() }
    }
}
 
// terminator: "" imprime sin salto de línea, y " " deja un espacio detrás de cada total.
print("Acumulado: ", terminator: "")
for await total in acumulado() { print(total, terminator: " ") }
print()   // un salto de línea para cerrar la línea

terminator es lo que print añade al final de lo que escribe: por defecto, un salto de línea. Con terminator: " " deja un espacio, y así todos los totales salen en la misma línea. Salida (con un espacio al final de la línea):

Acumulado: 0 10 15 35 

Marcador: un valor inmutable que viaja entre tareas

En vez de un Int, el stream de marcadores entrega un Marcador: un struct con los goles del local y del visitante. Es inmutable (let): cada gol no cambia un marcador, produce otro nuevo, con despuesDe. Se declara como struct Marcador: Equatable, Sendable, CustomStringConvertible:

  • Equatable permite comparar con ==: sirve para entregar un marcador solo si cambió (el distinctUntilChanged de Kotlin). Lo viste en Equatable y Hashable.
  • CustomStringConvertible da el texto de description ("2-1") que usa print. Lo viste en Protocolos y extensiones.
  • Sendable es la marca nueva: dice «este valor se puede pasar de una tarea a otra sin peligro».

Sendable importa aquí porque cada Marcador viaja desde la tarea que produce el stream hasta la que lo consume. Un struct con dos Int inmutables cumple Sendable sin más, y al escribirlo el compilador lo comprueba. Cómo se aplica en general está en Sendable y el modo Swift 6. El paquete sigue en el modo de lenguaje Swift 5, en el que el compilador es más tolerante con la concurrencia.

Reto · Ejercicio: la hucha

En tu Playground (borra antes lo anterior), escribe un saldo inmutable, que se pueda comparar con == (Equatable), que se pueda pasar entre tareas sin peligro y que se imprima como los euros seguidos de « €», con una función que devuelve el saldo tras un movimiento (sumándolo). Escribe un stream que entregue los movimientos 5, 0, −2, 0 y 10 y se cierre. A partir de él, escribe otro stream que entregue el saldo empezando en 0 € y, después, solo cuando un movimiento lo cambie; su tarea se cancela si el consumidor se va. Imprime todos los saldos en una sola línea, detrás de «Saldos:». Lo que debe salir (con un espacio al final):

Saldos: 0 € 5 € 3 € 13 € 
Ver solución
solución: la hucha
struct Saldo: Equatable, Sendable, CustomStringConvertible {
    let euros: Int
    var description: String { "\(euros) €" }
 
    func despuesDe(_ movimiento: Int) -> Saldo {
        Saldo(euros: euros + movimiento)
    }
}
 
func movimientos() -> AsyncStream<Int> {
    AsyncStream { continuacion in
        for movimiento in [5, 0, -2, 0, 10] { continuacion.yield(movimiento) }
        continuacion.finish()
    }
}
 
func saldos() -> AsyncStream<Saldo> {
    let origen = movimientos()
    return AsyncStream { continuacion in
        let tarea = Task {
            var saldo = Saldo(euros: 0)
            continuacion.yield(saldo)
            for await movimiento in origen {
                let nuevo = saldo.despuesDe(movimiento)
                if nuevo != saldo {
                    saldo = nuevo
                    continuacion.yield(saldo)
                }
            }
            continuacion.finish()
        }
        continuacion.onTermination = { _ in tarea.cancel() }
    }
}
 
print("Saldos: ", terminator: "")
for await saldo in saldos() { print(saldo, terminator: " ") }
print()

Los dos movimientos de 0 no cambian el saldo, así que no entregan nada: es el if nuevo != saldo, el distinctUntilChanged de Kotlin.

Paso · Marcador y marcadores

Objetivo: el struct Marcador, PartidoEnVivo.marcadores y los tests de los streams. Al terminar, torneo-cli imprime Marcadores: 0-0 1-0 1-1 2-1 y los tests pasan.

Torneo
Sources
Torneo
Marcador.swiftse crea
PartidoEnVivo.swiftse modifica
torneo-cli
main.swiftse modifica
Tests
TorneoTests
EventosEnVivoTests.swiftse crea y se copia
Un fichero nuevo, dos que cambian y un test que se copia. En Xcode, en el panel izquierdo (el Project navigator), con el paquete Torneo abierto.

Trabajas en el paquete Torneo, en la carpeta ~/Marcador-Swift/Torneo. Todos los comandos de esta lección se escriben en la Terminal, dentro de esa carpeta:

cd ~/Marcador-Swift/Torneo

Crea Marcador.swift: en el panel izquierdo de Xcode, clic derecho sobre la carpeta Sources → Torneo → New File from Template… → Swift File → Next, escribe Marcador y pulsa Create. Si el menú de tu versión de Xcode se llama distinto, crea el fichero desde la Terminal con touch Sources/Torneo/Marcador.swift. En un paquete no eliges «target» como en una app: el fichero pertenece a la carpeta donde lo creas. Lo que ya existe se modifica.

Piezas nuevas de este paso:

  • struct Marcador: Equatable, Sendable, CustomStringConvertible: lo que acabas de ver; Sendable es la única pieza que no habías usado.
  • guard case let .gol(_, _, equipo) = evento else { return self }: comprueba que el evento es un gol y, de paso, saca el equipo; si no lo es, ejecuta el else y sale de la función (aquí devuelve el marcador sin cambios). Es el if case que ya conoces (Closures con listas), con la ventaja de que equipo queda disponible en el resto de la función; el _ ignora los campos que no se usan.
  • extension Partido { … }: añade marcador() a un tipo que ya existe, como en Equatable y Hashable.
  • print(x, terminator: " "): escribe sin salto de línea, como en el ejemplo de las ventas.
  • Un stream que consume otro, con su Task y su onTermination: la forma que acabas de escribir con la hucha.

Intenta escribirlo tú, en este orden:

  1. Marcador, un struct público e inmutable con local y visitante (los goles de cada uno; su init es Marcador(local:visitante:)), goles, una propiedad calculada con el total de goles, que se imprime como «2-1», y una función, despuesDe(_:en:), que recibe un evento y (con la etiqueta en) el partido, y devuelve el marcador tras ese evento: solo cambia si es un gol, sumándolo al equipo que marcó. Y, en una extensión de Partido, la función marcador() (con paréntesis, no una propiedad), que da su marcador actual. Los tests usan esos nombres.
  2. En PartidoEnVivo, marcadores: el marcador tras cada gol, empezando en 0-0. Usa copias del guion (let guion = self.guion: así la tarea guarda solo el guion y no el PartidoEnVivo entero, que se quedaría vivo mientras la tarea siga), y su tarea se cancela si el consumidor se va.
  3. Al final de main.swift, los marcadores en una sola línea, detrás de «Marcadores:».

Lo que debes ver al ejecutar, tras lo de la lección anterior:

Marcadores: 0-0 1-0 1-1 2-1

Cuando termines, compara con la solución.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Torneo/Sources/Torneo/Marcador.swift
// El marcador en un instante. Inmutable: cada gol produce uno nuevo.
public struct Marcador: Equatable, Sendable, CustomStringConvertible {
    public let local: Int
    public let visitante: Int
 
    public init(local: Int, visitante: Int) {
        self.local = local
        self.visitante = visitante
    }
 
    public var goles: Int { local + visitante }
 
    public var description: String { "\(local)-\(visitante)" }
 
    // El marcador tras un evento: solo un gol lo cambia.
    public func despuesDe(_ evento: EventoDePartido, en partido: Partido) -> Marcador {
        guard case let .gol(_, _, equipo) = evento else { return self }
        return equipo == partido.local
            ? Marcador(local: local + 1, visitante: visitante)
            : Marcador(local: local, visitante: visitante + 1)
    }
}
 
extension Partido {
    public func marcador() -> Marcador { Marcador(local: golesLocal, visitante: golesVisitante) }
}

En PartidoEnVivo.swift, este bloque va justo debajo de primerGol() y antes de deinit (encima del comentario de deinit, si lo tienes). El resto del fichero no cambia:

Torneo/Sources/Torneo/PartidoEnVivo.swift
    // El marcador tras cada gol, empezando en 0-0: el `scan` + `distinctUntilChanged`
    // de Kotlin. Swift no trae `scan` en la biblioteca estándar (está en el paquete
    // swift-async-algorithms), así que se escribe a mano con un stream propio.
    public var marcadores: AsyncStream<Marcador> {
        let guion = self.guion
        let origen = eventos
        return AsyncStream { continuacion in
            let tarea = Task {
                var marcador = Marcador(local: 0, visitante: 0)
                continuacion.yield(marcador)
                for await evento in origen {
                    let nuevo = marcador.despuesDe(evento, en: guion)
                    if nuevo != marcador {
                        marcador = nuevo
                        continuacion.yield(marcador)
                    }
                }
                continuacion.finish()
            }
            continuacion.onTermination = { _ in tarea.cancel() }
        }
    }

En Sources → torneo-cli → main.swift, este bloque va al final del fichero. El resto del fichero no cambia:

Torneo/Sources/torneo-cli/main.swift
// `terminator: ""` imprime sin salto de línea: los marcadores salen en una línea.
print("Marcadores: ", terminator: "")
// Un marcador por cada gol, empezando en 0-0.
for await marcador in enVivoRapido.marcadores { print(marcador, terminator: " ") }
// Un salto de línea para cerrar la línea de marcadores.
print()

Los ocho tests de EventosEnVivoTests.swift no los escribes: son comprobaciones del mismo tipo, y lo que enseñan está en el código de la lección anterior y en el de hoy. Para eso está el botón Copiar fichero, que copia el contenido al portapapeles sin tocar tu proyecto. Tres pasos: crear el fichero, pulsar el botón y pegar.

  1. En el panel izquierdo de Xcode, clic derecho sobre Tests → TorneoTests → New File from Template… → Swift File → Next, escribe EventosEnVivoTests y pulsa Create.
  2. Pulsa el botón de abajo. Aparecerá un aviso de que se ha copiado.
  3. Haz clic dentro de EventosEnVivoTests.swift, selecciona todo con Cmd+A y pega con Cmd+V.
EventosEnVivoTests.swift

El que enseña algo comprueba que cancelar al consumidor cancela también al productor. Fíjate en la penúltima línea del test: for _ in 0..<50 where !terminado { … } es una espera activa. Repite hasta 50 veces, pero solo mientras terminado siga en false (eso hace el where), y en cada vuelta duerme 10 ms. En cuanto el productor avisa de que terminó, where deja de cumplirse y el bucle acaba sin gastar las 50 vueltas; el máximo es medio segundo de margen.

el test que enseña algo
    @Test func cancelarAlConsumidorCancelaAlProductor() async {
        var terminado = false
        let enVivo = PartidoEnVivo(guion: Ejemplo.rayoContraToros, msPorMinuto: 10, alTerminarLaEmision: { terminado = true })
        let consumidor = Task { () -> Int in   // `() -> Int` dice que la closure no recibe nada y devuelve un Int
            var vistos = 0
            for await _ in enVivo.eventos { vistos += 1 }
            return vistos
        }
        try? await Task.sleep(for: .milliseconds(200))   // minuto ~20: solo el gol del 12
        consumidor.cancel()
        let vistos = await consumidor.value
        #expect(vistos < 5)
        for _ in 0..<50 where !terminado { try? await Task.sleep(for: .milliseconds(10)) }
        #expect(terminado)
    }

Ejecuta, en la Terminal y desde ~/Marcador-Swift/Torneo, xcrun swift test y xcrun swift run torneo-cli. Sin contar las líneas de título que empiezan por ==, la salida de torneo-cli termina así:

  12' Gol de Ana (Rayo FC)
  30' Tarjeta amarilla para Iván
  55' Gol de Iván (Toros)
  80' Gol de Ana (Rayo FC)
  85' Cambio: sale Ana, entra Luis
Segundo recorrido: otro partido completo (5 eventos)
Primer gol: 12' Gol de Ana (Rayo FC) (el resto del partido se canceló)
Marcadores: 0-0 1-0 1-1 2-1

Punto de control

  • Desde ~/Marcador-Swift/Torneo, xcrun swift test termina con una línea como Test run with 117 tests in 15 suites passed.
  • xcrun swift run torneo-cli termina con la narración de cinco líneas, Segundo recorrido: otro partido completo (5 eventos), el primer gol y Marcadores: 0-0 1-0 1-1 2-1.
  • Sabes decir por qué marcadores es un stream propio y no un operador, y por qué también cancela su tarea en onTermination.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Por qué marcadores se escribe como un stream propio y no con un operador como scan?

  2. 2.¿Qué significa declarar struct Marcador: Sendable?

  3. 3.¿Qué le da description a print cuando un tipo adopta CustomStringConvertible?

  4. 4.¿Por qué marcadores también cancela su tarea en onTermination?

  5. 5.¿Qué hace print(total, terminator: " ")?

Faltan 5 de 5