std::expected: Manejo de errores funcional en C++23

std::expected<T, E> es un tipo suma (sum type) que representa una de dos posibilidades: un valor de éxito de tipo T o un error de tipo E. Es la respuesta de C++23 para manejar errores de lógica de negocio de forma explícita, evitando la pesada maquinaria de las excepciones para casos que no son realmente “excepcionales” y solucionando la fragilidad de los códigos de error estilo C (donde es fácil ignorar un entero de retorno).

Funciona mediante un discriminador interno que indica si el objeto contiene un valor o un error, almacenando ambos en la misma zona de memoria (stack-allocated) sin necesidad de asignación dinámica en el heap. Debes usarlo cuando un error es un resultado esperado en el flujo normal del programa, como un fallo en la apertura de un archivo, un error de parsing o una validación de parámetros. Si intentas acceder al valor mediante * o -> cuando el objeto contiene un error, has caído en comportamiento indefinido (UB).

#include <expected>
#include <iostream>
#include <string>
#include <string_view>

enum class ConfigError {
    FileNotFound,
    ParseError,
    ValueOutOfRange
};

// Simula la lectura de un archivo que puede fallar
std::expected<std::string, ConfigError> read_config_file(bool exists) {
    if (exists) {
        return "42"; // Retorno implícito de valor
    }
    return std::unexpected(ConfigError::FileNotFound); // Uso de std::unexpected para errores
}

// Transforma un string en un entero (puede fallar)
std::expected<int, ConfigError> parse_integer(std::string data) {
    try {
        return std::stoi(data);
    } catch (...) {
        return std::unexpected(ConfigError::ParseError);
    }
}

// Valida que el número esté en un rango aceptable
std::expected<int, ConfigError> validate_range(int value) {
    if (value > 0 && value < 100) {
        return value;
    }
    return std::unexpected(ConfigError::ValueOutOfRange);
}

int main() {
    // El pipeline monádico permite encadenar operaciones que pueden fallar
    // sin escribir múltiples bloques "if (error) return error;"
    auto process_pipeline = read_config_file(true)
        .and_then(parse_integer)      // Si es OK, llama a parse_integer (devuelve std::expected)
        .and_then(validate_range)     // Si es OK, llama a validate_range (devuelve std::expected)
        .transform([](int n) {         // Si es OK, transforma el valor (devuelve T, no std::expected)
            return n * n; 
        });

    if (process_pipeline) {
        // Acceso directo mediante operador * (asume que hay valor)
        std::cout << "Resultado procesado: " << *process_pipeline << '\n';
    } else {
        // Acceso al error mediante .error()
        std::cerr << "Error detectado en el pipeline.\n";
    }

    // Ejemplo de transformación de error para cambiar el tipo de error
    auto error_mapped = read_config_file(false)
        .transform_error([](ConfigError e) {
            return "Error de configuración: " + std::to_string(static_cast<int>(e));
        });

    if (!error_mapped) {
        std::cerr << error_mapped.error() << '\n';
    }

    return 0;
}

Para compilar este ejemplo: g++ -std=c++23 -Wall -Wextra -Wpedantic example.cpp -o example.

Desglose del ejemplo

En el pipeline process_pipeline, estamos utilizando composición monádica para crear un flujo de datos limpio. Observa cómo and_then actúa como un “flat_map”: recibe el valor del paso anterior y lo pasa a una función que, a su vez, devuelve otro std::expected. Si en cualquier punto una función devuelve std::unexpected, el resto de la cadena se salta automáticamente, propagando el error hasta el final.

La diferencia crucial aquí es entre and_then y transform. parse_integer y validate_range devuelven std::expected<int, ConfigError>, por lo que usamos and_then. Sin embargo, la lambda dentro de transform simplemente devuelve un int (el cuadrado). transform se encarga de envolver ese resultado de nuevo en un std::expected para mantener la compatibilidad con la cadena. Si el valor es correcto, transform mapea T -> U; si hay un error, lo ignora y propaga el error existente.

Fíjate en el uso de std::unexpected. No puedes simplemente devolver ConfigError::FileNotFound en una función que retorna std::expected; el compilador necesita saber que ese valor debe ser tratado como el lado de error del sum type.

transform_error nos permite cambiar el tipo del error. Esto es vital cuando una función de bajo nivel devuelve un error específico (como un ErrorCode de una API de sistema) y quieres mapearlo a un error de dominio más semántico en tu capa de aplicación.

El error frecuente

Un error crítico es el uso del operador de desreferencia * o el operador de acceso a miembros -> sin verificar primero si el objeto contiene un valor.

std::expected<int, ConfigError> res = std::unexpected(ConfigError::ParseError);

// ¡PELIGRO! Esto es Comportamiento Indefinido (UB)
int valor = *res; 

A diferencia de res.value(), que lanza una excepción std::bad_expected_access<E> si el objeto contiene un error, el operador * es una operación de “bajo nivel” diseñada para máxima performance. El compilador no inserta comprobaciones de seguridad en *res porque se asume que el programador ya verificó res.has_value() o if(res). Si usas * en un error, el programa podría fallar silenciosamente, corromper la memoria o crashear de forma errática. Herramientas como AddressSanitizer (ASan) son fundamentales para detectar estos accesos a objetos no inicializados o en estados inválidos.

Aprovecha la potencia del sistema de tipos para que el error sea parte de la interfaz, no un efecto secundario.

92

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio