wandres.dev
ERRORES · pcall y nada más

El idioma del nulo y el mensaje

Cuándo devolver un fallo como valor de retorno en vez de lanzarlo. El criterio exacto que sigue la biblioteca estándar, sus ambigüedades cuando el nulo es un resultado legítimo, y por qué en Lua ambas convenciones se convierten la una en la otra con una sola palabra.

⏱ 18 min

Hay dos formas de comunicar un fracaso en Lua, y la elección entre ellas no es de estilo. Una función puede lanzar, y entonces el fallo se propaga solo hasta que alguien lo detenga, con el riesgo de tumbar el programa si nadie lo hace. O puede devolver el fallo como un valor más, y entonces el fallo es invisible hasta que el llamante lo mire, con el riesgo opuesto: que nadie lo mire nunca y el programa continúe con datos inventados. La biblioteca estándar de Lua aplica un criterio para decidir entre ambas que casi nunca se enuncia explícitamente pero que es notablemente consistente, y entenderlo permite diseñar interfaces que no sorprenden a nadie.

🎯 Al terminar esta lección sabrás
  • Formular el criterio que separa un fallo esperado de una violación de contrato.
  • Reconocer la convención de retorno de la biblioteca estándar y sus tres valores habituales.
  • Detectar los casos donde el nulo como marca de fallo es ambiguo y aplicar la alternativa correcta.
  • Convertir en ambos sentidos entre fallo lanzado y fallo devuelto en una sola expresión.

El criterio de la biblioteca estándar

La regla que sigue la biblioteca puede formularse así: un fallo que forma parte del dominio normal de la operación se devuelve como valor; una violación del contrato de la función se lanza. Dicho de otro modo, si el llamante no podía haber evitado el fallo comprobando algo antes de llamar, es un resultado; si podía, es un defecto de programación.

local f, err, codigo = io.open("/no/existe", "r")
print(f, err, codigo)   --> nil    /no/existe: No such file or directory    2

local ok, err2 = pcall(string.rep, "a", "no soy un numero")
print(ok, err2)         --> false   bad argument #2 to 'rep' (number expected, got string)

Una forma útil de aplicar el criterio es preguntarse si el fallo es reproducible desde el código solo. Un argumento de tipo incorrecto lo es: la misma llamada falla siempre, en cualquier máquina, sin depender de nada exterior. Un fichero ausente no lo es: la misma llamada funciona o no según el estado del disco en ese instante. Los fallos reproducibles desde el código son defectos y deben lanzarse; los que dependen del mundo son resultados y deben devolverse.

Que un fichero no exista no es un error del programador: entre la comprobación de existencia y la apertura puede haberlo borrado otro proceso, y por tanto el fallo es intrínseco a la operación. Que se pase una cadena donde la función documenta un número sí lo es: es un defecto que ninguna condición externa provoca y que ninguna ejecución correcta produciría jamás.

📄

Fallo del entorno, valor de retorno

Abrir ficheros, renombrar, borrar, resolver nombres de red, convertir texto a número, buscar un patrón que no aparece. El mundo exterior tiene derecho a decir que no.

⚠️

Contrato roto, se lanza

Tipo de argumento incorrecto, índice fuera del rango documentado, tabla donde se esperaba una función, ausencia de un argumento obligatorio. Nada de esto ocurre en un programa correcto.

🧾

Tres valores, no dos

La convención completa devuelve nulo, un mensaje legible y un código numérico del sistema operativo cuando existe. El tercero es el que permite decidir por programa sin analizar texto.

🔍

La consulta no es un fallo

Una función que responde a una pregunta devuelve nulo sin mensaje cuando la respuesta es que no. Convertir texto a número o buscar una subcadena no fracasan: informan.

La ambigüedad del nulo

El idioma se rompe cuando nulo es un valor de retorno legítimo. Una función que consulta una tabla de configuración y devuelve el valor asociado a una clave no puede usar nulo para señalar el fallo, porque nulo es también lo que devuelve legítimamente cuando la clave existe y su valor es nulo, o cuando el diseño permite valores ausentes.

