wandres.dev
FUNCIONES Y CLOSURES · captura y escape

Closures como valores

Antes de hablar de captura hay que aceptar una idea que reordena el lenguaje entero: en Swift una función es un valor con tipo propio, y ese tipo es estructural, no nominal. Esta lección desarma la sintaxis abreviada de los closures como lo que realmente es, una escalera de omisiones que el compilador reconstruye por inferencia, y explica qué se gana y qué se pierde en cada peldaño. Después examina las trailing closures como decisión de diseño de API y no como azúcar, incluida la forma múltiple que sostiene los constructores declarativos de SwiftUI, y cierra con las funciones que devuelven funciones, donde la ciudadanía de primera clase deja de ser un eslogan y se vuelve una técnica de composición.

⏱ 18 min

Un closure no es una construcción exótica que Swift añadió para que las APIs parecieran bonitas. Es la consecuencia visible de una decisión más profunda: las funciones son valores, tienen tipo, se guardan en variables, viajan como argumentos y se devuelven como resultados. Todo lo demás de este nivel —qué captura un closure, cuándo lo hace, por qué el compilador exige marcar los que sobreviven a su llamada— se apoya sobre esa base. Y sin embargo la primera dificultad real no es conceptual sino tipográfica: la sintaxis abreviada de Swift permite escribir el mismo closure de cinco maneras distintas, cada una omitiendo algo que el compilador reconstruye, y quien no sabe qué se está omitiendo lee esas formas como magia en vez de como elisión. Esta lección desmonta esa escalera peldaño a peldaño, para que la brevedad sea una elección y no un ritual copiado de un ejemplo de Internet.

🎯 Al terminar esta lección sabrás
  • Describir el tipo función de Swift como tipo estructural y explicar qué información conserva y cuál descarta.
  • Reconstruir mentalmente las cinco formas abreviadas de un closure hasta su forma completa.
  • Justificar cuándo una trailing closure mejora una API y cuándo la vuelve ilegible.
  • Componer funciones que producen funciones y valorar el coste de esa indirección.

El tipo función: qué se nombra cuando se nombra un closure

Cuando escribes (Int) -> Int no estás escribiendo una anotación decorativa: estás nombrando un tipo tan legítimo como Int o String, con la particularidad de ser estructural en lugar de nominal. Dos tipos función son el mismo tipo si coinciden sus parámetros, su retorno y sus efectos; no hace falta declarar nada ni conformar a nada. Esa estructuralidad es lo que permite que una función declarada con func y un closure escrito entre llaves sean intercambiables sin adaptadores.

func doblar(_ n: Int) -> Int { n * 2 }

let f: (Int) -> Int = doblar
let g: (Int) -> Int = { n in n * 2 }

let cadena: [(Int) -> Int] = [doblar, g, { $0 + 1 }]
let resultado = cadena.reduce(3) { valor, paso in paso(valor) }

El tipo función incluye más de lo que la sintaxis sugiere. Los efectos forman parte de él: () async throws -> Data es un tipo distinto de () -> Data, y esa distinción es la que sostiene la comprobación estática de la concurrencia. Los atributos también participan, y por eso @escaping y @Sendable aparecen dentro de la firma y no como comentarios. Lo que no forma parte del tipo son las etiquetas de argumento: en el momento en que una función nombrada se convierte en valor, sus etiquetas se evaporan.

func mover(hacia destino: Punto, en segundos: Double) { }

// Las etiquetas desaparecen al tomar la funcion como valor
let animacion: (Punto, Double) -> Void = mover
animacion(Punto.origen, 0.3)

Esa erosión de las etiquetas tiene una consecuencia práctica que conviene anticipar: una API expresiva en el punto de declaración se vuelve posicional en el punto de reenvío, y el orden de los parámetros pasa a ser la única documentación disponible. Los inicializadores y los métodos también son valores. Punto.init es una función que produce puntos, y un método de instancia sin aplicar es una función curried que primero pide el receptor.

