wandres.dev
ERRORES · pcall y nada más

El modelo: dos funciones y ningún bloque

Lua no tiene excepciones, ni jerarquía de clases de error, ni sintaxis de captura: tiene una función que lanza y otra que ejecuta en modo protegido. Qué implica esa simplicidad radical para el diseño de programas y dónde se paga la factura.

⏱ 17 min

En casi todos los lenguajes que has usado, el manejo de errores es una construcción sintáctica: hay palabras reservadas para intentar y para capturar, hay una jerarquía de tipos de excepción, hay reglas de emparejamiento entre lo que se lanza y lo que se atrapa, y hay un bloque final que corre pase lo que pase. En Lua no existe nada de eso. Existe una función que interrumpe la ejecución llevándose un valor cualquiera, y otra función que ejecuta a un tercero vigilándolo. Todo lo demás —tipos de error, contexto, propagación, limpieza— es código de biblioteca que escribes tú o que alguien escribió por ti. Esta lección defiende que esa decisión no es una carencia pendiente de subsanar, sino una posición coherente con el resto del lenguaje, y describe con precisión qué te obliga a hacer a cambio.

🎯 Al terminar esta lección sabrás
  • Describir el mecanismo de error de Lua sin recurrir al vocabulario de las excepciones.
  • Separar el instante de lanzar del instante de desenrollar y ubicar qué función interviene en cada uno.
  • Enumerar las consecuencias de que un error sea un valor cualquiera y no una instancia de una clase.
  • Reconocer qué construcciones de otros lenguajes no existen aquí y con qué se sustituye cada una.

Dos funciones y ningún bloque

Todo el sistema cabe en dos primitivas. error interrumpe la ejecución de la función actual y de todas sus llamadoras hasta encontrar una barrera. pcall instala esa barrera: llama a la función que le pasas y, si algo revienta dentro, devuelve el control con un aviso en lugar de dejar que el fallo siga subiendo.

local ok, resultado = pcall(function()
  error("se rompio algo")
end)

print(ok, resultado)   --> false    archivo.lua:2: se rompio algo

local ok2, a, b = pcall(function() return 10, 20 end)
print(ok2, a, b)       --> true     10    20

Observa la forma del contrato. pcall devuelve siempre un booleano como primer valor; cuando es verdadero, lo siguen todos los retornos de la función; cuando es falso, lo sigue el único valor que se lanzó. No hay una zona sintáctica de éxito y otra de fracaso: hay una tupla de retorno cuyo primer elemento te dice cómo leer el resto. El manejo de errores en Lua es, literalmente, un if sobre un valor booleano.

Que la barrera sea una función corriente y no una construcción del analizador sintáctico tiene consecuencias que se notan enseguida. pcall es un valor: puedes guardarlo en una variable, pasarlo como argumento, sustituirlo por una versión instrumentada durante las pruebas o meterlo en una tabla de estrategias. Se puede aplicar parcialmente, componer y envolver. Nada de eso es posible con una palabra reservada, y esa diferencia explica por qué en Lua los mecanismos de reintento, de aislamiento de complementos o de captura selectiva se escriben en veinte líneas de biblioteca en lugar de pedirse al comité del lenguaje.

La contrapartida es que la barrera no es visible al leer. Un bloque de captura en otro lenguaje se ve desde el otro extremo de la pantalla por su indentación; una llamada protegida se parece a cualquier otra llamada y puede estar escondida tres funciones más abajo. Localizar dónde se detiene un error en una base de código ajena requiere buscar por nombre, no mirar la forma del texto.

Lo protegido, además, no son las llamadas que hay dentro del bloque sino una función concreta y todo lo que ella invoque. Esa diferencia importa porque obliga a un pequeño cambio de hábito: para proteger tres operaciones sucesivas no se abre un bloque, se agrupan en una función. Y si esa función necesita variables del ámbito exterior, o bien las recibe como argumentos —que es lo barato— o bien se declara como closure y se paga una asignación por invocación.

flowchart TD
A[Codigo ejecutandose con normalidad] --> B[Alguien llama a error]
B --> C[Se busca el marco protegido mas cercano]
C --> D[Si existe se desenrolla la pila hasta el]
D --> E[pcall devuelve falso y el valor lanzado]
C --> F[Si no existe el error escapa al anfitrion]
F --> G[El interprete imprime el mensaje y aborta]

El error es un valor, no un objeto

error acepta cualquier valor de Lua: una cadena, un número, una tabla, una función, incluso otro error. No hay una clase base de la que todo error deba descender, porque no hay clases; no hay comprobación de tipos en el punto de captura, porque no hay punto de captura tipado.

