nil a fondo: la ausencia convertida en valor
Por qué nil es un valor de pleno derecho y no un hueco, por qué asignar nil a una clave equivale a que la clave nunca haya existido, qué son los agujeros y por qué rompen la longitud de una tabla, y cómo se comporta una variable que jamás fue declarada.
En la mayoría de los lenguajes la ausencia se representa con un valor especial que el sistema de tipos trata aparte, y buena parte de su diseño moderno consiste en acorralarlo. Lua tomó el camino opuesto: nil es un tipo entero con un solo valor, ese valor circula como cualquier otro y su significado no es «error» sino «aquí no hay nada útil». La consecuencia más profunda es que en una tabla no existe diferencia entre una clave asignada a nil y una clave que nunca existió, y de esa identidad se derivan casi todas las sorpresas que te dará el lenguaje en las próximas semanas.
- Describir
nilcomo valor de primera clase y distinguirlo de la noción de error. - Justificar por qué asignar
nila una clave es indistinguible de que la clave no exista. - Reconocer un agujero en una secuencia y anticipar el comportamiento del operador de longitud.
- Explicar qué ocurre al leer y al escribir una variable global que nunca fue declarada.
La ausencia como valor de primera clase
nil es un tipo con exactamente un valor, igual que boolean tiene dos. No es una referencia nula ni un puntero inválido: es un valor que puedes guardar en una variable, pasar como argumento, devolver desde una función y comparar con igualdad.
local x = nil
print(type(x)) --> nil
print(x == nil) --> true
print(nil == nil) --> true
local function nada() return nil end
print(nada()) --> nil
Su papel semántico es el de ausencia, no el de fallo. Por eso el idioma dominante de la biblioteca estándar para señalar un problema recuperable es devolver nil seguido de un mensaje, en lugar de lanzar un error: el que llama decide si eso es excepcional o no.
local f, err = io.open("no-existe.txt")
if not f then
print("no se pudo abrir:", err)
end
Ese convenio tiene una consecuencia que conviene interiorizar pronto: nil es un resultado esperado en muchas APIs, así que tratarlo siempre como síntoma de bug es tan erróneo como ignorarlo siempre. En la documentación de Lua 5.4 aparece con el nombre fail precisamente para subrayar que ese nil es un valor de retorno previsto y no un accidente.
Asignar nil es borrar, y borrar es no haber existido nunca
Aquí está el núcleo de la lección. En una tabla de Lua, la operación de leer una clave inexistente y la de leer una clave cuyo valor es nil producen exactamente lo mismo, porque son la misma cosa. No hay dos estados: solo hay uno.
local t = { a = 1, b = 2 }
print(t.a, t.c) --> 1 nil (c nunca existio)
t.a = nil
print(t.a) --> nil (a ya no existe)
for k in pairs(t) do print(k) end --> solo imprime b
Fíjate en el recorrido final: tras asignar nil, la clave a desaparece de la iteración. No queda como clave con valor vacío; se elimina de la estructura. Por eso la forma canónica de borrar un campo en Lua no es una función delete sino una asignación corriente.
flowchart TD A[Lectura de una clave en una tabla] --> B[La clave esta presente] B -->|no| N[El resultado es nil] B -->|si| C[El valor guardado es nil] C -->|si| N C -->|no| V[El resultado es el valor] N --> Z[Los dos caminos son indistinguibles] style N fill:#f9e2af,color:#11111b style Z fill:#f38ba8,color:#11111b
Esa indistinguibilidad no es un descuido: es lo que permite que una tabla vacía sea también una tabla con infinitas claves de valor nil, y que el coste de memoria de una tabla dependa solo de lo que contiene de verdad. El precio es que no puedes modelar la distinción entre «no configurado» y «configurado a nada» con la tabla desnuda. Cuando tu dominio necesita esa diferencia, el patrón habitual es un centinela: un objeto único y privado que representa el vacío explícito.
local VACIO = setmetatable({}, { __tostring = function() return "VACIO" end })
local config = { color = "azul", borde = VACIO }
local function leer(t, k)
local v = t[k]
if v == nil then return nil, "sin configurar"
elseif v == VACIO then return nil, "configurado a vacio"
else return v end
end
Hay una segunda consecuencia, más abrupta: nil no puede ser una clave. Como una clave con valor nil no existe, una clave que sea nil carecería de sentido, y el intérprete prefiere avisar en lugar de ignorar la operación en silencio.
local t = {}
t[nil] = 1 -- error de ejecucion: table index is nil
print(t[nil]) --> nil, leer si esta permitido y siempre da nil
Escribir falla, leer no. La asimetría es deliberada: leer una clave nil es preguntar por algo que con certeza no está, y esa pregunta tiene respuesta; escribirla sería pedir un estado que la estructura no puede representar.
Agujeros: cuando una secuencia deja de serlo
Lua llama secuencia a una tabla cuyas claves numéricas van de 1 a n sin saltos. El operador de longitud # está definido solo para secuencias. Si hay un salto, la tabla tiene un agujero y # devuelve cualquier borde válido, es decir, cualquier índice i tal que i esté presente e i + 1 no lo esté. Cuál de ellos devuelve depende de la implementación, del historial de inserciones y del tamaño interno de la parte array.
local t = { 10, 20, 30, 40, 50 }
print(#t) --> 5
t[3] = nil -- agujero en la posicion 3
print(#t) --> 5 o 2, ambos son respuestas legitimas
for i, v in ipairs(t) do print(i, v) end --> solo 1 y 2: ipairs para en el hueco
El error de razonamiento a evitar es pensar que # cuenta elementos. No los cuenta: localiza un borde con una búsqueda que asume que no hay agujeros, y sobre una tabla con agujeros su resultado no es incorrecto sino no especificado. Dos versiones del intérprete pueden dar respuestas distintas sin que ninguna esté violando el lenguaje.
Para quitar un elemento del medio de una lista usa table.remove, que desplaza la cola y mantiene la secuencia intacta. Asignar nil a una posición intermedia deja la tabla en un estado sobre el que #, ipairs, table.sort y table.concat dejan de tener garantías.
El mismo problema aparece de forma disfrazada en las listas de argumentos variables, donde un nil intermedio es perfectamente legal y perfectamente destructivo para el conteo. La solución de la biblioteca estándar es no usar # en absoluto.
local function contar(...)
print(select("#", ...)) -- cuenta real, incluidos los nil
local t = table.pack(...)
print(t.n) -- misma cuenta, guardada en el campo n
print(#t) -- no fiable si hay nil intermedios
end
contar(1, nil, 3) --> 3 3 y un tercer numero imprevisible
Variables no declaradas y el entorno global
En Lua no existe el error «variable no declarada». Un nombre que no corresponde a ninguna variable local ni a ningún upvalue se compila como acceso a un campo de la tabla de entorno, así que leerlo es leer una clave que no está, y eso —por todo lo anterior— vale nil.
print(variableQueNoExiste) --> nil, sin error alguno
print(_G.variableQueNoExiste) --> nil, es literalmente lo mismo
contdor = 0 -- errata: crea una global nueva en silencio
contador = contador + 1 -- error: attempt to perform arithmetic on a nil value
La primera errata no falla: crea una variable global perfectamente válida con el nombre equivocado. El fallo aparece líneas después, cuando alguien intenta operar sobre el nil de la variable que sí quería usar. Esta cadena —errata silenciosa, ausencia silenciosa, error tardío en otro sitio— es la principal fuente de sesiones de depuración largas en Lua.
Las dos defensas prácticas son declarar local por defecto siempre, sin excepciones, y en proyectos serios instrumentar el entorno global con metamétodos que conviertan el acceso a un nombre desconocido en un error inmediato.
setmetatable(_G, {
__index = function(_, k) error("lectura de global no declarada: " .. k, 2) end,
__newindex = function(t, k, v) error("creacion de global: " .. k, 2) end,
})
Es tentador leer la identidad entre «clave ausente» y «clave con valor nil» como una simplificación cómoda para el implementador. Lo es, pero la razón de fondo es semántica y merece enunciarse con precisión: en Lua una tabla no es un contenedor con contenido, es una función parcial de claves a valores no nulos, extendida a total con nil. Bajo esa lectura, toda tabla es una función total definida sobre todos los valores posibles del lenguaje, y nil es el valor por defecto de ese dominio infinito. La estructura de datos que hay en memoria es solo una representación finita de las entradas que se apartan del valor por defecto. Desde ahí, todo encaja sin excepciones: asignar nil no borra nada, devuelve la clave a su valor por defecto; una tabla vacía no está vacía, es la función constante nil; pairs no recorre la tabla sino el conjunto finito de desviaciones respecto del valor por defecto; y nil no puede ser clave porque el dominio ya la incluiría trivialmente. El coste de esta elegancia es exactamente el que has visto: perdemos la capacidad de expresar el vacío explícito, y el operador de longitud solo tiene sentido si el conjunto de desviaciones forma un intervalo inicial. Merece la pena compararlo con la alternativa: un lenguaje que distinga ausencia de nulidad necesita dos operaciones de borrado, dos comprobaciones en cada lectura y una decisión en cada API sobre cuál de las dos devuelve. Lua paga con un centinela ocasional lo que otros pagan con una dualidad permanente en todo el modelo de datos.
- Construye una tabla con tres claves, borra una con
nily comprueba conpairsy connextque ha desaparecido de verdad. - Crea deliberadamente un agujero en una lista de cinco elementos y compara los resultados de
#,ipairsypairs. - Reescribe el mismo borrado con
table.removey verifica que la longitud vuelve a ser predecible. - Implementa el patrón del centinela para una tabla de configuración que necesite distinguir «sin definir» de «definido a vacío».
- Instala los metamétodos de global estricta en un archivo de prueba y comprueba que una errata en un nombre global falla en la línea correcta.