C++23 - Generador síncrono std::generator

El futuro estándar del lenguaje C++23 incorporará la plantilla de clase std::generator (cabecera <generator>) como modo de obtener generadores síncronos basados en corrutinas que modelen el concepto std::ranges::input_range (recordemos que los algoritmos que operan con este tipo de rangos no deben intentar atravesar un mismo iterador input_range más de una vez). Dichos generadores constituyen vistas que sólo pueden moverse (move-only views), con iteradores de igual naturaleza [1].

Como primer ejemplo, consideremos el siguiente generador de números enteros pertenecientes a la sucesión de Fibonacci, es decir, aquélla que comienza con los números 0 y 1 y para la que cada valor subsiguiente se calcula como suma de los dos elementos que lo preceden. Por sencillez, ignoraremos el inevitable desbordamiento de enteros para valores elevados de la sucesión [1]:

   auto fibonacci_gen() -> std::generator<int>    {       auto a = 0,            b = 1;       while (true) {          co_yield std::exchange(a, std::exchange(b, a + b));       }    }

El generador permite obtener los valores de la sucesión uno a uno por iteración --típicamente a través de un bucle for basado en rango-- sin necesidad de almacenarlos en un array o en un vector. El empleo en la corrutina de la palabra clave co_yield nos permite suspender su ejecución retornando el último valor calculado de la sucesión para, una vez procesado, poder retomar la función y calcular el siguiente número. El rango es formalmente infinito, por lo que resulta necesario adaptarlo con el fin de generar un conjunto finito de valores. Así, el siguiente bloque de código imprimiría en la terminal los siete primeros valores de la sucesión:

   for (int const n : fibonacci_gen() | std::views::take(7)) {       std::print("{} ", n); // output: 0 1 1 2 3 5 8   }

Resaltemos el hecho, una vez más, de que el rango adaptado fibonacci_gen()|std::views::take(7) no contiene los siete valores numéricos a imprimir (como sí lo haría un vector std::vector<int>), sino que los calcula sucesivamente conforme el bucle itera el rango.

A modo de segundo ejemplo, si quisiéramos obtener la suma de los valores octavo a décimo de la sucesión de Fibonacci (i.e., 13 + 21 + 34 = 68), bastaría adaptar el rango adecuadamente y acumular los enteros en la forma [1]:

   namespace stdr = std::ranges;    namespace stdv = std::views;    auto fib_rng = fibonacci_gen() | stdv::drop(7) | stdv::take(3);    auto const accum = stdr::fold_left(std::move(fib_rng), 0, std::plus{});    std::println("{}", accum); // output: 68

El algoritmo std::ranges::fold_left de C++23 [2], que recibe como argumento el rango fib_rng = [first,last), es en este caso el encargado de generar los valores a sumar mediante sucesivos incrementos y desreferencias del iterador first. Nuevamente, no es necesario que los valores a sumar convivan simultáneamente en memoria en ningún momento.

Como tercer y último ejemplo, consideremos un flujo de entrada std::istream a un fichero JSON Lines [3]. De querer parsear cada una de sus líneas a través de la biblioteca nlohmann/json [4] y construir con ellas objetos de tipo T (que asumiremos inicializable por defecto), podríamos codificar el siguiente generador:

   template<typename T>    concept constructible_from_json = std::default_initializable<T>                                  and requires (nlohmann::json const& j, T& t) {                                     {from_json(j, t)} -> std::same_as<void>;                                  };    template<constructible_from_json T>    auto from_json_lines(std::istream& is) -> std::generator<T>    {       auto ln = std::string{};       while (std::getline(is, ln)) {          co_yield nlohmann::json::parse(ln).get<T>();       }    }

Observemos la definición del concepto constructible_from_json con el fin de garantizar en tiempo de compilación que el tipo T sea inicializable por defecto, así como que el programador haya implementado adecuadamente una función from_json asociada a T que, tal y como requiere la biblioteca, sea capaz de transformar un objeto nlohmann::json en un objeto T (la biblioteca permite también el uso de tipos move-only, aspecto éste que no discutiremos en este artículo).

En un post anterior dedicado a la vista std::views::chunk_by procedimos a deserializar la totalidad de líneas de un fichero JSON Lines con el fin de almacenar la información disponible (nombre, país y año de victoria) de los campeones del individual masculino de Roland Garros - French Open dentro de objetos de tipo Champion:

   struct Champion {       std::string name,                   country;       int year;    };    // generación mediante macro de las funciones from_json y to_json para Champion:    NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Champion, name, country, year)

En dicho artículo, hicimos un uso combinado de las vistas tl::views::getlines (no estandarizada) y std::views::transform para parsear las líneas JSON y almacenarlas en un vector de objetos Champion:

   // vector de ganadores del certamen masculino Roland Garros:    auto champions = /* IILE */ []{       auto ifs = std::ifstream{"../../roland_garros.jsonl", std::ios::binary};       if (!ifs) throw std::ios_base::failure{"unable to open the JSONL file"};       return tlv::getlines(ifs)           | std::views::transform([](std::string_view ln){                 return nlohmann::json::parse(ln).get<Champion>();              })            | std::ranges::to<std::vector>();    }();

Gracias al parseador genérico from_json_lines, podemos ahora obtener dicho vector, sin recurrir a bibliotecas de terceros, en la siguiente forma alternativa:

   // vector de ganadores del certamen masculino Roland Garros:    auto champions = /* IILE */ []{       auto ifs = std::ifstream{"../../roland_garros.jsonl", std::ios::binary};       if (!ifs) throw std::ios_base::failure{"unable to open the JSONL file"};       return from_json_lines<Champion>(ifs) | std::ranges::to<std::vector>();    }();

O bien, integrando el establecimiento del flujo de entrada con el fichero en la propia función from_json_lines:

   template<constructible_from_json T>    auto from_json_lines(char const* path) -> std::generator<T>    {       auto ifs = std::ifstream{path, std::ios::binary};       if (!ifs) throw std::ios_base::failure{"unable to open the JSONL file"};       auto ln = std::string{};       while (std::getline(ifs, ln)) {          co_yield nlohmann::json::parse(ln).get<T>();       }    }    // ...    auto champions = from_json_lines<Champion>("../../roland_garros.jsonl")       | std::ranges::to<std::vector>();

El lector puede encontrar una implementación experimental de std::generator en la referencia [5], válida para los principales compiladores Clang, GCC y MSVC.


Referencias bibliográficas:
  1. P2502R2 - std::generator: Synchronous Coroutine Generator for Ranges - https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p2502r2.pdf
  2. P2322R6 - ranges::fold - https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p2322r6.html
  3. JSON Lines - Documentation - https://jsonlines.org/
  4. nlohmann/json - https://github.com/nlohmann/json
  5. Casey Carter, Lewis Baker, Corentin Jabot - std::generator implementation - https://godbolt.org/z/5hcaPcfvP

No hay comentarios:

Publicar un comentario