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().
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
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 :
- Si vous ne pouvez pas utiliser C++ 17,
Augustín Bergé a produit ce variant pour C++ 11
et C++ 14 :
- La vision d'Augustín Bergé sur std::variant, un
texte de 2015 :
http://talesofcpp.fusionfenix.com/post-21/rant-on-the-stdexperimentalvariant-to-come
- Réflexion de
Louis Dionne en 2015, à propos des
mathématiques associées aux instances vides de
std::variant et à
std::tuple :
http://ldionne.com/2015/07/14/empty-variants-and-tuples/
- Réflexions d'Anthony
Williams, aussi en 2015, sur le support d'un
std::variant vide :
https://www.justsoftwaresolutions.co.uk/cplusplus/standardizing-variant.html
- En 2015, David Sankel discute du design de ce
que devrait selon lui être std::variant :
http://davidsankel.com/c/a-variant-for-the-everyday-joe/
- Design d'un variant vide, la vision derrière le
design de Boost :
http://www.boost.org/doc/libs/1_59_0/doc/html/variant/design.html#variant.design.never-empty.memcpy-solution
- En 2015, Axel Naumann fait le point sur le
design de std::variant :
https://isocpp.org/blog/2015/11/the-variant-saga-a-happy-ending
- Texte de 2016 par Jonathan Müller, qui présente
son implémentation « maison » d'un variant :
http://foonathan.net/blog/2016/12/28/variant.html
- Comparaisons du
polymorphisme dynamique classique à celui
permis par le recours à std::variant :
- Arne Mertz discute de std::variant et de
std::visit() dans ce texte de 2018 :
https://arne-mertz.de/2018/05/modern-c-features-stdvariant-and-stdvisit/
- Textes de Bartlomiej Filipek :
- En 2018, Alfredo Correa explique comment implémenter un
Visiteur avec type de retour
covariant sur la base de std::variant :
https://arne-mertz.de/2018/06/functions-of-variants-are-covariant/
- Texte de 2018 par Edaqua Mortoray, qui relate
comment il utilise un variant dans un
implémentation du compilateur pour son langage Leaf :
https://mortoray.com/2018/05/25/how-i-use-variant-in-the-leaf-compiler/
- Composition de types comme
optional<T>, expected<T,E> et
variant, un texte de Jonathan Müller en 2018 :
https://foonathan.net/2018/12/nested-optionals-expected/
- Chic truc proposé par Zhihao Yuan en 2020 pour
alléger l'utilisation de variant :
https://simpleroseinc.github.io/2020/02/29/create-a-new-type.html