wandres.dev
FUNCIONES Y CLOSURES · captura y escape

La lista de captura

La lista de captura es el único lugar del lenguaje donde se puede intervenir sobre qué guarda un closure y con qué fuerza. Esta lección la trata como lo que es: una secuencia de enlaces evaluados en el instante de la creación, capaz de convertir una referencia en copia y una referencia fuerte en débil o sin dueño. Reconstruye el ciclo de retención clásico paso a paso, con el diagrama de quién sostiene a quién, y expone su cura; distingue con precisión `weak` de `unowned` según la relación de tiempos de vida y no según la costumbre; y cierra con los patrones idiomáticos modernos, incluido el matiz de por qué en una `Task` la captura débil rara vez es la respuesta correcta.

⏱ 20 min

Todo lo que un closure captura lo captura por defecto y con fuerza: enlaza la variable, no copia el valor, y mantiene vivo lo que apunta mientras él viva. Ese comportamiento por defecto es correcto la mayoría de las veces y catastrófico en un caso concreto y frecuentísimo, el del objeto que guarda un closure que a su vez lo sostiene a él. La lista de captura es la herramienta con la que Swift permite intervenir en esa mecánica, y su sintaxis es engañosamente pequeña: unos corchetes antes de los parámetros. Dentro caben dos operaciones distintas que conviene no confundir nunca, porque resuelven problemas diferentes: copiar un valor en el instante de la creación, y debilitar un enlace para que no impida morir a su destino. Confundirlas produce o bien fugas silenciosas o bien caídas en tiempo de ejecución, y ninguna de las dos avisa en el momento de escribir la línea.

🎯 Al terminar esta lección sabrás
  • Leer una lista de captura como una secuencia de enlaces evaluados en el momento de crear el closure.
  • Dibujar el grafo de retención de un ciclo clásico e identificar qué arista hay que cortar.
  • Elegir entre weak y unowned a partir de la relación real entre tiempos de vida.
  • Aplicar los patrones idiomáticos modernos y reconocer cuándo debilitar es un error.

La lista como secuencia de enlaces evaluados ya

La lista de captura va entre corchetes inmediatamente antes de los parámetros y de la palabra in. Cada entrada declara una constante nueva, visible solo dentro del closure, cuyo valor se calcula en el momento de crear el closure y no en el de ejecutarlo. Ese desplazamiento temporal es la primera de sus dos funciones y resuelve por completo el problema de la lectura tardía.

var mensaje = "hola"

let tarde = { print(mensaje) }
let ahora = { [mensaje] in print(mensaje) }

mensaje = "adios"
tarde()   // adios
ahora()   // hola

La entrada puede renombrar y puede contener una expresión completa, lo que la convierte en un lugar razonable para congelar estado derivado sin ensuciar el ámbito exterior.

let manejador = { [identificador = modelo.id, instante = Date.now] in
  registrar(identificador, en: instante)
}

Conviene ser exacto con la semántica de la copia, porque el atajo mental la lista de captura copia solo es cierto para tipos valor. Para un tipo referencia, lo que se copia es la referencia, no el objeto: [modelo] sigue reteniendo con fuerza el mismo objeto de siempre. La lista congela a qué se apunta, no el contenido de lo apuntado. De ahí que resolver un ciclo de retención nunca se consiga copiando, sino con la segunda función de la lista: los especificadores de fuerza.

El ciclo de retención clásico

El caso canónico tiene tres elementos y una simetría fatal. Un objeto guarda un closure en una propiedad, y ese closure usa una propiedad del objeto, con lo que captura self con fuerza. El objeto retiene al closure; el closure retiene al objeto. El conteo de referencias de ambos nunca llega a cero, y ninguno se libera jamás, aunque nadie más en el programa los conozca.

final class Reproductor {
  var titulo = "silencio"
  var alTerminar: (() -> Void)?

