Un ejemplo de programación con std::variant

Última actualización (terminología y funcionalidades de C++23): 18 de enero de 2026

Introducción


La plantilla std::variant<>, incluida por vez primera en el estándar C++17 en la cabecera <variant>, proporciona una unión etiquetada (o tipo suma) con la funcionalidad típica de las uniones de C, si bien garantizando la seguridad de tipos. Así, una instancia de tipo variante std::variant contendrá, en un instante dado, un objeto perteneciente a uno de varios tipos alternativos permitidos, o ningún valor en caso de error. 

Dicha instancia mantiene internamente un índice que identifica el tipo actualmente almacenado, así como una zona de memoria suficiente para contener el objeto más grande de entre sus alternativas. Es decir, el tamaño de un std::variant coincide con el del mayor alternante, más metadata.

Por ejemplo, una instancia de std::variant<char, int, double> podrá contener un carácter char, un entero int o un double, a conveniencia del programador y sin incurrir en alojamiento dinámico de memoria:

   auto v = std::variant<charintdouble>{}; // v contiene char{}    v = 7;  // ahora v contiene int{7}    v = 1.0; // ahora v contiene double{1.0}    if (auto p = std::get_if<char>(&v))       fmt::print("variant contains char = {}\n", *p);    else if (auto p = std::get_if<int>(&v))       fmt::print("variant contains int = {}\n", *p);    else if (auto p = std::get_if<double>(&v))       fmt::print("variant contains double = {}\n", *p);    // output: variant contains double = 1.0    // el siguiente código provoca la emisión de una excepción, al intentar    // acceder a un entero cuando el variante v contiene actualmente un double:    try {       auto& i = std::get<int>(v); // lanza una excepción       ++i;    } catch (std::bad_variant_access const& e) {       fmt::print("{}\n", e.what());    } // output: wrong index for variant

Puede consultarse la interfaz pública de la plantilla y varios ejemplos de uso en la referencia [1] de la bibliografía.

En este post, haremos uso de la funcionalidad ofrecida por std::variant, junto a varias características de C++20 (particularmente, conceptos e inicializadores designados [3, 4]) y C++23 (cabecera <print> para facilitar el formato de texto) con el fin de demostrar la utilidad y alcance de abstracción de esta herramienta. Compilaremos nuestro proyecto con GCC 15.1 o posterior.


El problema


Consideremos un ejercicio sencillo consistente en calcular las raíces (reales o complejas) de una expresión cuadrática de la forma ax2 + bx + c, donde ab y c son coeficientes reales. Existen tres posibles resultados, dependiendo del valor de su discriminante (d := b2 - 4ac):
  • Dos raíces reales distintas (d > 0).
  • Una única raíz real de multiplicidad dos (d = 0).
  • Dos raíces complejas (d < 0).
Estas posibilidades invitan de forma natural al empleo de std::variant como mecanismo unificado con el que comunicar al usuario el resultado del cálculo de raíces.


Agregado Quadratic<>


En primer lugar, implementemos una plantilla Quadratic<> cuyos objetos representen expresiones cuadráticas. Hablamos aquí de un mero agregado de datos que almacenará los coeficientes del polinomio. El agregado se encontrará parametrizado según el tipo numérico de los coeficientes. Gracias a la inclusión de conceptos en C++20, podemos restringir dicho tipo de dato subyacente a coma flotante (floatdouble o long double), impidiendo la compilación de expresiones cuadráticas con coeficientes de cualquier otro tipo. Así, por ejemplo, Quadratic<char> o Quadratic<string> no serán expresiones permitidas, conduciendo a errores de compilación al carecer de sentido en este contexto:

#include <array> #include <cassert> #include <cmath> #include <complex> #include <concepts> #include <iostream> #include <print> #include <tuple> #include <utility> #include <variant> using namespace std::complex_literals; template<std::floating_point T> struct Quadratic {  // polinomio ax^2 + bx + c    T a{}, b{}, c{};     auto operator()(T x) const { return a*x*x + b*x + c; }     auto discriminant() const { return b*b - 4*a*c; } };

Gracias a la técnica CTAD (class template argument deduction), podremos inicializar un polinomio como (1.0f)x2 + (-1.0f)x mediante la expresión auto q = Quadratic{1.0f,-1.0f}, evitando la necesidad de explicitar el argumento de la plantilla en la forma auto q = Quadratic<float>{1.0f,-1.0f} como haríamos de forma redundante en estándares previos a 2017. Recordemos, en este punto, que el estándar C++17 requeriría que se proporcionaran guías de deducción apropiadas con el fin de permitir el empleo de CTAD durante la inicialización de agregados como Quadratic:

template<std::floating_point T> // reglas de deducción para CTAD (C++17) explicit Quadratic(T c_1, T c_2 = T{}, T c_3 = T{}) -> Quadratic<T>;

La inclusión de dichas guías, sin embargo, resulta innecesaria desde C++20 [6].


Algoritmo de cálculo de raíces


Ha llegado el momento de definir el tipo variante Root_var retornado por nuestro algoritmo de cálculo de raíces:

  template<std::floating_point T>   using Root_var = std::variant<T, std::pair<T, T>, std::pair<std::complex<T>, std::complex<T>>>;

Se trata de un variant parametrizado en el que los tipos de datos alternativos a contener son:
  • Un único valor coma flotante de tipo T (raíz real doble).
  • Una pareja std::pair<T,T> de valores coma flotante (raíces reales distintas).
  • Una pareja std::pair<std::complex<T>,std::complex<T>> de números complejos (raíces complejas).
Nuevamente, el tipo T se encuentra restringido por el concepto std::floating_point.

Conviene hacer notar que los tipos std::pair podrían ser sustituidos por tipos std::array de longitud 2, transmitiendo claramente que deseamos almacenar dos valores reales sin orden significativo.

De acuerdo con las especificaciones anteriores, el algoritmo de cálculo de raíces se codifica de forma inmediata como (ignoraremos, por simplicidad, detalles relacionados con los errores de redondeo o de cancelación catastrófica en coma flotante):

