Implementación de generadores con corrutinas en C++20

Una corrutina es, técnicamente, una función que puede suspender su ejecución, devolver el control al llamador y reanudarse más tarde desde el mismo punto, preservando su estado local. A diferencia de una función normal cuya activación se gestiona íntegramente en la pila (stack) de la CPU, la corrutina requiere un coroutine frame (un bloque de memoria en el heap) donde se almacenan sus variables locales, sus parámetros y el puntero de instrucción actual.

El comportamiento de una corrutina no lo define la función en sí, sino su tipo de retorno. El estándar exige que este tipo contenga una estructura interna llamada promise_type. Esta “promesa” actúa como el centro de control: es la que decide si la corrutina se suspende al empezar (initial_suspend), cómo se gestionan los valores devueltos por co_yield (yield_value), cómo se finaliza mediante co_return (return_value) y qué sucede si ocurre una excepción (unhandled_exception).

Cuando utilizas co_yield x, el compilador realiza una transformación compleja: invoca promise.yield_value(x) y luego suspende la ejecución. Por su parte, co_await es la primitiva de suspensión; permite pausar la ejecución hasta que un objeto awaitable (que implementa await_ready, await_suspend y await_resume) indique que la tarea ha terminado. Si intentas implementar un generador y fallas en la gestión del ciclo de vida de la promesa o del std::coroutine_handle, el programa caerá en comportamiento indefinido (UB), generalmente debido a un dangling handle (intentar reanudar una corrutina que ya ha sido destruida).

Este patrón es ideal cuando necesitas evaluar secuencias de forma perezosa (lazy evaluation), como series matemáticas infinitas o flujos de datos que provienen de red, donde no quieres cargar todo el conjunto en memoria de golpe. En C++20, estas son las piezas de construcción; no tienes un framework de alto nivel como en Python, sino las primitivas para construir el tuyo. De hecho, la biblioteca estándar no proporcionó un std::generator hasta C++23, obligándonos a implementar nuestra propia infraestructura de promise_type e iteradores.

#include <iostream>
#include <coroutine>
#include <exception>
#include <utility>

// Un generador genérico que permite iterar sobre valores producidos perezosamente.
template <typename T>
class Generator {
public:
    // La promesa es el objeto que el compilador utiliza para gestionar la corrutina.
    struct promise_type {
        T current_value;

        // Se llama al iniciar la corrutina. Crea el objeto de retorno.
        Generator get_return_object() {
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }

        // Suspendemos al inicio para que el usuario decida cuándo empezar a iterar.
        auto initial_suspend() { return std::suspend_always{}; }

        // Suspendemos al terminar para asegurar que el handle sea controlado por el dueño.
        auto final_suspend() noexcept { return std::suspend_always{}; }

        // Implementación de co_yield: almacena el valor y suspende.
        auto yield_value(T value) {
            current_value = std::move(value);
            return std::suspend_always{};
        }

        // Implementación de co_return: almacena el valor final.
        void return_value(T value) {
            current_value = std::move(value);
        }

        void unhandled_exception() {
            std::terminate(); // En producción, se debería propagar la excepción.
        }
    };

    using handle_type = std::coroutine_handle<promise_type>;

    explicit Generator(handle_type h) : handle(h) {}
    
    // El dueño del Generator es responsable de la vida de la corrutina.
    ~Generator() {
        if (handle) handle.destroy();
    }

    // Prohibimos la copia para evitar que dos objetos intenten destruir el mismo handle.
    Generator(const Generator&) = delete;
    Generator& operator=(const Generator&) = delete;

    // Movemos la propiedad del handle.
    Generator(Generator&& other) noexcept : handle(other.handle) {
        other.handle = handle_type();
    }

    // Interfaz necesaria para usar la corrutina en un range-based for loop.
    struct iterator {
        handle_type h;
        bool done;

        void operator++() {
            h.resume(); // Reanuda la corrutina hasta el siguiente co_yield o co_return.
            done = h.done();
        }

        const T& operator*() const {
            return h.promise().current_value;
        }

        bool operator!() const { return done; }
    };

    iterator begin() {
        if (handle) {
            handle.resume(); // Iniciamos la corrutina (por el initial_suspend).
            return {handle, handle.done()};
        }
        return {handle_type(), true};
    }

    iterator end() {
        return {handle_type(), true};
    }

private:
    handle_type handle;
};

// Ejemplo de uso: Generador de la serie Fibonacci.
Generator<int> fibonacci(int n) {
    int a = 0, b = 1;
    for (int i = 0; i < n; ++i) {
        co_yield a; // Suspende y devuelve 'a'
        int next = a + b;
        a = b;
        b = next;
    }
}

int main() {
    std::cout << "Serie Fibonacci (10 elementos): ";
    for (int val : fibonacci(10)) {
        std::cout << val << " ";
    }
    std::cout << std::endl;

    return 0;
}

Desglose técnico

En el código anterior, fibonacci no devuelve un Generator, sino que devuelve un objeto que contiene una promesa. El compilador transforma fibonacci en una máquina de estados donde cada co_yield es un punto de salida potencial.

  1. promise_type::get_return_object: Cuando llamas a fibonacci(10), el compilador primero instancia la promise_type en el heap y luego llama a este método. Este método nos devuelve nuestra clase Generator que encapsula el std::coroutine_handle.
  2. initial_suspend: Al usar std::suspend_always, la corrutina se pausa inmediatamente después de la asignación de parámetros. Por eso, en Generator::begin(), llamamos explícitamente a handle.resume() para que la ejecución entre en el cuerpo de la función y llegue al primer co_yield.
  3. yield_value: Cuando se encuentra co_yield a, se llama a promise.yield_value(a). Esta función mueve el valor a current_value y retorna un std::suspend_always, lo que le dice a la máquina de estados: “detente aquí y devuelve el control al llamador”.
  4. iterator::operator++: Es el motor del bucle. Llama a h.resume(), lo que salta al siguiente co_yield o al final_suspend. Luego verifica h.done() para saber si la corrutina ha terminado.
  5. Gestión de memoria: El std::coroutine_handle<promise_type> es esencialmente un puntero al bloque de memoria en el heap. Si no llamamos a handle.destroy() en el destructor de Generator, tendremos una fuga de memoria en el heap (el coroutine frame nunca se liberará).

El error frecuente

Un error clásico al trabajar con corrutinas es la extensión de la vida de los datos en la promesa. Fíjate en este escenario peligroso:

// ERROR: Peligro de Undefined Behavior
Generator<const std::string&> bad_generator() {
    std::string s = "Data"; 
    co_yield s; 
}

Aunque parece que s vive mientras la corrutina esté suspendida, co_yield devuelve una referencia a s. El iterador del Generator almacenará una referencia a un objeto que reside dentro del coroutine frame. Si la corrutina termina o el objeto Generator sale de ámbito, el frame se destruye y la referencia en el iterador se convierte en un puntero colgante. Para evitar esto, siempre que el generador produzca valores que no sean de vida estática, el current_value de la promesa debe ser por valor (como en el ejemplo completo) o el usuario debe garantizar que el ciclo de vida del iterador sea estrictamente menor al de la corrutina.

Para compilar el ejemplo con GCC o Clang:
g++ -std=c++20 -Wall -Wextra -Wpedantic -o example example.cpp

103

Dejar un comentario

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

Scroll al inicio