Un ejemplo de programación con std::variant

Última actualización: 5 de agosto de 2020

Introducción


La plantilla std::variant<>, incluida por vez primera en el estándar C++17 en el fichero de cabecera <variant>, proporciona 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. 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++17 (por ejemplo, CTAD [2]) y C++20 (particularmente, conceptos e inicializadores designados [3, 4]) con el fin de demostrar la utilidad y alcance de abstracción de esta herramienta. Compilaremos nuestro proyecto con GCC 10.1 o posterior incluyendo el flag -std=c++2a. Asimismo, haremos uso de la biblioteca {fmt}lib [5] con el fin de facilitar el formato de texto.


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 <fmt/format.h> #include <iostream> #include <tuple> #include <utility> #include <variant> using namespace std; using namespace std::complex_literals; template<floating_point T> struct Quadratic {  // polinomio ax^2 + bx + c    T a{}, b{}, c{};     auto operator()(T x) const { return a*pow(x, 2) + b*x + c; }     auto discriminant() const { return pow(b, 2) - 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<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 en 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<floating_point T>   using Root_var = variant<T, pair<T, T>, pair<complex<T>, 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.

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 en coma flotante):

template<typename 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 = sqrt(d);        return pair{(-q.b + s)/(2*q.a), -(q.b + s)/(2*q.a)};     }     else if (d < T{}) { // raíces complejas        auto const s = sqrt(abs(d));        return 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 [[nodiscard]] indica al compilador que debe emitir un warning en caso de que el usuario no emplee el valor retornado por la función. 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:

  template<typename Variant>   [[nodiscard]] auto match(Variant&& var)   {      return [t = tuple<Variant>{forward<Variant>(var)}]             <typename... Handlers>(Handlers&&... handlers) -> decltype(auto) {                struct Overload : Handlers... { using Handlers::operator()...; };                return visit(Overload{forward<Handlers>(handlers)...}, 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<typename T> void print_roots(Root_var<T> const& roots) {     match(roots)(        [](T r) {           fmt::print("double real root: {:.2f}\n", r);        },        [](pair<T, T> const& rs) {           auto const& [r_1, r_2] = rs;           fmt::print("distinct real roots: {:.2f}, {:.2f}\n", r_1, r_2);        },        [](pair<complex<T>, complex<T>> const& rs) {           auto const& [r_1, r_2] = rs;           fmt::print("complex roots: {:.2f} + ({:.2f})i, {:.2f} + ({:.2f})i\n",                      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++23 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;     fmt::print("Polynomial coefficients (a, b, and c for ax^2 + bx + c): ");     auto [c1, c2, c3] = array<coeff_type, 3>{};     cin >> c1 >> c2 >> c3;     auto const q = Quadratic{.a = c1, .b = c2, .c = c3};     fmt::print("Roots of {:.2f}x^2 + {:.2f}x + {:.2f}\n", 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.



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