wandres.dev
VALORES Y TIPOS · ocho tipos y ya

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.

⏱ 18 min

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.

🎯 Al terminar esta lección sabrás
  • Describir nil como valor de primera clase y distinguirlo de la noción de error.
  • Justificar por qué asignar nil a 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.

⚠️
Nunca crees un agujero con una asignación directa

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,
})
Por qué Lua se niega a distinguir el hueco de la nada

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.

⚔️ Provoca y domestica la ausencia
  1. Construye una tabla con tres claves, borra una con nil y comprueba con pairs y con next que ha desaparecido de verdad.
  2. Crea deliberadamente un agujero en una lista de cinco elementos y compara los resultados de #, ipairs y pairs.
  3. Reescribe el mismo borrado con table.remove y verifica que la longitud vuelve a ser predecible.
  4. Implementa el patrón del centinela para una tabla de configuración que necesite distinguir «sin definir» de «definido a vacío».
  5. 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.