wandres.dev
MEMORIA Y ARC · ciclos y captura

deinit y el ciclo de vida: cuándo muere realmente un objeto

El destructor es la única ceremonia determinista que ofrece ARC, y precisamente por eso conviene saber con exactitud qué garantiza, qué no garantiza y en qué hilo se ejecuta. Esta lección recorre el orden completo de la destrucción, desmonta la intuición falsa de que un objeto vive hasta el final de su ámbito léxico, y cataloga las causas reales por las que la liberación se retrasa: charcos de autoliberación, cierres en vuelo, referencias no propietarias que retienen el bloque de memoria y jerarquías de interfaz que sostienen sin que se note.

⏱ 18 min

deinit es la contrapartida exacta de la promesa de ARC: si el conteo es determinista, entonces existe un instante preciso en el que el objeto deja de existir, y ese instante puede llevar código tuyo. Ahí se cierran descriptores, se invalidan temporizadores, se retiran observadores y se sueltan bloqueos. El problema práctico no es escribir el destructor sino saber cuándo se ejecuta, porque casi todas las intuiciones que traemos de otros lenguajes son falsas en Swift: no se ejecuta necesariamente al final del ámbito, no se ejecuta necesariamente en el hilo que creó el objeto, y no siempre significa que la memoria haya vuelto al sistema. Tres precisiones que explican la mayoría de los ratos perdidos delante de un print que no aparece.

🎯 Al terminar esta lección sabrás
  • Enumerar en orden las fases de la destrucción de una instancia y lo que es legal hacer en cada una.
  • Refutar la idea de que un objeto vive hasta el cierre de su ámbito léxico y aplicar la herramienta que sí lo garantiza.
  • Catalogar las causas por las que la liberación observada llega más tarde de lo previsto.
  • Escribir destructores correctos frente a hilos, aislamiento y jerarquías de interfaz.

Qué garantiza el destructor

Cuando el conteo fuerte llega a cero ocurre una secuencia fija. Primero se ejecuta el cuerpo de deinit de la clase más derivada, con todas las propiedades todavía intactas y accesibles. Después, y automáticamente, el de cada superclase en orden ascendente, sin que tengas que llamar a nada. Solo cuando esa cadena termina se liberan las propiedades almacenadas, lo que produce la cascada de liberaciones sobre todo lo que el objeto poseía. Y solo entonces, si no queda ninguna referencia no propietaria pendiente, la memoria vuelve al asignador.

final class Conexion {
    private let descriptor: Int32
    private var observador: NSObjectProtocol?

    init(descriptor: Int32) { self.descriptor = descriptor }

    deinit {
        if let observador { NotificationCenter.default.removeObserver(observador) }
        close(descriptor)          // las propiedades siguen vivas aqui
    }
}

El destructor no admite parámetros, no devuelve nada, no puede lanzar errores, no puede ser async y no puede invocarse a mano: solo lo llama el sistema. Tampoco existe en estructuras ni en enumerados, y no por una limitación del compilador sino porque un valor puede copiarse sin límite y no hay ningún instante privilegiado al que atar la ceremonia. Hay una regla adicional que no aparece en el compilador pero sí en la realidad: nunca dejes escapar self desde un destructor. Entregarlo a algo que lo almacene resucita un objeto cuyo conteo ya llegó a cero, y el resultado es memoria liberada con vida aparente.

Tampoco existe garantía de orden entre objetos distintos más allá de la que impone la propiedad: si dos hermanos mueren a la vez porque su padre acaba de soltarlos, el lenguaje no promete cuál se destruye primero, y escribir código que dependa de ese orden es apoyarse en un detalle de implementación. Lo único ordenado es la relación vertical: el dueño termina su destructor antes de que empiece el de lo que poseía.

Un punto que sorprende y provoca errores tercos: el destructor se ejecuta en el hilo que hizo la última liberación, que no tiene por qué ser el principal. Si la última referencia la sostenía un bloque de finalización de red, tu deinit corre en un hilo de fondo, y cualquier limpieza de interfaz que hagas allí es un error de concurrencia. Las versiones recientes de Swift permiten declarar el destructor aislado a un actor para resolver justamente ese caso, pero mientras no lo hagas explícito la regla vigente es que el destructor no está aislado en ninguna parte.

flowchart TB
a[La ultima referencia fuerte se libera] --> b[Cuerpo de deinit de la clase derivada]
b --> c[Cuerpo de deinit de cada superclase en orden]
c --> d[Se liberan las propiedades almacenadas]
d --> e[Cascada de liberaciones sobre lo que el objeto poseia]
e --> f[Quedan referencias no propietarias]
f --> g[El bloque sigue reservado como zombi]
e --> h[No quedan: la memoria vuelve al asignador]

El último release no está donde crees