error("una cadena")                          -- lo habitual
error(404)                                   -- un numero, sin adornos
error({ codigo = "ENOENT", ruta = "/tmp/x" }) -- un error estructurado

Comparar el error recibido no tiene, por tanto, una forma canónica. Puedes comparar por identidad contra un centinela, mirar un campo discriminante, consultar la metatabla, o analizar el texto —esto último es lo único que nunca deberías hacer, porque el texto de los errores del intérprete depende de la versión y en algunos casos del idioma del sistema.

De aquí se derivan tres consecuencias que conviene tener presentes desde el principio. La primera es que el filtrado por tipo de error, que en otros lenguajes hace el compilador o el intérprete al emparejar cláusulas de captura, aquí lo escribes tú: inspeccionas el valor recibido y decides si es tuyo o no. La segunda es que si el valor no es tuyo, la única forma honesta de tratarlo es volver a lanzarlo, y esa disciplina no la impone nadie más que tu criterio. La tercera es que solo las cadenas reciben información de posición automática: error antepone el fichero y la línea únicamente cuando el valor lanzado es una cadena, de modo que un error estructurado en forma de tabla llega intacto y sin adornos, lo cual es una ventaja cuando se quiere procesar y un inconveniente cuando alguien tiene que leerlo.

⚠️
Un error no capturado que no sea cadena puede imprimirse fatal

Si un valor no textual escapa hasta el intérprete, este intentará convertirlo con el metamétodo de conversión a cadena. Si la tabla no lo define, el mensaje que verá el operador será una nota genérica indicando que el objeto de error no es una cadena, sin ninguna pista sobre qué falló. Todo error estructurado que puedas lanzar debería llevar su propio metamétodo de conversión a texto.

Lanzar y desenrollar son dos momentos distintos

Este es el punto que separa una comprensión superficial del modelo de una operativa. Cuando se lanza un error, ocurren dos cosas en secuencia y no simultáneamente: primero se determina el valor del error, y después se deshace la pila de llamadas hasta la barrera protegida. Entre ambos instantes hay una ventana en la que la pila todavía existe completa, con todos sus marcos, sus líneas y sus variables locales.

Conviene ser preciso sobre qué significa aquí desenrollar. Lua no ejecuta destructores marco a marco ni recorre la pila buscando cláusulas compatibles: simplemente restaura el estado del intérprete al punto que la barrera había guardado y descarta todo lo que había por encima. Es una operación de coste constante, no proporcional a la profundidad, y esa es una de las razones de que lanzar errores en Lua sea barato comparado con lenguajes que deben consultar tablas de desenrollado y ejecutar código de limpieza en cada marco.

Esa ventana es la razón de que existan dos funciones de llamada protegida en vez de una. pcall no ofrece acceso a la ventana: cuando recibes el control, la pila ya se deshizo y lo único que queda es el valor. xpcall acepta un manejador que se ejecuta dentro de la ventana, antes del desenrollado, y por eso es el único capaz de producir un rastro de pila útil. La lección tercera de este nivel se dedica entera a esa distinción; por ahora basta con fijar que el desenrollado destruye información y que decidir si la quieres es una decisión de diseño, no un detalle.

-- La pila existe aqui dentro, y solo aqui
local ok, informe = xpcall(nivel1, function(err)
  return debug.traceback(err, 2)   -- ve todos los marcos del fallo
end)

-- Aqui fuera ya no queda nada que inspeccionar
local ok2, err2 = pcall(nivel1)
ℹ️
El mismo error, dos cantidades de información muy distintas

Las dos llamadas del ejemplo capturan exactamente el mismo valor de error. La primera puede además decir por qué camino se llegó hasta él; la segunda no, y ninguna cantidad de código posterior podrá recuperarlo. La diferencia no está en lo que se captura sino en cuándo se mira.

Hay una segunda consecuencia, menos comentada. Como el desenrollado no ejecuta código de limpieza —no hay bloques finales que Lua deba respetar—, el lenguaje necesitaba otro mecanismo para liberar recursos cuando un error atraviesa un ámbito. Ese mecanismo son las variables de cierre de Lua 5.4, que se disparan al abandonar el bloque por cualquier vía, incluido un error en tránsito. En la práctica esto significa que la limpieza y la captura son en Lua dos conceptos ortogonales que se resuelven con herramientas distintas, en lugar de venir empaquetados en la misma sintaxis.

Lo que no existe, y con qué se sustituye

⚠️

No hay tipos de excepción