template<std::floating_point T> [[nodiscard]] auto compute_roots(Quadratic<T> const& q) -> Root_var<T> {     assert(!(q.a == T{} and q.b == T{}));     auto const d = q.discriminant();     if (d > T{}) { // raíces reales y distintas        auto const s = std::sqrt(d);        return std::pair{(-q.b + s)/(2*q.a), -(q.b + s)/(2*q.a)};     }     else if (d < T{}) { // raíces complejas        auto const s = std::sqrt(abs(d));        return std::pair{(-q.b + s*1i)/(2*q.a), -(q.b + s*1i)/(2*q.a)};     }     else { // raíz real de multiplicidad dos        return -q.b/(2*q.a); } }

El atributo estándar [[nodiscard]] indica al compilador que debe emitir un warning en caso de que el usuario no emplee el valor retornado por la función. Por su parte, la aserción establece, a modo de precondición, que ningún polinomio enviado a la función compute_roots sea de grado cero (es decir, un mero número real).

Finalmente, procedemos a definir una función que imprima en pantalla el resultado del algoritmo, atendiendo al tipo de dato contenido en el objeto variant. Para ello, introduzcamos en primer lugar una función match que --de forma aproximada y en el contexto restringido de los tipos variantes-- emule un esquema de pattern matching similar al proporcionado por Haskell, Swift o Rust mediante despacho por visitante (std::visit) y un conjunto de sobrecargas (solución idiomática overload pattern):

template<typename V> concept Variant = requires(V&& v) { std::visit([](auto&&){}, std::forward<V>(v)); };   template<Variant V>   [[nodiscard]] constexpr auto match(V&& var)   {      return [t = std::tuple<V>{std::forward<V>(var)}]             <typename... Handlers>(Handlers&&... handlers) mutable -> decltype(auto) {                struct Overload : Handlers... { using Handlers::operator()...; };                return std::visit(Overload{std::forward<Handlers>(handlers)...},   std::forward<V>(std::get<0>(t)));             };   }

Gracias a esta función seremos capaces de discernir en tiempo de ejecución el tipo estático de dato contenido en std::variant<> y aplicar, para éste, la sobrecarga de función adecuada de entre un conjunto proporcionado de handlers (típicamente lambdas). Para más información acerca de la función estándar std::visit() y la implementación del sobrecargador variádico Overload puede visitarse el enlace [7]. Para una discusión detallada acerca de la captura en expresiones lambda de argumentos pasados mediante referencias de reenvío, véase [8]. Una biblioteca C++17 header-only muy recomendable que implementa una función match de mayor funcionalidad que la anterior viene dada por scelta [9].

Llegados a este punto, la función de impresión de raíces toma la forma siguiente:

template<std::floating_point T> void print_roots(Root_var<T> const& roots) {     match(roots)(        [](T r) {           std::println("double real root: {:.2f}", r);        },        [](std::pair<T, T> const& rs) {           auto const& [r_1, r_2] = rs;           std::println("distinct real roots: {:.2f}, {:.2f}", r_1, r_2);        },        [](std::pair<std::complex<T>, std::complex<T>> const& rs) {           auto const& [r_1, r_2] = rs;           std::println("complex roots: {:.2f} + ({:.2f})i, {:.2f} + ({:.2f})i",                       r_1.real(), r_1.imag(), r_2.real(), r_2.imag());        }     ); }

Observemos cómo cada controlador lambda contiene el código de impresión correspondiente a cada posible tipo de raíz contenida en el variant.
Nota: Previsiblemente, habrá que aguardar hasta C++29 para simplificar el código anterior mediante la introducción de un auténtico esquema de pattern matching en C++ (propuesta P1260 [10]). Puedes encontrar más información en este post del blog.

Un ejemplo de uso


Un ejemplo sencillo de nuestro cálculo de raíces anterior viene dado por la siguiente función principal, en la que el usuario tiene la posibilidad de introducir los coeficientes del polinomio a través de la terminal para obtener sus raíces en pantalla:

auto main() -> int {     using coeff_type = double;     std::print("Polynomial coefficients (a, b, and c for ax^2 + bx + c): ");     auto [c1, c2, c3] = std::array<coeff_type, 3>{};     std::cin >> c1 >> c2 >> c3;     auto const q = Quadratic{.a = c1, .b = c2, .c = c3};     std::println("Roots of {:.2f}x^2 + {:.2f}x + {:.2f}", q.a, q.b, q.c);     auto const roots = compute_roots(q);     print_roots(roots); }

¿Qué tiene de extraordinario este código? En primer lugar, observemos que el programador no se ve obligado a poblar la función main() con estructuras de control atendiendo al tipo de raíces obtenidas. Dicha información queda registrada en la variable variant de nombre roots. Asimismo, la función print_roots es la responsable de discriminar el número de soluciones e imprimir el mensaje correcto en pantalla, simplificando en extremo la codificación y comprensión de la función main().

De igual trascendencia resulta el hecho de que si decidiéramos modificar el tipo de coeficientes del polinomio coeff_type a, por ejemplo, float (con el fin de trabajar con menor precisión numérica), sólo sería necesario modificar la directiva using en la primera línea de código de main(). El resto del código descansa en el uso de algoritmos genéricos y la deducción automática de tipos, comunicando el cambio de forma implícita. Todas las operaciones de nuestro programa se realizarían entonces con variables float.

Fijémonos, por último, en que C++20 permite especificar el nombre de los datos miembro del agregado Quadratic durante su inicialización mediante inicializadores designados. Se trata de una novedad muy esperada que mejora la legibilidad y mantenimiento del código, aunque posea aún ciertas limitaciones frente a otros lenguajes, como por ejemplo la imposibilidad de modificar el orden preestablecido de los inicializadores o el que su uso se limite a agregados.

El ejemplo tratado en este post cubre varias de las ventajas ofrecidas por std::variant, siendo su uso de elección natural cuando el conjunto de tipos posibles sea cerrado y conocido en tiempo de compilación, deseemos seguridad de tipos sin herencia y el coste de almacenamiento de varios tipos diferentes resulte razonable.



Este estudio continúa en el post std::variant - Polimorfismo sin herencia.


Referencias bibliográficas:
  1. https://en.cppreference.com/w/cpp/utility/variant
  2. https://blogs.msdn.microsoft.com/vcblog/2018/10/11/how-to-use-class-template-argument-deduction/
  3. https://en.cppreference.com/w/cpp/language/constraints
  4. https://en.cppreference.com/w/cpp/language/aggregate_initialization#Designated_initializers
  5. https://github.com/fmtlib/fmt
  6. http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1021r2.html
  7. https://en.cppreference.com/w/cpp/utility/variant/visit
  8. Vittorio Romeo - https://vittorioromeo.info/index/blog/capturing_perfectly_forwarded_objects_in_lambdas.html
  9. https://github.com/SuperV1234/scelta
  10. http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1260r0.pdf
  11. Nikolai Wuttke - Meeting C++ 2018 - std::variant and the power of pattern matching - https://www.youtube.com/watch?v=CELWr9roNno

No hay comentarios:

Publicar un comentario