La intuición heredada de otros lenguajes dice que una variable local vive hasta la llave de cierre. Swift no garantiza eso. El modelo del lenguaje es que una referencia debe estar viva mientras pueda usarse, y el optimizador tiene derecho a insertar la liberación inmediatamente después del último uso. En compilación de depuración el tiempo de vida suele estirarse hasta el final del ámbito, lo que produce el peor escenario posible: código que funciona mientras lo depuras y falla al publicarlo.

func leer() {
    let fichero = Fichero(ruta: "/tmp/datos")
    let puntero = fichero.punteroCrudo        // ultimo uso de `fichero`
    procesar(puntero)                         // el objeto puede estar ya destruido
}

func leerBien() {
    let fichero = Fichero(ruta: "/tmp/datos")
    withExtendedLifetime(fichero) {
        procesar(fichero.punteroCrudo)        // vida garantizada dentro del bloque
    }
}

withExtendedLifetime existe precisamente porque el tiempo de vida no es léxico, y su ámbito de aplicación es estrecho pero crítico: punteros crudos obtenidos de un objeto, identificadores de recursos del sistema y cualquier situación en la que la validez de algo dependa de que otra cosa siga existiendo sin que el compilador pueda verlo. Fuera de esos casos, el hecho de que deinit se adelante no molesta a nadie.

La consecuencia metodológica es incómoda pero clara: cualquier razonamiento del tipo “esto sigue vivo porque la variable todavía está en el ámbito” es inválido en Swift, aunque funcione al depurarlo. Si la validez de un puntero, de un identificador del sistema o de un bloqueo depende de que un objeto siga existiendo, esa dependencia hay que escribirla, porque el compilador no puede inferirla desde código no seguro.

Conviene añadir el matiz simétrico: el destructor tampoco se retrasa por cortesía. Si el objeto muere en mitad de una función porque nadie más lo sostiene, tu deinit corre ahí mismo, en medio de la ejecución de otra cosa, y todo lo que haga —tomar un bloqueo, publicar una notificación, tocar una colección compartida— ocurre en ese punto y no en un momento tranquilo.

Por qué a veces tarda más de lo que crees

Descartada la ilusión del ámbito, quedan las causas reales del retraso, y ninguna de ellas es un fallo de ARC: todas son referencias que existen y que no habías contado.

🧠

Charco de autoliberación

La interoperabilidad con Objective-C devuelve objetos autoliberados que no mueren hasta que se vacía el charco, normalmente al final de la iteración del bucle de eventos. En un bucle cerrado eso parece una fuga: la memoria sube y baja de golpe al terminar. Envuelve la iteración en autoreleasepool y el perfil se aplana.

Referencias en vuelo

Un bloque encolado, una tarea suspendida, una animación en curso o una petición pendiente sostienen lo que capturaron hasta que terminan. El objeto no está fugado: está esperando a que algo que ya no te importa acabe de ocurrir.

La regla general para leer ambas cartas es la misma: pregunta siempre quién sostiene la referencia, no cuándo dejaste tú de usarla. El objeto no muere cuando pierdes interés en él, sino cuando el último dueño lo suelta, y el sistema tiene muchos más dueños de los que aparecen en tu código.

A esas dos se suman otras tres que producen diagnósticos equivocados con frecuencia. La primera es la fase de zombi que introduce unowned: si alguien conserva una referencia no propietaria, el destructor ya se ejecutó pero el bloque sigue reservado, de modo que un instrumento de memoria mostrará el objeto vivo mientras tus registros dicen que murió. Las dos cosas son ciertas y describen fases distintas.

La segunda son las jerarquías de la interfaz, que sostienen mucho más de lo que la vista de código sugiere: una vista pertenece a su supervista, un controlador pertenece a la pila de navegación o al contexto de presentación, y un gesto reconocido puede sostener su destino. Cerrar una pantalla no equivale a liberarla si algo del sistema aún la referencia.

La tercera es el propio depurador. Evaluar expresiones sobre un objeto lo retiene mientras la sesión lo inspecciona, y detener el proceso congela los ámbitos activos con todo lo que contienen. Diagnosticar tiempos de vida con puntos de interrupción es medir con un instrumento que altera lo medido; para eso están el grafo de memoria y las pruebas automáticas de la lección siguiente.

Cómo se escribe un destructor correcto

De todo lo anterior se deducen unas pocas reglas que no son de estilo sino de corrección. La primera es que el destructor debe ser barato y sin bloqueo. Como se ejecuta en el hilo que casualmente soltó la última referencia y en el punto exacto en que eso ocurre, esperar dentro de él a un semáforo, a una escritura en disco o a una respuesta de red congela un hilo arbitrario en un momento arbitrario, y es una de las formas más eficaces de provocar un bloqueo mutuo difícil de reproducir.

