wandres.dev
METATABLAS I · cambiar el comportamiento

Valores por defecto y memoización

Los dos patrones que salen casi gratis de __index y __newindex: la tabla que rellena la clave que falta, el caché perezoso que solo calcula una vez, las tablas débiles que no filtran memoria y las trampas del nil como resultado.

⏱ 15 min

Las dos lecciones anteriores presentaron __index y __newindex como mecanismos generales. Esta lección los pone a trabajar en los dos usos que justifican por sí solos el capítulo entero: dar un valor por defecto a las claves que faltan y calcular una sola vez lo que se pide muchas veces. Son patrones que en otros lenguajes tienen nombre propio, biblioteca propia y a veces sintaxis propia —diccionarios con valor por defecto, decoradores de caché, propiedades perezosas, inicialización diferida— y que aquí caben en tres o cuatro líneas cada uno, comparten la misma estructura y, lo más importante, tienen coste cero en el camino rápido.

🎯 Al terminar esta lección sabrás
  • Distinguir el valor por defecto que se devuelve del que se materializa en la tabla.
  • Construir un caché perezoso que se rellena solo en el primer acceso.
  • Usar __mode para que un caché no impida la recolección de lo que guarda.
  • Reconocer las trampas del nil como resultado y de la memoización con varios argumentos.

La clave que falta

El caso más simple es un valor por defecto puro: la lectura fallida devuelve algo, pero no cambia la tabla.

local ceros = setmetatable({}, {__index = function() return 0 end})

ceros.a = ceros.a + 1        -- 0 + 1, sin inicializar nada antes
ceros.a = ceros.a + 1
print(ceros.a, ceros.b)      --> 2  0

Ese contador prescinde de toda la ceremonia de comprobar si la clave existe antes de incrementarla, que es una de las cinco líneas más repetidas de la historia de la programación. Si los valores por defecto son fijos, la versión con tabla —__index apuntando a un registro de defectos— es aún más barata, porque el bucle vive en C y no hay ninguna llamada a Lua.

Ahora la variante que sí modifica: autovivificación, la tabla que crea la clave en el momento en que alguien la pide. La diferencia con la anterior es una línea, y cambia la semántica por completo.

local function autotabla()
  return setmetatable({}, {
    __index = function(t, k)
      local sub = autotabla()
      rawset(t, k, sub)      -- se materializa: la próxima lectura ya no pasa por aquí
      return sub
    end,
  })
end

local arbol = autotabla()
arbol.servidores.europa.madrid = "10.0.0.1"   -- ningún nivel existía
print(arbol.servidores.europa.madrid)          --> 10.0.0.1

Esa única expresión ha creado tres tablas anidadas sin que hubiera que declarar ninguna. Es el patrón con el que se construyen árboles de configuración, índices agrupados y estructuras de agregación sin escribir comprobaciones defensivas en cada nivel.

La distinción entre las dos variantes es la que hay que tener siempre presente, porque no es de estilo sino de observabilidad:

  • Devolver sin materializar deja la tabla intacta. pairs no verá la clave, el operador de longitud no la contará, un serializador no la guardará y rawget seguirá diciendo nil. El valor por defecto es una ficción de lectura, y eso suele ser exactamente lo correcto.
  • Materializar con rawset hace la clave real. A partir de ahí se itera, se cuenta y se serializa, y el metamétodo no vuelve a dispararse para esa clave. Es lo que quieres si el valor es caro de producir o si el objeto debe recordar que se le preguntó.

La autovivificación tiene además un efecto secundario incómodo que conviene anticipar: leer crea. Una comprobación inocente como if arbol.a.b then deja tras de sí dos tablas vacías. En un árbol grande consultado muchas veces, eso es una fuga silenciosa. Para consultar sin crear, rawget, que es exactamente el uso legítimo de la lección anterior.

El caché perezoso

Cambia el valor por defecto por el resultado de un cálculo caro y el mismo patrón se convierte en memoización. La estructura es siempre la misma y ocupa cinco líneas.

flowchart TD
A[Pedir cache indexado por n] --> B[Existe la clave en crudo]
B -->|si| C[Devolver el valor guardado sin coste]
B -->|no| D[Se dispara __index]
D --> E[Calcular el resultado]
E --> F[rawset guarda el valor en la tabla]
F --> G[Devolverlo al llamador]
style C fill:#a6e3a1,color:#11111b
style E fill:#f9e2af,color:#11111b
local function memoizar(f)
  return setmetatable({}, {
    __index = function(t, k)
      local v = f(k)
      rawset(t, k, v)
      return v
    end,
  })
end

local cuadrados = memoizar(function(n) return n * n end)
print(cuadrados[12])         --> 144, calculado
print(cuadrados[12])         --> 144, leído

Lo que hace elegante a este patrón no es la brevedad, es el perfil de coste. En el acierto —que es el caso mayoritario en cualquier caché que valga la pena— no hay metamétodo, no hay llamada de función, no hay condicional: hay una consulta de hash y nada más, exactamente igual de rápida que leer una tabla normal, porque el intérprete ni siquiera llega a mirar la metatabla. Toda la maquinaria vive en la rama fría. Un memoizador implementado como función envolvente, en cambio, paga una llamada en cada consulta, acierte o falle.

El caso recursivo es el más bonito, porque la recursión atraviesa el caché sin que haya que programarlo:

local fib
fib = setmetatable({[0] = 0, [1] = 1}, {
  __index = function(t, n)
    local v = fib[n - 1] + fib[n - 2]    -- cada llamada consulta el caché
    rawset(t, n, v)
    return v
  end,
})

print(fib[80])               --> 23416728348467685

