wandres.dev
OWNERSHIP · borrowing y consuming

deinit en estructuras no copiables: RAII sin clases

Liberación determinista de recursos sin asignar en el montículo ni pagar recuento de referencias. Cuándo corre exactamente el destructor de un valor único, cómo lo cancela `discard self` y por qué viaja con el valor mientras `defer` se queda en el ámbito.

⏱ 18 min

Durante años, la única forma de garantizar en Swift que un recurso se liberase fue meterlo dentro de una clase y confiar en su destructor. El precio era doble y siempre el mismo: una asignación en el montículo por cada recurso, por diminuto que fuera, y una liberación cuyo momento exacto dependía de quién hubiera retenido la referencia y hasta cuándo. Un cierre que escapaba, un ciclo olvidado o una captura en un contexto asíncrono bastaban para que el fichero siguiera abierto mucho después de la línea donde el programador creía haberlo cerrado. Una estructura no copiable con deinit elimina las dos facturas a la vez: sin montículo, sin recuento y con un instante de liberación que el compilador conoce en tiempo de compilación.

🎯 Al terminar esta lección sabrás
  • Determinar con precisión en qué punto del programa se ejecuta el destructor de un valor único.
  • Combinar un método de cierre explícito con discard self para evitar la doble liberación.
  • Contrastar el alcance de deinit con el de defer y con el destructor de una clase.
  • Tratar los dos puntos débiles del patrón: el cierre que puede fallar y el consumo parcial.

Cuándo corre exactamente

Una estructura no copiable puede declarar deinit, y esa capacidad depende directamente de la unicidad: como en cada instante hay una sola vía de acceso al valor, existe un único punto donde esa vía termina, y ese punto está determinado estáticamente.

struct Fichero: ~Copyable {
    private let fd: Int32

    init(ruta: String) throws {
        fd = 3   // apertura real omitida
    }

    func leer() -> [UInt8] { [] }

    deinit {
        // close(fd)
    }
}

func procesar() throws {
    let f = try Fichero(ruta: "/tmp/datos")
    _ = f.leer()
    if Bool.random() { return }   // el deinit corre aqui
    _ = f.leer()
}                                  // o aqui, y solo en uno de los dos

El destructor se ejecuta cuando termina la vida del último dueño sin que este haya transferido la propiedad a nadie. La consecuencia decisiva es que la responsabilidad viaja con el valor: si f se pasa a una función consuming, el destructor no corre en procesar, sino allí donde el nuevo dueño termine. Si se devuelve, viaja al llamante. Si se guarda dentro de otra estructura no copiable, viaja al agregado, cuyo propio destructor correrá antes que el de sus campos.

Conviene fijar también el orden interno, porque importa cuando el tipo contiene otros recursos. El cuerpo del destructor se ejecuta primero, con todas las propiedades todavía válidas y accesibles, y solo después se destruyen los campos en su propio orden. Es el mismo orden que aplica el destructor de una clase, y garantiza que el cuerpo pueda leer cualquier propiedad para hacer su trabajo sin encontrarse un campo ya liberado.

Todos los caminos de salida están cubiertos, incluido el lanzamiento de un error, y no hace falta escribir nada en cada uno: el compilador inserta la llamada en cada punto donde la vida acaba. Nada de esto necesita un objeto, un contador ni una comprobación en ejecución. Es liberación determinista con coste de instrucción cero más allá del cuerpo del propio destructor.

Cerrar a propósito sin cerrar dos veces

El destructor cubre el caso general, pero muchos recursos necesitan además un cierre explícito, aunque solo sea para elegir el momento. El problema es evidente: si un método libera el recurso y después el destructor vuelve a liberarlo, hay doble liberación. La solución del lenguaje es una sentencia que desactiva el destructor para ese valor concreto.

struct Conexion: ~Copyable {
    private let socket: Int32

    consuming func cerrar() {
        // apagado ordenado y liberacion aqui
        discard self          // el deinit ya no correra
    }

    deinit {
        // red de seguridad para quien olvide cerrar
    }
}

El método es consuming, de modo que cerrar agota el valor y nadie puede usarlo después: la segunda llamada no es un fallo en ejecución, es un error de compilación. Y discard self deja el valor como consumido sin invocar el destructor, cediendo la responsabilidad al cuerpo del método. La combinación produce un patrón muy limpio: un camino explícito para quien quiere controlar el momento y una red de seguridad para quien no.

⚠️
Dos condiciones para discard

Solo puede usarse dentro de un método consuming del propio tipo, y solo si el tipo no tiene requisitos que impidan abandonar sus campos sin destruirlos. Además, descartar no libera nada por sí mismo: si el cuerpo del método olvida cerrar el recurso, has convertido una doble liberación en una fuga silenciosa, que es peor porque no falla nunca de forma visible.

Por qué no basta con defer

La comparación con defer aclara el alcance real del mecanismo, porque a primera vista parecen resolver el mismo problema. defer está atado al ámbito léxico: se ejecuta al salir del bloque donde se escribió, siempre, y esa es su virtud y su límite. El destructor está atado al valor, y por eso lo acompaña a donde vaya.

Ahí está la diferencia que decide: un recurso creado en una función y devuelto al llamante no puede protegerse con defer, porque el bloque termina justo cuando el recurso empieza a ser útil. Tampoco puede protegerse un recurso almacenado dentro de otra estructura, ni uno cuya propiedad se transfiere condicionalmente en una rama y no en otra. Con deinit, ninguno de esos casos requiere pensamiento adicional: la liberación acompaña al valor por construcción, y quien recibe el valor recibe con él la obligación.

