@escaping: el closure que sobrevive
Swift asume por defecto que un closure recibido como parámetro muere con la llamada, y obliga a declarar `@escaping` cuando no es así. Esta lección explica por qué ese valor por defecto se eligió al revés de como estaba en las primeras versiones del lenguaje, qué gana el compilador con la garantía de no escape —desde no promocionar variables al heap hasta razonar sobre exclusividad y aliasing— y qué restricciones impone escapar: `self` explícito, prohibición de `inout`, exigencias de `Sendable`. Cierra con los casos que confunden a todo el mundo, los closures en posición opcional y en propiedades, y con la válvula de escape controlada que es `withoutActuallyEscaping`.
El atributo @escaping es una de esas piezas del lenguaje que se aprenden como un trámite: el compilador se queja, se añade la palabra, el error desaparece y nadie vuelve a pensar en ello. Es una lástima, porque detrás de esa palabra hay una de las decisiones de diseño mejor razonadas de Swift, y entenderla explica de golpe media docena de comportamientos aparentemente inconexos: por qué hay que escribir self. en unos closures y no en otros, por qué un parámetro inout no puede usarse dentro de ciertos bloques, por qué la concurrencia estricta empieza a protestar justo en las funciones que guardan manejadores, y por qué un map no cuesta memoria y un manejador de red sí. La palabra no marca una excepción molesta: marca la frontera entre dos regímenes distintos de razonamiento sobre tiempos de vida.
- Definir con precisión qué significa que un closure escape y enumerar las formas en que puede hacerlo.
- Justificar por qué el valor por defecto de Swift es el no escape y qué optimizaciones habilita.
- Anticipar las restricciones que impone
@escapingsobreself, sobreinouty sobre la concurrencia. - Identificar los casos donde el escape es implícito y usar la válvula controlada cuando corresponde.
Qué significa exactamente escapar
Un closure escapa cuando puede ser invocado después de que la función que lo recibió haya retornado. No se trata de dónde se guarda sino de cuándo puede ejecutarse. Las formas concretas son pocas y conviene tenerlas listadas, porque reconocerlas de un vistazo evita casi todas las sorpresas.
- Se almacena en una propiedad, una variable global o una colección que sobrevive a la llamada.
- Se pasa a otra función que a su vez lo declara
@escaping. - Se ejecuta de forma asíncrona en otra cola, en un temporizador o dentro de una
Task. - Se devuelve como resultado de la función.
var manejadores: [() -> Void] = []
func registrar(_ accion: @escaping () -> Void) {
manejadores.append(accion) // sobrevive a la llamada
}
func aplicarAhora(_ accion: () -> Void) {
accion() // muere con la llamada
}
La diferencia entre las dos firmas no es estilística: son tipos distintos. @escaping () -> Void y () -> Void no son intercambiables, y el compilador rechaza pasar un no escapante donde se espera uno escapante y también propagar un parámetro no escapante hacia una función que lo guarda. Esa propagación es transitiva y es la razón por la que un @escaping aparecido en una capa profunda acaba subiendo por toda una cadena de funciones como una obligación contagiosa.
Por qué el valor por defecto está donde está
En las primeras versiones de Swift el valor por defecto era el contrario: los closures escapaban salvo que se marcaran @noescape. El cambio, discutido en la propuesta de evolución que introdujo el criterio actual, se apoyó en dos argumentos que siguen siendo válidos y que conviene entender por separado.
El primero es estadístico y de ergonomía: la inmensa mayoría de los closures que se pasan como parámetro se consumen dentro de la llamada. Todos los combinadores de colecciones, todos los constructores de comparación, todos los bloques de configuración. Marcar el caso raro es menos escritura y menos ruido que marcar el caso común.
El segundo es el que importa de verdad y es semántico: el no escape es una garantía verificable que el compilador puede explotar, mientras que el escape no le permite suponer nada. Con la garantía de no escape, el optimizador sabe que el closure no sobrevive al marco de pila, y eso desbloquea toda una familia de decisiones.
| Garantía de no escape | Qué habilita |
|---|---|
| El closure muere con la llamada | Las variables capturadas se quedan en la pila, sin caja en el heap |
| No hay referencias vivas después | No hace falta conteo de referencias sobre el contexto |
| El sitio de llamada es visible | Especialización y expansión en línea del cuerpo |
| El acceso está acotado | Se puede razonar sobre exclusividad y permitir inout |
Ese cuadro explica por qué numeros.map { $0 * 2 } no asigna nada en el heap y suele compilarse a un bucle sin llamada indirecta, mientras que guardar un manejador implica al menos una asignación y una llamada que el optimizador rara vez puede resolver estáticamente. La diferencia de coste entre ambos regímenes no es marginal en trayectos calientes.
El error de @escaping es a menudo un buen diagnóstico de diseño. Aparece cuando una función que parecía síncrona está en realidad reteniendo comportamiento para más tarde. Antes de añadir la palabra, comprueba si la operación podría expresarse como async y devolver un valor: media década de manejadores guardados se ha podido borrar con esa pregunta.
Lo que escapar te prohíbe
Marcar un closure como escapante no es solo una anotación informativa: activa un conjunto de restricciones que el compilador aplica de inmediato, y cada una responde a un riesgo concreto.
La primera es la captura explícita de self. Dentro de un closure escapante hay que escribir self. o incluir self en la lista de captura. La razón es exactamente la de la lección anterior: un closure que sobrevive y retiene self puede formar un ciclo, y el lenguaje quiere que esa retención sea legible en el texto en lugar de deducible. En un closure no escapante la exigencia desaparece, porque sin supervivencia no hay ciclo posible.
La segunda es la prohibición de capturar parámetros inout. Un parámetro inout es un acceso exclusivo con duración acotada a la llamada; permitir que un closure lo capturara y lo usara después significaría escribir sobre un almacenamiento cuyo acceso ya se cerró.
func acumular(en total: inout Int, con accion: @escaping (Int) -> Void) {
// accion no puede capturar `total`
}
func acumularAhora(en total: inout Int, con accion: (Int) -> Void) {
accion(total) // aqui si, porque el acceso sigue vigente
}
La tercera es la presión de la concurrencia estricta. Un closure que escapa puede ejecutarse en otro contexto de aislamiento, así que en Swift 6 casi siempre acaba necesitando ser @Sendable, y eso arrastra la exigencia de que todo lo capturado también lo sea. Muchas de las quejas que aparecen al activar la comprobación estricta no son problemas de concurrencia recién creados sino escapes que llevaban años ahí sin que nadie hubiera examinado qué se llevaban consigo.
Los escapes implícitos y la válvula controlada
Hay tres lugares donde un closure escapa sin que nadie escriba @escaping, y los tres confunden a quien conoce solo la regla del parámetro.
El primero es el closure en posición opcional. Un parámetro de tipo (() -> Void)? es implícitamente escapante, porque el opcional envuelve el valor en una caja y esa caja no ofrece la garantía de acotación. Escribir el atributo ahí es incluso un error de compilación.
El segundo son las propiedades. Un closure guardado en una propiedad de un tipo escapa por definición, sin atributo, porque su tiempo de vida es el de la instancia.
El tercero son los parámetros de tipos genéricos y las estructuras que contienen closures, donde el compilador no puede acotar el uso y asume escape.
struct Configuracion {
var alGuardar: () -> Void // escapante sin atributo
}
func opcional(_ accion: (() -> Void)?) { } // implicitamente escapante
Finalmente existe una válvula para el caso contrario: tienes un closure no escapante y necesitas pasárselo a una API que exige uno escapante, sabiendo que en realidad no lo va a guardar. withoutActuallyEscaping proporciona una versión escapante temporal con una promesa que tú firmas y el runtime comprueba en modo de depuración.
func filtrarConcurrente(_ predicado: (Int) -> Bool, sobre datos: [Int]) -> [Int] {
withoutActuallyEscaping(predicado) { escapante in
datos.filter(escapante)
}
}
Es una herramienta legítima y estrecha. La promesa es que ninguna referencia al closure sobrevive al bloque, y romperla produce comportamiento indefinido, no un error amable. En compilaciones de depuración el runtime instala una comprobación que detecta la fuga y termina el proceso, lo cual convierte el incumplimiento en un fallo diagnosticable en vez de en corrupción silenciosa; en lanzamiento esa red desaparece.
El caso simétrico también existe y se resuelve solo. Un parámetro no escapante puede pasarse sin ceremonia a otro parámetro no escapante, y el compilador verifica la cadena entera sin pedir nada. Esa transitividad silenciosa es la que hace que un reduce sobre un map sobre un filter siga sin asignar memoria por sus closures, por muy profunda que sea la composición.
Escapar es sobre el cuándo
No importa dónde se guarde el closure sino si puede ejecutarse después del retorno. Esa es la única pregunta que decide el atributo.
El no escape es una promesa rentable
Sin escape no hay caja en el heap, ni conteo de referencias, ni llamada indirecta. Es la razón de que los combinadores de colecciones no cuesten nada.
flowchart TB P[Closure como parametro] --> Q[Puede ejecutarse tras el retorno] Q -->|No| N[Sin atributo] Q -->|Si| E[Requiere escaping] N --> V1[Capturas en la pila] N --> V2[Permite inout y self implicito] E --> V3[Promocion al heap] E --> V4[Exige self explicito y a menudo Sendable] style N fill:#a6e3a1,color:#11111b style E fill:#fab387,color:#11111b
Hay un desplazamiento mental que vuelve @escaping inmediatamente obvio y que casi nunca se enseña: el atributo no dice nada sobre el closure que se pasa, dice todo sobre la función que lo recibe. Es una cláusula del contrato que esa función publica hacia sus llamantes, y su contenido es una promesa de tiempo de vida. Una función sin el atributo está prometiendo terminaré con tu comportamiento antes de devolverte el control, y esa promesa es lo que permite al llamante razonar con tranquilidad sobre lo que hay en su propia pila, mutar variables antes y después sin preocuparse, pasar un inout sin miedo y omitir el self. porque sabe que no está regalando una referencia. Una función con el atributo está diciendo lo contrario: me quedo con esto, y no te digo hasta cuándo. Vista así, la asimetría de restricciones deja de parecer arbitraria y se convierte en aritmética: el que promete menos obtiene menos garantías, y el compilador se limita a cobrarlas. La consecuencia práctica es que añadir @escaping a una firma existente es un cambio incompatible de API, aunque el compilador no siempre lo señale con esas palabras, porque rompe suposiciones que los llamantes ya habían hecho. Y la consecuencia de diseño es que la pregunta correcta al escribir una función que recibe comportamiento no es cómo hago que compile, sino qué promesa de tiempo de vida quiero poder ofrecer, porque esa promesa determina a la vez el rendimiento, la seguridad de memoria y la ergonomía de todo el código que la use.
- Elige una función tuya con un parámetro
@escapingy sigue la cadena hacia arriba: cuenta cuántas firmas del proyecto llevan el atributo únicamente porque esa lo lleva. - Toma un manejador guardado como propiedad y reescribe la operación como una función
asyncque devuelve el valor. Compara el número de líneas y de capturas explícitas antes y después. - Añade
@escapinga un parámetro que hoy no lo tiene y observa qué se rompe: capturas deself, usos deinout, exigencias deSendable. Cada rotura es una garantía que acabas de perder. - Escribe un caso con
withoutActuallyEscapingy después, deliberadamente, guarda el closure fuera del bloque en una compilación de depuración para ver cómo el runtime detecta la promesa rota. - Mide con un perfilador un bucle que usa
mapfrente a otro que llama a un closure guardado en una propiedad, con la misma carga. Anota la diferencia de asignaciones.