wandres.dev
ERRORES · pcall y nada más

error y assert: lanzar, y decidir a quién se culpa

El valor lanzado puede ser de cualquier tipo, la posición solo se añade a las cadenas, y el argumento de nivel decide qué línea aparece en el mensaje. Por qué assert no es un atajo sintáctico y cuándo su comodidad se paga en cada iteración.

⏱ 18 min

La función que lanza errores en Lua tiene dos parámetros, y el segundo es el que separa a una biblioteca amateur de una profesional. El primero es el valor del error, que puede ser cualquier cosa y del que se deriva toda la discusión sobre errores estructurados. El segundo es un número que no cambia nada del error salvo una cosa aparentemente trivial: qué línea del programa se señala como culpable. Esa aparente trivialidad determina si el usuario de tu módulo ve una línea de tu código fuente, que a él no le dice nada, o la línea suya donde cometió el fallo. Junto a ambos está assert, la abreviatura más usada del lenguaje, cuyo comportamiento es más sutil de lo que su nombre sugiere y cuyo coste se paga incluso cuando todo va bien.

🎯 Al terminar esta lección sabrás
  • Lanzar valores no textuales y saber exactamente qué información pierde y gana el error al hacerlo.
  • Usar el argumento de nivel para culpar a la línea que corresponde en cada capa de una biblioteca.
  • Explicar qué devuelve assert cuando la condición es verdadera y por qué eso habilita un idioma concreto.
  • Identificar los casos en los que assert es una elección incorrecta por coste o por semántica.

Lanzar cualquier valor

error toma el valor que le des y lo lanza tal cual, con una única excepción: si el valor es una cadena y el nivel es distinto de cero, antepone la posición del código en el formato de fichero y línea seguido de dos puntos. Ningún otro tipo recibe ese tratamiento.

local function fallar(v) error(v) end

print(select(2, pcall(fallar, "texto")))      --> archivo.lua:1: texto
print(select(2, pcall(fallar, 42)))           --> 42
print(select(2, pcall(fallar, { c = "E" })))  --> table: 0x...

La asimetría es deliberada y responde a un razonamiento sencillo: un mensaje textual está destinado a un humano y necesita contexto de ubicación, mientras que un valor estructurado está destinado a un programa que lo va a inspeccionar, y contaminarlo con un prefijo lo estropearía. Si quieres ambas cosas —un error inspeccionable y legible— la solución idiomática es lanzar una tabla con un campo de posición que rellenes tú y un metamétodo de conversión a cadena que produzca el texto cuando alguien lo imprima.

Un error estructurado bien hecho tiene tres partes: un discriminante estable que el código de captura pueda comparar, unos datos asociados al fallo concreto, y una representación textual para cuando nadie lo capture.

local ErrorIO = {}
ErrorIO.__index = ErrorIO
ErrorIO.__tostring = function(e)
  return string.format("[%s] %s: %s", e.clase, e.operacion, e.ruta)
end

local function lanzarIO(operacion, ruta)
  error(setmetatable({ clase = "io", operacion = operacion, ruta = ruta }, ErrorIO))
end

El argumento de nivel

El segundo parámetro de error selecciona de qué marco de la pila se toma la posición. El valor uno, que es el que se aplica si no dices nada, señala la línea donde se llamó a error. El valor dos señala la línea donde se llamó a la función que llamó a error. El valor cero suprime por completo el prefijo.

local M = {}

function M.dividir(a, b)
  if b == 0 then
    error("el divisor no puede ser cero", 2)   -- culpa a quien llamo a dividir
  end
  return a / b
end

M.dividir(1, 0)   --> programa.lua:12: el divisor no puede ser cero

Con nivel uno el mensaje habría apuntado a la línea cinco del módulo, que es donde vive el error. Para el usuario del módulo eso es ruido: él no escribió esa línea, no puede corregirla y ni siquiera tiene por qué saber que existe. Con nivel dos, el mensaje señala la llamada errónea que él sí escribió. Esa es la regla operativa para toda validación de argumentos en una interfaz pública: el nivel dos es el valor correcto por defecto en cualquier función que valide lo que le pasan.

flowchart TD
A[Nivel 0 sin posicion] --> B[El mensaje viaja limpio]
C[Nivel 1 por defecto] --> D[Se culpa a la linea que llamo a error]
E[Nivel 2] --> F[Se culpa a quien llamo a la funcion actual]
G[Nivel 3 o mas] --> H[Se sube un marco mas por cada unidad]
B --> I[Util al relanzar un error ya formateado]
D --> J[Util en errores internos del propio modulo]
F --> K[Util al validar argumentos de una API publica]

Los niveles superiores a dos existen para las funciones auxiliares de validación. Si extraes la comprobación a una rutina compartida, esa rutina está un marco más abajo y necesita subir uno más para seguir señalando al usuario:

local function comprobarNumero(v, pos, nombre, nivel)
  if type(v) ~= "number" then
    error(string.format("argumento %d de %s: se esperaba number, llego %s",
                        pos, nombre, type(v)), (nivel or 2) + 1)
  end
end

function M.escalar(v, factor)
  comprobarNumero(factor, 2, "escalar", 2)   -- culpa a quien llamo a escalar
  return v * factor
end

Pasar el nivel como parámetro en lugar de fijarlo dentro del validador es lo que permite reutilizarlo desde profundidades distintas. Es exactamente la técnica que emplea la biblioteca auxiliar de C de Lua para producir sus mensajes sobre argumentos incorrectos, y la razón de que esos mensajes señalen siempre la llamada del usuario y nunca las tripas del intérprete.

