wandres.dev
FUNCIONES Y CLOSURES · captura y escape

La captura: qué recuerda un closure

Un closure no es solo código: es código más el entorno del que salió. Esta lección explica qué decide capturar el compilador de Swift, por qué la captura por defecto es por referencia y no por valor, y cuál es el momento exacto en que se lee lo capturado, que casi nunca es el momento en que el lector supone. Recorre el destino físico de las variables capturadas, de la pila al heap, con la promoción de caja que hace posible que un valor sobreviva a su ámbito; distingue la captura de un valor de tipo valor de la captura de una referencia; y termina con los dos malentendidos que producen más errores reales, el del bucle y el del valor leído demasiado tarde.

⏱ 19 min

La palabra closure no significa bloque, ni función anónima, ni argumento entre llaves. Significa clausura: una función a la que se le ha cerrado el entorno encima, de modo que las variables libres de su cuerpo quedan ligadas al contexto donde se escribió y no al contexto donde se ejecuta. Esa definición, que suena a teoría de lenguajes, es exactamente lo que explica los errores prácticos que produce: un valor que llega distinto de lo que se esperaba, un objeto que no se libera nunca, un bucle que imprime cinco veces el mismo número. Ninguno de esos fallos viene de una regla oscura. Todos vienen de la misma pregunta mal contestada: qué se guardó, cuándo se guardó y cuándo se lee.

🎯 Al terminar esta lección sabrás
  • Determinar qué variables captura un closure y cuáles no, sin ejecutarlo.
  • Explicar por qué la captura por defecto es por referencia y qué implica para el momento de lectura.
  • Describir la promoción a caja en el heap y su efecto sobre el tiempo de vida de las variables.
  • Anticipar el comportamiento de un closure creado dentro de un bucle o antes de una mutación.

Qué se captura y por qué no hay que declararlo

Swift no exige declarar lo que un closure captura. El compilador analiza el cuerpo, identifica las variables libres —los identificadores que se usan pero no se declaran dentro ni son parámetros— y captura exactamente esas. Lo demás no entra: ni las constantes globales, ni los tipos, ni las funciones de nivel superior, porque ninguno depende del contexto de creación.

func ejemplo(entrada: Int) -> () -> Int {
  let constante = 10
  var acumulado = 0

  return {
    let local = 5           // no se captura: es local al closure
    acumulado += entrada    // se capturan `entrada` y `acumulado`
    return acumulado + constante + local
  }
}

Aquí se capturan tres cosas: el parámetro entrada, la variable acumulado y la constante constante. La variable local no, porque nace y muere dentro del cuerpo. La distinción importa porque cada elemento capturado tiene consecuencias sobre el tiempo de vida: mientras el closure exista, lo capturado sigue vivo, y esa es la mecánica exacta que produce los ciclos de retención que veremos en la lección siguiente.

Hay una captura que Swift hace sin escribirla nunca y que conviene ver desde el principio, porque es el origen de la mayoría de problemas de memoria en aplicaciones reales: dentro de un método, usar una propiedad o llamar a otro método captura self entero, aunque en el texto solo aparezca el nombre corto de la propiedad.

final class Contador {
  var valor = 0
  var alCambiar: (() -> Void)?

  func configurar() {
    alCambiar = {
      valor += 1      // esto es self.valor: captura `self`
    }
  }
}

Swift obliga a escribir self.valor de forma explícita en closures que escapan, precisamente para que esa captura sea visible en el texto y no un descubrimiento posterior. Esa obligación no es una molestia sintáctica: es un mecanismo de diseño del lenguaje que convierte una decisión de gestión de memoria en algo que se lee.

Por referencia, y por tanto tarde

La captura por defecto en Swift es por referencia a la variable, no por copia de su valor. El closure no guarda una fotografía del contenido en el instante de la creación: guarda un enlace al almacenamiento de esa variable, y lee el contenido cuando el cuerpo se ejecuta. La diferencia es invisible mientras nada muta y decisiva en cuanto algo lo hace.

var mensaje = "hola"

let imprimir = { print(mensaje) }

mensaje = "adios"
imprimir()   // imprime "adios", no "hola"

Que esto sorprenda a tanta gente revela una intuición equivocada muy extendida: se supone que capturar es copiar. No lo es. El closure y el ámbito exterior comparten la misma variable, y la comparten en las dos direcciones. Un closure puede escribir en lo que capturó, y el cambio es visible desde fuera.

var total = 0
let sumar = { (n: Int) in total += n }

sumar(3)
sumar(4)
print(total)   // 7

Esta bidireccionalidad convierte a los closures en una forma de estado mutable compartido, con todas las virtudes y todos los peligros que eso implica. La virtud es que permite acumuladores, contadores y generadores expresados en tres líneas. El peligro es que dos closures que capturan la misma variable están acoplados por un canal que no aparece en ninguna firma, y que ese canal cruza fronteras de concurrencia sin pedir permiso. Bajo comprobación estricta, el compilador de Swift 6 rechaza precisamente esas configuraciones cuando el closure es @Sendable, porque una variable capturada por referencia y mutable es una carrera de datos esperando a ocurrir.

💡
La regla operativa: por referencia salvo que digas lo contrario

Si necesitas la fotografía del valor en el instante de creación, no confíes en el orden del código: pídela explícitamente con una lista de captura, que es el tema de la lección siguiente. Un [valor = mensaje] al principio del closure convierte la referencia en copia y elimina de raíz toda ambigüedad sobre el momento de lectura.

Dónde vive lo capturado