  func preparar() {
    alTerminar = {
      print("fin de \(self.titulo)")   // captura fuerte de self
    }
  }

  deinit { print("liberado") }
}

var r: Reproductor? = Reproductor()
r?.preparar()
r = nil        // no imprime "liberado": el ciclo lo impide

Lo que hace peligroso a este patrón no es su dificultad conceptual sino su invisibilidad. No hay error de compilación, no hay aviso, no hay caída. El programa funciona; simplemente no devuelve la memoria, y el síntoma aparece semanas después como un crecimiento sostenido bajo navegación repetida, o como un manejador de un objeto ya cerrado que sigue reaccionando a eventos y produce efectos fantasma. Ese segundo síntoma suele ser más caro que el primero, porque se manifiesta como un fallo lógico y nadie lo relaciona con la memoria.

La cura es cortar una de las dos aristas, y por convención se corta la del closure hacia el objeto, porque la otra es la que expresa la relación de propiedad real.

alTerminar = { [weak self] in
  guard let self else { return }
  print("fin de \(self.titulo)")
}
💡
El ciclo no necesita ser directo

Los ciclos de tres o cuatro nudos son igual de reales y bastante más difíciles de ver: una vista retiene un modelo, el modelo retiene un servicio, el servicio guarda un closure que captura la vista. Ninguna de las tres líneas parece sospechosa por separado. Cuando dudes, el instrumento correcto no es la lectura sino un deinit con una traza y una prueba manual de abrir y cerrar la pantalla veinte veces.

weak frente a unowned

Los dos especificadores capturan sin retener. La diferencia está en qué ocurre cuando el destino muere antes que el closure, y esa diferencia es total.

Aspecto weak unowned
Tipo dentro del closure Opcional No opcional
Si el destino murió Vale nil Comportamiento indefinido o caída
Coste Registro en tabla de referencias débiles Prácticamente nulo
Cuándo usarlo El destino puede morir antes El destino garantiza sobrevivir

La regla de decisión correcta no es usa siempre weak por seguridad, aunque sea el consejo más repetido, sino una comparación de tiempos de vida. Si el closure puede ejecutarse legítimamente después de que el objeto haya desaparecido —una respuesta de red que llega tarde, un temporizador, una notificación— entonces weak es lo apropiado y el opcional expresa una posibilidad real. Si el closure está garantizadamente contenido dentro del objeto y muere con él, unowned es más honesto: declara la invariante en el código y convierte su violación en un fallo ruidoso en vez de en un silencio.

final class Sesion {
  private var cierre: (() -> Void)!

  init() {
    // El closure es propiedad de self y no puede sobrevivirle
    cierre = { [unowned self] in self.limpiar() }
  }

  func limpiar() { }
}

La objeción evidente es que unowned puede provocar una caída. Es cierta y es precisamente su valor: una caída inmediata en el punto exacto del error es infinitamente más barata de diagnosticar que un weak self que se evalúa a nil y hace que el closure retorne pronto en silencio, dejando una operación a medias que nadie sabe que no ocurrió. El coste de weak no es de rendimiento, es de ocultación: el guard let self else { return } convierte un error de tiempo de vida en una rama vacía sin traza.

Los patrones modernos y sus matices

Desde Swift 5.8 se puede escribir guard let self else { return } sin renombrar, y desde Swift 5.3 la lista de captura admite [weak self] combinado con desenvolvimiento inmediato. La forma idiomática actual es corta y merece leerse con atención a qué hace cada parte.

cargar { [weak self] resultado in
  guard let self else { return }
  self.estado = .cargado(resultado)
}

Hay un caso donde la costumbre se vuelve contraproducente, y es el de las tareas estructuradas. Una Task no la retiene el objeto: la retiene el sistema de concurrencia hasta que termina. Capturar self con fuerza dentro de una Task no crea un ciclo, solo alarga la vida del objeto hasta el final de la tarea, que suele ser exactamente lo que se quiere si la tarea va a escribir en él. Escribir [weak self] en ese contexto produce, en el peor caso, que el trabajo se descarte a mitad porque el objeto murió y el guard cortó la ejecución, sin que nadie se entere.

