Closures
Al terminar: Escribir closures, trailing closures, capturas y @escaping.
Al terminar esta lección sabes escribir closures en Swift, guardarlas, pasarlas y devolverlas, igual que las lambdas de tu versión de Kotlin. La closure es un trozo de código que se pasa como si fuera un dato. Además, tu paquete tendrá Closures.swift y cambiarás una línea del manifiesto para que el compilador acepte un fichero que guarda closures en constantes globales. En la lección siguiente, Closures con listas, las usarás para preguntarle cosas al partido.
Temas: sintaxis de closures, $0, trailing closures (una y varias), capturas, @escaping y el modo de lenguaje del paquete.
Una función sin nombre
Una closure es una función sin nombre que se escribe donde se necesita, o se guarda en una constante, o se pasa a otra función. Va entre llaves, con los parámetros a la izquierda de la palabra in y el cuerpo a la derecha. El tipo se escribe como la firma de una función (lo que recibe y lo que devuelve), igual que en Kotlin: (EventoDePartido) -> Bool significa «recibe un evento y devuelve verdadero o falso». Una función que recibe o devuelve otra función es de orden superior.
Dónde se prueba. Los ejemplos mínimos y el ejercicio de esta lección se prueban en tu Playground de Xcode, el de Tu primer paquete de Swift. Reutiliza el mismo: antes de pegar cada ejemplo, borra lo anterior, porque algunos repiten nombres.
let doble: (Int) -> Int = { n in n * 2 } // n es el parámetro; in separa parámetros y cuerpo
let triple: (Int) -> Int = { $0 * 3 } // sin nombre, el primer parámetro es $0
// regla es un parámetro de tipo función: recibe un Int y devuelve un Int.
func aplicar(_ valor: Int, regla: (Int) -> Int) -> Int {
regla(valor) // se llama como cualquier función
}
// Dos parámetros de tipo función.
func clasificar(_ n: Int, siPar: (Int) -> String, siImpar: (Int) -> String) -> String {
n % 2 == 0 ? siPar(n) : siImpar(n) // % es el resto de dividir; ¿resto 0? llama a siPar, si no a siImpar
}
var llamadas = 0
let sumar = { llamadas += 1 } // captura llamadas: la misma variable, no una copia
sumar()
sumar()
// aplicar(5) { $0 + 1 }: la última closure sale de los paréntesis (trailing closure).
print(doble(4), triple(2), aplicar(5, regla: doble), aplicar(5) { $0 + 1 })
print(llamadas) // 2: la closure cambió la variable de fuera
// Dos closures finales: la primera sin etiqueta, la segunda con siImpar:.
print(clasificar(7) { "par \($0)" } siImpar: { "impar \($0)" })Imprime 8 6 10 6, luego 2 y luego impar 7. Línea a línea:
{ n in n * 2 }:nes el parámetro yinsepara parámetros y cuerpo; con una sola expresión no hace faltareturn.{ $0 * 3 }: si no le pones nombre al parámetro, es$0(el segundo sería$1). Es elitde Kotlin, y también sirve con varios parámetros.aplicar(5) { $0 + 1 }: como la closure es el último parámetro, sale de los paréntesis. Es la trailing closure (closure final), la trailing lambda de Kotlin.clasificar(7) { … } siImpar: { … }: con varias closures finales, la primera va sin etiqueta y las siguientes con la etiqueta de su parámetro. Kotlin solo permite una lambda final.sumarcambiallamadas, que está fuera: eso es una captura, y la variable es la misma, no una copia. Por eso, tras llamarla dos veces,llamadasvale2.
Capturas: la closure y su variable viven juntas
Una closure que usa una variable de fuera la captura: la variable sigue viva mientras la closure exista. Dos closures creadas en el mismo sitio comparten esa variable; en cambio, cada llamada a una función que crea la closure produce una variable nueva (más abajo, dos contadores creados con dos llamadas a la función contador() no comparten la cuenta). Cuando lo capturado es una clase, la captura mantiene el objeto vivo mientras exista la closure. Si además la clase guarda la closure, ninguna de las dos desaparece nunca: se verá en ARC y se arregla en Retain cycles. Por ahora, quédate con que capturar mantiene vivo lo capturado.
@escaping: la closure que se guarda para después
Una closure recibida como parámetro es, por defecto, no escapada (non-escaping): solo puede usarse mientras la función corre y no puede guardarse. Si la función la guarda en una propiedad, o la llamará cuando ya haya terminado, hay que marcarla @escaping. Swift lo exige para que quien lee la firma sepa que esa closure vive más que la llamada. En Kotlin esta distinción no existía: una lambda se podía guardar siempre.
Error típico: guardar una closure sin @escaping
Quieres una clase que guarde los oyentes que le pasan, y escribes el parámetro como en cualquier otra función:
final class Avisador {
private var oyentes: [(Int) -> Void] = []
func alEvento(_ oyente: (Int) -> Void) {
oyentes.append(oyente)
}
}No compila. El compilador dice:
error: converting non-escaping parameter 'oyente' to generic parameter 'Element' may allow it to escapeFalla porque append guarda la closure dentro del array y la función alEvento ya habrá terminado cuando alguien la llame. (Element es el nombre que la librería estándar da al tipo de los elementos de un array: aquí, la propia closure.) El arreglo es marcar el parámetro:
final class Avisador {
private var oyentes: [(Int) -> Void] = []
func alEvento(_ oyente: @escaping (Int) -> Void) {
oyentes.append(oyente)
}
}Void es «no devuelve nada»: (Int) -> Void es una closure que recibe un número y no devuelve nada. oyentes es un array de closures: los tipos función también se pueden guardar en colecciones, y una guardada se llama como una función, oyente(minuto).
Tu Kotlin y este Swift, línea a línea
Kotlin (tu Lambdas.kt) | Swift (Closures.swift) | Diferencia |
|---|---|---|
{ it is Gol } | { if case .gol = $0 { true } else { false } } | it es $0; la pregunta de tipo se hace con if case |
{ evento, limite -> … } | { evento, limite in … } | -> pasa a ser in |
| Una sola trailing lambda | Varias trailing closures, con etiqueta las siguientes | Solo Swift |
| Una lambda se puede guardar siempre | Guardarla exige @escaping | Solo Swift |
| Captura una variable | Captura una variable | Igual; con una clase, la captura es fuerte |
Las piezas que vas a escribir
Para el proyecto hacen falta estas piezas, todas sobre closures:
public let esGol: (EventoDePartido) -> Bool = { … }: una constante cuyo valor es una closure, con su tipo función explícito.if case .gol = $0 { true } else { false }: pregunta si el evento es un gol sin ligar sus datos; cada rama delifes el valor.-> (EventoDePartido) -> Boolcomo tipo de retorno: la función devuelve una closure. Al salir de la función esa closure sigue viva (escapa), y en un valor devuelto no se escribe@escaping.1...veces: el rango cerrado de La regla de los puntos y tu primer test, de1avecesincluido;for vuelta in 1...veceslo recorre.- Una closure de varias líneas necesita
returnexplícito (la de una sola expresión no, como en el ejemplo del principio). public init() {}: un inicializador vacío pero público; sin él, quien use el paquete desde fuera no podría crear unAvisador.
Todas, juntas y comentadas, en otro dominio: los pedidos de una cafetería. No es tu código de Marcador; pruébalo en tu Playground, tras borrar lo anterior.
enum Pedido { // como EventoDePartido: casos con datos
case cafe(mesa: Int)
case tostada(mesa: Int, extra: String)
}
// Una constante cuyo valor es una closure; su tipo se escribe como una firma.
let esCafe: (Pedido) -> Bool = {
if case .cafe = $0 { true } else { false } // ¿es un café? Sin abrir sus datos
}
// Dos parámetros con nombre: a la izquierda de `in`, separados por coma.
let esMayor: (Int, Int) -> Bool = { a, b in
a > b
}
// La función DEVUELVE una closure: `-> (Int) -> Bool` es su tipo de retorno.
func masDe(_ minimo: Int) -> (Int) -> Bool {
{ $0 > minimo } // la closure recuerda `minimo` al salir
}
// Varias líneas: hace falta `return`.
func dispensador() -> () -> Int {
var ticket = 100 // cada llamada a dispensador() crea su propio ticket
return {
ticket += 1
return ticket
}
}
public final class Cocina {
public init() {} // vacío pero public: desde otro módulo se puede crear
}
let siguiente = dispensador()
let otro = dispensador() // otra llamada: otro ticket, empieza de nuevo
print(esCafe(.cafe(mesa: 1)), esCafe(.tostada(mesa: 2, extra: "tomate"))) // true false
print(esMayor(3, 1), masDe(6)(20), masDe(6)(2)) // true true false
print(siguiente(), siguiente(), otro()) // 101 102 101Imprime true false, true true false y 101 102 101: siguiente y otro no comparten el ticket.
Reto · Ejercicio: descuentos y un sumador con memoria
En tu Playground (borra antes lo anterior):
- Escribe una función que reciba una lista de precios enteros y una regla (una closure que convierte un precio en otro) y devuelva la lista con la regla aplicada a cada precio. Llámala con 100, 250 y 40, pasando como closure final una regla que quite un 10 %: el precio menos su décima parte, con división entera.
- Escribe una función que reciba un número de partida y devuelva una closure: cada vez que la llames con un número, lo suma a lo acumulado y devuelve el total.
- Crea un sumador que parta de 10 y llámalo dos veces con 5; crea otro que parta de 0 y llámalo una vez con 1. Imprime cada resultado. Antes de ejecutar, anota lo que esperas y qué variable captura cada closure.
Lo que debe salir:
[90, 225, 36]
15
20
1Ver solución
func aplicarDescuento(_ precios: [Int], regla: (Int) -> Int) -> [Int] {
precios.map(regla)
}
func crearSumador(desde inicio: Int) -> (Int) -> Int {
var total = inicio
return { n in
total += n
return total
}
}
print(aplicarDescuento([100, 250, 40]) { $0 - $0 / 10 })
let sumador = crearSumador(desde: 10)
print(sumador(5))
print(sumador(5))
let otro = crearSumador(desde: 0)
print(otro(1))Imprime [90, 225, 36], 15, 20 y 1. Cada llamada a crearSumador crea su propia variable total, y la closure devuelta la conserva viva: sumador acumula 10 + 5 + 5 y otro empieza de cero, sin compartir nada con él. (precios.map(regla) aplica la regla a cada precio y devuelve la lista de resultados; map se ve a fondo en la lección siguiente.)
Paso · Closures.swift y el modo de lenguaje
Objetivo. Crear Closures.swift, ver cómo el compilador lo rechaza, cambiar el modo de lenguaje del paquete en Package.swift y ver el resultado en main.swift. Al terminar, xcrun swift run torneo-cli imprime cuatro líneas nuevas al final de la crónica.
El título de cada bloque es la ruta del fichero dentro de ~/Marcador-Swift, la misma que ves en el árbol de arriba.
Crear Closures.swift. En el Project navigator (el panel de la izquierda de Xcode; se muestra u oculta con Cmd+1), despliega Sources, clic derecho sobre la carpeta Torneo (la de dentro de Sources) → New File from Template… → Swift File → Next, escribe Closures y pulsa Create. Si el menú de tu versión de Xcode se llama distinto, crea el fichero desde la Terminal, en ~/Marcador-Swift/Torneo: touch Sources/Torneo/Closures.swift.
Lo que tiene que ofrecer Closures.swift. Los tests de la lección siguiente usan estos nombres:
| Nombre | Qué recibe y qué hace |
|---|---|
esGol (una constante pública cuyo valor es una closure) | recibe un evento y dice si es un gol |
esDelMinutoOAntes (otra constante) | recibe un evento y un minuto límite, y dice si el evento es de ese minuto o anterior |
repetir | recibe un número de veces (sin etiqueta) y, con la etiqueta accion, qué hacer en cada vuelta; hace la acción con 1, 2… hasta ese número |
antesDelMinuto | recibe un minuto (sin etiqueta) y devuelve una closure que dice si un evento es de un minuto menor que ese |
contador | no recibe nada y devuelve una closure que da 1, 2, 3… en llamadas sucesivas |
alResolver | recibe un partido (sin etiqueta) y dos closures, enVictoria (que recibe el ganador) y enEmpate; llama a una u otra según el resultado del partido |
Avisador (una clase pública de la que nadie hereda) | se crea sin nada desde otro módulo; alEvento guarda una closure que recibe un evento, y avisar llama a todas las guardadas con el evento que le pasen |
Intenta escribirlo tú: primero Closures.swift y, después, en main.swift, entre la línea de Colores de tarjeta y la de let torneo = … (el resto no cambia), un comentario // Closures: filtrar eventos y sacar goleadores. y unas líneas que repitan dos veces una vuelta numerada; saquen tres números seguidos de un contador; y digan quién ganó el partido alFinal o, si empataron, «Tablas». Lo que debes ver, cuando compile (en la sección siguiente):
Vuelta 1
Vuelta 2
Contador: 1, 2, 3
Ganó Rayo FCTodavía no toques Package.swift: lo que ocurre al compilar es parte de la lección. Cuando termines, abre el bloque y compara con la solución.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
public let esGol: (EventoDePartido) -> Bool = {
if case .gol = $0 { true } else { false }
}
public let esDelMinutoOAntes: (EventoDePartido, Int) -> Bool = { evento, limite in
evento.minuto <= limite
}
public func repetir(_ veces: Int, accion: (Int) -> Void) {
for vuelta in 1...veces { accion(vuelta) }
}
public func antesDelMinuto(_ minuto: Int) -> (EventoDePartido) -> Bool {
{ $0.minuto < minuto }
}
public func contador() -> () -> Int {
var cuenta = 0
return {
cuenta += 1
return cuenta
}
}
public func alResolver(
_ partido: Partido,
enVictoria: (Equipo) -> Void,
enEmpate: () -> Void
) {
switch partido.resultado() {
case let .victoria(ganador, _): enVictoria(ganador)
case .empate: enEmpate()
}
}
public final class Avisador {
private var oyentes: [(EventoDePartido) -> Void] = []
public init() {}
public func alEvento(_ oyente: @escaping (EventoDePartido) -> Void) {
oyentes.append(oyente)
}
public func avisar(_ evento: EventoDePartido) {
for oyente in oyentes { oyente(evento) }
}
}// Closures: filtrar eventos y sacar goleadores.
// `repetir` llama a la closure con 1 y con 2.
repetir(2) { vuelta in print("Vuelta \(vuelta)") }
// `contador()` devuelve una closure que recuerda la cuenta entre llamada y llamada.
let siguiente = contador()
print("Contador: \(siguiente()), \(siguiente()), \(siguiente())")
// Dos closures finales: la primera sin etiqueta, la segunda con `enEmpate:`.
alResolver(alFinal) { ganador in
print("Ganó \(ganador.nombre)")
} enEmpate: {
print("Tablas")
}El compilador rechaza Closures.swift
Compila, desde ~/Marcador-Swift/Torneo:
xcrun swift buildNo termina en Build complete!. Sale esto (los números de fila y columna son los de tu fichero):
Closures.swift:1:12: error: let 'esGol' is not concurrency-safe because non-'Sendable' type '(EventoDePartido) -> Bool' may have shared mutable state [#MutableGlobalVariable]y otro igual para esDelMinutoOAntes. Tu código está bien. El mensaje habla de concurrencia, que es ejecutar varias cosas a la vez, cada una en su hilo (una línea de ejecución independiente). Una constante global se puede leer desde cualquier hilo, y el compilador no puede comprobar qué toca una closure por dentro, así que no la da por segura salvo que se marque como Sendable («se puede compartir entre hilos sin peligro»; lo verás en Sendable y el modo Swift 6). Esa comprobación estricta es el modo de lenguaje Swift 6, que es el que tiene por defecto un paquete con swift-tools-version: 6.2 (la primera línea de Package.swift).
Por ahora no vamos a arreglarlo marcando nada: ese tema tiene su propio módulo. Pasamos el paquete al modo Swift 5, que relaja esas reglas. El compilador sigue siendo el mismo, el de tu Xcode; .v5 solo cambia las reglas con las que compila, no instala un Swift más viejo. Es un apagado temporal: en Sendable y el modo Swift 6 volverás al modo Swift 6 y ese mismo error se resolverá de verdad.
Cambia el modo de lenguaje en Package.swift
Es la primera vez que tocas un ajuste del manifiesto. swiftLanguageModes es un argumento de Package(…) que dice con qué reglas compila el paquete entero: .v5 es el modo Swift 5, sin la comprobación estricta de concurrencia. Lo necesitas ahora porque Closures.swift guarda closures en constantes globales.
Abre Package.swift con doble clic en el Project navigator (está en la raíz del paquete Torneo, encima de Sources). No tocas nada de lo de arriba: solo el final del fichero. Ahora termina así, con el bloque .testTarget(…) y los dos cierres (] cierra la lista targets, y ) cierra Package(…)):
.testTarget(
name: "TorneoTests",
dependencies: ["Torneo"]
),
]
)Inténtalo tú: después de la lista targets, añade el argumento de los modos de lenguaje, swiftLanguageModes, con un único modo, el de Swift 5. Ten en cuenta que los argumentos de Package(…) se separan con comas (el último no lleva) y van en un orden fijo, y este va después de targets. Lo que debes ver al compilar: Build complete!. El resultado está debajo.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
.testTarget(
name: "TorneoTests",
dependencies: ["Torneo"]
),
],
swiftLanguageModes: [.v5]
)Compila: xcrun swift build vuelve a terminar en Build complete!: el manifiesto lo lee SwiftPM cada vez que compilas, y no hay un «Sync» aparte como en Gradle.
Punto de control
- Build. Desde
~/Marcador-Swift/Torneo,xcrun swift buildtermina enBuild complete!. - Run.
xcrun swift run torneo-cliimprime, después de la línea de colores de tarjeta:
Vuelta 1
Vuelta 2
Contador: 1, 2, 3
Ganó Rayo FCy termina con la línea de «Partidos vistos desde torneo: 1».
- Test.
xcrun swift testtermina conTest run with 34 tests in 5 suites passed: los mismos que antes, sin ninguno nuevo (los de closures llegan en la lección siguiente). - Provocado. Quita
swiftLanguageModes: [.v5]dePackage.swift:xcrun swift buildvuelve a fallar conis not concurrency-safe. Ponlo de nuevo. - Sabes decir qué es una closure, por qué
Avisadornecesita@escapingy por quécontador()da1, 2, 3sin reiniciarse.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.Tienes
let triple: (Int) -> Int = { $0 * 3 }. ¿Qué es$0?2.Una función tiene dos parámetros de tipo closure,
siParysiImpar. ¿Cuál es una llamada válida con trailing closures?3.Una clase guarda en un array la closure que recibe en
func alEvento(_ oyente: (Int) -> Void). ¿Qué ocurre?4.Creas
let uno = contador()ylet otro = contador(), dondecontador()devuelve una closure que suma 1 a una variablecuentadeclarada dentro de la función. ¿Qué pasa al llamaruno()dos veces y luegootro()?