El nivel cero merece una nota aparte porque su caso de uso es el relanzado. Cuando capturas un error que ya trae su posición y quieres devolverlo hacia arriba, lanzarlo con el nivel por defecto le añadiría una segunda posición delante, produciendo mensajes con dos ubicaciones encadenadas que confunden en lugar de informar. Relanzar siempre con nivel cero conserva el mensaje original exactamente como estaba.

assert no es azúcar sintáctico

La descripción habitual —assert lanza si su primer argumento es falso— es correcta pero incompleta, y la parte que se omite es la que da lugar al idioma más usado del lenguaje. Si el primer argumento es verdadero, assert devuelve todos sus argumentos sin modificarlos.

local f = assert(io.open("config.lua", "r"))

Aquí io.open devuelve o bien un manejador de fichero, o bien nulo seguido de un mensaje explicativo. En el caso de éxito, assert deja pasar el manejador. En el caso de fallo, ve un primer argumento nulo y lanza usando el segundo como valor del error, con lo que el mensaje de la biblioteca del sistema llega intacto al usuario. Una sola palabra convierte la convención de devolver el fallo como valor en la convención de lanzarlo, y es reversible: pcall hace el camino inverso.

💡
Con mensaje explícito, assert pierde los retornos extra

Si escribes la forma de dos argumentos con un mensaje propio, ese mensaje sustituye al que traía la función y solo se propaga el primer valor. Cuando quieras conservar el diagnóstico original, deja assert con un único argumento y confía en el segundo retorno de la función llamada.

El coste que se paga siempre

Aquí está la trampa. Lua evalúa todos los argumentos antes de llamar a assert, porque no es una construcción sintáctica sino una función ordinaria. El mensaje se construye en cada llamada, incluso en el noventa y nueve por ciento de las veces en que la condición es verdadera y ese mensaje se descarta.

-- Construye una cadena nueva en CADA iteracion, aunque nunca falle
for i = 1, 1e6 do
  assert(tabla[i], "falta el elemento " .. i .. " en la tabla")
end

-- No construye nada mientras todo va bien
for i = 1, 1e6 do
  if not tabla[i] then error("falta el elemento " .. i .. " en la tabla", 2) end
end

La diferencia es un millón de concatenaciones, un millón de asignaciones de cadena y la presión correspondiente sobre el recolector, todo para producir texto que nadie lee. En código de arranque, de configuración o de prueba, ese coste es irrelevante y la brevedad de assert gana. En un bucle caliente, en el interior de un manejador que se ejecuta por fotograma o en una ruta de datos, el condicional explícito es la única opción defendible.

⚠️

Mensaje construido, siempre

Toda concatenación, formato o llamada que aparezca como segundo argumento se ejecuta en cada invocación, pase o no pase la comprobación. El coste es proporcional a la frecuencia, no a la de los fallos.

🚀

Arranque y configuración

Ahí assert gana sin discusión: se ejecuta una vez, la brevedad ayuda a leer y el fallo debe detener el programa de todas formas.

🔬

Pruebas y contratos internos

También ganador. La claridad importa más que los nanosegundos y el mensaje lo lee quien tiene el fuente abierto delante.

🔥

Bucles y rutas calientes

Perdedor claro. Condicional explícito con error, y si el mensaje es caro de construir, constrúyelo solo dentro de la rama que falla.

Hay además una razón semántica para preferir el condicional en interfaces públicas: assert no admite argumento de nivel. Su error siempre señala la línea donde se escribió el propio assert, es decir, una línea de tu módulo. Para culpar al llamante hace falta error con nivel dos, y por tanto toda validación de argumentos de una API bien hecha se escribe con el condicional, no con la abreviatura.

El nivel de error es una declaración sobre dónde crees que está el defecto

Elegir entre nivel uno y nivel dos parece una cuestión cosmética sobre qué línea sale impresa, y en realidad es una afirmación epistemológica sobre la naturaleza del fallo. Cuando lanzas con nivel uno estás diciendo aquí dentro ha pasado algo que no debía, y por tanto el lector del mensaje es el mantenedor de este código. Cuando lanzas con nivel dos estás diciendo tú me has llamado mal, y el lector es alguien que probablemente no ha visto jamás tu fuente y no debería tener que verlo. Son dos diagnósticos incompatibles del mismo evento, y el que elijas condiciona cuántas horas pierde otra persona. Piensa en lo que ocurre cuando esta decisión se toma mal a escala: una biblioteca con diez capas internas que valida en la más profunda con el nivel por defecto produce mensajes que apuntan a un fichero que el usuario no puede abrir, sobre una variable cuyo nombre no reconoce, en una línea que no explica nada de lo que él hizo. El usuario concluye que la biblioteca está rota, abre una incidencia, y el mantenedor descubre tres días después que solo había pasado una cadena donde se esperaba un número. Todo eso lo previene un dígito. Por eso la disciplina profesional no es validar los argumentos, que todo el mundo hace, sino validarlos en la frontera y culpar a la frontera: la comprobación va en la primera función pública que toca el dato, con nivel dos, y las capas internas asumen el contrato como cierto en lugar de repetir la comprobación con un nivel que ya no corresponde a nadie.

⚔️ Ajusta el nivel hasta que el mensaje sea útil
  1. Escribe un módulo con una función pública que valide su argumento. Lánzalo primero con nivel uno y después con nivel dos, y compara los mensajes desde un programa que la use.
  2. Añade una capa intermedia entre la pública y la validación, y averigua experimentalmente qué nivel hace falta para seguir culpando al usuario.
  3. Construye un error estructurado con metamétodo de conversión a cadena. Compruébalo capturado y sin capturar.
  4. Mide con os.clock un bucle de un millón de iteraciones con assert y mensaje concatenado frente al condicional equivalente. Anota la diferencia.
  5. Captura el fallo de abrir un fichero inexistente con la forma de un argumento de assert y con la de dos argumentos. Explica qué información se pierde en el segundo caso.