La solución es devolver el estado en un valor aparte del resultado, invirtiendo el orden habitual:

-- Ambiguo: no distingue clave ausente de valor nulo almacenado
local function leerMal(cfg, clave) return cfg[clave] end

-- Sin ambiguedad: el estado va primero y el valor despues
local function leer(cfg, clave)
  if cfg[clave] == nil then return false, "clave ausente: " .. clave end
  return true, cfg[clave]
end

Hay un segundo modo de ambigüedad, más traicionero, que aparece cuando la función devuelve varios valores en el caso de éxito. Escrito con descuido, el fallo y el éxito se leen igual desde el punto de llamada:

-- Si buscar devuelve nil y un mensaje, esta linea no distingue nada
local x, y = buscar(mapa, objetivo)
if x then usar(x, y) end          -- y es la coordenada o el mensaje de error

La regla que evita esto es no reutilizar posiciones: el segundo valor de retorno debe significar siempre lo mismo. Si el éxito ya ocupa dos posiciones, el fallo necesita un canal propio, y eso obliga a la forma con estado explícito por delante.

Existe una variante más sutil que conviene conocer: usar el valor falso en lugar de nulo como marca de fallo. Falso es tan comprobable como nulo en un condicional pero distinguible de él mediante una comparación explícita, lo que permite reservarlo para el fracaso y dejar nulo disponible como resultado válido. Es la convención que emplean varias bibliotecas del ecosistema, y su único inconveniente es que rompe la simetría con la biblioteca estándar.

💡
El mensaje pertenece al llamante, el código al programa

Devuelve siempre las dos cosas cuando puedas. El texto está pensado para acabar en un registro o en la pantalla y no debe analizarse nunca con expresiones de patrón, porque depende del idioma del sistema y cambia entre versiones. El código numérico o el discriminante textual estable es lo único sobre lo que puede ramificar el programa.

Convertir entre las dos convenciones

La propiedad más elegante del diseño de Lua en esta materia es que ambas convenciones son intercambiables en una expresión, sin biblioteca ni ceremonia. Para pasar de valor devuelto a error lanzado basta una palabra; para el camino inverso, otra.

-- De fallo devuelto a fallo lanzado
local f = assert(io.open(ruta, "r"))

-- De fallo lanzado a fallo devuelto
local function intentar(fn, ...)
  local ok, resultado = pcall(fn, ...)
  if ok then return resultado end
  return nil, resultado
end
flowchart LR
A[Funcion que devuelve nulo y mensaje] -->|assert| B[Funcion que lanza]
B -->|pcall| A
A --> C[El llamante debe mirar el valor]
B --> D[El llamante puede ignorarlo y morir arriba]
C --> E[Riesgo de fallo silencioso]
D --> F[Riesgo de caida no controlada]

El envoltorio del ejemplo tiene un defecto deliberado que conviene señalar: descarta todos los valores de retorno salvo el primero. Una versión honesta empaqueta la tupla completa, y esa diferencia entre la versión de tres líneas que se escribe de memoria y la correcta es precisamente donde se cuelan los fallos difíciles de una capa de compatibilidad.

Esa reversibilidad tiene una consecuencia arquitectónica que suele pasarse por alto: como convertir es trivial, la decisión sobre qué convención usar deja de ser global y pasa a ser local a cada frontera. Una biblioteca puede exponer la convención de valores y un consumidor que prefiera lanzar la adapta en la línea donde llama. Lo contrario también funciona. Lo que no funciona, y es la patología más común en bases de código grandes, es mezclar ambas dentro de un mismo módulo sin una regla explícita, obligando al lector a recordar función por función cuál se comporta de qué manera.

La asimetría del riesgo

Las dos convenciones fallan de maneras opuestas y no equivalentes. Un error lanzado que nadie captura detiene el programa con un mensaje: es ruidoso, visible y aparece en cuanto se prueba el camino. Un fallo devuelto que nadie comprueba no detiene nada: el programa sigue con un nulo donde esperaba un dato y revienta más tarde, en otro sitio, con un mensaje que no guarda relación con la causa.

