Swift y Objective-C

[Fundamentos de Swift #2] Captura de cierres, weak self y escaping

En el código Swift, los cierres están por todas partes: condiciones de ordenación, manejadores de finalización de red, acciones de botones e incluso el body de SwiftUI. El código que pasa bloques entre llaves aparece decenas de veces al día. Sin embargo, cuando hay que explicar qué significa exactamente que un cierre capture valores, por qué se usa [weak self] o por qué se añade @escaping, es frecuente quedarse sin palabras.

7 min de lectura
Imagen de portada de [Fundamentos de Swift #2] Captura de cierres, weak self y escaping

En el código Swift, los cierres están por todas partes: condiciones de ordenación, manejadores de finalización de red, acciones de botones e incluso el body de SwiftUI. El código que pasa bloques entre llaves aparece decenas de veces al día. Sin embargo, cuando hay que explicar qué significa exactamente que un cierre capture valores, por qué se usa «[weak self]» o por qué se añade @escaping, es frecuente quedarse sin palabras.

En realidad, estas tres preguntas forman un solo tema. Al entender cómo un cierre captura los valores de su entorno, también se entienden por qué es un tipo por referencia, por qué aparecen referencias circulares y por qué hace falta marcarlo como escaping. En este segundo artículo de la serie de fundamentos de Swift, repasamos esa conexión paso a paso.

Qué es un cierre: más importante que el nombre es su capacidad de «capturar»

Empecemos con la sintaxis mínima. Un cierre es una forma de tratar un bloque de código ejecutable como un valor. Puede guardarse en una variable, pasarse como argumento y devolverse.

let add: (Int, Int) -> Int = { a, b in a + b }
add(2, 3) // 5

De hecho, una función declarada con func también es un cierre con nombre. En Swift, funciones y cierres pertenecen a la misma familia; la sintaxis { } solo es la versión que se crea al instante y sin nombre.

Pero el nombre closure no viene de «bloque de código», sino de otra propiedad: envolver y cerrar sobre las variables del entorno, es decir, hacer close over.

func makeCounter() -> () -> Int {
    var count = 0
    return {
        count += 1
        return count
    }
}

let counter = makeCounter()
counter() // 1
counter() // 2
counter() // 3

Está ocurriendo algo extraño. count es una variable local de makeCounter, así que debería desaparecer cuando la función retorna. Sin embargo, sigue creciendo cada vez que llamamos al cierre devuelto. El cierre captura la variable count, que está fuera de su propio cuerpo, y la mantiene viva después de que termine la función. Esa es la esencia de los cierres y el origen de todo lo demás en este artículo.

El significado exacto de capturar: no es copiar, sino referenciar

El modo de captura predeterminado de los cierres de Swift es por referencia. No se llevan una copia del valor, sino que mantienen el vínculo con la variable misma. Por eso obtenemos este resultado.

var multiplier = 2
let times = { (n: Int) in n * multiplier }

times(10)      // 20
multiplier = 3
times(10)      // 30 — El cierre ve el valor actualizado

No se usa el multiplier (2) del momento en que se creó el cierre, sino el multiplier (3) del momento de ejecución. El cierre no transporta una instantánea de la variable, sino la variable misma.

Si quieres fijar el valor del momento de creación, usa una lista de captura.

let times = { [multiplier] (n: Int) in n * multiplier }

times(10)      // 20
multiplier = 3
times(10)      // 20 — Fijado al valor 2del momento de creación

Las variables escritas entre corchetes se copian al crear el cierre y quedan congeladas dentro de él como constantes. En resumen: la captura predeterminada es por referencia (una conexión viva), mientras que la lista de captura es por valor (una instantánea de creación). Antes de explicar [weak self], hay que distinguirlas.

Este almacenamiento de capturas también hace que el cierre sea un tipo por referencia. Las variables capturadas deben compartir la vida del cierre, por lo que se guardan en el heap; al copiar el valor del cierre, simplemente aumenta en uno el número de referencias que comparten ese almacenamiento. Por eso un cierre se comporta como una clase en un Swift centrado en struct. Si la diferencia entre tipos por valor y por referencia te resulta nueva, conviene leer primero el artículo sobre priorizar los tipos por valor.

La captura predeterminada es una conexión viva; la lista de captura, una instantánea de creación
La captura predeterminada es una conexión viva; la lista de captura, una instantánea de creación

Referencias circulares y [weak self]: el problema típico que provoca la captura

El coste de capturar por referencia son las referencias circulares. El esquema siempre es el mismo.

class ProfileViewModel {
    var onUpdate: (() -> Void)?
    var name = ""

    func bind() {
        onUpdate = {
            print("Nombre: \(self.name)")
        }
    }
}

El modelo de vista posee firmemente el cierre mediante la propiedad onUpdate. Pero ese cierre captura por referencia a self, es decir, al modelo de vista. El ciclo de propiedad queda cerrado: modelo de vista → cierre → modelo de vista. En el mundo de ARC, ambos se retienen mutuamente y nunca se liberan. Aunque se cierre la pantalla, el modelo de vista permanece en memoria: la fuga de memoria está completa.

La solución es el segundo uso de la lista de captura. [weak self] indica que self debe capturarse como referencia débil. El cierre retiene self, pero no lo posee, así que el ciclo se rompe. Como self puede liberarse antes, dentro del cierre pasa a ser opcional y normalmente se empieza con guard let self else { return }. El patrón de salida temprana explicado en el artículo sobre opcionales vuelve a aparecer aquí.

onUpdate = { [weak self] in
    guard let self else { return }
    print("Nombre: \(self.name)")
}

Lo importante es que [weak self] no es un prefijo universal. La condición del ciclo es que «self posea el cierre y el cierre capture self». Si no se cumple, no hace falta weak. Por ejemplo, el sistema descarta tras ejecutarlo el cierre que se pasa a DispatchQueue.main.asyncAfter; self solo vive un poco más y no hay fuga. En lugar de añadir weak por reflejo, acostúmbrate a preguntar quién posee el cierre y durante cuánto tiempo. Los casos conocidos con estructuras de propiedad especiales, como NSTimer, se tratarán en otro artículo.

@escaping: cuando el cierre vive más que la función

Aquí está la última pieza. Los cierres recibidos como parámetros de función a veces llevan la marca @escaping.

func fetchUser(completion: @escaping (User) -> Void) {
    URLSession.shared.dataTask(with: url) { data, _, _ in
        let user = parse(data)
        completion(user)   // Se ejecuta mucho después de que la función haya retornado
    }.resume()
}

El criterio es el momento de ejecución. Un cierre que se ejecuta y se descarta dentro de la función antes de que esta retorne es non-escaping (el valor predeterminado). Si se guarda en una propiedad o se pasa a una tarea asíncrona y puede ejecutarse después del retorno, es escaping: un cierre que escapa de la función.

¿Por qué obligar a distinguirlos? Porque la forma de razonar del compilador y del desarrollador cambia según escape o no. Como se garantiza que un non-escaping solo vive durante la ejecución de la función, el compilador puede optimizar el almacenamiento de capturas y las referencias circulares quedan descartadas de raíz. Por eso no hace falta preocuparse por self en cierres que se pasan a map o filter. En cambio, escaping significa que el cierre se guarda en algún lugar y vive más tiempo, así que debe revisarse por si forma una referencia circular. @escaping funciona como una etiqueta de advertencia de la API: «este cierre vivirá más tiempo; presta atención a sus capturas».

Por cierto, los cierres escaping con estilo de manejador de finalización están disminuyendo en el código nuevo desde la llegada de async/await, pero siguen siendo esenciales para leer y adaptar API existentes.

weak rompe el ciclo de retención mutua
weak rompe el ciclo de retención mutua

Tres criterios para aplicar en la práctica

Si condensamos la teoría en criterios prácticos, son tres.

Primero, al ver un cierre, pregunta por su ciclo de vida. ¿Se consume dentro de la función y termina (non-escaping), o se guarda en algún sitio y vive más tiempo (escaping)? Esta pregunta determina cuánto debes preocuparte por la captura.

Segundo, usa weak con criterio, no por reflejo. En los puntos donde realmente se cierra el ciclo de propiedad (manejadores guardados en propiedades, callbacks de tipo delegado), usa [weak self]; para ejecuciones únicas sin ciclo, no hace falta. Si no está claro, compruébalo con Leaks de Instruments o con registros de deinit.

Tercero, documenta la intención con una lista de captura. Para fijar un valor, [value]; para no poseerlo, [weak self]. La lista de captura no es solo un mecanismo de rendimiento: documenta en el código cómo se conecta el cierre con el mundo exterior.

Resumen

  • La esencia de un cierre no es el bloque de código, sino la captura: envolver y cerrar sobre variables del entorno para prolongar su ciclo de vida.
  • La captura predeterminada es una referencia, no una copia, por lo que el cierre ve el valor en el momento de ejecución; la lista de captura [x] lo fija al valor de creación.
  • Como debe compartir el almacenamiento de capturas, el cierre es un tipo por referencia.
  • Si un cierre poseído por self captura self, aparece una referencia circular; [weak self] + guard let self es la solución estándar. Sin ciclo de propiedad, weak tampoco es necesario.
  • @escaping es una etiqueta de advertencia que indica «cierre que vive más que la función» y señala dónde hay que revisar las capturas.

En el próximo artículo repasaremos las opciones ocultas en una declaración de variable: propiedades almacenadas y calculadas, lazy y observadores de propiedades.

Seguir leyendo