Una variable local normal vive en la pila y muere cuando su ámbito termina. Un closure que la captura y sobrevive a ese ámbito exigiría leer memoria liberada, así que el compilador hace algo distinto: promociona la variable capturada a una caja asignada en el heap, con conteo de referencias propio. La variable deja de ser un espacio en el marco de pila y pasa a ser un objeto compartido entre el ámbito original y todos los closures que la capturaron.

func fabricar() -> (() -> Int, () -> Void) {
  var n = 0                       // promocionada a caja en el heap
  let leer = { n }
  let incrementar = { n += 1 }
  return (leer, incrementar)      // el marco de pila muere, la caja no
}

let (leer, incrementar) = fabricar()
incrementar()
incrementar()
print(leer())   // 2

Los dos closures comparten la misma caja, y por eso uno ve los efectos del otro después de que la función que los creó haya retornado. Este ejemplo es la demostración mínima de que capturar no es copiar: si lo fuera, leer devolvería siempre cero.

El compilador no promociona siempre. Cuando puede demostrar que el closure no escapa —que se consume dentro de la llamada y no se guarda en ninguna parte— mantiene la variable en la pila y el coste de la captura es cero. Por eso map, filter y sorted no asignan memoria por sus closures en el caso habitual, mientras que un manejador guardado en una propiedad sí lo hace. Esa asimetría es exactamente la que @escaping hace visible, y es el tema de la lección cuarta.

Un matiz importante para tipos valor: capturar un struct grande por referencia captura la variable, no su contenido, así que no hay copia en el momento de la captura. La copia ocurre después, si acaso, según las reglas normales de copia sobre escritura cuando alguien muta.

Los dos malentendidos que producen errores reales

El primero es el del bucle. Cada iteración de un for en Swift crea una variable nueva, así que los closures creados dentro capturan variables distintas y el comportamiento es el intuitivo. Pero una variable declarada fuera del bucle y mutada dentro se comparte entre todos los closures, y el resultado cambia por completo.

var acciones: [() -> Void] = []

for i in 1...3 {
  acciones.append { print(i) }      // 1, 2, 3
}

var j = 0
for _ in 1...3 {
  j += 1
  acciones.append { print(j) }      // 3, 3, 3
}

El segundo es el de la lectura tardía, y es más traicionero porque el código parece correcto en la línea donde se escribe. Un closure que se guarda ahora y se ejecuta dentro de dos segundos leerá el estado de dentro de dos segundos, no el de ahora. En una interfaz eso significa que un manejador construido con el índice de una fila puede leer un índice ya obsoleto cuando la lista se ha reordenado entre medias.

var indiceSeleccionado = 0
let confirmar = { aplicar(indice: indiceSeleccionado) }

indiceSeleccionado = 7
confirmar()   // aplica sobre 7, no sobre 0
🍊

Capturar es enlazar, no fotografiar

Mientras no digas lo contrario, el closure lee la variable en el momento de ejecutarse. Si el valor puede cambiar entre la creación y la llamada, ya tienes un error latente.

📦

La caja alarga la vida

Todo lo capturado vive mientras viva el closure. Esa es la mecánica exacta de las fugas de memoria y también la de los acumuladores elegantes.

flowchart TB
A[Variable local en la pila] -->|capturada por closure que escapa| B[Promocion a caja en el heap]
B --> C[Closure uno]
B --> D[Closure dos]
C -->|lee y escribe| B
D -->|lee y escribe| B
A -->|closure que no escapa| E[Sin promocion y sin coste]
style B fill:#89b4fa,color:#11111b
style E fill:#a6e3a1,color:#11111b
El closure no captura valores, captura el derecho a mirar

La formulación más útil que conozco para no equivocarse nunca con la captura es esta: un closure no guarda valores, guarda el derecho a consultar unas variables concretas más adelante. Todo lo demás se deduce. Se deduce que el momento de lectura es el de ejecución y no el de escritura, porque un derecho a mirar se ejerce cuando se ejerce. Se deduce que dos closures que capturan la misma variable están viendo la misma cosa y no dos copias, porque el derecho apunta al mismo sitio. Se deduce que lo mirado no puede morir antes que quien tiene derecho a mirarlo, y de ahí sale la promoción al heap y, por el mismo mecanismo, sale la fuga de memoria cuando el objeto mirado es también quien sostiene al closure. Se deduce incluso por qué la concurrencia estricta se pone nerviosa: un derecho a mirar y escribir una variable, ejercido desde dos hilos, es la definición literal de carrera de datos. Lo interesante es que esta formulación también explica la cura, y la cura es siempre la misma operación conceptual: convertir el derecho a mirar en una copia del valor mirado, que es lo que hace la lista de captura, o debilitar el derecho para que no impida morir a lo mirado, que es lo que hacen weak y unowned. Quien tiene clara esa dicotomía —copiar el valor o debilitar el enlace— tiene ya en la mano las dos únicas herramientas que el resto del nivel va a desarrollar.

⚔️ Predice antes de ejecutar
  1. Escribe una función que devuelva dos closures compartiendo una variable capturada, uno que lea y otro que escriba. Predice la salida de una secuencia de llamadas antes de compilar y comprueba si acertaste.
  2. Construye el caso del bucle con una variable declarada fuera y otra declarada dentro. Explica en una frase por qué las salidas difieren.
  3. Busca en tu proyecto un closure guardado en una propiedad que lea una variable mutable del entorno. Determina cuánto tiempo transcurre entre su creación y su ejecución, y si algo puede mutar en ese intervalo.
  4. Toma un closure que captura self implícitamente y reescríbelo con self. explícito en todas partes. Cuenta cuántas capturas de self había realmente.
  5. Marca un closure como @Sendable y observa qué capturas rechaza el compilador. Cada rechazo señala un estado compartido que no habías declarado.