// Habitualmente correcto: se quiere que self viva hasta terminar
Task { await self.refrescar() }

// Correcto solo si el trabajo es realmente descartable
Task { [weak self] in await self?.refrescarSiSigueVivo() }

La misma cautela vale para closures que no escapan: map, filter, forEach y compañía se ejecutan y desaparecen dentro de la llamada, así que no pueden formar un ciclo y no necesitan [weak self]. Escribirlo ahí es ruido que además obliga a un desenvolvimiento innecesario y confunde al lector sobre la naturaleza del closure.

🍊

Copiar no rompe ciclos

Poner el objeto en la lista de captura sin especificador sigue reteniéndolo. Solo weak y unowned cortan la arista.

🔍

Compara tiempos de vida, no costumbres

La pregunta no es cuál es más seguro sino si el destino puede morir antes que el closure. La respuesta a esa pregunta elige sola el especificador.

flowchart LR
O[Objeto] -->|propiedad fuerte| C[Closure]
C -->|captura fuerte de self| O
O2[Objeto] -->|propiedad fuerte| C2[Closure]
C2 -.->|weak o unowned| O2
C2 --> L[Ambos se liberan]
style C fill:#f38ba8,color:#11111b
style L fill:#a6e3a1,color:#11111b
El especificador de captura es una afirmación sobre el futuro, no una precaución

Merece la pena resistir la lectura de weak como la opción prudente y unowned como la opción temeraria, porque esa lectura convierte una decisión de diseño en un reflejo y produce código peor. Lo que se escribe en una lista de captura es una afirmación sobre la relación entre dos tiempos de vida, y esa afirmación es verdadera o falsa con independencia de lo cómodos que nos sintamos. Cuando escribes unowned afirmas que el destino sobrevivirá a toda ejecución del closure; si es cierto, has documentado una invariante y has obtenido una referencia no opcional que hace el cuerpo más limpio, y si es falso el programa te lo dirá en el primer instante y en el lugar exacto. Cuando escribes weak afirmas que el destino puede haber desaparecido y que el closure tiene una respuesta razonable para ese caso; el problema es que casi nadie escribe esa respuesta razonable, y en su lugar aparece un guard let self else { return } que convierte cada aparición del caso previsto en un silencio sin traza. Un programa lleno de retornos silenciosos no es un programa seguro: es un programa que ha decidido no enterarse de sus propios errores de tiempo de vida, y que fallará igual pero más tarde y con menos información. La disciplina que propongo es sencilla y desagradable al principio: cada vez que escribas weak self, escribe también qué debe pasar cuando sea nil, aunque sea una traza de registro. Si al hacerlo descubres que no hay nada sensato que hacer en ese caso, acabas de descubrir que el caso no ocurre, y que lo que necesitabas era unowned.

⚔️ Audita las capturas de tu proyecto
  1. Añade un deinit con traza a las tres clases de larga vida más importantes y navega diez veces por sus pantallas. Cada deinit que no aparece es un ciclo.
  2. Localiza todos los [weak self] en closures que no escapan y elimínalos. Comprueba que nada cambia y anota cuánto ruido desaparece.
  3. Busca los guard let self else { return } de tu base de código y escribe, para cada uno, qué se pierde cuando esa rama se toma. Los que no tengan respuesta son candidatos a unowned.
  4. Construye a propósito un ciclo de tres nudos entre vista, modelo y servicio, obsérvalo en el depurador de memoria y córtalo eligiendo conscientemente qué arista debilitar.
  5. Toma una Task con [weak self] y decide si el trabajo es realmente descartable. Si no lo es, quita el especificador y explica en un comentario por qué la captura fuerte es correcta.