Error handling en Zig: error unions, try y cómo evitar las excepciones

Las excepciones tienen un problema fundamental: cuando una función lanza una excepción, no queda rastro en su firma. El llamador no sabe si puede fallar y si falla, no sabe qué tipo de error esperar. Zig resuelve esto de raíz: los errores son parte del tipo de retorno de la función. Si una función puede fallar, lo dice explícitamente en su firma.

Error unions: el tipo !T

En Zig, una función que puede devolver un error o un valor usa el tipo !T, llamado error union. El ! significa «o un error, o un T». El compilador obliga al llamador a manejar ambas posibilidades.

const std = @import("std");

// Esta función puede devolver un error o un i32
fn dividir(a: i32, b: i32) !i32 {
    if (b == 0) return error.DivisionPorCero;
    return @divTrunc(a, b);
}

pub fn main() !void {
    const resultado = try dividir(10, 2);
    std.debug.print("10 / 2 = {d}n", .{resultado});

    // Esto causaría un error en tiempo de ejecución
    // const mal = try dividir(10, 0);
}

El tipo de error es un conjunto de etiquetas, no una jerarquía de clases. error.DivisionPorCero es simplemente un valor del conjunto de errores de la función.

try: propagar el error

try expresion es equivalente a expresion catch |err| return err. Si la expresión devuelve un error, lo propaga al llamador. Si devuelve un valor, lo extrae del error union y lo devuelve limpio.

const std = @import("std");

fn leerFichero(allocator: std.mem.Allocator, ruta: []const u8) ![]u8 {
    const file = try std.fs.cwd().openFile(ruta, .{});
    defer file.close();

    const contenido = try file.readToEndAlloc(allocator, 1024 * 1024);
    return contenido;
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    const contenido = try leerFichero(gpa.allocator(), "fichero.txt");
    defer gpa.allocator().free(contenido);

    std.debug.print("Leídos {d} bytesn", .{contenido.len});
}

Cada try es un punto explícito donde puede ocurrir un error. Leyendo el código sabes exactamente dónde pueden fallar las cosas, sin necesidad de conocer los detalles de implementación internos.

catch: manejar el error en el sitio

Cuando no quieres propagar el error sino manejarlo localmente, usas catch. Puedes capturar el error para inspeccionarlo o simplemente proporcionar un valor por defecto.

const std = @import("std");

fn parsearEntero(texto: []const u8) !i32 {
    return std.fmt.parseInt(i32, texto, 10);
}

pub fn main() void {
    // Valor por defecto si falla
    const n = parsearEntero("abc") catch 0;
    std.debug.print("n = {d}n", .{n}); // n = 0

    // Inspeccionar el error
    const m = parsearEntero("xyz") catch |err| blk: {
        std.debug.print("Error al parsear: {}n", .{err});
        break :blk -1;
    };
    std.debug.print("m = {d}n", .{m}); // m = -1
}

Conjuntos de errores explícitos

Puedes definir el conjunto de errores que puede devolver una función de forma explícita, en lugar de dejar que el compilador lo infiera:

const std = @import("std");

const ErrorConexion = error{
    ConexionRechazada,
    Timeout,
    DNSNoResuelto,
};

fn conectar(host: []const u8) ErrorConexion!void {
    _ = host;
    // Simulamos un timeout
    return error.Timeout;
}

pub fn main() void {
    connectar("ejemplo.com") catch |err| switch (err) {
        error.ConexionRechazada => std.debug.print("Conexión rechazadan", .{}),
        error.Timeout => std.debug.print("Timeout al conectarn", .{}),
        error.DNSNoResuelto => std.debug.print("DNS no resuelton", .{}),
    };
}

fn connectar(host: []const u8) ErrorConexion!void {
    return conectar(host);
}

Cuando el compilador conoce el conjunto completo de errores posibles, el switch sobre el error puede verificarse en tiempo de compilación: si añades un nuevo error al conjunto y no lo manejas en el switch, el compilador te avisa.

errdefer: limpiar recursos solo en caso de error

errdefer ejecuta su bloque solo si la función termina con un error. Es el complemento de defer para los casos donde inicializas varios recursos y quieres limpiar los que ya inicializaste si algo falla más adelante.

const std = @import("std");

fn inicializarRecursos(allocator: std.mem.Allocator) !struct { buf1: []u8, buf2: []u8 } {
    const buf1 = try allocator.alloc(u8, 1024);
    errdefer allocator.free(buf1); // se libera solo si hay error

    const buf2 = try allocator.alloc(u8, 2048);
    errdefer allocator.free(buf2); // ídem

    // Si llegamos aquí, transferimos ownership: el llamador libera
    return .{ .buf1 = buf1, .buf2 = buf2 };
}

Sin errdefer, si la segunda asignación falla tendrías que liberar buf1 manualmente con condicionales. Con errdefer, el código queda lineal y el compilador garantiza que no habrá fugas aunque se añadan más puntos de error en el futuro.

Panic: el último recurso

Hay situaciones que son realmente errores de programación, no condiciones de error que el usuario pueda recuperar. Para esas situaciones existe @panic, que termina el programa con un mensaje de error y un stack trace. Es el equivalente de las aserciones, pero para condiciones que nunca deberían ocurrir.

fn obtenerElemento(slice: []u32, indice: usize) u32 {
    if (indice >= slice.len) @panic("índice fuera de rango");
    return slice[indice];
}

Los accesos a arrays fuera de rango en Zig también causan panic en modo debug. En modo ReleaseFast se pueden desactivar las comprobaciones si el rendimiento es crítico.

Comparación con otros lenguajes

El sistema de errores de Zig se parece más al Result<T, E> de Rust que a las excepciones de Java o C++, pero con algunas diferencias. En Zig los conjuntos de errores son más ligeros que los tipos de error de Rust: no llevan datos asociados por defecto (aunque puedes añadirlos con structs). Esto los hace más adecuados para código de bajo nivel donde cada byte de overhead importa.

Lo que comparte con Rust es la filosofía: los errores deben ser explícitos, el compilador debe ayudarte a no ignorarlos y el código que maneja errores debe ser tan legible como el código del camino feliz. Puedes ver más sobre esta filosofía en el artículo de introducción a Zig de esta serie, y compararla con cómo Rust lo resuelve en el artículo sobre ownership y borrowing en Rust.

Imagen: Pexels / Pixabay

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP