Ir al contenido principal

Manejo básico de errores en Lua

Cuando desarrollamos programas, es inevitable que se produzcan errores. Estos pueden ser causados por múltiples factores: datos de entrada incorrectos, fallos en operaciones externas, errores lógicos en nuestro código, o situaciones inesperadas durante la ejecución. El manejo adecuado de errores es fundamental para crear aplicaciones robustas y confiables que puedan recuperarse de situaciones problemáticas o, al menos, informar al usuario de manera clara sobre qué ha fallado.

En Lua, el sistema de manejo de errores es simple pero efectivo. A diferencia de lenguajes como Java o Python que utilizan excepciones estructuradas con bloques try-catch, Lua emplea un enfoque basado en funciones que permite capturar y gestionar errores de forma flexible. Este sistema se integra de manera natural con la filosofía minimalista del lenguaje, proporcionando las herramientas necesarias sin añadir complejidad innecesaria.

En este artículo exploraremos los fundamentos del manejo de errores en Lua. Aprenderás a identificar los tipos comunes de errores, a lanzar errores manualmente cuando sea necesario, a proteger tu código contra fallos inesperados, y a implementar estrategias efectivas para que tus programas sean más resilientes y fáciles de depurar.

Tipos comunes de errores en Lua

Antes de aprender a manejar errores, es importante conocer qué tipos de errores podemos encontrar durante el desarrollo con Lua. Estos se pueden clasificar en cuatro categorías principales:

Errores de sintaxis

Los errores de sintaxis ocurren cuando el código no cumple con las reglas gramaticales del lenguaje. Estos errores impiden que el programa se ejecute, ya que el intérprete no puede entender lo que se intenta hacer. Son detectados en la fase de carga o compilación del script.

-- Error de sintaxis: falta 'then'
if a > 5
    print("Mayor que 5")
end

-- Error de sintaxis: falta 'end'
function sumar(a, b)
    return a + b

Cuando ejecutamos código con errores de sintaxis, Lua nos muestra mensajes claros indicando la línea y la naturaleza del problema:

lua: ejemplo.lua:2: 'then' expected near 'print'

Errores de tipo

Los errores de tipo suceden cuando intentamos realizar operaciones con datos de tipos incompatibles. Aunque Lua es un lenguaje de tipos dinámicos, no todas las operaciones están permitidas entre todos los tipos.

-- Error de tipo: intento de sumar un número con nil
local a = 5
local b = nil
print(a + b)  -- Error: attempt to perform arithmetic on a nil value

-- Error de tipo: intento de llamar a una cadena como función
local texto = "hola"
texto()  -- Error: attempt to call a string value

Errores de índice

Estos errores ocurren cuando intentamos acceder a elementos que no existen en una tabla, o cuando usamos índices de forma incorrecta.

-- Acceder a una clave inexistente devuelve nil, no genera error
local tabla = {a = 1, b = 2}
print(tabla.c)  --> nil

-- Pero intentar indexar un valor nil sí genera error
local datos = nil
print(datos.campo)  -- Error: attempt to index a nil value

-- Indexar con un valor inválido
local lista = {10, 20, 30}
print(lista[nil])  -- Error: table index is nil

Errores de lógica

Los errores de lógica son los más sutiles y difíciles de detectar. El código se ejecuta sin problemas técnicos, pero produce resultados incorrectos debido a fallos en el razonamiento del programador.

-- Error de lógica: condición invertida
function esPar(numero)
    if numero % 2 == 1 then  -- Debería ser == 0
        return true
    else
        return false
    end
end

print(esPar(4))  --> false (incorrecto)
print(esPar(5))  --> true (incorrecto)

Lanzamiento manual de errores

En ocasiones, queremos provocar un error deliberadamente cuando detectamos una situación anómala en nuestro programa. Lua proporciona dos funciones principales para este propósito: error() y assert().

Función error()

La función error() detiene la ejecución del programa y lanza un error con un mensaje personalizado. Su sintaxis es:

error(mensaje [, nivel])

El parámetro mensaje es el texto que se mostrará cuando ocurra el error. El parámetro opcional nivel indica en qué nivel de la pila de llamadas se debe reportar el error (por defecto es 1, que señala a la función que llamó a error()).

Veamos un ejemplo práctico:

function dividir(a, b)
    if b == 0 then
        error("No se puede dividir por cero")
    end
    return a / b
end

print(dividir(10, 2))  --> 5
print(dividir(10, 0))  -- Error: No se puede dividir por cero

El parámetro nivel es útil cuando queremos que el error se reporte en un contexto diferente:

function validarEdad(edad)
    if type(edad) ~= "number" then
        -- nivel 2: reporta el error en quien llamó a la función que llama a validarEdad
        error("La edad debe ser un número", 2)
    end
    return edad
end

function procesarUsuario(nombre, edad)
    validarEdad(edad)
    print("Usuario: " .. nombre .. ", Edad: " .. edad)
end

procesarUsuario("Ana", "veinticinco")  
-- El error señalará a procesarUsuario, no a validarEdad

Función assert()

La función assert() es una forma compacta de validar condiciones. Evalúa una expresión y, si es falsa o nil, lanza un error. Su sintaxis es:

assert(expresion [, mensaje])

Si expresion es true o cualquier valor diferente de nil/false, assert() devuelve todos los valores de la expresión. Si es false o nil, lanza un error con el mensaje especificado (o un mensaje genérico si no se proporciona).

function abrirArchivo(nombre)
    local archivo = io.open(nombre, "r")
    assert(archivo, "No se pudo abrir el archivo: " .. nombre)
    return archivo
end

-- Si el archivo existe, todo funciona normalmente
local f = abrirArchivo("datos.txt")

-- Si no existe, se lanza un error descriptivo
local f2 = abrirArchivo("inexistente.txt")
-- Error: No se pudo abrir el archivo: inexistente.txt

La función assert() es especialmente útil para validaciones rápidas al inicio de funciones:

function calcularAreaRectangulo(ancho, alto)
    assert(type(ancho) == "number", "El ancho debe ser un número")
    assert(type(alto) == "number", "El alto debe ser un número")
    assert(ancho > 0, "El ancho debe ser positivo")
    assert(alto > 0, "El alto debe ser positivo")
    
    return ancho * alto
end

print(calcularAreaRectangulo(5, 10))  --> 50
print(calcularAreaRectangulo(-5, 10))  -- Error: El ancho debe ser positivo

Captura de errores con pcall()

Cuando ejecutamos código que puede fallar, no siempre queremos que todo el programa se detenga. La función pcall() (protected call) nos permite ejecutar código de forma protegida, capturando cualquier error que pueda ocurrir.

La sintaxis de pcall() es:

estado, resultado = pcall(funcion, arg1, arg2, ...)

La función pcall() ejecuta la función especificada con los argumentos proporcionados y devuelve dos valores:

  • estado: un valor booleano que indica si la ejecución fue exitosa (true) o si hubo un error (false)
  • resultado: si estado es true, contiene el valor de retorno de la función; si es false, contiene el mensaje de error

Veamos un ejemplo básico:

function dividir(a, b)
    if b == 0 then
        error("División por cero no permitida")
    end
    return a / b
end

-- Llamada normal (sin protección)
-- print(dividir(10, 0))  -- Esto detendría el programa

-- Llamada protegida
local exito, resultado = pcall(dividir, 10, 2)
if exito then
    print("Resultado: " .. resultado)  --> Resultado: 5
else
    print("Error: " .. resultado)
end

-- Otro intento con división por cero
local exito2, resultado2 = pcall(dividir, 10, 0)
if exito2 then
    print("Resultado: " .. resultado2)
else
    print("Error: " .. resultado2)  --> Error: División por cero no permitida
end

Un ejemplo más completo de manejo de diferentes casos:

function procesarDatos(tabla, indice)
    assert(type(tabla) == "table", "El primer argumento debe ser una tabla")
    assert(type(indice) == "number", "El índice debe ser un número")
    
    if tabla[indice] == nil then
        error("El índice " .. indice .. " no existe en la tabla")
    end
    
    return tabla[indice] * 2
end

local datos = {10, 20, 30, 40, 50}

-- Caso exitoso
local ok, valor = pcall(procesarDatos, datos, 3)
if ok then
    print("Valor procesado: " .. valor)  --> Valor procesado: 60
else
    print("Error al procesar: " .. valor)
end

-- Caso con índice inexistente
local ok2, valor2 = pcall(procesarDatos, datos, 10)
if ok2 then
    print("Valor procesado: " .. valor2)
else
    print("Error al procesar: " .. valor2)  
    --> Error al procesar: El índice 10 no existe en la tabla
end

-- Caso con argumento inválido
local ok3, valor3 = pcall(procesarDatos, "no es tabla", 1)
if ok3 then
    print("Valor procesado: " .. valor3)
else
    print("Error al procesar: " .. valor3)  
    --> Error al procesar: El primer argumento debe ser una tabla
end

Captura de errores con xpcall()

La función xpcall() (extended protected call) es similar a pcall(), pero permite especificar una función manejadora de errores que se ejecutará cuando ocurra un fallo. Esto es útil para obtener información adicional sobre el error, como el stack trace completo.

La sintaxis es:

estado, resultado = xpcall(funcion, manejadorError, arg1, arg2, ...)

El manejadorError es una función que recibe el mensaje de error como parámetro y puede procesarlo o ampliarlo antes de devolverlo.

function manejadorError(mensajeError)
    print("\n=== Se produjo un error ===")
    print("Mensaje: " .. mensajeError)
    print("Stack trace:")
    print(debug.traceback())
    return mensajeError
end

function operacionRiesgosa(valor)
    if valor < 0 then
        error("El valor no puede ser negativo")
    end
    return math.sqrt(valor)
end

print("Inicio del programa")

local exito, resultado = xpcall(operacionRiesgosa, manejadorError, -5)

if exito then
    print("Resultado: " .. resultado)
else
    print("\nEl programa continúa después del error")
end

La salida mostrará información detallada sobre dónde ocurrió el error y cómo se llegó hasta ese punto, lo cual es invaluable durante el desarrollo y la depuración.

Interpretación de mensajes de error

Saber leer e interpretar los mensajes de error es una habilidad fundamental. Los mensajes de error de Lua siguen un formato estándar que nos proporciona información valiosa.

Un mensaje de error típico tiene esta estructura:

archivo.lua:numero_linea: descripcion_error
stack traceback:
    archivo.lua:numero_linea: en contexto
    ...

Por ejemplo:

-- archivo: calculadora.lua
function dividir(a, b)
    return a / b
end

function calcular()
    local resultado = dividir(10, nil)
    print(resultado)
end

calcular()

Este código producirá un error como:

calculadora.lua:2: attempt to perform arithmetic on a nil value (local 'b')
stack traceback:
    calculadora.lua:2: in function 'dividir'
    calculadora.lua:6: in function 'calcular'
    calculadora.lua:9: in main chunk

Del mensaje de error podemos extraer:

  1. Archivo y línea: calculadora.lua:2 nos dice exactamente dónde está el problema
  2. Descripción: "attempt to perform arithmetic on a nil value" explica qué salió mal
  3. Variable problemática: "(local 'b')" identifica la variable que causó el error
  4. Stack trace: muestra la secuencia de llamadas que llevó al error

Patrones básicos de manejo de errores

Existen varios patrones comunes para manejar errores de forma efectiva en Lua.

Validación de entrada

Siempre valida los parámetros al inicio de una función:

function crearUsuario(nombre, edad, email)
    assert(type(nombre) == "string" and #nombre > 0, 
           "El nombre debe ser una cadena no vacía")
    assert(type(edad) == "number" and edad >= 0 and edad <= 150, 
           "La edad debe ser un número entre 0 y 150")
    assert(type(email) == "string" and email:match("@"), 
           "El email debe contener el símbolo @")
    
    return {
        nombre = nombre,
        edad = edad,
        email = email
    }
end

-- Uso correcto
local usuario1 = crearUsuario("Carlos", 30, "carlos@ejemplo.com")
print(usuario1.nombre)  --> Carlos

-- Uso incorrecto
local usuario2 = crearUsuario("", 30, "carlos@ejemplo.com")
-- Error: El nombre debe ser una cadena no vacía

Operaciones con archivos

Las operaciones con archivos son propensas a errores, por lo que siempre deben protegerse:

function leerArchivo(nombreArchivo)
    local archivo, mensajeError = io.open(nombreArchivo, "r")
    
    if not archivo then
        return nil, "No se pudo abrir el archivo: " .. mensajeError
    end
    
    local contenido = archivo:read("*all")
    archivo:close()
    
    return contenido
end

-- Uso con manejo de errores
local contenido, error = leerArchivo("datos.txt")

if contenido then
    print("Contenido del archivo:")
    print(contenido)
else
    print("Error: " .. error)
end

Retornar nil y mensaje de error

En lugar de usar error(), muchas funciones de Lua retornan nil seguido de un mensaje de error. Este patrón es más flexible:

function buscarUsuario(id)
    local usuarios = {
        [1] = {nombre = "Ana", edad = 25},
        [2] = {nombre = "Luis", edad = 30}
    }
    
    if type(id) ~= "number" then
        return nil, "El ID debe ser un número"
    end
    
    local usuario = usuarios[id]
    if not usuario then
        return nil, "Usuario no encontrado con ID: " .. id
    end
    
    return usuario
end

-- Uso del patrón
local usuario, error = buscarUsuario(1)
if usuario then
    print("Usuario encontrado: " .. usuario.nombre)
else
    print("Error: " .. error)
end

local usuario2, error2 = buscarUsuario(99)
if usuario2 then
    print("Usuario encontrado: " .. usuario2.nombre)
