Reflexión en C++26 (Parte I)

Información para el lector
Tiempo estimado de lectura: 30 minutos
Nivel: Avanzado (C++26, metaprogramación, reflexión estática)

Esta nueva serie de posts pretende introducir al lector en las nuevas capacidades de reflexión estática que formarán parte del próximo estándar ISO C++26, tal y como fueron introducidas en la propuesta P2996 [1] y que ya han sido incorporadas (con algunas modificaciones y extensiones) al draft del estándar [2]. Por reflexión estática entendemos aquí la capacidad del programa de analizar su propia estructura en tiempo de compilación y producir código derivado de ella.

Esta nueva meta-programación reflexiva se basa en:

  1. La representación de elementos del programa mediante expresiones constantes (constant‑expressions) que producen valores de reflexión —reflexiones— del tipo opaco std::meta::info.
  2. El operador unario de reflexión (prefijo ^^), capaz de proyectar múltiples construcciones gramaticales del lenguaje —como namespace-names, type-ids, id-expressions o el espacio de nombres global ::— a sus correspondientes reflexiones.
  3. Un amplio conjunto de operaciones consteval en la cabecera <meta> para trabajar con dichas reflexiones (incluyendo la derivación de otras reflexiones).
  4. Los denominados splicers [:…:] (más formalmente, splice-expressions), que permiten generar construcciones sintácticas a partir de reflexiones.
Podemos entender este sistema como una interacción entre dos dominios: el dominio del programa (tipos, miembros, expresiones, etc.) y el meta‑dominio formado por los valores std::meta::info. El operador ^^ proyecta elementos del programa al meta‑dominio, donde pueden inspeccionarse y manipularse (por ejemplo, consultando sus tipos, sus identificadores, sus miembros o sus argumentos de plantilla). Los splicers realizan el movimiento inverso, reintroduciendo en el código construcciones derivadas de esas reflexiones. Por supuesto, un splicer no puede expandirse a código arbitrario: siempre deberá producir una construcción bien formada, válida en el contexto gramatical donde aparezca. Esta bidireccionalidad constituye la base de la generación de código mediante reflexión estática en C++26.


A modo de ejemplo:

// el operador ^^ produce un meta-valor std::meta::info que representa un tipo:    constexpr auto refl = std::meta::info{^^int};        // refl representa al tipo int    static_assert(std::meta::is_arithmetic_type(refl));  // ok, es tipo aritmético    static_assert(std::meta::alignment_of(refl) == 4);   // ok, típicamente 4 bytes
// podemos obtener un std::string_view (almacenamiento estático) con el nombre del tipo:    constexpr auto str = std::string_view{std::meta::display_string_of(refl)};    std::println("{}", str); // imprime: int // usamos el splicer [:refl:] para inyectar el tipo representado en código real: auto const i = typename[:refl:]{7}; // equivalente a: auto const i = int{7}; std::println("{} bytes", sizeof(typename[:refl:])); // imprime típicamente: 4 bytes

Conviene resaltar que la reflexión en C++26 no deja expuesto el AST del compilador, sino que ofrece una proyección estandarizada de parte de él mediante meta‑valores std::meta::info.

Iremos explorando los aspectos de la reflexión en C++ a través de varios ejemplos de nivel relativamente avanzado. En este artículo, en particular, abordaremos un ejemplo canónico: el diseño de un parseador de opciones de línea de comandos (Command‑Line Options) que mapee automáticamente valores a datos miembro de un agregado. Puede consultarse una codificación simplificada en el ejemplo 3.11 de la referencia [1].

El código puede compilarse con GCC (rama trunk), habilitando el soporte experimental de reflexión mediante -freflection y utilizando -std=c++26.


Ejemplo 1: Parseador de opciones de línea de comandos (CLI parser)

Por sencillez de implementación, nuestro analizador de línea de comandos admitirá únicamente opciones largas (long options), según el formato:

 --option_name option_value

Cada opción deberá comenzar necesariamente por dos guiones (--), seguidos del nombre de la opción option_name. Su valor asociado (opcional en el caso de booleanos) se suministrará como un token independiente option_value. Por diseño, no se admitirán valores embebidos mediante el operador = (del tipo --option_name=option_value) ni abreviaturas de opción con un solo guion (como -o). Únicamente las opciones booleanas podrán aparecer sin valor explícito, en cuyo caso se inferirá implícitamente como true.