Ochenta niveles de recursión exponencial reducidos a ochenta sumas, sin escribir un bucle ni una tabla de programación dinámica: la tabla es la tabla de programación dinámica, y el metamétodo es la relación de recurrencia.

Un caché que no olvida nada acaba siendo una fuga, y la cura cabe en una línea más de la metatabla. Añadir __mode = "k" junto al __index convierte las claves en débiles, de modo que el recolector puede llevarse una entrada en cuanto su clave deja de estar viva en cualquier otro sitio: exactamente lo que quieres cuando indexas por objetos —nodos, widgets, sesiones— y no por números. Cuidado con la combinación, eso sí: si el valor guardado referencia a su propia clave, algo facilísimo de hacer sin darse cuenta, la entrada no muere nunca aunque las claves sean débiles, porque el valor la mantiene viva. Ese es justamente el caso para el que Lua incorporó las tablas efímeras en la versión 5.2.

Las trampas

El nil no se puede cachear. Si tu función devuelve nil para una entrada, rawset guarda nil, que en Lua es la ausencia, y la siguiente consulta vuelve a calcular. Para una función cara con resultados vacíos legítimos, eso convierte el caché en un generador de trabajo. La solución es un centinela y una capa de traducción:

local NADA = {}                       -- centinela único, imposible de confundir

local function memoizar_con_nil(f)
  local almacen = setmetatable({}, {
    __index = function(t, k)
      local v = f(k)
      rawset(t, k, v == nil and NADA or v)
      return rawget(t, k)
    end,
  })
  return function(k)
    local v = almacen[k]
    if v ~= NADA then return v end
  end
end

El false sí se cachea, pero engaña. A diferencia del nil, un false guardado ocupa la clave y no vuelve a disparar el metamétodo. Cualquier comprobación con la forma if cache[k] then calcular() end escrita a mano fallará con él, porque false es un valor perfectamente válido y perfectamente falso. La ausencia se comprueba contra nil, nunca por veracidad.

Varios argumentos no caben en una clave. La memoización con __index recibe exactamente una clave. Con dos o tres argumentos hay dos salidas: anidar cachés —una tabla de tablas, cada nivel con su propio metamétodo— o serializar los argumentos a una cadena, que es más simple pero paga la concatenación en cada consulta y solo funciona si los argumentos tienen representación textual estable. Anidar es más rápido y no colisiona; concatenar se lee mejor.

Lo que sale casi gratis

🪄

Contadores y estructuras que crecen solas

Un __index que devuelve cero elimina la comprobación de existencia de todo el código de conteo e histogramas; con rawset se convierte en árboles de configuración e índices agrupados que se materializan al escribirlos.

Memoización con acierto gratis

El camino rápido es una consulta de hash pura. El coste del mecanismo se paga solo la primera vez que se pregunta por cada clave, nunca después.

😴

Carga perezosa y canonicalización

Un __index que hace require la primera vez que alguien nombra un submódulo da arranque instantáneo; uno que devuelve siempre el mismo objeto para el mismo contenido convierte la comparación estructural en comparación de identidad.

🕸️

Datos asociados sin tocar el objeto

Con __mode en k, una tabla débil adjunta metadatos a objetos que no puedes modificar, y los suelta cuando el objeto muere.

El patrón es uno solo, y su virtud es dónde no está

Mira las tarjetas de arriba y busca la diferencia entre ellas: no la hay. Todas son la misma tabla con el mismo metamétodo, y lo único que cambia es qué se hace cuando falta la clave —devolver un neutro, crear un hijo, calcular, cargar, canonicalizar, medir—. Que media docena de patrones con nombre propio en otros lenguajes colapsen en una estructura de cinco líneas es la demostración más limpia de la tesis de este nivel: exponer el punto de decisión en vez de la característica multiplica lo que se puede construir sin ampliar el lenguaje. Pero el argumento decisivo no es la economía sintáctica, es el perfil de coste. Un memoizador construido con __index no cuesta nada cuando acierta, porque el intérprete resuelve la clave presente sin llegar a mirar la metatabla; toda la lógica de caché vive en una rama que solo se ejecuta una vez por clave en toda la vida del programa. Esa propiedad —el mecanismo se cobra únicamente cuando hace falta— es rarísima. La mayoría de las abstracciones de intercepción cobran un peaje en cada operación: un decorador es una llamada de función siempre, un proxy es una indirección siempre, un descriptor es una comprobación siempre. Aquí la abstracción se disuelve en el caso frecuente y solo reaparece en el infrecuente. Por eso estos dos patrones no son ejemplos didácticos de metatablas: son la razón por la que un lenguaje sin caché en la biblioteca estándar nunca lo ha echado de menos.

⚔️ Cachés que no mienten
  1. Escribe el contador con __index que devuelve cero y comprueba con pairs que las claves nunca leídas no aparecen. Explica por qué es lo correcto.
  2. Implementa la autotabla y demuestra que un simple if arbol.a.b then deja dos tablas vacías detrás. Reescribe la comprobación con rawget.
  3. Memoiza una función deliberadamente lenta y mide con os.clock la primera consulta frente a un millón de consultas posteriores.
  4. Construye el caché de Fibonacci y compara con la versión recursiva sin caché para el valor 32. Anota los dos tiempos.
  5. Escribe una función cara que devuelva nil para la mitad de las entradas, memoízala sin centinela y demuestra que no cachea nada. Arréglala con el centinela.
  6. Crea un caché con __mode igual a k cuyas claves sean tablas. Suelta las referencias, llama a collectgarbage y comprueba con pairs que el caché se ha vaciado solo.