El acceso crudo: rawget, rawset, rawequal y rawlen
Las cuatro puertas traseras que ignoran la metatabla: por qué todo metamétodo que quiere tocar su propia tabla las necesita, cómo se produce la recursión infinita al olvidarlas y cuándo saltarse la metatabla es un error.
Cada mecanismo que redefine una operación necesita, por fuerza, una forma de ejecutar la operación original sin redefinirla. Sin esa escotilla, el metamétodo no podría hacer su trabajo sin invocarse a sí mismo, y el sistema entero sería un bucle. Lua expone esa escotilla como cuatro funciones globales de la biblioteca base —rawget, rawset, rawequal y rawlen— que hacen exactamente lo que su nombre dice: la operación desnuda, sin consultar metatabla alguna. Son la mitad menos vistosa del capítulo de metatablas y la que causa más horas perdidas cuando se ignora, porque olvidarlas no produce un error claro sino un desbordamiento de pila a diez llamadas de distancia.
- Conocer la firma y la semántica exacta de las cuatro operaciones crudas.
- Reconstruir el mecanismo de la recursión infinita y saber diagnosticarla.
- Usar
rawgetpara distinguir lo propio de lo heredado. - Decidir con criterio cuándo saltarse la metatabla y cuándo hacerlo es un fallo de diseño.
Las cuatro puertas traseras
Las cuatro funciones son deliberadamente aburridas. Ninguna dispara metamétodos, ninguna asigna memoria de más y ninguna tiene sorpresas.
rawget(t, k) -- lee t[k] sin consultar __index; t debe ser tabla
rawset(t, k, v) -- escribe t[k] sin consultar __newindex; devuelve t
rawequal(a, b) -- compara identidad sin consultar __eq; cualquier tipo
rawlen(v) -- longitud sin consultar __len; tabla o cadena
Los detalles finos importan. rawget y rawset exigen que el primer argumento sea una tabla de verdad: pasarles un userdata con metatabla es un error de tipo, no un rodeo. rawset devuelve la tabla, lo que permite encadenar. rawequal funciona con valores de cualquier tipo y para todo lo que no sean tablas ni userdata da el mismo resultado que el operador normal, porque solo esos dos tipos tienen __eq. Y rawlen acepta tablas y cadenas: sobre una cadena devuelve su longitud en bytes, sobre una tabla devuelve un borde de la parte secuencia, que es lo mismo que haría el operador si nadie hubiera definido __len.
Conviene notar lo que no está en la lista. No hay rawcall, no hay rawconcat, no hay rawadd. La razón es que esas operaciones no tienen versión cruda con sentido: sumar dos tablas sin metamétodo no es una operación más simple, es un error. Las cuatro que existen son precisamente aquellas cuya versión sin metatabla sigue siendo una operación bien definida.
Un ejemplo mínimo deja clara la diferencia entre las dos parejas, que es la diferencia entre lo que el objeto dice y lo que el objeto es:
local a = setmetatable({1, 2, 3}, {
__len = function() return 100 end,
__eq = function() return true end,
})
local b = setmetatable({}, getmetatable(a))
print(#a, rawlen(a)) --> 100 3
print(a == b, rawequal(a, b)) --> true false
Hay una quinta puerta que casi nadie cuenta como tal: next. La función que sostiene a pairs recorre la tabla en crudo y no sabe nada de metatablas, y esa es la razón última de que un proxy vacío no itere nada por muchos metamétodos que le pongas.
La recursión que no se ve venir
Aquí está el error que todo el mundo comete una vez. Escribes un __newindex que quiere hacer dos cosas: registrar la escritura y luego dejarla pasar.
-- MAL: el bucle está en la última línea
local t = setmetatable({}, {
__newindex = function(tabla, k, v)
print("asignando", k)
tabla[k] = v -- k sigue sin existir: vuelve a disparar __newindex
end,
})
t.x = 1 --> asignando x, asignando x, asignando x, ...
--> error: stack overflow
El razonamiento erróneo es sutil y por eso resiste. Uno piensa: “ya estoy dentro del metamétodo, la asignación de dentro es la normal”. Pero no hay ningún estado de estar dentro. tabla[k] = v es una asignación como cualquier otra, la clave k sigue sin existir en crudo —justamente por eso se llamó al metamétodo— y por tanto se vuelve a consultar la metatabla, que sigue teniendo el mismo __newindex. La única salida es la puerta trasera.
flowchart LR A[t indexado por k recibe v] --> B[k no existe en crudo] B --> C[Se dispara __newindex] C --> D[El metametodo hace t indexado por k igual a v] D --> B C -.->|con rawset| E[Escritura directa y fin del ciclo] style E fill:#a6e3a1,color:#11111b style D fill:#f38ba8,color:#11111b
La misma trampa existe en cada metamétodo que toca su propio objeto con la operación que él mismo redefine, y conviene reconocer las cuatro caras de la familia.
__index = function(t, k) return t[k] end -- bucle
__newindex = function(t, k, v) t[k] = v end -- bucle
__eq = function(a, b) return a == b end -- bucle
__len = function(t) return #t end -- bucle
__tostring = function(t) return tostring(t) end -- bucle
Y la versión correcta de todas ellas es la misma sustitución mecánica: rawget, rawset, rawequal, rawlen, y para el último caso una construcción de la cadena que no vuelva a pedir la conversión.
__index = function(t, k) return rawget(t, k) end
__newindex = function(t, k, v) rawset(t, k, v) end
__eq = function(a, b) return rawequal(a, b) end
__len = function(t) return rawlen(t) end
Una precisión que evita confusión: la recursión no puede ocurrir en la búsqueda del metamétodo. Cuando el intérprete va a la metatabla a por __newindex, esa consulta es cruda por definición del lenguaje, así que una metatabla con su propia metatabla no complica nada. El ciclo siempre lo introduce el cuerpo de tu función, nunca el mecanismo.
Un detalle asimétrico entre __index y __newindex merece atención. Con __index como tabla, un ciclo produce el error controlado de cadena demasiado larga; con __index como función, produce un desbordamiento de pila de verdad, porque cada vuelta es una llamada real de Lua. Diagnosticar es más fácil de lo que parece: si el mensaje habla de la cadena, tienes tablas encadenadas mal; si habla de la pila, tienes una función que se llama a sí misma.
Distinguir lo propio de lo heredado
El uso más interesante de rawget no tiene nada que ver con la recursión: es preguntar si una clave está en esta tabla concreta y no en algún antepasado de la cadena. Esa pregunta no se puede formular con el operador normal, porque la indexación normal miente por diseño.
local Base = {saludar = function() return "hola" end}
local Derivada = setmetatable({}, {__index = Base})
print(Derivada.saludar) --> function: 0x... hereda
print(rawget(Derivada, "saludar")) --> nil no es suya
De esa distinción viven varias herramientas cotidianas. Un serializador que copie Derivada debe guardar solo lo propio, o duplicará la jerarquía entera en cada instancia. Un depurador que muestre un objeto quiere separar los campos de la instancia de los métodos de la clase. Un sistema de módulos que compruebe si una tabla ya definió un símbolo antes de sobrescribirlo tiene que mirar en crudo, o creerá que lo definió cuando en realidad lo heredó.
El caso canónico es el guardián de globales, un idioma clásico de Lua que se apoya en las dos puertas traseras a la vez:
setmetatable(_G, {
__newindex = function(t, k, v)
print(("nueva global %s en %s"):format(k, debug.traceback("", 2):match("[^\n]*")))
rawset(t, k, v)
end,
__index = function(_, k)
error("lectura de global no declarada: " .. tostring(k), 2)
end,
})
Sin el rawset, ese guardián no guarda nada y además se cuelga. Con él, tienes en cinco líneas la detección de erratas en nombres de variables que en otros lenguajes requiere un compilador.
Cuándo saltarse la metatabla es un error
La tentación, una vez que conoces las puertas traseras, es usarlas por costumbre o por rendimiento. Es cierto que rawget es marginalmente más rápido que la indexación normal, porque se ahorra la comprobación de metatabla en el caso fallido. También es cierto que ese ahorro es irrelevante en el 99% del código y que el precio es alto.
rawget en código de aplicación rompe la abstracción. Si un objeto hereda un valor por defecto, tu lectura cruda devolverá nil y tu código tomará la rama equivocada. Si mañana alguien convierte esa tabla en un proxy que carga datos bajo demanda, tu lectura cruda verá una tabla vacía. Cada rawget es una promesa de que el objeto es exactamente lo que aparenta, y esa promesa envejece mal.
La regla es de responsabilidad, no de rendimiento. Usa las funciones crudas dentro del código que implementa el mecanismo; usa las operaciones normales en el código que lo consume. Si estás escribiendo un metamétodo, un serializador, un depurador, un sistema de clases o un sandbox, eres el implementador y las puertas traseras son tu herramienta legítima. Si estás escribiendo lógica de negocio, cada acceso crudo es una fuga de la implementación hacia arriba.
Hay una excepción honesta: rawequal y rawlen sobre datos que vienen de fuera de tu control, en código que necesita ser robusto frente a un __eq o un __len maliciosos o simplemente caros. En un sandbox, comparar con el operador normal significa ejecutar código ajeno.
El patrón que hay debajo de estas cuatro funciones aparece en todos los sistemas que permiten redefinir su propio comportamiento, y reconocerlo vale mucho más que memorizar las firmas. Si permites interceptar una operación, tienes que ofrecer la operación sin interceptar; si no, el interceptor no puede completar su trabajo sin invocarse a sí mismo. Es el mismo problema que resuelven las llamadas al método de la superclase en los lenguajes con herencia, el paso al siguiente eslabón en una cadena de middleware o el descriptor original bajo una propiedad calculada. Lua lo resuelve del modo más literal posible: cuatro funciones globales que hacen la operación desnuda, sin ceremonia, sin contexto y sin memoria de dónde se llaman. Esa literalidad tiene un coste que se paga en disciplina, porque nada te impide usarlas donde no debes y el lenguaje no distingue entre el implementador legítimo y el consumidor que se salta la abstracción. Pero también tiene una virtud que rara vez se aprecia: el mecanismo no tiene magia residual. No hay una noción de estar dentro de un metamétodo, no hay una pila de interceptores suspendidos, no hay un estado invisible que decida si tu asignación es normal o especial. Hay una regla —clave ausente más metatabla con campo— evaluada exactamente igual todas las veces, y una función que la desactiva. Que la recursión infinita sea tan fácil de provocar es el precio de que el modelo se explique entero en tres frases.
- Escribe el
__newindexincorrecto del ejemplo y provoca el desbordamiento de pila. Envuélvelo enpcally lee el mensaje de error completo. - Arréglalo con
rawsety comprueba que la segunda asignación al mismo campo ya no imprime nada. Explica por qué. - Construye la jerarquía
BaseyDerivaday escribe una funcióncampos_propiosque devuelva solo las claves que la tabla tiene en crudo. - Instala el guardián de
_Gen un script y ejecuta un programa con una errata en el nombre de una variable. Comprueba que la caza. - Define un
__lenque mienta —que devuelva siempre cero— y demuestra conrawlenque la tabla sí tiene elementos. Razona en qué situación esa diferencia sería un agujero de seguridad.