No hay jerarquía ni emparejamiento por clase. Se sustituye lanzando tablas con un campo discriminante y comprobándolo a mano en el punto de captura, o comparando contra un objeto centinela único por identidad.

🧹

No hay bloque final

No hay una sección que se ejecute pase lo que pase. Se sustituye con variables declaradas con el atributo de cierre, o con la secuencia manual de llamar en modo protegido, limpiar, y relanzar si hubo fallo.

🏷️

No hay excepciones comprobadas

Nada en la firma de una función indica qué puede lanzar. Se sustituye con documentación y con la convención de la biblioteca estándar de devolver los fallos previsibles como valores de retorno.

🔗

No hay causa encadenada

Un error no guarda referencia al que lo provocó. Se sustituye concatenando contexto al relanzar, o construyendo tablas de error con un campo que apunte al error interior.

La sustitución del emparejamiento por clase merece un ejemplo, porque es la que más código genera en la práctica. Sin jerarquía de tipos, distinguir tus errores de los ajenos exige un discriminante que no pueda colisionar por accidente:

local MI_ERROR = {}            -- identidad unica, imposible de duplicar

local function lanzarMio(datos)
  error(setmetatable(datos, MI_ERROR))
end

local ok, err = pcall(operacion)
if not ok then
  if getmetatable(err) == MI_ERROR then
    tratarLocalmente(err)      -- es mio, se lo que contiene
  else
    error(err, 0)              -- no es mio, no lo entiendo, sube
  end
end

La rama del else es la que casi todo el mundo olvida, y su ausencia es el defecto más caro de esta familia: un fallo de memoria, un error de sintaxis en código cargado dinámicamente o un desbordamiento de pila terminan silenciosamente tratados como si fueran un caso previsto de tu dominio.

Merece la pena señalar que ninguna de estas ausencias es un olvido. Cada una de ellas habría requerido una estructura de datos nueva en el núcleo, una regla de emparejamiento en el analizador sintáctico o una fase adicional en el desenrollado, y el compromiso de Lua con un intérprete que quepa en unos pocos cientos de kilobytes no admite ese gasto. Lo que el lenguaje ofrece a cambio es que el mecanismo entero se explica en un párrafo y se implementa sobre él lo que cada proyecto necesite.

La ausencia de excepciones no es minimalismo estético, es una redistribución de la responsabilidad

Cuando un lenguaje incorpora excepciones tipadas, bloques finales y encadenamiento de causas, está tomando por ti una serie de decisiones que parecen técnicas y son en realidad políticas: decide qué es un error y qué es un resultado, decide qué información viaja con el fallo, decide dónde termina la responsabilidad de una capa y empieza la de la siguiente. Lua se niega a tomar esas decisiones, y no por incapacidad —implementar excepciones sobre una máquina virtual de pila es un problema resuelto desde hace décadas— sino porque el lenguaje está diseñado para ser incrustado en anfitriones cuyas convenciones de error ya existen y no se parecen entre sí. Un motor de juego en C++, un servidor web en C y un editor de texto tienen ideas radicalmente distintas de qué es un fallo recuperable, y un mecanismo de excepciones opinado habría obligado a cada uno a traducir el suyo al de Lua en la frontera. La primitiva mínima —un valor que sube y una barrera que lo detiene— es lo único que los tres pueden mapear sin pérdida. El coste de esa neutralidad es que en tu proyecto no existe una política de errores hasta que tú la escribes, y que si no la escribes explícitamente acabarás teniendo tres o cuatro políticas incompatibles conviviendo en el mismo proceso, cada una nacida del hábito del programador que tocó ese fichero. Por eso el trabajo serio con errores en Lua empieza siempre por una decisión de arquitectura que en otros lenguajes viene resuelta de fábrica: qué se lanza, qué se devuelve, y en qué frontera exacta se convierte lo uno en lo otro.

⚔️ Explora los bordes del modelo
  1. Lanza un error con una tabla como valor y captúralo con pcall. Comprueba que llega intacta, sin fichero ni línea añadidos.
  2. Repite el experimento sin capturarlo y observa qué imprime el intérprete. Añade después un metamétodo de conversión a cadena y compara.
  3. Escribe una función que llame en modo protegido a otra que a su vez llama en modo protegido a una tercera que falla. Determina cuál de las dos barreras atrapa el error y por qué.
  4. Comprueba qué devuelve pcall cuando su primer argumento no es invocable en absoluto, por ejemplo un número.
  5. Con lo aprendido, escribe en cinco líneas cuál sería la política de errores de un proyecto tuyo: qué situaciones lanzan y cuáles devuelven un fallo como valor.