let puntos = coordenadas.map(Punto.init)
let mayusculas: (String) -> () -> String = String.uppercased

La escalera de abreviaturas

La sintaxis de closures de Swift es una secuencia de omisiones autorizadas por la inferencia de tipos desde el contexto. Verlas como escalones y no como formas independientes elimina de golpe la sensación de arbitrariedad.

Peldaño Qué se omite Qué lo hace posible
Forma completa Nada Siempre válida
Tipos inferidos Tipos de parámetros y retorno El tipo esperado en el contexto
Retorno implícito La palabra return Cuerpo de una sola expresión
Nombres abreviados La lista de parámetros y in Posiciones $0, $1
Método de operador El closure entero El operador ya es una función
let nombres = ["Ada", "Grace", "Alan"]

nombres.sorted(by: { (a: String, b: String) -> Bool in return a < b })
nombres.sorted(by: { a, b in return a < b })
nombres.sorted(by: { a, b in a < b })
nombres.sorted(by: { $0 < $1 })
nombres.sorted(by: <)

Las cinco líneas producen el mismo resultado y compilan al mismo código. La diferencia es de comunicación, y el criterio para elegir peldaño no es la moda sino la densidad semántica del cuerpo. Un closure de una expresión trivial gana con $0. Uno de seis líneas con dos parámetros que representan cosas distintas pierde muchísimo: $0 y $1 obligan al lector a mantener en la cabeza un mapa posicional que el autor podría haberle regalado con dos nombres.

💡
La inferencia es contextual y por eso a veces se cae

Los peldaños altos dependen de que el compilador conozca el tipo esperado. Cuando el closure aparece en un contexto sin esa información —una variable sin anotar, una expresión genérica muy abierta, un cuerpo con varias sentencias— la inferencia se agota y el error resultante casi nunca señala el problema real. La cura mecánica es bajar un peldaño: anota los tipos o escribe el return, y el mensaje del compilador se vuelve inteligible al instante.

Trailing closures y el diseño de la llamada

Cuando el último argumento de una función es un closure, Swift permite sacarlo del paréntesis. La regla parece cosmética y no lo es: cambia el aspecto de la llamada de invocación a bloque, y con ello el orden en que el lector procesa la información. En una trailing closure lo primero que se lee es qué se hace y lo último es con qué se hace, que suele ser el orden correcto para el código que describe comportamiento.

// Sin trailing: el cuerpo queda enterrado entre parentesis
UIView.animate(withDuration: 0.3, animations: { vista.alpha = 0 })

// Con trailing: la llamada se lee como una estructura del lenguaje
UIView.animate(withDuration: 0.3) {
  vista.alpha = 0
}

Desde Swift 5.3 la forma admite varias trailing closures: la primera va sin etiqueta y las siguientes la conservan. Esa extensión es la que permite que constructores con dos o tres bloques —contenido y acción, éxito y fallo, cuerpo y respaldo— se lean como una declaración en lugar de como una llamada con argumentos anidados.

func cargar(
  desde url: URL,
  onExito: (Data) -> Void,
  onFallo: (Error) -> Void
) { }

cargar(desde: url) { datos in
  procesar(datos)
} onFallo: { error in
  registrar(error)
}

Hay un precio, y la comunidad lo aprendió por el camino largo. La primera trailing closure pierde su etiqueta, así que el nombre de la función debe cargar sola con el significado de ese bloque. Una función llamada configurar con dos bloques sin nombre útil produce una llamada indescifrable. La regla de diseño que se deriva es simple: si al quitar la etiqueta del último argumento la llamada deja de explicarse a sí misma, la etiqueta hacía falta y la trailing closure no es una mejora.

Funciones que devuelven funciones

La ciudadanía de primera clase se completa cuando una función produce otra. Aquí el closure deja de ser un argumento que se consume y pasa a ser un valor construido, con estado propio, que sobrevive a la llamada que lo fabricó. Es el puente natural hacia la captura, que es el tema del resto del nivel.

func multiplicador(por factor: Int) -> (Int) -> Int {
  { n in n * factor }
}

