Encapsulación real y su precio
Closures como campos verdaderamente privados frente a la convención del guion bajo, la vía intermedia de la tabla privada con claves débiles, y una comparación honesta del coste de memoria de cada enfoque.
El idioma de la clase no tiene privacidad: cualquier código puede leer, escribir o borrar cualquier campo de cualquier instancia, y no existe modificador que lo impida. La comunidad Lua responde con una convención tipográfica —el guion bajo inicial— que no impide nada, y con una alternativa que sí lo impide de verdad: guardar el estado en upvalues de closures, donde ninguna indexación llega. Esa alternativa funciona, es hermosa y cuesta memoria de forma no despreciable. Este nivel pone las dos opciones sobre la mesa con sus cifras, sin la retórica habitual de que una es correcta y la otra pereza.
- Situar la convención del guion bajo como lo que es: documentación ejecutable, no protección.
- Construir objetos cuyo estado vive en upvalues y comprobar que es inaccesible desde fuera.
- Comparar el coste de memoria de ambos enfoques y saber medirlo en tu propio programa.
- Evaluar la vía intermedia de la tabla privada con claves débiles y sus costes ocultos.
La convención del guion bajo
El acuerdo es universal en el ecosistema: un campo o método cuyo nombre empieza por guion bajo es interno, y quien lo toque desde fuera acepta que se rompa en la siguiente versión sin aviso.
local Buffer = {}
Buffer.__index = Buffer
function Buffer.nuevo()
return setmetatable({ _piezas = {}, _longitud = 0 }, Buffer)
end
function Buffer:anadir(texto)
self._piezas[#self._piezas + 1] = texto
self._longitud = self._longitud + #texto
return self
end
function Buffer:longitud() return self._longitud end
No hay ninguna barrera. Un objeto._longitud = 999 desde otro módulo corrompe el invariante y nadie protesta. Y sin embargo esta convención es la dominante en Lua por razones que no son pereza.
Primera: el coste es exactamente cero, tanto en memoria como en velocidad. Segunda: no rompe la depuración, la serialización ni la inspección; un pairs sobre la instancia muestra todo su estado, algo que resulta impagable cuando el programa falla en producción a las tres de la mañana. Tercera: preserva el acceso legítimo desde fuera del objeto, que en Lua es más frecuente de lo que la ortodoxia admite —bibliotecas de igualdad estructural, sistemas de guardado, editores de propiedades—. Cuarta: la privacidad que impide no es la que causa los errores reales. El código que lee _longitud sin permiso es código que tú escribiste o revisaste; el que se rompe es el que confunde el punto con los dos puntos.
Closures como campos privados
La alternativa fuerte abandona la metatabla. El constructor declara variables locales y devuelve una tabla de funciones que las capturan como upvalues:
local function nuevo_buffer()
local piezas = {} -- inaccesible desde fuera, punto
local longitud = 0
local self = {}
function self.anadir(texto)
piezas[#piezas + 1] = texto
longitud = longitud + #texto
return self
end
function self.longitud() return longitud end
function self.volcar() return table.concat(piezas) end
return self
end
local b = nuevo_buffer()
b.anadir("hola")
print(b.longitud()) --> 4
Aquí la privacidad es real y no depende de la buena voluntad de nadie. Las variables piezas y longitud no son campos de ninguna tabla: son upvalues compartidos por las tres funciones, y desde fuera solo existen las funciones. Ninguna indexación, ningún pairs, ningún rawget puede alcanzarlas. Solo la biblioteca de depuración, con debug.getupvalue, puede violarlas, y ese es precisamente el límite del sandbox: si le has dado la biblioteca de depuración a código no confiable, la privacidad de tus objetos es el menor de tus problemas.
Dos consecuencias de diseño acompañan al enfoque. Los métodos se invocan con punto, no con dos puntos, porque no hay receptor: la identidad del objeto ya está capturada en el closure. Y no hay clase: cada objeto es autónomo, no comparte tabla con nadie, y por tanto no hay herencia posible salvo por composición manual —que es, de hecho, uno de los argumentos a favor—.
flowchart TD subgraph Metatabla I1[Instancia con campos visibles] --> C1[Clase compartida con los metodos] end subgraph Closures I2[Tabla con tres funciones] --> U1[Upvalue piezas] I2 --> U2[Upvalue longitud] end C1 --> R1[Una copia de cada metodo en todo el proceso] U1 --> R2[Un closure por metodo y por instancia] U2 --> R2
El coste de memoria, con cifras
Esta es la parte que casi siempre se omite. En el enfoque con metatabla, cada instancia paga la cabecera de una tabla más las entradas de hash de sus datos; los métodos existen una vez en todo el proceso. En el enfoque con closures, cada instancia paga la cabecera de la tabla, una entrada de hash por método, un objeto closure por método, y un objeto upvalue por cada variable capturada compartida entre varios closures.
Los órdenes de magnitud en una compilación de 64 bits son estos: la cabecera de una tabla ronda los cincuenta y seis bytes; cada entrada del hash, unos treinta y dos; un closure de Lua, alrededor de cuarenta más ocho por cada upvalue que referencia; cada upvalue cerrado, unos cuarenta. No memorices estas cifras —dependen de la versión y de la compilación—, memoriza la forma de la conclusión.
Para un objeto con cinco métodos y tres campos privados, el enfoque con metatabla consume del orden de ciento cincuenta bytes por instancia. El mismo objeto con closures se acerca a los seiscientos. Es un factor de cuatro, y crece con el número de métodos. Con mil objetos la diferencia es irrelevante; con un millón —partículas, entidades de un juego, nodos de un árbol sintáctico— es la diferencia entre caber en caché y no caber.
Hay un coste adicional que las cifras no capturan: cada closure es un objeto más que el recolector debe recorrer en cada ciclo de marcado. Multiplicar por cinco el número de objetos vivos multiplica el trabajo del recolector, y eso se paga en latencia, que es justo lo que importa en un bucle de juego o en un editor interactivo.
-- Mide en tu propia máquina antes de creer cualquier cifra
collectgarbage("collect")
local base = collectgarbage("count")
local guardados = {}
for i = 1, 100000 do guardados[i] = nuevo_buffer() end
collectgarbage("collect")
print((collectgarbage("count") - base) * 1024 / 100000, "bytes por objeto")
La vía intermedia y cuándo elegir cada una
Existe un tercer camino que conserva la clase compartida y saca el estado privado a una tabla auxiliar indexada por el propio objeto:
local privado = setmetatable({}, { __mode = "k" }) -- claves débiles
local Buffer = {}
Buffer.__index = Buffer
function Buffer.nuevo()
local obj = setmetatable({}, Buffer)
privado[obj] = { piezas = {}, longitud = 0 }
return obj
end
function Buffer:longitud() return privado[self].longitud end
La tabla privado es local al módulo, así que nadie de fuera la alcanza, y sus claves son débiles para que un objeto sin referencias pueda recolectarse. Los métodos siguen compartidos en la clase, con lo que el coste por instancia es una tabla de datos más una entrada en la tabla auxiliar: mucho menos que los closures.
El precio está en otro sitio. Cada acceso a un campo privado cuesta ahora dos indexaciones en lugar de una, y una de ellas es sobre una tabla que crece con el número de objetos vivos. Las tablas con claves débiles obligan al recolector a hacer trabajo adicional en cada ciclo, porque debe visitarlas en una fase separada tras determinar qué sigue vivo. Y la depuración empeora: un pairs sobre la instancia ya no muestra su estado, que ahora vive en una estructura invisible desde el objeto.
Guion bajo
Elígelo por defecto. Coste cero, depuración intacta, serialización trivial. Es lo correcto para el noventa por ciento del código de aplicación, y para todo lo que se cree en cantidades grandes.
Closures
Elígelo cuando la privacidad sea un requisito y no una preferencia: objetos que cruzan una frontera de confianza, tokens, manejadores de recursos, o cuando el número de instancias se cuenta por decenas.
Tabla privada débil
Elígelo cuando quieras métodos compartidos y estado oculto a la vez, y estés dispuesto a pagar una indexación extra por acceso y presión adicional sobre el recolector.
Proxy con __newindex
Una tabla vacía como fachada, con __index y __newindex interceptando todo hacia un almacén interno. Permite validación y campos de solo lectura, a cambio de que cada lectura y cada escritura pasen por una llamada a Lua.
La discusión sobre encapsulación en Lua suele plantearse mal, como si hubiera una respuesta técnica correcta esperando a ser descubierta. No la hay, porque la pregunta que de verdad se está respondiendo no es si el estado debe estar protegido, sino contra quién. Si el adversario es tu yo de dentro de seis meses leyendo el código con prisa, el guion bajo lo resuelve con coste cero y sin sacrificar la capacidad de inspeccionar todo cuando algo falla. Si el adversario es otro equipo que integrará tu biblioteca y descubrirá tus campos internos con un autocompletado, la convención se erosiona con el tiempo y necesitas closures o una tabla auxiliar. Y si el adversario es código no confiable ejecutándose en tu proceso, ninguna de las tres te salva: necesitas un entorno restringido, sin debug, y esa es una conversación de otro nivel del camino. Lo que Lua hace, al no ofrecer private, es negarse a fingir que existe una respuesta universal, y devolverte la decisión junto con su factura: cada bloque de privacidad se paga en bytes, en ciclos de recolección o en visibilidad al depurar. Un ingeniero maduro en este lenguaje no es el que siempre encapsula ni el que nunca lo hace, sino el que puede decir en qué unidad está pagando su elección y por qué el precio le parece justo en ese objeto concreto.
- Implementa el mismo objeto de tres formas: con metatabla y guion bajo, con closures, y con tabla privada de claves débiles.
- Crea cien mil instancias de cada versión y mide el consumo con
collectgarbage("count")siguiendo el patrón del ejemplo. Anota los bytes por objeto reales de tu máquina. - Cronometra un millón de lecturas del campo privado en las tres versiones con
os.clocky ordénalas por velocidad. Comprueba si el orden coincide con tu intuición previa. - Intenta acceder al estado del objeto con closures desde fuera. Después consíguelo con
debug.getupvaluey reflexiona sobre qué garantía acabas de romper. - Elimina la marca de claves débiles de la tabla privada, crea y descarta un millón de objetos y observa cómo la memoria no baja. Explica exactamente por qué.