local f = io.open(ruta)      -- si falla, f es nil y nadie se entera
local datos = f:read("a")    -- error a dos lineas de distancia, sin la ruta

Merece la pena notar por qué el coste no es simétrico. Un error lanzado y no capturado produce un mensaje en el instante y en el lugar del fallo, con la ruta dentro del texto y el rastro disponible; el diagnóstico es casi inmediato. Un nulo no comprobado viaja: se asigna a una variable, se guarda en una tabla, se pasa a otra función, y el síntoma aparece cuando alguien intenta usarlo, potencialmente en otro módulo y varios segundos después. En ese momento el mensaje habla de indexar un valor nulo y no menciona ni la ruta ni la operación que fracasó, porque esa información se perdió en la línea que nadie miró.

ℹ️
La comprobación olvidada es un defecto invisible a las pruebas

El camino de éxito de un fallo devuelto y no comprobado se ejecuta correctamente en todas las pruebas, porque en las pruebas el fichero existe y la red responde. El defecto solo se manifiesta en producción, bajo la condición que precisamente nadie simuló. Por eso las revisiones de código deberían tratar toda llamada a una función de la convención de valores cuyo segundo retorno se ignora como lo que es: una rama sin escribir.

Este segundo modo de fallo es sistemáticamente más caro de diagnosticar, y es la razón de que el envoltorio con assert sea tan frecuente en código de aplicación bien escrito. La convención de valores es la correcta para la biblioteca que produce el fallo, porque no puede saber si su llamante quiere recuperarse; el envoltorio que lanza es la decisión correcta del llamante que sabe que no tiene plan alternativo. Ambas cosas son ciertas a la vez, y esa es la clave de por qué el diseño funciona.

Devolver el fallo es delegar la decisión, lanzarlo es afirmar que no hay decisión que tomar

Bajo la discusión técnica hay una pregunta que rara vez se hace en voz alta: quién tiene la información necesaria para decidir qué hacer con este fracaso. Cuando una función devuelve nulo y un mensaje está declarando que no lo sabe, y con razón, porque una rutina de apertura de ficheros no puede tener opinión sobre si la ausencia de ese fichero concreto es fatal, esperada o irrelevante; eso depende de si el fichero es la base de datos principal o un caché opcional, y esa información vive varios niveles más arriba. Cuando en cambio una función lanza, está afirmando lo contrario: que ningún llamante razonable puede continuar, que no hay estrategia alternativa concebible, que la única salida es hacia arriba. Los dos son actos de comunicación entre autores separados por el tiempo, y por eso la biblioteca estándar es tan consistente: sus autores no conocían ni podían conocer a sus llamantes, así que devuelven valor siempre que el fracaso admita más de una lectura, y lanzan únicamente cuando el fracaso demuestra que alguien escribió código incorrecto, caso en el que no hay nada que decidir porque no hay ejecución válida que salvar. La aplicación de esta idea a tu propio código es directa y produce interfaces mucho mejores: antes de elegir, pregúntate si tú, escribiendo esa función, puedes imaginar dos llamantes distintos que quisieran reaccionar de forma distinta al mismo fallo. Si puedes, devuélvelo como valor y no le robes la decisión a ninguno de los dos. Si no puedes, lánzalo y ahorra a todos el trabajo de comprobar algo que solo tiene una respuesta posible.

⚔️ Clasifica los fallos de tu propia interfaz
  1. Recorre cinco funciones de la biblioteca estándar que puedan fracasar y clasifica cada una según el criterio de esta lección. Busca alguna que te parezca inconsistente y argumenta por qué.
  2. Escribe una función de consulta sobre una tabla que distinga con claridad entre clave ausente y valor nulo almacenado.
  3. Implementa el envoltorio que convierte fallo lanzado en fallo devuelto conservando todos los valores de retorno, no solo el primero.
  4. Reproduce el fallo silencioso del ejemplo de apertura de fichero y mide cuántas líneas separan la causa del síntoma.
  5. Toma un módulo tuyo, escribe en una frase su política de fallos, y corrige toda función que no la cumpla.