__newindex: interceptar la escritura
El metamétodo simétrico de la lectura: por qué solo se dispara con claves ausentes, cómo se construyen tablas de solo lectura y validación al asignar, y el patrón del proxy vacío con almacenamiento aparte.
Si __index describe qué ocurre cuando una lectura falla, __newindex describe qué ocurre cuando una escritura se estrena. La simetría es tentadora pero engañosa, y de esa asimetría escondida sale casi todo el diseño de esta lección: __newindex no intercepta todas las escrituras, solo las que crean una clave nueva. Comprender esa restricción, y la técnica con la que se rodea —una tabla proxy permanentemente vacía cuyos datos viven en otro sitio—, es lo que separa un objeto de solo lectura de verdad de uno que se cree de solo lectura.
- Precisar cuándo se dispara
__newindexy por qué la primera escritura lo desactiva. - Construir tablas de solo lectura que resistan intentos de escritura reales.
- Validar y normalizar valores en el momento de la asignación.
- Dominar el patrón del proxy vacío con tabla de almacenamiento aparte y conocer sus costuras.
El punto ciego de la escritura
La regla es exactamente la contraria a la intuición. Cuando escribes en una tabla, el intérprete comprueba primero si la clave ya existe en crudo. Si existe, sobrescribe el valor y no consulta absolutamente nada más. Solo si la clave está ausente —o su valor era nil, que es lo mismo— mira la metatabla en busca de __newindex.
flowchart TD A[Asignar v en t indexado por k] --> B[Existe k en la parte cruda de t] B -->|si| C[Sobrescribir el valor y terminar] B -->|no| D[Tiene t metatabla con __newindex] D -->|no| E[Crear la clave en t y terminar] D -->|es una funcion| F[Llamarla con t y k y v] D -->|es una tabla| G[Reintentar la asignacion sobre esa tabla] G --> B style C fill:#f9e2af,color:#11111b style E fill:#a6e3a1,color:#11111b style F fill:#89b4fa,color:#11111b
La consecuencia práctica es demoledora para el principiante: un interceptor de escrituras deja de interceptar en cuanto la clave existe. Si tu tabla ya tiene datos, __newindex solo verá los campos nuevos.
local registro = setmetatable({x = 1}, {
__newindex = function(t, k, v)
print("escritura nueva:", k, v)
rawset(t, k, v)
end,
})
registro.y = 2 --> escritura nueva: y 2
registro.x = 99 -- silencio absoluto: x ya existía
registro.y = 3 -- silencio: rawset la creó en la primera línea
Fíjate en la tercera línea. El propio metamétodo se ha desactivado a sí mismo: al hacer rawset creó la clave, y a partir de ahí las escrituras sobre y son directas. Esa autodesactivación es a veces justo lo que quieres —un coste de inicialización que se paga una vez— y a veces el bug que te tiene una tarde entera.
Como en la lectura, si __newindex es una tabla la asignación se redirige a ella y vuelve a empezar el proceso completo, incluyendo la posibilidad de que esa tabla tenga su propio __newindex. Y si es una función, se la llama con tres argumentos —tabla, clave, valor— y su valor de retorno se descarta: la única forma de que algo quede guardado es que la función lo guarde explícitamente.
Solo lectura
El caso más simple es negarse a escribir. La función tiene que fallar de forma audible, porque un fallo silencioso en una asignación es una de las fuentes de bugs más caras que existen.
local function solo_lectura(datos)
return setmetatable({}, {
__index = datos,
__newindex = function(_, k)
error("intento de escribir en una tabla inmutable: " .. tostring(k), 2)
end,
__len = function() return #datos end,
__metatable = false, -- sella la metatabla: nadie la reemplaza
})
end
local CONFIG = solo_lectura {host = "localhost", puerto = 8080}
print(CONFIG.puerto) --> 8080
CONFIG.puerto = 9090 --> error: intento de escribir ... 'puerto'
Hay tres decisiones deliberadas ahí y las tres son necesarias. La tabla que se devuelve está vacía, para que ninguna clave exista en crudo y por tanto ninguna escritura escape al interceptor. El __index apunta a los datos reales, que quedan capturados en el closure y no son alcanzables desde fuera. Y __metatable = false impide que alguien haga setmetatable(CONFIG, {}) y desmonte toda la protección en una línea: sin ese campo, tu tabla inmutable es una sugerencia.
El segundo argumento de error, ese 2, tampoco es decorativo: hace que el mensaje señale la línea del llamador que intentó escribir y no la línea del metamétodo, que al usuario no le dice nada.
Queda una grieta conocida: la inmutabilidad es superficial. Si un valor de datos es a su vez una tabla, CONFIG.opciones.debug = true funciona sin protesta, porque esa escritura ya no es sobre el proxy. Para congelar en profundidad hay que envolver recursivamente y memorizar los envoltorios ya creados, para no fabricar uno nuevo en cada lectura.
Validar al asignar
El mismo punto de intervención, con la función escribiendo en vez de negándose, da validación en el momento exacto de la asignación: la única forma de garantizar que un objeto nunca ha estado en un estado inválido.
local esquema = {
nombre = function(v) return type(v) == "string" and #v > 0 end,
edad = function(v) return math.type(v) == "integer" and v >= 0 end,
correo = function(v) return type(v) == "string" and v:find("@", 1, true) ~= nil end,
}
local function persona()
local campos = {}
return setmetatable({}, {
__index = campos,
__newindex = function(_, k, v)
local valida = esquema[k]
if not valida then
error("campo no declarado: " .. tostring(k), 2)
elseif not valida(v) then
error("valor invalido para " .. k .. ": " .. tostring(v), 2)
end
campos[k] = v -- escritura directa en el almacén, no en el proxy
end,
__pairs = function() return next, campos, nil end,
})
end
local p = persona()
p.nombre = "Ada" -- correcto
p.edad = 36 -- correcto
p.edad = -1 --> error: valor invalido para edad: -1
p.telefono = "555" --> error: campo no declarado: telefono
Observa que el metamétodo escribe en campos, no en el proxy. Si escribiera en el proxy con rawset, la clave pasaría a existir y la siguiente asignación al mismo campo se saltaría la validación por completo: habrías validado solo el primer valor de cada campo, que es peor que no validar, porque parece que funciona.
El proxy vacío y sus costuras
Lo que han hecho los dos ejemplos anteriores es el patrón general: una tabla proxy que se mantiene vacía para siempre y un almacén separado que contiene los datos de verdad. La tabla vacía no es un detalle de implementación, es la condición que garantiza que todas las lecturas pasen por __index y todas las escrituras por __newindex, sin excepciones y sin caducidad.
El almacén se puede guardar de tres formas, y cada una tiene su carácter. En un upvalue del closure, como arriba: máxima privacidad, inaccesible salvo por debug.getupvalue. En un campo del propio proxy con un nombre convenido: cómodo, pero rompe la invariante de tabla vacía y expone los datos. O en una tabla externa indexada por el proxy, que es la opción más limpia cuando hay muchos objetos:
local almacen = setmetatable({}, {__mode = "k"}) -- claves débiles: sin fugas
local meta = {
__index = function(p, k) return almacen[p][k] end,
__newindex = function(p, k, v) almacen[p][k] = v end,
__len = function(p) return #almacen[p] end,
}
local function envolver(t)
local p = setmetatable({}, meta)
almacen[p] = t
return p
end
El __mode = "k" es esencial: sin él, almacen mantendría vivo cada proxy que hayas creado en la vida del programa y tendrías una fuga de memoria perfecta. Con claves débiles, cuando el proxy deja de ser alcanzable, el recolector se lleva también su entrada.
Y ahora las costuras, que son reales y hay que conocerlas antes de repartir proxies por un programa. El operador de longitud ve una tabla vacía salvo que definas __len. La iteración con pairs no recorre nada, porque el proxy no tiene claves; __pairs existe justamente para eso, aunque su estatus ha ido cambiando entre versiones y lo portable es exponer un iterador propio. La igualdad compara proxies, no contenidos, salvo que definas __eq. Y cada lectura y cada escritura pasan de ser una consulta de hash a una llamada de función completa, lo que en un bucle caliente se nota. Un proxy es una herramienta de frontera —configuración, API pública, depuración, sandbox—, no una forma de escribir estructuras de datos.
Que __index se dispare en cada lectura fallida y __newindex solo en la primera escritura parece una incoherencia del diseño, y es justo lo contrario: es la misma decisión aplicada con honestidad a dos operaciones que no son simétricas. La lectura fallida es idempotente y no deja rastro, así que puede consultarse siempre sin cambiar nada. La escritura, por definición, modifica la tabla, y una vez que la clave existe, seguir consultando la metatabla en cada asignación significaría pagar una llamada de función por cada escritura durante toda la vida del programa para preguntar algo que ya se preguntó. Lua eligió que la intercepción de escrituras fuera un evento de transición de estado —de ausente a presente— y no un peaje permanente. La consecuencia es que el lenguaje te obliga a ser explícito: si de verdad quieres interceptar todas las escrituras, no puedes limitarte a poner un metamétodo, tienes que garantizar estructuralmente que la clave nunca llegue a existir, y eso se hace con una tabla vacía y un almacén aparte. El patrón del proxy no es un truco que alguien descubrió a pesar del diseño: es la forma que tiene el lenguaje de cobrarte el coste de la intercepción total solo cuando la pides de verdad. Es la misma filosofía que en todo lo demás: nada oculto, nada gratis, y el mecanismo desnudo delante de ti para que decidas cuánto quieres pagar.
- Implementa
solo_lecturay comprueba queCONFIG.opciones.debug = trueatraviesa la protección cuandoopcioneses una tabla. Arréglalo envolviendo en profundidad. - Quita el
__metatable = falsede tu implementación y rompe la inmutabilidad en una sola línea desde fuera. Vuelve a ponerlo. - Cambia la validación para que escriba con
rawseten el proxy y demuestra con un caso concreto que a partir de la segunda asignación deja de validar. - Añade a la tabla validada un contador de escrituras rechazadas, accesible por un método pero no escribible desde fuera.
- Escribe el proxy con almacén de claves débiles, crea cien mil proxies en un bucle, llama a
collectgarbagey comprueba concollectgarbage("count")que la memoria vuelve a bajar.