Frente al destructor de una clase la diferencia es de determinismo. Ambos liberan, pero el de la clase corre cuando el contador llega a cero, y ese instante depende de retenciones que pueden estar en cierres que escapan, en cachés, en ciclos no rotos o en un contexto asíncrono. El de una estructura no copiable corre en un punto que el compilador ya conoce al generar el código.

Queda un tercer contraste, con el bloque de manejo de errores. Un cierre escrito en el camino de recuperación libera solo cuando algo falla, y esa asimetría entre el camino normal y el excepcional es la que produce las fugas más difíciles de encontrar, porque el código correcto se ejercita en cada ejecución y el otro casi nunca. Con un destructor de valor no hay dos caminos que mantener sincronizados: hay uno solo, escrito una vez, que el compilador coloca en todas las salidas por igual.

flowchart TB
n[Nace el valor unico] --> u[Se usa mediante prestamos]
u --> d1[Termina su vida sin transferirla]
u --> d2[Se pasa a una funcion consuming]
u --> d3[Se devuelve al llamante]
u --> d4[Metodo consuming con discard self]
d1 --> run[Corre el deinit aqui]
d2 --> mov[La obligacion viaja al nuevo dueno]
d3 --> mov
d4 --> skip[No corre el deinit: libera el cuerpo]
mov --> run
style run fill:#a6e3a1,color:#11111b
style skip fill:#f9e2af,color:#11111b
⏱️

Instante conocido

El punto de liberación se decide en compilación. No hay contador que consultar ni retención remota que pueda retrasarlo.

🧳

Viaja con el valor

A diferencia de defer, la obligación acompaña al dato al devolverlo, al almacenarlo o al transferirlo a otro dueño.

🪶

Sin montículo

No hay objeto ni recuento. Es el patrón RAII disponible incluso donde no existe asignación dinámica.

Los dos puntos débiles

El primero es que el destructor no puede fallar ni esperar. No admite throws ni async, y esa restricción no es un descuido: se ejecuta en puntos que el compilador inserta, incluidos caminos de propagación de errores, donde no existe un sitio razonable al que entregar un fallo nuevo. Para recursos cuyo cierre puede fallar de verdad, y un fichero con escrituras pendientes lo es, el diseño correcto es ofrecer un método de cierre explícito que lance y reservar el destructor como red de seguridad silenciosa.

struct Diario: ~Copyable {
    consuming func cerrar() throws {
        // vaciar buferes; aqui si se puede propagar un error
        discard self
    }
    deinit {
        // ultimo recurso: intentar vaciar e ignorar el fallo
    }
}

El segundo afecta al consumo parcial. Un valor no copiable sin destructor puede desmontarse campo a campo, tomando cada pieza por separado. Un tipo con deinit no lo permite, y la razón es exacta: el destructor está definido sobre el valor completo, de modo que dejarlo a medias lo dejaría en un estado en el que su contrato ya no se sostiene. La salida disponible es un método consuming que descarte primero y desmonte después.

El destructor como sitio final de una obligación

Vale la pena mirar este mecanismo desde arriba, porque resuelve un problema que atraviesa la historia de la programación de sistemas: la liberación de recursos no es un problema de memoria, sino de obligaciones. Abrir un fichero, tomar un cerrojo, reservar una región o iniciar una transacción crean todos la misma cosa, una deuda que alguien tendrá que pagar exactamente una vez, y las tres estrategias históricas para gestionarla fracasan de maneras distintas. La disciplina manual, pagar al final de cada camino, falla porque los caminos se multiplican con cada rama y cada error propagado, y basta un return añadido en una revisión apresurada para introducir una fuga. El recolector de basura falla porque libera memoria, no obligaciones: no sabe cuándo cerrar el fichero y termina exigiendo un cierre explícito que reintroduce el problema original. El recuento de referencias acierta en el determinismo pero difumina el responsable, porque cualquiera puede retener y basta una retención olvidada para que el pago se retrase indefinidamente. Lo que aporta un destructor sobre un valor único es que el sistema de tipos sabe en todo momento quién tiene la deuda, y como la deuda se transfiere con el valor, el compilador puede insertar el pago exactamente en el punto donde la última vía de acceso termina. Es la unificación de dos ideas que solían vivir separadas: la duración de un dato y la duración de un permiso. Cuando ambas coinciden, olvidar liberar deja de ser un descuido posible y pasa a ser un estado que el programa no puede alcanzar.

📝
Lo esencial del destructor de valor

El deinit de una estructura no copiable corre en el punto donde acaba la vida del último dueño, y ese punto se conoce en compilación. La obligación viaja con el valor al devolverlo, transferirlo o almacenarlo, cosa que defer no hace. discard self cancela el destructor dentro de un método consuming, que es el patrón para un cierre explícito sin doble liberación. No puede lanzar ni suspender, y su presencia impide el consumo parcial del agregado.

⚔️ Construye un guardián determinista
  1. Escribe una estructura no copiable con deinit que registre por consola y comprueba en qué línea exacta corre con distintos caminos de salida.
  2. Añade un método consuming de cierre con discard self y verifica que el destructor deja de ejecutarse.
  3. Crea el mismo recurso como clase y provoca con un cierre que escapa que su liberación se retrase; contrasta el resultado.
  4. Intenta desmontar campo a campo un valor con destructor y explica el diagnóstico a partir del contrato del tipo.
  5. Diseña un guardián de cerrojo cuyo destructor libere el bloqueo y razona por qué su tipo no admitiría ser copiable.