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 (enPartidoEnVivo, el propio partido): si lo pidiera dentro de la tarea, tendría que capturarself. - El productor sigue siendo una
Taskque lanzas tú, así que también hay que pararla enonTermination. 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.
// 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íneaterminator 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:
Equatablepermite comparar con==: sirve para entregar un marcador solo si cambió (eldistinctUntilChangedde Kotlin). Lo viste en Equatable y Hashable.CustomStringConvertibleda el texto dedescription("2-1") que usaprint. Lo viste en Protocolos y extensiones.Sendablees 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
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.
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/TorneoCrea 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;Sendablees 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 elelsey sale de la función (aquí devuelve el marcador sin cambios). Es elif caseque ya conoces (Closures con listas), con la ventaja de queequipoqueda disponible en el resto de la función; el_ignora los campos que no se usan.extension Partido { … }: añademarcador()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
Tasky suonTermination: la forma que acabas de escribir con la hucha.
Intenta escribirlo tú, en este orden:
Marcador, un struct público e inmutable conlocalyvisitante(los goles de cada uno; suinitesMarcador(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 etiquetaen) 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 dePartido, la funciónmarcador()(con paréntesis, no una propiedad), que da su marcador actual. Los tests usan esos nombres.- 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 elPartidoEnVivoentero, que se quedaría vivo mientras la tarea siga), y su tarea se cancela si el consumidor se va. - 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-1Cuando termines, compara con la solución.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// 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:
// 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:
// `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.
- En el panel izquierdo de Xcode, clic derecho sobre
Tests → TorneoTests→ New File from Template… → Swift File → Next, escribeEventosEnVivoTestsy pulsa Create. - Pulsa el botón de abajo. Aparecerá un aviso de que se ha copiado.
- Haz clic dentro de
EventosEnVivoTests.swift, selecciona todo con Cmd+A y pega con Cmd+V.
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.
@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-1Punto de control
- Desde
~/Marcador-Swift/Torneo,xcrun swift testtermina con una línea comoTest run with 117 tests in 15 suites passed. xcrun swift run torneo-clitermina con la narración de cinco líneas,Segundo recorrido: otro partido completo (5 eventos), el primer gol yMarcadores: 0-0 1-0 1-1 2-1.- Sabes decir por qué
marcadoreses un stream propio y no un operador, y por qué también cancela su tarea enonTermination.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Por qué
marcadoresse escribe como un stream propio y no con un operador comoscan?2.¿Qué significa declarar
struct Marcador: Sendable?3.¿Qué le da
descriptionaprintcuando un tipo adoptaCustomStringConvertible?4.¿Por qué
marcadorestambién cancela su tarea enonTermination?5.¿Qué hace
print(total, terminator: " ")?