El analizador no admitirá argumentos posicionales: cualquier token que no comience por -- y que no sea consumido explícitamente como valor de una opción será considerado inválido. Tras procesar todas las opciones reconocidas, de permanecer tokens sin consumir (incluidos los posicionales o cualquier otro valor ajeno a nuestro modelo de opciones) el analizador producirá un error indicando que existen argumentos sobrantes (leftovers).

Como primer paso, desglosaremos las cabeceras estándar necesarias para nuestro parseador:

#include <algorithm> #include <array> #include <charconv> #include <concepts> #include <cstdlib> #include <expected> #include <format> #include <meta> // nueva en C++26 #include <ranges> #include <sstream> #include <string> #include <string_view> #include <type_traits> #include <utility> #include <variant> #include <vector>

así como una serie de alias para los espacios de nombres estándar que emplearemos en nuestro código:

namespace stdm = std::meta; namespace stdr = std::ranges; namespace stdv = std::views;

Observemos, en particular, la inclusión de la nueva cabecera <meta>, puerta de entrada al sistema de reflexión estática estándar en C++26.

A continuación, definiremos una plantilla de función constexpr denominada nsdm_table<T>(), capaz de construir un std::array que asocie cada dato‑miembro no estático de un tipo T con un objeto Nsdm_info, el cual contendrá:
  • El nombre del dato miembro (std::string_view sobre una secuencia de caracteres constante, gestionada por el compilador de forma estática).
  • Un variante (std::variant) capaz de almacenar el puntero al dato miembro. Dado que distintos tipos de datos miembro conducen a tipos diferentes de punteros, es necesario un tipo suma que permita agruparlos en un único array homogéneo.
Nsdm son aquí las siglas inglesas de non-static data member. La estructura Nsdm_info queda definida como:

template<typename ...Ts> struct Nsdm_info { std::string_view identifier; std::variant<Ts...> member_ptr; };

La función nsdm_table<T>() adopta entonces la forma siguiente:

