wandres.dev
MEMORIA Y ARC · ciclos y captura

Ciclos de retención: cuando dos objetos se sostienen mutuamente

Un ciclo de retención no es un fallo de ARC sino una afirmación falsa escrita en el código: la de que dos objetos se poseen el uno al otro y ninguno puede morir antes que el otro. Esta lección formula el ciclo como una propiedad del grafo de referencias fuertes, analiza los dos casos que producen casi todas las fugas reales en aplicaciones Swift —la relación con el delegado y el closure que se guarda a sí mismo— y ofrece un método para reconocer la topología del ciclo antes de escribirlo, no después de perseguirlo con un perfilador.

⏱ 18 min

El conteo de referencias toma decisiones locales: cada objeto sabe cuántos lo sostienen, y muere cuando ese número llega a cero. Esa localidad es su virtud —destrucción inmediata, coste predecible— y también su límite exacto. Si dos objetos se sostienen mutuamente, cada uno ve un conteo perfectamente legítimo de uno, ninguno tiene motivo para morir, y el par sobrevive aunque ningún otro punto del programa pueda alcanzarlos. No hay error, no hay aviso, no hay excepción: hay una isla de memoria viva y muda. Reconocer esa topología es la única defensa, porque ARC nunca la va a ver por ti.

🎯 Al terminar esta lección sabrás
  • Formular el ciclo de retención como propiedad del grafo dirigido de referencias fuertes.
  • Diagnosticar la relación con el delegado y explicar por qué la propiedad debe apuntar en un solo sentido.
  • Identificar el ciclo del closure almacenado y localizar sus tres ingredientes.
  • Anticipar los ciclos indirectos con más de dos participantes y los que introducen temporizadores, notificaciones y tareas.

La forma del ciclo

Piensa el programa como un grafo dirigido en el que los nodos son instancias de clase y las aristas son referencias fuertes. Las raíces son la pila, las variables globales y los objetos que el sistema sostiene por su cuenta. Bajo ARC un objeto vive mientras tenga al menos una arista entrante; bajo trazado, mientras haya un camino desde alguna raíz. La diferencia entre ambos criterios es precisamente el conjunto de las fugas: una componente fuertemente conexa sin ninguna arista entrante desde fuera de ella. Todos sus miembros tienen conteo positivo y ninguno es alcanzable.

final class Persona {
    let nombre: String
    var piso: Piso?            // fuerte
    init(nombre: String) { self.nombre = nombre }
    deinit { print("adios \(nombre)") }
}

final class Piso {
    let numero: Int
    var inquilino: Persona?    // fuerte: aqui se cierra el ciclo
    init(numero: Int) { self.numero = numero }
    deinit { print("adios piso \(numero)") }
}

do {
    let ana = Persona(nombre: "Ana")
    let atico = Piso(numero: 7)
    ana.piso = atico
    atico.inquilino = ana
}   // no se imprime nada: ambos conteos quedan en 1

El silencio de ese bloque es el síntoma. Los dos deinit no se ejecutan nunca, no porque haya un error, sino porque el código afirmó dos cosas incompatibles con la muerte: que la persona posee su piso y que el piso posee a su inquilino. La solución no es técnica sino conceptual, y consiste en responder a una pregunta del dominio: si un piso deja de existir, ¿debe desaparecer su inquilino? Evidentemente no. Entonces esa arista no era de propiedad, y declararla fuerte fue mentir.

flowchart LR
raiz[Ambito local] --> a[Persona conteo uno]
a --> b[Piso conteo uno]
b --> a
raiz -.-> fin[El ambito termina y suelta sus referencias]
fin --> isla[La isla queda inalcanzable pero viva]

El delegado y la jerarquía

El patrón de delegación es el productor histórico de ciclos porque invierte la dirección natural de la propiedad. Un controlador crea y posee una vista o un servicio; ese servicio necesita avisar a alguien cuando ocurre algo, y ese alguien es el controlador. Si la propiedad del delegado se declara fuerte, el hijo pasa a poseer al padre y el ciclo queda cerrado en dos aristas.

protocol CargaDelegado: AnyObject {          // restringido a clases: obligatorio
    func termino(_ datos: Data)
}

final class Descargador {
    weak var delegado: CargaDelegado?        // el hijo NO posee al padre
}

final class Pantalla: CargaDelegado {
    private let descargador = Descargador()  // el padre SI posee al hijo
    init() { descargador.delegado = self }
    func termino(_ datos: Data) { }
}

Dos detalles de esas seis líneas no son opcionales. El primero es AnyObject en el protocolo: sin esa restricción el protocolo podría conformarse desde una estructura, y una referencia débil solo existe para tipos con identidad, así que el compilador rechazaría la propiedad weak. El segundo es que la debilidad va en la arista de vuelta, la del hijo hacia el padre, y nunca en la de ida. Si por comodidad hicieras débil la propiedad descargador, el objeto moriría en cuanto terminase el inicializador y tendrías el error opuesto: una referencia que vale nulo sin explicación aparente.

🧠

Regla de la jerarquía

El padre posee al hijo con una referencia fuerte. El hijo mira hacia atrás con weak o unowned. Si no hay padre evidente, el diseño tiene un problema anterior al de la memoria.

🔁

Regla de la reciprocidad

