std::variant - Polimorfismo sin herencia

Introducción


Última actualización: 6 de agosto de 2020

Como analizamos en un post anterior, la plantilla de clase std::variant<>, incluida por vez primera en el estándar C++17, proporciona la funcionalidad típica de las uniones de C, si bien garantizando la seguridad de tipos. Así, una instancia de std::variant<> podrá contener, 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> permitirá almacenar un carácter char, un entero int o un double, a conveniencia del programador y sin incurrir en alojamientos dinámicos de memoria.

Puedes consultar algunos ejemplos de uso simples en la siguiente referencia:

https://en.cppreference.com/w/cpp/utility/variant


Un nuevo polimorfismo dinámico


std::variant<> da pie a un nuevo mecanismo de polimorfismo dinámico que no requiere el empleo de las tradicionales jerarquías de clases basadas en la herencia. En efecto, imaginemos un videojuego que, atendiendo al nivel de dificultad seleccionado en tiempo de ejecución, enfrenta al jugador a una especie distinta de dinosaurio:

struct Brachio {     auto species() const -> std::string { return "brachiosaurus"; } }; struct Raptor {     auto species() const -> std::string { return "velociraptor"; } }; struct Trex {     auto species() const -> std::string { return "tyrannosaurus rex"; } };

Las tres clases anteriores disponen de una función miembro pública species()->std::string que proporciona el nombre de la especie correspondiente. Observemos, pues esto resultará crucial en nuestra discusión posterior, que ninguna de estas clases hereda de un tipo base, así como que las funciones species() no se han declarado virtuales. Las clases comparten, sencillamente, una interfaz pública común.

Definamos un alias para una especialización de std::variant<> que pueda contener objetos de las tres especies anteriores:

using Dinosaur = std::variant<Brachio, Raptor, Trex>;

Empleemos, a continuación, la función genérica match() implementada en un post anterior, gracias a la cual podemos 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):

  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));             };   }

La ejecución de la siguiente función principal main() muestra cómo, dependiendo del nivel de dificultad (entero level) seleccionado por el usuario en tiempo de ejecución, el variant enemy se puebla con una especie de dinosaurio distinta (Brachio para nivel 0Raptor para nivel 1 y Trex para nivel 2). La función match() invocada por print_species() se encarga entonces de mostrar en pantalla el mensaje que corresponda a cada dinosaurio. Observemos, en particular, que en el caso del T-Rex el mensaje se encuentra especializado con el fin de explicitar su estatus especial como final boss:

#include <cassert> #include <exception> #include <iostream> #include <fmt/format.h> #include <string> #include <utility> #include <variant> // sitúa aquí las estructuras de dinosaurios, el alias Dinosaur y // el código de implementación de la función match() auto create_dino(int level) -> Dinosaur // factoría { assert(level>=0 and level<=2); // precondición     auto dino = Dinosaur{};     switch (level) {        case 0: dino = Brachio{}; break;        case 1: dino = Raptor{}; break;        case 2: dino = Trex{}; break;        default: std::abort();     }     return dino; } void print_species(Dinosaur const& dino) {     match(dino)(        [](auto const& d){ // mensaje genérico para Brachio y Raptor            fmt::print("Enemy: {}\n", d.species());        },        [](Trex const& trx){ // mensaje específico para Trex            fmt::print("Final boss!: {}\n", trx.species());        }     ); } auto main() -> int {     auto level = -1;     while (level < 0 or level > 2) {     fmt::print("Select level [0,1,2]: ");     std::cin >> level; }     auto const enemy = create_dino(level);     print_species(enemy); }

El polimorfismo proporcionado por std::variant<> puede resultar la opción más adecuada cuando nuestro conjunto de clases sea cerrado. De forma notable, evitamos la necesidad de heredar de una clase base (una técnica sin duda invasiva), así como el uso de métodos virtuales y el alojamiento dinámico a través del operador new. Se recupera, además, una semántica de valor frente a una de referencia. Esto último puede resultar especialmente eficiente cuando situemos objetos de tamaño similar (como los dinosaurios de nuestro ejemplo) en un array, pues éstos quedarán localizados de forma contigua en memoria. Hablamos, aquí, de un contenedor heterogéneo como el del siguiente ejemplo:

   auto dinos = std::vector<Dinosaur>{};    dinos.push_back(Trex{});    dinos.push_back(Brachio{});    dinos.push_back(Raptor{});    for (auto const& dino : dinos) {       match(dino)([](auto&& d){ fmt::print("{}\n", d.species()); });    }

Eso sí, nuestra función match() sigue empleando una tabla virtual (vtable) local, lo que conlleva un sobrecoste de rendimiento (performance overhead) asociado.


Polimorfismo dinámico basado en herencia


A modo de comparación, nuestro programa tomaría la forma siguiente de optar por emplear un polimorfismo tradicional basado en herencia:

#include <cassert> #include <iostream> #include <fmt/format.h> #include <memory> #include <string> struct Dinosaur { // interfaz     virtual auto species() const -> std::string = 0;     virtual ~Dinosaur() = default; }; struct Brachio : Dinosaur {     auto species() const -> std::string override { return "brachiosaurus"; } }; struct Raptor : Dinosaur {     auto species() const -> std::string override { return "velociraptor"; } }; struct Trex : Dinosaur {     auto species() const -> std::string override { return "tyrannosaurus rex"; } }; auto create_dino(int level) -> std::unique_ptr<Dinosaur> {     assert(level>=0 and level<=2); // precondición     auto dino = std::unique_ptr<Dinosaur>{}; // puntero a clase base     switch (level) {        case 0: dino = std::make_unique<Brachio>(); break;        case 1: dino = std::make_unique<Raptor>(); break;        case 2: dino = std::make_unique<Trex>(); break;        default: std::abort();     }     return dino; } void print_species(Dinosaur const* dino) { assert(dino);     if (dynamic_cast<Trex const*>(dino))        fmt::print("Final boss!: {}\n", dino->species());     else        fmt::print("Enemy: {}\n", dino->species()); } auto main() -> int {     auto level = -1;     while (level < 0 or level > 2) {        fmt::print("Select level [0,1,2]: ");        std::cin >> level;     }     auto const enemy = create_dino(level);     print_species(enemy.get()); }


Referencias bibliográficas:
  • Nikolai Wuttke - Meeting C++ 2018 - std::variant and the power of pattern matching - https://www.youtube.com/watch?v=CELWr9roNno
  • Nicolai Josuttis - Meeting C++ 2019 - Combining C++17 Features - https://www.youtube.com/watch?v=6HoxXeEBtW0

No hay comentarios:

Publicar un comentario