Le type std::variant

Depuis C++ 17, un type std::variant est offert pour représenter une valeur d'un type parmi un ensemble de types. Par exemple, un std::variant<int,std::string,float> pourra contenir un int, ou un std::string, ou encore un float.

#include <variant>
#include <string>
using namespace std;
int main() {
   variant<int, string, float> v = 3; // ici, v contient un int
   v = "Yo"s; // v contient maintenant un std::string
   int n = 3;
   // v = &n; // illégal : v ne peut contenir un int*
}

On peut lire d'un std::variant ce qui y a été le plus récemment entreposé. Ainsi, dans l'exemple qui précède, après avoir affecté "Yo"s à v, il est légal d'en lire une std::string mais pas d'en lire un int. Pour accéder au contenu d'un std::variant, quelques approches possibles sont :

Utiliser la fonction globale std::get(), aussi utilisable (sous une forme semblable) sur des std::tuple ou des std::pair d'ailleurs. Cette fonction permet d'accéder à l'élément par sa position dans la liste des types possibles, de même que par le type lui-même, dans la mesure bien entendu où ce type n'est pas ambigu, ce qui peut se produire – pensez à un std::variant<unsigned int,std::size_t> sur une plateforme où le second est un alias pour le premier.

Avec cette approche, accéder au mauvais élément (p. ex. : faire std::get<int>(v) dans l'exemple à droite) lèvera une exception de type std::bad_variant_access.

#include <variant>
#include <string>
#include <iostream>
using namespace std;
int main() {
   variant<int, string, float> v = 3; // ici, v contient un int
   v = "Yo"s; // v contient maintenant un std::string
   cout << get<1>(v) << endl; // par position
   cout << get<string>(v) << endl; // par type
}

Utiliser la fonction globale std::get_if(). Cette fonction permet d'accéder à l'élément par sa position dans la liste des types possibles, de même que par le type lui-même, dans la mesure bien entendu où ce type n'est pas ambigu, et retourne un pointeur sur cet élément.

Avec cette approche, accéder au mauvais élément (p. ex. : faire std::get_if<int>(v) dans l'exemple à droite) retournera un pointeur nul.

#include <variant>
#include <string>
#include <iostream>
using namespace std;
int main() {
   variant<int, string, float> v = 3; // ici, v contient un int
   v = "Yo"s; // v contient maintenant un std::string
   cout << *get_if<1>(v) << endl; // par position
   cout << *get_if<string>(v) << endl; // par type
}

Si le souhait n'est que de vérifier si un variant contient un élément spécifique, indiqué par son type (qui ne doit pas être ambigu), il est possible d'utiliser le prédicat global std::holds_alternative(). Il n'y a pas de version positionnelle de cette fonction au moment d'écrire ces lignes.

#include <variant>
#include <string>
#include <cassert>
using namespace std;
int main() {
   variant<int, string, float> v = 3; // ici, v contient un int
   v = "Yo"s; // v contient maintenant un std::string
   assert(holds_alternative<std::string>(v)); // par type
}

Cependant, la version la plus chouette de procéder est probablement la fonction std::visit().

La fonction std::visit()

L'une des vocations des std::variant est d'offrir une alternative Type-Safe aux union étiquetés. En effet, il est possible d'agir (ou de réagir) en fonction du type réellement entreposé dans un std::variant par voie d'une application charmante du schéma de conception Visiteur.

Avec un union étiqueté Avec std::visit() sur un std::variant
#include <string>
#include <iostream>
using namespace std;
const int TAILLE_TEXTE = 128;
enum class type_message { entier, texte, reel };
union Contenu {
   int n;
   char s[TAILLE_TEXTE]; // pas une string, car les union, ça fonctionne mieux avec des trucs triviaux
   double d;
};
struct Message {
   type_message lequel;
   Contenu contenu;
};
Message creer_message() { /* ... */ }
// ...
int main() {
   auto msg = creer_message();
   switch(msg.lequel) {
   case type_message::entier:
      cout << "Entier de valeur " << msg.contenu.n << endl;
      break;
   case type_message::texte:
      cout << "Texte \"" << msg.contenu.s << "\"" << endl;
      break;
   case type_message::reel:
      cout << "Réel de valeur " << msg.contenu.d << endl;
      break;
   default:
      throw "Message corrompu";
   };
}
#include <string>
#include <iostream>
#include <variant>
using namespace std;
using Message = std::variant<int, string, double>;
Message creer_message() { /* ... */ }
// ...
struct Visiteur {
   void operator()(int n) const {
      cout << "Entier de valeur " << n << endl;
   }
   void operator()(const string &s) const {
      cout << "Texte \"" << s << "\"" << endl;
   }
   void operator()(double d) const {
      cout << "Réel de valeur " << d << endl;
   }
};
// ...
int main() {
   auto msg = creer_message();
   visit(Visiteur{}, msg);
}

L'approche par std::visit() est à la fois plus simple et moins risquée, du fait qu'elle impose de couvrir tous les types possibles pour un variant donné et qu'elle ne risque pas de se retrouver en situation d'étiquette incorrecte (le cas default dans l'exemple de gauche).

Si l'action à poser lors d'une visite est générique, alors les λ génériques de C++ 14 nous simplifient singulièrement l'existence :

#include <string>
#include <iostream>
#include <variant>
using namespace std;
using Message = std::variant<int, string, double>;
Message creer_message() { /* ... */ }
// ...
// ...
int main() {
   auto msg = creer_message();
   visit([](auto && val) { cout << val << endl; }, msg);
}

Ici, l'intention se limitant à projeter l'objet visité sur la sortie standard, sans égard à son type, une simple λ générique suffit amplement.

Une combinaison générique

Pour comprendre ce qui suit, mieux vaut se familiariser d'abord avec les templates variadiques.

Si créer une classe pour chaque visite d'un std::variant nous vous convient pas, il est possible de créer un visiteur adéquat à partir d'une agrégation d'expressions λ. Par exemple :

Avec visiteur « manuel » Avec combinaison de λ
 
template <class ... P> struct Combine : P... {
   Combine(P... ps) : P{ ps }... {
   }
   //
   // le using qui suit est nécessaire, mais peut ne pas être
   // supporté sur certains compilateurs en 2018
   //
   using P::operator()...;
};
template <class ... F>
   Combine<F...> combine(F... fs) {
      return { fs ... };
   }
#include <string>
#include <iostream>
#include <variant>
using namespace std;
using Message = std::variant<int, string, double>;
Message creer_message() { /* ... */ }
// ...
struct Visiteur {
   void operator()(int n) const {
      cout << "Entier de valeur " << n << endl;
   }
   void operator()(const string &s) const {
      cout << "Texte \"" << s << "\"" << endl;
   }
   void operator()(double d) const {
      cout << "Réel de valeur " << d << endl;
   }
};
// ...
int main() {
   auto msg = creer_message();
   visit(Visiteur{}, msg);
}
#include <string>
#include <iostream>
#include <variant>
using namespace std;
using Message = std::variant<int, string, double>;
Message creer_message() { /* ... */ }
// ...
int main() {
   auto msg = creer_message();
   visit(combine(
      [](int n) { cout << "Entier de valeur " << n << endl; },
      [](const string &s) { cout << "Texte \"" << s << "\"" << endl; },
      [](double d) { cout << "Réel de valeur " << d << endl; }
   ), msg);
}

Avec Combine et la fonction génératrice combine(), il devient possible de créer des classes de visite au besoin, sans faire d'effort particulier.

Qu'en est-il de la vitesse?

Un variant peut être envisagé comme alternative au polymorphisme quand l'ensemble des types possibles est fini et connu a priori (si l'ensemble est ouvert, le polymorphisme permet d'étendre cet ensemble plus aisément). Sachant cela, est-il utile de recourir à un variant pour accélérer l'exécution d'un programme?

Pour répondre à cette question, examinons un programme construit précisément pour comparer le coût des deux approches, à service égal.

Le code en exemple se limitera à des outils standards, ce qui vous permettra de le tester sur d'autres plateformes si le coeur vous en dit.

#include <iostream>
#include <iterator>
#include <vector>
#include <numeric>
#include <chrono>
#include <random>
#include <variant>
#include <utility>
#include <memory>
#include <algorithm>
using namespace std;
using namespace std::chrono;

Les tests de vitesse d'exécution se feront à l'aide de la fonction test() présentée à droite. Voir ../AuSecours/Mesurer-le-temps.html pour plus de détails.

template <class F, class ... Args>
auto test(F f, Args &&... args) {
   auto pre = high_resolution_clock::now();
   auto res = f(std::forward<Args>(args)...);
   auto post = high_resolution_clock::now();
   return make_pair(res, post - pre);
}

Je comparerai donc deux designs, l'un polymorphique et l'autre avec variant. Dans les deux cas, nous aurons des instances des classes X, Y et Z à droite.

struct Base {
   virtual int f() const = 0;
   virtual ~Base() = default;
};
struct X : Base {
   int f() const override { return 3; }
};
struct Y : Base {
   int f() const override { return 4; }
};
struct Z : Base {
   int f() const override { return 5; }
};

Dans la version polymorphique, nous utiliserons un vector<unique_ptr<Base>> et nous utiliserons la méthode f() à travers la classe parent.

Dans la version avec variant, nous utiliserons un vector<variant<X,Y,Z>> et nous visiterons le variant avec une λ générique du fait que la méthode f() portera (évidemment) le même nom dans les trois classes. Ceci n'est qu'un raccourci pour ne pas avoir à écrire plus de code de visite, et n'impacte pas le temps d'exécution.

Notez que j'ai pris soin de mélanger les objets dans chaque vecteur pour essayer d'empêcher un compilateur trop brillant de tout optimiser, mais les deux conteneurs auront en pratique le même nombre d'instances de X, de Y et de Z.

auto creer_poly(int n) {
   vector<unique_ptr<Base>> v;
   for (int i = 0; i != n; ++i) {
      v.emplace_back(make_unique<X>());
      v.emplace_back(make_unique<Y>());
      v.emplace_back(make_unique<Z>());
   }
   shuffle(begin(v), end(v), mt19937{ random_device{}() });
   return v;
}
auto creer_vari(int n) {
   vector<variant<X,Y,Z>> v;
   for (int i = 0; i != n; ++i) {
      v.emplace_back(X{});
      v.emplace_back(Y{});
      v.emplace_back(Z{});
   }
   shuffle(begin(v), end(v), mt19937{ random_device{}() });
   return v;
}

Le code de test s'assure d'instancier le vecteur à la construction des λ qui modélisent chaque test (je ne mesure donc pas le temps pour créer les X, les Y et les Z; ceci désavantagerait la version polymorphique qui doit avoir recours à de l'allocation dynamique de mémoire dans chaque cas). Le code de test est identique dans chaque cas, outre la manière d'appeler la fonction f() bien entendu.

Nous paierons donc dans un cas pour l'appel indirect à f() et le fait que les objets ne sont pas contigus en mémoire, et dans l'autre pour la visite (essentiellement : une sélective pour déterminer le type réellement logé dans le variant suivi d'un appel direct à f() sur l'objet entreposé).

int main() {
   enum { N = 1'000'000 };
   auto[res_poly, dt_poly] = test([v = creer_poly(N)]{
      return accumulate(begin(v), end(v), 0, [](auto && so_far, auto && p) {
         return so_far + p->f();
      });
   });
   auto[res_vari, dt_vari] = test([v = creer_vari(N)]{
      return accumulate(begin(v), end(v), 0, [](auto && so_far, auto && var) {
         return so_far + visit([](auto && obj) { return obj.f(); }, var);
      });
   });
   cout << "Avec la version polymorphique, res : " << res_poly << ", temps : "
        << duration_cast<microseconds>(dt_poly).count() << " us." << endl;
   cout << "Avec la version variante, res      : " << res_vari << ", temps : "
        << duration_cast<microseconds>(dt_vari).count() << " us." << endl;
}

Avec Visual Studio 2017.7 sur mon ordinateur portatif, j'obtiens ceci :

Avec la version polymorphique, res : 12000000, temps : 59633 us.
Avec la version variante, res      : 12000000, temps : 28393 us.

Avec wandbox.org, un g++ en ligne, j'obtiens ceci :

Avec la version polymorphique, res : 12000000, temps : 690296 us.
Avec la version variante, res      : 12000000, temps : 440922 us.

... ce qui est somme tout pas mal du tout.

Lectures complémentaires

Quelques liens pour enrichir le propos.

Le design de std::variant n'a pas été chose simple, en particulier pour ce qui est du sens à donner à un std::variant vide ou à l'état d'un std::variant si l'affectation d'une valeur y échoue (typiquement, si le constructeur de mouvement de la valeur qui y est affectée lève une exception). À cet effet :


Valid XHTML 1.0 Transitional

CSS Valide !