La segunda es que el destructor no puede depender de nada externo que también esté muriendo. El orden entre destructores de objetos distintos no está especificado más allá de la relación de propiedad, así que si tu deinit consulta un objeto compartido que quizá ya se destruyó, estás confiando en una coincidencia. Todo lo que el destructor necesite debe pertenecerle o ser una referencia no propietaria cuya vida esté garantizada.

final class Suscripcion {
    private let cancelar: () -> Void
    private var activa = true

    init(cancelar: @escaping () -> Void) { self.cancelar = cancelar }

    func detener() {              // idempotente: puede llamarse antes de morir
        guard activa else { return }
        activa = false
        cancelar()
    }

    deinit { detener() }          // el destructor solo repite lo mismo
}

Ese patrón resuelve de una vez el problema entero: la limpieza vive en un método normal, comprobable y llamable de forma explícita, y el destructor se limita a invocarlo por si nadie lo hizo. Así la corrección del programa no depende del instante impredecible en que el conteo llega a cero, y las pruebas pueden verificar la limpieza sin tener que provocar una destrucción. Cuando la limpieza sí debe ocurrir en un contexto aislado —tocar estado de un actor o de la interfaz—, la vía correcta es declarar el destructor aislado si tu versión del lenguaje lo permite, y nunca lanzar desde él una tarea que capture self.

ℹ️
Destrucción en cascada y el coste de morir

Liberar la raíz de un grafo profundo no es una operación barata: la última liberación desencadena la de todas sus propiedades, y estas la de las suyas, recorriendo el árbol entero de forma síncrona en el hilo que soltó primero. Descartar de golpe una estructura de datos con decenas de miles de objetos puede producir un salto de varios milisegundos, y si eso ocurre en el hilo principal se ve como un tirón en la interfaz. Cuando lo midas y sea el caso, la solución habitual es trasladar la última referencia a un hilo de fondo antes de soltarla, para que la cascada ocurra allí.

💡
Un destructor que imprime vale más que una hipótesis

Añadir temporalmente deinit { print("muere \(Self.self)") } a las clases de un flujo y recorrerlo entero es la forma más barata de convertir una sospecha en un hecho. Si el mensaje no aparece nunca, tienes un ciclo o una referencia olvidada; si aparece tarde, tienes trabajo en vuelo; si aparece en un hilo inesperado, acabas de encontrar un error de concurrencia que todavía no se había manifestado.

El destructor es lo que convierte la memoria en un modelo del tiempo

Lo verdaderamente notable de deinit no es que libere memoria, porque de eso ya se encarga el asignador sin ayuda: es que da al programa un lugar donde afirmar que algo ha terminado. Un objeto con destructor deja de ser una región de bytes para convertirse en un episodio con principio y final, y esa conversión es la que permite atar recursos escasos —descriptores, bloqueos, suscripciones, sesiones— al tiempo de vida de una entidad del dominio en lugar de a la disciplina del programador. La tradición de C++ lo llamó adquisición es inicialización y descubrió que la simetría entre construir y destruir es más fiable que cualquier convención de limpieza, porque el compilador la mantiene incluso cuando el flujo se rompe por una salida temprana o por un error. Swift hereda esa idea entera, y le añade una honestidad poco frecuente: se niega a prometer lo que no puede cumplir. No promete un orden entre destructores de objetos distintos, no promete un hilo, no promete que el ámbito léxico defina la vida y no promete que la memoria se devuelva en el mismo instante en que el valor deja de ser válido. Cada una de esas negativas es una puerta cerrada al razonamiento mágico, y quien las ignora acaba escribiendo destructores que dependen de coincidencias. La lectura correcta es la contraria: deinit garantiza exactamente una cosa, que se ejecutará una vez, cuando el último dueño suelte, y esa única garantía basta para construir un modelo de recursos correcto siempre que no le pidas más de lo que ofrece.

⚔️ Fijar el instante exacto de la muerte
  1. Instrumenta con destructores que impriman todas las clases de un flujo real y recórrelo anotando el orden y el hilo de cada mensaje.
  2. Escribe la función que obtiene un puntero crudo de un objeto y demuéstrala rota en publicación; arréglala con withExtendedLifetime y explica la diferencia.
  3. Crea un bucle que produzca muchos objetos con interoperabilidad de Objective-C, observa el perfil de memoria y compáralo envolviendo la iteración en autoreleasepool.
  4. Provoca un destructor en hilo de fondo dejando que la última referencia la suelte un bloque de red, y describe qué operación de interfaz sería incorrecta dentro de él.
  5. Conserva una referencia unowned a un objeto ya destruido y contrasta lo que dicen tus registros con lo que muestra el instrumento de memoria sobre el bloque reservado.