let triple = multiplicador(por: 3)
triple(7)  // 21

func componer<A, B, C>(
  _ f: @escaping (A) -> B,
  _ g: @escaping (B) -> C
) -> (A) -> C {
  { a in g(f(a)) }
}

El closure devuelto por multiplicador recuerda el valor de factor mucho después de que la función haya retornado y su marco de pila haya desaparecido. Que eso funcione no es evidente: exige que el compilador detecte la dependencia y traslade la variable a memoria de larga duración. Ese mecanismo es la captura, y el atributo @escaping de componer es la señal visible de que Swift sabe que esos closures van a sobrevivir. La indirección tiene un coste real —una asignación de heap por cada closure con contexto, una llamada indirecta que el optimizador solo elimina cuando puede ver el valor concreto— y por eso conviene tratar la fabricación de funciones como una herramienta de expresividad, no como un estilo por defecto.

🍊

Baja un peldaño cuando duela

La abreviatura es una cortesía hacia el lector, no una prueba de competencia. Si el cuerpo tiene sustancia, dale nombres a los parámetros.

🧩

El nombre carga con la etiqueta perdida

Toda trailing closure convierte una etiqueta de argumento en responsabilidad del nombre de la función. Diseña el nombre en consecuencia.

flowchart LR
F[Funcion nombrada] --> V[Valor de tipo funcion]
C[Closure literal] --> V
M[Referencia a metodo] --> V
V --> A[Argumento]
V --> R[Retorno de otra funcion]
R --> K[Captura de contexto]
style V fill:#89b4fa,color:#11111b
style K fill:#fab387,color:#11111b
La abreviatura no simplifica el lenguaje, redistribuye el trabajo

Conviene ser preciso sobre qué hace exactamente la sintaxis abreviada, porque la lectura ingenua es que hace el lenguaje más simple y es justo lo contrario. Cada peldaño que se sube traslada trabajo del autor al lector y del texto al compilador: los tipos siguen existiendo, la lista de parámetros sigue existiendo, el return sigue existiendo; lo único que cambia es quién los reconstruye. Cuando el que lee tiene el contexto fresco —acaba de escribir la función, conoce la colección, sabe qué devuelve el predicado— esa reconstrucción es instantánea y la brevedad es beneficio puro. Cuando el que lee llega seis meses después a una expresión con tres $0 anidados en cierres distintos, la reconstrucción es un trabajo arqueológico que el autor podría haberle ahorrado tecleando quince caracteres. Hay además un efecto que se nota antes en la práctica que en la teoría: la inferencia agresiva degrada los mensajes de error de forma no lineal, porque el compilador que no puede resolver un tipo no sabe en qué punto de la cadena de omisiones estaba la intención, y responde con un diagnóstico genérico a varias líneas de distancia. Por eso la disciplina útil no es escribir siempre corto ni siempre largo, sino tratar el nivel de abreviatura como un parámetro de legibilidad que se ajusta caso por caso, y tener siempre presente que bajar un peldaño es la primera herramienta de depuración cuando el compilador empieza a hablar en un idioma extraño.

⚔️ Reconstruye y evalúa la escalera en tu propio código
  1. Localiza en tu proyecto las tres expresiones con closures más densas y reescríbelas en forma completa, con tipos y return explícitos. Anota cuánto tardas en entender cada una antes y después.
  2. Busca un closure con $0 y $1 donde los dos parámetros representen cosas distintas. Dales nombres y decide con honestidad si la versión larga se lee mejor.
  3. Toma una función tuya con dos closures como parámetros y conviértela a la forma de trailing closures múltiples. Comprueba si el nombre de la función sigue explicando qué hace el primer bloque sin su etiqueta.
  4. Escribe una función que devuelva otra función capturando un parámetro. Antes de ejecutarla, predice qué imprime si cambias el valor capturado después de crearla.
  5. Convierte una cadena de tres map en una única función compuesta con un combinador propio, y compara la legibilidad de ambas versiones.