Ciclo de vida: __gc como finalizador y __close como cierre garantizado
El finalizador solo se activa si el metamétodo estaba presente en el instante de asignar la metatabla, corre en un momento que no controlas y a veces no corre nunca. Las variables to-be-closed de Lua 5.4 resuelven el otro problema: liberar un recurso en la salida del bloque, incluso si esa salida es un error.
Los dos metamétodos de esta lección parecen resolver el mismo problema —soltar un recurso cuando ya no hace falta— y en realidad resuelven problemas opuestos. El finalizador contesta a la pregunta qué hago con lo que alguien olvidó liberar, y su respuesta llega tarde, en un momento impredecible y, en algunos casos perfectamente normales, nunca. La variable de cierre contesta a cómo garantizo que este recurso se libere al salir de aquí, y su respuesta es inmediata, determinista y resistente a los errores. Confundirlos produce fugas de descriptores en producción; entenderlos por separado convierte a Lua en un lenguaje razonable para manejar recursos externos.
- Aplicar la regla de oro de
__gc: el metamétodo debe estar antes de asignar la metatabla. - Enumerar las situaciones en las que un finalizador no llega a ejecutarse.
- Declarar variables de cierre con
__closey saber qué recibe el metamétodo. - Elegir con criterio entre finalizador y cierre garantizado para cada recurso.
El finalizador y su regla de oro
Un objeto se marca para finalización en el momento exacto en que recibe una metatabla que ya contiene la clave __gc. Ese instante es el único que cuenta: si asignas la metatabla primero y añades el metamétodo después, el objeto nunca fue marcado y su finalizador jamás se ejecutará, por correcto que sea el código.
local mt = {}
mt.__gc = function(obj) print("finalizo", obj.id) end
local bien = setmetatable({ id = "A" }, mt) -- marcado: mt ya tiene __gc
local mt2 = {}
local mal = setmetatable({ id = "B" }, mt2) -- NO marcado
mt2.__gc = function(obj) print("finalizo", obj.id) end -- demasiado tarde
bien, mal = nil, nil
collectgarbage("collect") -- imprime solo: finalizo A
La razón de una regla tan estricta es de implementación y merece conocerse: mantener una lista aparte con los objetos finalizables permite al recolector no preguntar por el metamétodo en cada barrido, que es la operación más frecuente de todo el ciclo. El marcado ocurre una sola vez, en la asignación de la metatabla, y a cambio el recolector se ahorra millones de consultas. Es el mismo intercambio que hace el lenguaje en todas partes: una regla que hay que recordar a cambio de un intérprete que cabe en un puñado de kilobytes.
Cuando el recolector determina que un objeto marcado es inalcanzable, no libera su memoria de inmediato: lo saca de la lista de pendientes, lo resucita temporalmente y llama al metamétodo pasándole el propio objeto. Los objetos se finalizan en orden inverso al de marcado, de modo que si un recurso depende de otro creado antes, el dependiente cae primero. Solo en el siguiente ciclo, si nadie lo guardó durante la finalización, la memoria se recupera de verdad.
Durante la finalización, los objetos que tu objeto apunta pueden estar a su vez en proceso de finalización o ya finalizados. No supongas que una conexión guardada en un campo sigue siendo utilizable. Un finalizador debe tocar solo recursos propios y externos al recolector, típicamente un descriptor o un puntero de la biblioteca anfitriona.
Cuándo no se ejecuta
flowchart TB A[El objeto deja de ser alcanzable] --> B[Estaba marcado en el momento de setmetatable] B --> C[Si no lo estaba se libera sin llamar a nada] B --> D[Si lo estaba entra en la lista de pendientes] D --> E[Corre un ciclo del recolector antes de terminar el programa] E --> F[Si corre se ejecuta el finalizador en orden inverso al marcado] E --> G[Si el programa termina cerrando el estado tambien se ejecuta] E --> H[Si se sale sin cerrar el estado no se ejecuta jamas]
Tres escenarios habituales dejan el finalizador sin correr. El primero es el olvido de la regla de oro. El segundo es salir del programa sin cerrar el estado: la función de salida de la biblioteca del sistema operativo acepta un segundo argumento que indica si debe cerrarse el estado de Lua, y su valor por defecto es negativo, así que una salida abrupta se lleva por delante todos los finalizadores pendientes. El tercero, más sutil, es que un ciclo de recolección simplemente no llegue a ocurrir mientras el objeto está muerto y el programa vivo: el recolector se ejecuta cuando le conviene, no cuando a ti te haría falta.
Añade a eso que un error lanzado dentro de un finalizador no se propaga: en Lua 5.4 se convierte en un aviso a través del sistema de advertencias, que por defecto está silenciado. Un finalizador puede estar fallando en todas las ejecuciones sin que nadie se entere.
Puedes marcar sin metamétodo definitivo
Basta con que la clave exista en la metatabla en el momento de asignarla, aunque su valor sea falso. Si más tarde le pones la función de verdad, el objeto ya estaba marcado y el finalizador correcto se ejecutará. Es el truco para preparar el marcado antes de conocer la lógica de cierre.
La resurrección es real
Durante la finalización el objeto vuelve a ser alcanzable, y si tu metamétodo lo guarda en una estructura global, seguirá vivo. Pero ya no está marcado: no volverá a finalizarse aunque muera otra vez. El finalizador de un objeto corre como mucho una vez.
Forzar no es controlar
Pedir una recolección completa ejecuta los finalizadores pendientes en ese instante, y es la forma de comprobar tu código en pruebas. Depender de esa llamada en producción es sustituir una heurística por una pausa costosa, no por determinismo.
Los avisos están apagados
El sistema de advertencias de Lua 5.4 nace desactivado. Para ver los errores de tus finalizadores hay que encenderlo con la función de aviso de la biblioteca básica y una cadena de control, o instalar un manejador desde el anfitrión en C.
Variables con cierre garantizado
Lua 5.4 introdujo un mecanismo distinto para el caso que de verdad importa. Una variable local declarada con el atributo <close> es una variable de cierre: en cuanto la ejecución abandona el bloque donde se declaró, por la razón que sea, Lua llama al metamétodo __close de su valor.
Por la razón que sea significa literalmente eso: llegar al final del bloque, un retorno anticipado, una ruptura de bucle, un salto, o un error que se propaga desde cualquier punto. El valor debe tener __close en su metatabla —o ser falso o nulo, que se ignoran en silencio—; cualquier otra cosa produce un error en la propia declaración.
local function abrirRecurso(nombre)
local r = { nombre = nombre, abierto = true }
return setmetatable(r, {
__close = function(self, err) -- err es el error o nil si no lo hubo
self.abierto = false
print("cierro", self.nombre, err and "por error" or "con normalidad")
end,
})
end
local function trabajar(fallar)
local a <close> = abrirRecurso("A")
local b <close> = abrirRecurso("B") -- se cierra ANTES que A
if fallar then error("algo se torcio") end
return "ok"
end
print(pcall(trabajar, false)) -- cierra B, cierra A, luego devuelve ok
print(pcall(trabajar, true)) -- cierra B por error, cierra A por error
Los cierres ocurren en orden inverso a las declaraciones, igual que una pila. El metamétodo recibe dos argumentos: el valor y el objeto de error que provocó la salida, o nulo si la salida fue ordinaria, lo que te permite distinguir un cierre limpio de un abandono y actuar distinto en cada caso. Y a diferencia del finalizador, un error lanzado dentro de __close sí se propaga, porque aquí el cierre forma parte del flujo normal del programa.
Los manejadores de fichero de la biblioteca estándar ya traen __close, así que puedes escribir la apertura de un fichero como variable de cierre y olvidarte de cerrarlo. Y una corrutina suspendida que tenga variables de cierre pendientes las libera cuando la cierras explícitamente con la función correspondiente de la biblioteca de corrutinas, que existe justamente para eso: sin ella, una corrutina abandonada a medias dejaría sus recursos colgando hasta que el recolector se acordara de ellos.
Se permite declarar una variable de cierre con valor falso o nulo, y en ese caso no se hace nada al salir del bloque. No es una excepción arbitraria: permite escribir código condicional del estilo abre este recurso solo si hace falta sin duplicar el bloque entero. Cualquier otro valor sin __close en su metatabla produce un error inmediato en la declaración, no al salir.
Merece la pena señalar lo que este mecanismo no es. No es un bloque de limpieza al estilo de otros lenguajes, porque no hay un cuerpo de código asociado a la salida sino un valor con comportamiento; y no es un gestor de contexto, porque no hay fase de entrada. Es más pequeño y más componible que ambos: la unidad de cierre es un valor, y por tanto se puede devolver desde una función, guardar en una estructura o construir con parámetros, cosas que un bloque sintáctico no permite.
Cuál usar
| Aspecto | __gc |
__close |
|---|---|---|
| Quién decide el momento | El recolector | El bloque léxico |
| Determinismo | Ninguno | Total |
| Se ejecuta con un error | Puede que sí, puede que no | Siempre, y recibe el error |
| Se ejecuta al salir del programa | Solo si se cierra el estado | Sí, al deshacerse la pila |
| Errores dentro del metamétodo | Se convierten en aviso | Se propagan |
| Condición para activarse | Estar en la metatabla antes de asignarla | Declarar la variable con el atributo |
La lectura de esa tabla se resume en una recomendación operativa. Para todo recurso escaso —un fichero, un socket, un bloqueo, una transacción, un identificador de la biblioteca anfitriona— define __close y expón un constructor pensado para usarse como variable de cierre. Define además __gc con la misma lógica de liberación, protegida para que sea idempotente, de modo que un objeto que se escapó de todo bloque tenga todavía una oportunidad. Y no definas __gc solo, nunca, si el recurso importa.
Una advertencia final sobre la combinación: si el mismo objeto puede cerrarse por las dos vías, la función de liberación debe poder ejecutarse dos veces sin daño. La forma habitual es una bandera en el propio objeto que la primera llamada baja y la segunda comprueba, exactamente como el campo del ejemplo anterior.
La lectura que ordena todo esto es que __gc y __close no compiten, porque responden a preguntas de naturaleza distinta. El recolector de basura resuelve un problema de memoria, y la memoria tiene una propiedad que ningún otro recurso comparte: sobra. Si un objeto muerto tarda dos ciclos más en liberarse, no pasa nada, porque hay más memoria y porque nadie está esperando esa en concreto. Los descriptores de fichero, los bloqueos, las conexiones a una base de datos y los identificadores de la biblioteca anfitriona no se parecen en nada a eso: son escasos, están numerados, alguien más los está esperando y su liberación tardía es indistinguible de una fuga. Encomendar su cierre al recolector significa atar la corrección de tu programa a la heurística de un algoritmo cuyo único objetivo es no gastar memoria de más, y que además no tiene forma de saber que ese objeto de treinta bytes está reteniendo un recurso carísimo del sistema. Ahí está el error conceptual que Lua 5.4 vino a corregir con las variables de cierre: el momento correcto para soltar un recurso no lo determina la inalcanzabilidad, lo determina el alcance léxico, que es una propiedad que el programador escribe y el lector ve. Por eso la disciplina profesional es asimétrica: define __close siempre que un valor represente algo que hay que devolver, y define __gc además, no en lugar de, entendiéndolo como lo que es —una última red que atrapa el objeto que alguien construyó fuera de todo bloque, o guardó en una estructura y olvidó—. Que esa red a veces no llegue a desplegarse no es un defecto de la implementación: es el recordatorio de que nunca debió ser tu primera línea de defensa.
Un objeto se marca para finalización en el instante en que recibe una metatabla que ya contiene __gc; añadirlo después no sirve de nada. Los finalizadores corren en orden inverso al marcado, resucitan el objeto durante la llamada, se ejecutan como mucho una vez, convierten sus errores en avisos silenciados y no llegan a correr si el programa sale sin cerrar el estado. Las variables declaradas con el atributo de cierre invocan __close al abandonar el bloque por cualquier vía, incluido un error, en orden inverso a su declaración, recibiendo el objeto y el error o nulo, y sus errores sí se propagan. El finalizador es la red; el cierre es la política.
- Reproduce el ejemplo de la regla de oro y confirma que el objeto B nunca se finaliza, por muy correcto que sea su metamétodo.
- Crea tres objetos con finalizador, elimina las referencias y fuerza una recolección. Anota el orden en que se imprimen y contrástalo con el orden de creación.
- Escribe un programa con un finalizador pendiente y termínalo con la salida abrupta del sistema operativo, sin pedir el cierre del estado. Comprueba que no se ejecuta.
- Lanza un error dentro de un finalizador y otro dentro de un
__close. Explica por qué solo uno de los dos se ve. - Abre un fichero como variable de cierre, provoca un error a continuación y verifica con el manejador que el fichero quedó cerrado.