Toda relación bidireccional entre clases necesita que exactamente uno de los dos lados sea no propietario. Da igual cuál elijas mientras lo elijas conscientemente y lo documentes.

El closure que se guarda a sí mismo

Un closure en Swift es un objeto del montículo con su propio conteo: contiene un puntero a código y una caja con lo capturado. Cuando captura self, retiene self. El ciclo aparece cuando ese mismo closure acaba almacenado dentro del objeto que capturó, porque entonces el objeto sostiene al closure y el closure sostiene al objeto.

final class Formulario {
    var alPulsar: (() -> Void)?
    var texto = ""

    func configurar() {
        alPulsar = {                 // el closure retiene self
            self.texto = "enviado"   // ...y self retiene al closure
        }
    }

    func correcto() {
        alPulsar = { [weak self] in
            guard let self else { return }
            self.texto = "enviado"
        }
    }
}

Los ingredientes son siempre tres y hacen falta los tres a la vez: que el closure escape, que capture self de forma fuerte y que quede alcanzable desde self. Si falta alguno, no hay ciclo. Por eso un closure no escapante —el de map, el de sort, el cuerpo de una animación síncrona— jamás produce uno: muere al terminar la llamada y no llega a almacenarse en ninguna parte. Y por eso escribir [weak self] en todas partes por costumbre no es prudencia sino ruido, que además obliga a razonar sobre un self opcional que nunca podía ser nulo.

Hay una familia de casos donde la referencia que cierra el ciclo no está en tu código sino en el del sistema, y son los que más cuesta ver. Un temporizador programado retiene su destino hasta que alguien lo invalida. Un observador registrado con el bloque de notificaciones retiene el bloque, y el bloque a self, hasta que se retira el testigo. Una tarea estructurada mantiene vivo lo que capturó mientras siga suspendida en espera. Y una cadena de publicadores retiene sus suscriptores mientras la suscripción no se cancele o se guarde en un conjunto que muera con el objeto.

final class Reloj {
    private var temporizador: Timer?

    func arrancar() {
        temporizador = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            self?.tick()
        }
    }
    private func tick() { }
    deinit { temporizador?.invalidate() }   // sin esto, el ciclo lo cierra el runloop
}

Fíjate en la trampa fina de ese ejemplo: el deinit que invalida el temporizador solo se ejecuta si el objeto muere, y el objeto no moriría si el bloque capturase self con fuerza. El deinit de limpieza y la captura débil no son alternativas entre las que elegir; son dos piezas de la misma solución y se necesitan mutuamente.

Un ciclo es una proposición falsa, no un descuido

Cuando declaras una propiedad de tipo clase sin anotación, estás escribiendo una frase con contenido: mientras yo exista, esto también. Es una obligación que asumes en nombre de tu objeto, y como toda obligación tiene sentido solo si es asimétrica: un contenedor sostiene a lo contenido, un padre a su hijo, un dueño a su recurso. Un ciclo de retención aparece cuando dos objetos firman esa obligación en las dos direcciones a la vez, y entonces la frase resultante deja de ser una descripción del dominio para convertirse en una paradoja: cada uno espera al otro para morir, como dos personas educadas en una puerta. ARC no puede resolverla porque no es un problema de conteo sino de sentido: los números son correctos, lo que es falso es la afirmación. De ahí se sigue el método completo de diagnóstico, que no consiste en buscar fugas sino en dibujar el grafo y preguntar por cada arista quién sobrevive a quién. Si un objeto puede seguir siendo útil cuando el otro ya no está, la arista de vuelta no era propiedad y debía ser débil. Si ninguno de los dos tiene sentido sin el otro, entonces no eran dos objetos sino uno mal partido, y la corrección no es poner weak sino unir lo que nunca debió separarse. Por eso los ciclos abundan justamente donde el diseño es más vago: en los observadores sin dueño claro, en los closures que capturan el mundo entero, en las relaciones bidireccionales que se declararon por comodidad de navegación. La fuga es el síntoma; la ambigüedad ontológica es la enfermedad.

⚠️
El ciclo puede tener más de dos nodos

Casi toda la literatura ilustra el ciclo con dos objetos, y eso educa mal el ojo. En una aplicación real los ciclos frecuentes son de tres o cuatro pasos: la vista sostiene al modelo de vista, el modelo de vista sostiene al coordinador, el coordinador guarda la vista en su pila de navegación. Ninguna de esas tres aristas parece sospechosa por separado, y solo el grafo completo revela la componente cerrada. Cuando busques ciclos, no busques pares: busca caminos que vuelvan al punto de partida.

⚔️ Cerrar y abrir ciclos a propósito
  1. Reproduce el ejemplo de la persona y el piso, comprueba que no se imprime ningún deinit y corrígelo eligiendo razonadamente cuál de las dos aristas deja de ser propietaria.
  2. Escribe un protocolo de delegación sin AnyObject, intenta declarar la propiedad weak y explica con precisión el error del compilador.
  3. Construye un ciclo de tres objetos donde ninguna arista parezca sospechosa por sí sola, y dibuja el grafo hasta localizar la componente cerrada.
  4. Programa un temporizador repetitivo que capture self con fuerza, verifica que el objeto no muere nunca y arréglalo con captura débil más invalidación en deinit.
  5. Recorre un proyecto tuyo buscando closures almacenados en propiedades y clasifica cada uno según los tres ingredientes: escapa, captura self, es alcanzable desde self.