Saltar al contenido
K
Swift: el lenguajePaso a paso40 min de lectura · 10 min de máquina Intermedio

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.

ejemplo mínimo: closures, $0, trailing closure y captura
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 }: n es el parámetro y in separa parámetros y cuerpo; con una sola expresión no hace falta return.
  • { $0 * 3 }: si no le pones nombre al parámetro, es $0 (el segundo sería $1). Es el it de 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.
  • sumar cambia llamadas, que está fuera: eso es una captura, y la variable es la misma, no una copia. Por eso, tras llamarla dos veces, llamadas vale 2.

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:

con el bug: guardar una closure no escapada
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 escape

Falla 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:

corregido: @escaping
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 lambdaVarias trailing closures, con etiqueta las siguientesSolo Swift
Una lambda se puede guardar siempreGuardarla exige @escapingSolo Swift
Captura una variableCaptura una variableIgual; 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 del if es el valor.
  • -> (EventoDePartido) -> Bool como 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, de 1 a veces incluido; for vuelta in 1...veces lo recorre.
  • Una closure de varias líneas necesita return explí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 un Avisador.

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.

ejemplo mínimo: las piezas del paso, con los pedidos de una cafetería
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 101

Imprime 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):

  1. 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.
  2. 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.
  3. 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
1
Ver solución
solución: descuentos y sumador
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.

Torneo
Package.swiftse modifica (al final del paso)
Sources
Torneo
Closures.swiftse crea
torneo-cli
main.swiftse modifica
Lo que tocas, en el Project navigator de Xcode con el paquete Torneo abierto.

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:

NombreQué 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
repetirrecibe 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
antesDelMinutorecibe un minuto (sin etiqueta) y devuelve una closure que dice si un evento es de un minuto menor que ese
contadorno recibe nada y devuelve una closure que da 1, 2, 3… en llamadas sucesivas
alResolverrecibe 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 FC

Todaví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
Torneo/Sources/Torneo/Closures.swift
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) }
    }
}
Torneo/Sources/torneo-cli/main.swift
// 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 build

No 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(…)):

Package.swift: el final, antes
        .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
Package.swift: el final, después
        .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 build termina en Build complete!.
  • Run. xcrun swift run torneo-cli imprime, después de la línea de colores de tarjeta:
Vuelta 1
Vuelta 2
Contador: 1, 2, 3
Ganó Rayo FC

y termina con la línea de «Partidos vistos desde torneo: 1».

  • Test. xcrun swift test termina con Test 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] de Package.swift: xcrun swift build vuelve a fallar con is not concurrency-safe. Ponlo de nuevo.
  • Sabes decir qué es una closure, por qué Avisador necesita @escaping y por qué contador() da 1, 2, 3 sin reiniciarse.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.Tienes let triple: (Int) -> Int = { $0 * 3 }. ¿Qué es $0?

  2. 2.Una función tiene dos parámetros de tipo closure, siPar y siImpar. ¿Cuál es una llamada válida con trailing closures?

  3. 3.Una clase guarda en un array la closure que recibe en func alEvento(_ oyente: (Int) -> Void). ¿Qué ocurre?

  4. 4.Creas let uno = contador() y let otro = contador(), donde contador() devuelve una closure que suma 1 a una variable cuenta declarada dentro de la función. ¿Qué pasa al llamar uno() dos veces y luego otro()?

Faltan 4 de 4