else
    print("Error: " .. error2)  --> Error: Usuario no encontrado con ID: 99
end

Limpieza de recursos

Cuando trabajamos con recursos que deben liberarse (archivos, conexiones, etc.), es importante garantizar su limpieza incluso si ocurre un error:

function procesarArchivo(nombreArchivo)
    local archivo = assert(io.open(nombreArchivo, "r"))
    
    local exito, resultado = pcall(function()
        local datos = archivo:read("*all")
        -- Procesamiento que puede fallar
        return datos:upper()
    end)
    
    archivo:close()  -- Se ejecuta siempre, haya o no error
    
    if not exito then
        error("Error al procesar archivo: " .. resultado)
    end
    
    return resultado
end

Depuración básica

La depuración es el proceso de identificar y corregir errores. Lua proporciona herramientas simples pero efectivas para depurar código.

Uso de print() para depuración

El método más simple y efectivo es usar print() para mostrar valores intermedios:

function calcularPromedio(numeros)
    print("DEBUG: Números recibidos:", #numeros)  -- Depuración
    
    local suma = 0
    for i, valor in ipairs(numeros) do
        print("DEBUG: Procesando índice", i, "valor", valor)  -- Depuración
        suma = suma + valor
    end
    
    print("DEBUG: Suma total:", suma)  -- Depuración
    
    local promedio = suma / #numeros
    print("DEBUG: Promedio calculado:", promedio)  -- Depuración
    
    return promedio
end

local numeros = {10, 20, 30, 40, 50}
local resultado = calcularPromedio(numeros)
print("Resultado final:", resultado)

Función de depuración condicional

Para no llenar el código de prints, podemos crear una función de depuración que se active solo cuando sea necesario:

local DEBUG = true  -- Cambiar a false para desactivar depuración

function debug_print(...)
    if DEBUG then
        print("[DEBUG]", ...)
    end
end

function procesarDatos(datos)
    debug_print("Iniciando procesamiento de datos")
    debug_print("Número de elementos:", #datos)
    
    local resultado = {}
    for i, valor in ipairs(datos) do
        debug_print("Procesando elemento", i, "con valor", valor)
        resultado[i] = valor * 2
    end
    
    debug_print("Procesamiento completado")
    return resultado
end

local datos = {1, 2, 3, 4, 5}
local resultado = procesarDatos(datos)

Inspección de tablas

Para depurar tablas complejas, es útil tener una función que las imprima de forma legible:

function imprimirTabla(t, nivel)
    nivel = nivel or 0
    local sangria = string.rep("  ", nivel)
    
    for clave, valor in pairs(t) do
        if type(valor) == "table" then
            print(sangria .. clave .. " = {")
            imprimirTabla(valor, nivel + 1)
            print(sangria .. "}")
        else
            print(sangria .. clave .. " = " .. tostring(valor))
        end
    end
end

-- Ejemplo de uso
local configuracion = {
    servidor = {
        host = "localhost",
        puerto = 8080,
        opciones = {
            timeout = 30,
            reintentos = 3
        }
    },
    debug = true
}

print("Configuración:")
imprimirTabla(configuracion)

Buenas prácticas

Para escribir código robusto y fácil de mantener, sigue estas recomendaciones:

Cuándo usar error() vs return nil

  • Usa error() cuando:

    • El problema es tan grave que no tiene sentido continuar
    • Es un error de programación que debe corregirse
    • La función no puede cumplir su propósito de ninguna manera
  • Usa return nil, mensaje cuando:

    • El problema es esperado o recuperable
    • La función es parte de una API pública
    • El llamador puede tomar decisiones alternativas
-- error() para situaciones críticas
function dividir(a, b)
    assert(type(a) == "number", "El dividendo debe ser un número")
    assert(type(b) == "number", "El divisor debe ser un número")
    
    if b == 0 then
        error("División por cero")
    end
    return a / b
end

-- return nil para situaciones recuperables
function buscarEnCache(clave)
    if type(clave) ~= "string" then
        return nil, "La clave debe ser una cadena"
    end
    
    local valor = cache[clave]
    if not valor then
        return nil, "Clave no encontrada en caché"
    end
    
    return valor
end

Mensajes de error descriptivos

Los mensajes deben ser claros, informativos y útiles:

-- Mensaje pobre
function procesar(datos)
    if not datos then
        error("Error")  -- ¿Qué error? ¿Por qué?
    end
end

-- Mensaje descriptivo
function procesar(datos)
    if not datos then
        error("procesar(): se esperaba una tabla de datos, se recibió nil")
    end
    if type(datos) ~= "table" then
        error("procesar(): se esperaba una tabla de datos, se recibió " .. type(datos))
    end
end

No silenciar errores

Nunca captures un error y no hagas nada con él. Esto oculta problemas y dificulta la depuración:

-- MAL: silencia el error
local exito, resultado = pcall(operacionRiesgosa)
-- No hace nada con el error

-- BIEN: maneja o propaga el error
local exito, resultado = pcall(operacionRiesgosa)
if not exito then
    print("Error en operación: " .. resultado)
    -- O propaga el error:
    error("Fallo al ejecutar operación riesgosa: " .. resultado)
end

Validación exhaustiva en funciones públicas

Las funciones que forman parte de una API pública deben validar todos sus parámetros:

function crearRectangulo(ancho, alto, color)
    -- Validación exhaustiva
    if type(ancho) ~= "number" then
        error("crearRectangulo: el ancho debe ser un número, se recibió " .. type(ancho))
    end
    if ancho <= 0 then
        error("crearRectangulo: el ancho debe ser positivo, se recibió " .. ancho)
    end
    if type(alto) ~= "number" then
        error("crearRectangulo: el alto debe ser un número, se recibió " .. type(alto))
    end
    if alto <= 0 then
        error("crearRectangulo: el alto debe ser positivo, se recibió " .. alto)
    end
    if color and type(color) ~= "string" then
        error("crearRectangulo: el color debe ser una cadena, se recibió " .. type(color))
    end
    
    return {
        ancho = ancho,
        alto = alto,
        color = color or "blanco"
    }
end

Ejercicios prácticos

Para consolidar lo aprendido, intenta resolver estos ejercicios:

Ejercicio 1: Calculadora protegida

Crea una calculadora que maneje errores correctamente:

-- Implementa esta función
function calculadora(operacion, a, b)
    -- Debe validar:
    -- - Que 'operacion' sea una cadena válida: "+", "-", "*", "/"
    -- - Que 'a' y 'b' sean números
    -- - Que no se divida por cero
    -- Retorna: resultado, nil en caso de éxito
    -- Retorna: nil, mensaje_error en caso de fallo
end

-- Pruebas
print(calculadora("+", 5, 3))      -- Debe mostrar: 8
print(calculadora("/", 10, 0))     -- Debe mostrar: nil, mensaje de error
print(calculadora("^", 2, 3))      -- Debe mostrar: nil, mensaje de error
print(calculadora("*", "cinco", 3)) -- Debe mostrar: nil, mensaje de error

Ejercicio 2: Lector de configuración

Crea una función que lea un archivo de configuración y maneje todos los posibles errores:

-- Implementa esta función
function leerConfiguracion(nombreArchivo)
    -- Debe manejar:
    -- - Archivo no existe
    -- - Archivo no se puede leer
    -- - Contenido mal formado
    -- Retorna: tabla_configuracion en caso de éxito
    -- Retorna: nil, mensaje_error en caso de fallo
end

Ejercicio 3: Validador de formulario

Crea un validador que verifique datos de un formulario:

function validarFormulario(datos)
    -- Debe validar que 'datos' contenga:
    -- - nombre: cadena no vacía
    -- - edad: número entre 0 y 120
    -- - email: cadena que contenga "@"
    -- - telefono: cadena de 9 dígitos (opcional)
    -- 
    -- Retorna: true si todo es válido
    -- Lanza error con mensaje descriptivo si algo falla
end

Resumen

El manejo de errores es una parte fundamental de la programación profesional. En este artículo hemos aprendido los conceptos básicos que te permitirán escribir código Lua más robusto y confiable. Hemos visto cómo identificar los distintos tipos de errores que pueden ocurrir, desde errores de sintaxis hasta errores de lógica, y cómo cada uno requiere un enfoque diferente para su resolución.

Las funciones error() y assert() nos permiten lanzar errores cuando detectamos situaciones anómalas, comunicando claramente qué ha salido mal. Por otro lado, pcall() y xpcall() nos dan las herramientas para ejecutar código de forma protegida, capturando errores y permitiendo que nuestros programas se recuperen o al menos finalicen de forma elegante. La interpretación correcta de los mensajes de error y el stack trace es crucial para identificar rápidamente el origen de los problemas.

Finalmente, hemos explorado patrones comunes de manejo de errores y buenas prácticas que te ayudarán a escribir código más limpio y mantenible. Recuerda que un buen manejo de errores no solo hace que tus programas sean más robustos, sino que también facilita enormemente el proceso de depuración y mantenimiento. En el siguiente artículo del tutorial, exploraremos conceptos más avanzados que complementarán lo aprendido hasta ahora.