template<typename T> requires std::is_class_v<T> [[nodiscard]] constexpr auto nsdm_table() { constexpr auto nsdms = std::define_static_array( // (A) stdm::nonstatic_data_members_of( ^^std::remove_cvref_t<T>, stdm::access_context::current())); static_assert(nsdms.size() > 0, "nsdm_table<T>: T must have at least one non‑static data member"); return /* IILE */ [&]<std::size_t... I>(std::index_sequence<I...>){ // (B) static_assert((... and not stdm::is_bit_field(nsdms[I])), "nsdm_table<T>: bit-fields are not supported for member tables");
using Variant = std::variant<decltype(&[:nsdms[I]:])...>; // (C) return std::array{ // (D) Nsdm_info<decltype(&[:nsdms[I]:])...>{ .identifier = stdm::identifier_of(nsdms[I]), .member_ptr = Variant{std::in_place_index<I>, &[:nsdms[I]:]} // (E) }... }; }(std::make_index_sequence<nsdms.size()>{}); }

El uso de reflexión en nuestro parseador está completamente encapsulado en esta función. La sintaxis puede resultar confusa en un principio, por lo que nos detendremos a analizar los puntos clave comentados en el código:
  • (A): Obtenemos en tiempo de compilación todos los datos miembro no-estáticos declarados directamente en T (no heredados). El operador ^^T genera un meta-valor std::meta::info que representa al tipo T. A partir de él, nonstatic_data_members_of() obtiene una secuencia de meta-valores std::meta::info, uno por cada dato miembro no estático de T. Finalmente, define_static_array() materializa esa secuencia en un span indexable, lo que nos permite acceder al meta-valor I‑ésimo —aquél que representa al miembro I‑ésimo de T— mediante nsdms[I].
  • (B): La colección obtenida en (A) se despliega con std::index_sequence<I...>, que genera un pack de índices I de 0 a N-1, siendo N el número de datos miembro accesibles de T (véanse más detalles acerca del contexto de acceso más adelante).
  • (C): Definimos un tipo Variant con una alternativa por cada dato miembro de T. Cada alternativa es de tipo M T::*, siendo M el tipo del dato miembro. La declaración decltype(&[:nsdms[I]:]) nos permite determinar en tiempo de compilación el tipo del puntero al dato miembro I-ésimo.
  • (D): Generamos finalmente un std::array de objetos Nsdm_info y longitud N=sizeof...(I), que será empleado posteriormente como tabla de despacho. Para cada dato miembro:
    • identifier almacena el nombre del miembro (identifier_of), y
    • member_ptr almacena el puntero al miembro, convenientemente empaquetado en el variante.
  • (E): Es importante notar que varias alternativas del variante pueden tener el mismo tipo (por ejemplo, si contiene varios int, las correspondientes alternativas son todas de tipo int T::*). Para identificar cada miembro de forma única, el variante se inicializa mediante std::in_place_index<I> [3], asignando a cada entrada el índice correspondiente a su orden de declaración. La expresión &[:nsdms[I]:] produce el puntero al miembro I‑ésimo, que queda almacenado sin ambigüedad en la alternativa I-ésima.
Las siglas IILE representan invocación inmediata de expresión lambda (véase este post para más detalles).

Observemos que, si bien la reflexión como tal sólo puede evaluarse en tiempo de compilación, hemos definido nsdm_table<T>() como una función constexpr, no consteval. Ello permite que pueda invocarse indistintamente en tiempo de compilación o en tiempo de ejecución. En el caso de runtime, se emplea sencillamente un valor determinado en compile‑time.

Conviene remarcar que C++26 contempla varios posibles contextos de acceso en los que se realizan consultas reflexivas:
  • access_context::current: representa el contexto de acceso actual del punto en el que se realiza la reflexión, respetando íntegramente las reglas habituales de acceso en C++ (incluyendo public, protected, private, herencia y amistad). Sólo permite reflexionar miembros que sean accesibles en ese punto del programa.
  • access_context::unprivileged: equivale a evaluar la reflexión desde el espacio de nombres global, sin privilegios de clase ni amistad. Ello implica en la práctica que el acceso quede limitado a la interfaz pública.
  • access_context::unchecked: omite las comprobaciones de control de acceso durante la reflexión y permite reflexionar todos los miembros del tipo, incluidos private y protected, sin aplicar las restricciones habituales de acceso.
  • access_context::via(cls): mantiene el ámbito actual pero establece cls como clase designante para efectos de control de acceso. Sirve para simular acceso como si se estuviese dentro de otra clase.


Llegados a este punto, el resto de la codificación consistirá en un mero ejercicio de programación genérica con templates. Como tipos de valores option_value admitiremos aquéllos que satisfagan el siguiente concepto cli_parsable, el cual expresa un dominio semántico de tipos CLI:

template<typename T> concept istream_extractable = requires (std::istream& is, T& t) { { is >> t } -> std::same_as<std::istream&>; };
template<typename T> concept cli_parsable = std::is_default_constructible_v<T> and (std::same_as<T, bool> or std::same_as<T, std::string> or std::is_arithmetic_v<T> or istream_extractable<T>);

Consideramos, pues, valores construibles por defecto de tipo booleano, cadena de caracteres, tipos aritméticos o cualquier tipo que sea extraíble desde un flujo de entrada. Las enumeraciones y los tipos opcionales std::optional, entre otros, podrían admitirse con relativa facilidad, pero se omitirán por simplicidad.

Definamos un parser genérico de valores para opciones CLI, llamado parse_cli_value<T>(), que reciba:
  • el nombre de una opción (string_view name), y
  • el valor textual asociado (string_view value, por ejemplo "4", "true", "5.7", "[2,7]", etc.).
La función seleccionará en tiempo de compilación la estrategia de parseo adecuada según el tipo T y producirá un resultado usando std::expected [4]. Si el valor textual proporcionado resulta ser válido para el tipo T, devolveremos la conversión con éxito; en caso contrario, retornaremos un std::unexpected<std::string> conteniendo un mensaje descriptivo del motivo del fallo (distinguiendo entre valor ausente missing o inválido invalid):

[[nodiscard]] constexpr auto to_lower_ascii(unsigned char c) noexcept -> unsigned char { return (c >= 'A' and c <= 'Z')? static_cast<unsigned char>(c - 'A' + 'a') : c; } [[nodiscard]] constexpr auto equals_ascii(std::string_view sv1, std::string_view sv2) noexcept -> bool { return sv1.size() == sv2.size() and std::ranges::equal(sv1, sv2, {}, [](char x) { return to_lower_ascii(static_cast<unsigned char>(x)); }, // proyección 1 [](char y) { return to_lower_ascii(static_cast<unsigned char>(y)); } // proyección 2 ); }
enum class Parse_errc { missing, invalid };
template<cli_parsable T> [[nodiscard]] auto parse_cli_value(std::string_view name, std::string_view value) -> std::expected<T, std::string> { using U = std::remove_cvref_t<T>; auto err = [&](Parse_errc ec) { return std::unexpected(std::format("{} value for --{} (expected {})", (ec == Parse_errc::missing)? "missing" : "invalid", name, stdm::display_string_of(^^U))); }; if constexpr (std::same_as<U, bool>) { if (value.empty() or equals_ascii(value, "true") or equals_ascii(value, "1") or equals_ascii(value, "yes") or equals_ascii(value, "y")) { return true; } if (equals_ascii(value, "false") or equals_ascii(value, "0") or equals_ascii(value, "no") or equals_ascii(value, "n")) { return false; } return err(Parse_errc::invalid); } else if constexpr (std::same_as<U, std::string>) { if (value.empty()) { return err(Parse_errc::missing); } return std::string{value}; } else if constexpr (std::is_arithmetic_v<U>) { if (value.empty()) { return err(Parse_errc::missing); } auto res = U{}; auto const first = value.data(); auto const last = first + value.size(); auto const [ptr, ec] = std::from_chars(first, last, res); if (ec != std::errc{} or ptr != last) { return err(Parse_errc::invalid); } return res; } else if constexpr (istream_extractable<U>) { if (value.empty()) { return err(Parse_errc::missing); } auto res = U{}; auto iss = std::istringstream{value}; iss.imbue(std::locale::classic()); if (not (iss >> res)) { return err(Parse_errc::invalid); } iss >> std::ws; if (not iss.eof()) { return err(Parse_errc::invalid); } return res; } else { std::unreachable(); } }

Observemos que la función aplica verificaciones estrictas a nivel de detección de contenido sobrante tras el parseo (por ejemplo, valores enteros como "4a" conducirán a error), así como rechazo explícito de formatos no válidos.


Finalmente, dado un agregado Opts, la función parse_cli_options<Opts>() creará una instancia de ese tipo leyendo los argumentos recibidos en argv —que representan los parámetros pasados al programa desde la línea de comandos— y mapeándolos a los datos miembro correspondientes. Como indicamos al inicio de este ejemplo, cada opción válida deberá aparecer en la forma --option_name option_value, y la función identificará el dato miembro de Opts cuyo identificador coincide con option_name, asignándole el valor option_value previamente convertido al tipo adecuado. La función rechazará cualquier opción desconocida o argumento sobrante, de modo que sólo producirá un objeto Opts válido cuando todas las opciones coincidan exactamente con los datos miembro del agregado y todos los tokens de entrada hayan sido correctamente consumidos:

template<typename> struct Data_member;
template<typename Member, typename Class> struct Data_member<Member Class::*> { using type = Member; };
template<typename T> using Data_member_t = typename Data_member<T>::type; template<typename Opts> // Opts debe ser un agregado sin clases base requires std::is_aggregate_v<Opts> and (stdm::bases_of(^^Opts, stdm::access_context::current()).empty()) [[nodiscard]] auto parse_cli_options(char** argv, std::size_t argc) -> std::expected<Opts, std::string> { auto res = Opts{}; auto const args = std::vector<std::string_view>{argv + 1, argv + argc}; auto is_name = [&](std::size_t idx) { return args[idx].starts_with("--"); }; auto is_followed_by_value = [&](std::size_t idx) { return idx + 1 < args.size() and not is_name(idx + 1); }; constexpr auto table = nsdm_table<Opts>(); // datos miembro no-estáticos de Opts auto consumed = std::vector<bool>(args.size(), false); for (auto i = 0uz; i < args.size(); /* incremento dentro */) { if (not is_name(i)) { ++i; continue; } auto const arg = args[i]; // "--option_name" auto const option_name = arg.substr(2); // "option_name" // buscamos el miembro de Opts cuyo identificador coincida con el nombre de la opción: if ( auto const it = stdr::find_if(table, [&](auto&& m){ return m.identifier == option_name; }); it != table.end() ){ auto const r = it->member_ptr.visit( [&](auto const& mptr) -> std::expected<void, std::string> { using M = Data_member_t<std::remove_cvref_t<decltype(mptr)>>; static_assert(not std::is_const_v<M>, "cannot assign to const member"); static_assert(not std::is_array_v<M>, "cannot assign to array member"); static_assert(cli_parsable<M>, "unsupported CLI option type"); auto const has_value = is_followed_by_value(i); auto const option_value = has_value? args[i + 1] : std::string_view{}; if (auto p = parse_cli_value<M>(option_name, option_value)) { res.*mptr = std::move(*p); consumed[i] = true; if (has_value) { consumed[i + 1] = true; } i += has_value? 2 : 1; return {}; } else { return std::unexpected{p.error()}; } } ); if (not r) { return std::unexpected{r.error()}; } } else { return std::unexpected{std::format("unknown CLI option --{}", option_name)}; } } // comprobamos si existen tokens sin procesar: auto leftovers = std::string{}; for (auto const i : stdv::indices(args.size())) { if (not consumed[i]) { if (not leftovers.empty()) { leftovers += ", "; } leftovers += args[i]; } } if (not leftovers.empty()) { return std::unexpected{std::format("unused CLI tokens remained: [{}]", leftovers)}; } return res; }

La restricción de que el agregado Opts no pueda heredar de clases base responde a una decisión de diseño destinada a simplificar la implementación, puesto que los miembros de datos heredados no son devueltos por nonstatic_data_members_of<Opts>(). No obstante, el código podría extenderse para soportar herencia utilizando funciones meta como bases_of y subobjects_of [2].


Como aplicación práctica, consideremos el código:

// insertar aquí todo el código precedente    #include <print>    struct Color {       int r{}, g{}, b{};    };    auto operator>>(std::istream& is, Color& c) -> std::istream& {       auto comma_1 = char{};       auto comma_2 = char{};       if (is >> c.r >> comma_1 >> c.g >> comma_2 >> c.b) {          // formato esperado: "R,G,B"          if (comma_1 == ',' and comma_2 == ',') { return is; }       }       is.setstate(std::ios::failbit);       return is;    }    struct Circle {       std::string name;       double radius{};       double border_width{};       double opacity{};       Color color{};       bool filled = false;    };
   auto main(int argc, char* argv[]) -> int    {       auto const c = parse_cli_options<Circle>(argv, argc);       if (not c) {           std::println("Error: {}", c.error());           return EXIT_FAILURE;       }
      auto const& [name, radius, borderw, opacity, color, filled] = *c;       auto const& [r, g, b] = color;       std::println("name = {}", name);       std::println("radius = {}", radius);       std::println("border width = {}", borderw);       std::println("opacity = {}", opacity);       std::println("color = {},{},{}", r, g, b);       std::println("filled = {}", filled);       return EXIT_SUCCESS;    }

Los argumentos de ejecución
--name "Circle #1" --radius 9.4 --border_width 2 --opacity 0.8 --color 44,55,125 --filled
producirán la salida esperada:
name          = Circle #1
radius        = 9.4
border width  = 2
opacity       = 0.8
color         = 44,55,125
filled        = true
mientras que valores erróneos como
--name "Circle #1" --radius ra --border_width 2 --opacity 0.8 --color 44,55,125 --filled
conducirán a mensajes de error como
Error: invalid value for --radius (expected U {aka double})
Por su parte, la ausencia de valores esperados
--name "Circle #1" --radius --border_width 2 --opacity 0.8 --color 44,55,125 --filled
generará errores como
Error: missing value for --radius (expected U {aka double})
Los nombres de opciones que no encuentren correspondencia con los miembros del agregado serán también rechazados:
--name "Circle #1" --r 9
Error: unknown CLI option --r
Finalmente, de permanecer tokens sin consumir (leftovers), obtendremos un mensaje de error:
i --name "Circle #1" 0.5 --radius 9.4 j
Error: unused CLI tokens remained: [i, 0.5, j]


Referencias bibliográficas:
  1. P2996R13 - Reflection for C++26 - https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2025/p2996r13.html
  2. Working Draft Programming Languages - C++ - Metaprogramming library - https://eel.is/c++draft/meta.reflection
  3. cppreference - std::in_place - https://en.cppreference.com/w/cpp/utility/in_place.html
  4. cppreference - std::expected - https://en.cppreference.com/w/cpp/utility/expected.html

No hay comentarios:

Publicar un comentario