Bonjour à tous ! L’article d’aujourd’hui clôture le trio entamé par std::any et std::optional, avec std::variant, introduit dans le standard depuis C++17.
Introduction
std::optional<T> est encore une fois basée sur son homologue boost:optional<T>. Ce type vise à permettre la création d’union type-safe.
Une union, pour rappel, est une construction héritée du C permettant de créer des types a états :
1 2 3 4 5 6 7 8 9 10 11 12 13 |
union U { double d; int i; char c; }; std::cout << sizeof(U) << '\n'; // affiche max(sizeof(double), sizeof(int), sizeof(char); U u{ 2.0 }; // u contient un double, u.d est le membre actif. std::cout << u.d << '\n'; // affiche: 2.0 u.i = 25; // u contient maintenant un int, u.i est le membre actif. std::cout << u.i << '\n'; // affiche: 25 std::cout << u.d << '\n'; // undefined behavior, u.d n'est pas le membre actif. |
Les unions ont deux gros problèmes. Premièrement, elles ne sont pas type-safe, dans le sens où il n’est pas possible de tester quel est le type contenu ou d’avoir une erreur en cas de mauvaise manipulation.
De plus, les unions ne sont pas faite pour travailler avec des types complexes et ne gèrent pas la destruction des objets. Je vous propose ci-dessous l’exemple de cppreference qui illustre bien la problématique :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
#include <iostream> #include <string> #include <vector> union S { std::string str; std::vector<int> vec; ~S() {} // needs to know which member is active, only possible in union-like class }; // the whole union occupies max(sizeof(string), sizeof(vector<int>)) int main() { S s = {"Hello, world"}; // at this point, reading from s.vec is undefined behavior std::cout << "s.str = " << s.str << '\n'; s.str.~basic_string(); new (&s.vec) std::vector<int>; // now, s.vec is the active member of the union s.vec.push_back(10); std::cout << s.vec.size() << '\n'; s.vec.~vector(); } |
Ici, il est nécessaire d’effectuer des appels explicites aux destructeurs et des placement new de manière à faire fonctionner correctement l’union.
C’est ici qu’intervient std::variant, qui est une union type-safe qui gère elle même la durée de vie des objets lors du changement du type contenu. Voici le même exemple avec std::variant :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#include <iostream> #include <string> #include <vector> #include <iostream> int main() { std::variant<std::string, std::vector<int>> s{ "Hello World" }; // s contient une std::string. std::cout << std::get<0>(s) << '\n'; s = std::vector<int>{}; // changement de type, le destructeur de la std::string contenue est appelé. std::get<1>(s).push_back(10); if(std::holds_alternative<std::vector<int>>(v)) // Possibilité de tester le type actuel. std::cout << std::get<1>(s).size() << '\n'; // appel au destructeur du variant, qui se charge de détruire l'objet actuellement contenu. } |
std::variant en détail
Création d’un std::variant
Comme pour std::any<T> et std::optional<T>, std::variant<T> propose un certain nombre de méthodes de création :
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// construction sans initialisation. std::variant<int, double> v1; // v1 contient un int construit par défaut. std::variant<int, double> v2{ 23.3 }; // initialisation avec une valeur, v2 contient un double. // copie. auto v3 = v1; std::variant v4 = v3; // construction 'in_place'. std::variant<MyType, double> v5{std::in_place_index<0>, getSomething(), 12.0, "Salut"}; std::variant<int, char, int> v6{std::in_place_index<int>, 1}; |
Notez qu’ici, les deductions guides ne permettent pas d’inférer le type du variant lors de la construction.
std::variant propose aussi std::monostate, qui est le type choisi lorsque aucun autre type n’est initialisé, notamment lorsque les types ne sont pas constructibles par défaut :
1 2 3 4 5 |
struct NoDefaultCtor { NoDefaultCtor() = delete; }; std::variant<std::monostate, NoDefaultCtor> v; |
Deux std::monostate sont toujours égaux.
Récupérer la valeur contenue
Pour récupérer la valeur d’un std::variant, il y a trois choix : std::get, std::get_if et std::visit (qui sera abordé un peu plus tard puisque son cas est particulier).
std::get permet d’accéder à la valeur voulue soit en spécifiant le type souhaité, soit l’index de ce type (dans les paramètres template du std::variant) :
1 2 3 4 5 6 7 8 9 10 11 12 |
std::variant<int, double> v{ 2 }; int i = std::get<int>(v); int i = std::get<0>(v); // auto s = std::get<2>(v); : error: index out of bound. try { auto d = std::get<double>(v); } catch (std::bad_variant_access& e) { std::cout << e.what() << std::endl; } |
Si le type demandé n’est pas le type actuellement contenu, une exception std::bad_variant_access est levée.
std::get_if retourne un pointeur sur l’objet demandé. Si le type demandé n’est pas le bon, nullptr est retourné :
1 2 3 4 5 6 7 8 9 |
std::variant<int, double> v{ 2.0 }; if(auto d = std::get_if<double>(&v)) { std::cout << *d << std::endl; } else { std::cout << "bad type." << std::endl; } std::cout << *(std::get_if<1>(&v)) << std::endl; |
Modifier la valeur
L’affectation est possible sur std::variant :
1 2 3 4 5 6 7 |
std::variant<int, std::string> v{ "Salut" }; std::variant u{ v }; v = 12; // v contient maintenant un int. v = u; // v contient à nouveau une string. u = "Hello"; // modification de la string contenue dans u. |
std::get et std::get_if permettent de modifier la valeur, l’un retournant une référence, le second une constante :
1 2 3 4 5 6 7 |
std::variant<int, char> v{ 'a' }; std::get<1>(v) = 'b'; *std::get_if<1>(&v) = '\a'; v = 12; std::get<int>(v)++; |
La fonction membre emplace permet de remplacer la valeur courante in-place :
1 2 3 4 |
std::variant<int, double, Something> v{ 2.0 }; v.emplace<int>(3); v.emplace<2>(255, 245, 198); |
Visiteur
Nous arrivons maintenant à la fameuse fonction std::visit.
std::visit prends un visiteur en paramètre. Le visiteur doit posséder une surcharge pour chaque type possible du std::variant. Par conséquent, les foncteurs, les lambdas ou encore std::function peuvent être passés en paramètre ( visit attends un Callable) :
1 2 3 4 5 6 7 8 9 10 |
std::variant<char, int, float, double, std::string, std::vector<int>> v; std::visit(MyVisitor{}, v); auto printVisitor = [](const auto& v) { std::cout << v << std::endl; }; auto doubleVisitor = [](const auto& v) { return static_cast<int>(v) * 2; } std::variant<int8_t, long, float> v2; std::visit(printVisitor, v2); std::visit(doubleVisitor, v2); |
Une fonction membre générique dans un foncteur ou une lambda générique permettent de traiter plusieurs cas en même temps.
La fonction std::visit peut prendre plusieurs std::variant en paramètre. Dans ce cas, le visiteur doit être capable de gérer tous les types de toutes les std::variant. Depuis C++20, il existe également une surcharge de std::visit permettant de préciser le type de retour (qui prime alors sur le type déduit).
Il y a également une proposition en cours pour std::overload, qui permettrait de facilité l’écriture des foncteurs :
1 2 3 4 5 6 7 |
auto visitor = overload( [](int i, int j) { ... }, [](int i, string const &j) { ... }, [](auto const&i, auto const& j) { ... }, ); visitor(1, std::string{"2"} ); // ok - calls (int, std::string) "overload" |
Cette fonction pourra être simplement utilisée avec std::variant :
1 2 3 4 5 6 |
std::variant<char, int, float, double, std::string, std::vector<int>> v; std::visit(std::overload( [](const std::string& s) { std::cout << std::quoted(s) << '\n'; }, [](const std::vector<int>& v) { std::cout << v.size() << '\n'; }, [](const auto& a) { std::cout << a << '\n'; } ), v); |
Notez que dans ce cas, chaque lambda peut avoir ses propres captures ce qui n’est pas possible avec un foncteur.
Cet article propose une implémentation en attendant la validation de la proposition, et explique d’ailleurs comment s’en servir pour simuler du pattern matching :
1 2 |
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; }; template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>; |
Autres
Pour conclure cette section, je vous propose de passer en revue les autres opérations possibles sur std::variant.
La fonction membre index retourne la position du type actuellement contenu :
1 2 3 4 5 |
std::variant<std::string, int> v{ "Hello" }; std::cout << v.index() << std::endl; // Affiche 0. v = 12; std::cout << v.index() << std::endl; // Affiche 1. |
La fonction std::holds_alternative retourne un booléen déterminant si un type particulier est actuellement actif :
1 2 3 4 5 6 7 |
std::variant<std::string, double> v{ 2.0 }; std::cout << std::boolalpha << std::holds_alternative<std::string>(v) << std::endl << std::holds_alternative<double>(v); // affiche : true false |
La fonction membre valueless_by_exception retourne un booléen permettant de savoir si le std::variant est actuellement dans un état invalide. En effet, si une exception a lieu lors de l’affectation par copie ou par mouvement, le variant est garanti d’être dans un état invalide. L’état du variant peut optionnellement être invalide si une exception survient pendant une affectation ou un appel à emplace changeant le type contenu.
Si un std::variant est dans un état invalide (c’est-à-dire si valueless_by_exception retourne true), index retourne std::variant_npos et std::get et std::visit lèvent std::bad_variant_access.
La comparaison entre des std::variant permet de comparer le type contenu entre deux variant ayant la même liste de paramètres templates. Cette page sur cppreference.com résume les règles de comparaison, qui prennent en compte si le variant est dans un état invalide, si le type contenu est le même et qui le cas échéant effectuent la comparaison sur l’objet contenu (si les objets ne peuvent être comparé, il s’agit d’un undefined behavior).
Les traits std::variant_size et std::variant_alternative (et les helpers correspondant), premettent respectivement de récupérer le nombre de types possibles et le type d’un index spécifique.
std::variant dispose aussi d’une spécialisation pour std::hash et std::swap.
Cas d’usage
En terme d’empreinte mémoire, std::variant occupe au minimum la taille du type le plus large possible. On remarque que de l’espace additionnel est nécessaire pour stocker les informations sur le type actuel et que la taille varie en fonction de l’alignement :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
std::cout << "sizes: \n" << "int: " << sizeof(int) << '\n' << "float: " << sizeof(float) << '\n' << "double: " << sizeof(double) << '\n' << "std::string: " << sizeof(std::string) << '\n' << "std::vector<int>: " << sizeof(std::vector<int>) << "\n\n"; std::cout << "std::variant<int, float>: " << sizeof(std::variant<int, float>) << '\n' << "std::variant<int, double>: " << sizeof(std::variant<int, double>) << '\n' << "std::variant<int, std::string>: " << sizeof(std::variant<int, std::string>) << '\n' << "std::variant<int, std::vector<int>>: " << sizeof(std::variant<int, std::vector<int>>) << '\n' << "std::variant<int, double, std::vector<int>>: " << sizeof(std::variant<int, double, std::vector<int>>) << "\n\n"; |
1 2 3 4 5 6 7 8 9 10 11 12 13 |
sizes (GCC 8.1): int: 4 float: 4 double: 8 std::string: 32 std::vector<int>: 24 std::variant<int, float>: 8 std::variant<int, double>: 16 std::variant<int, std::string>: 40 std::variant<int, std::vector<int>>: 32 std::variant<int, double, std::vector<int>>: 32 |
Comme une union classique, aucun espace additionnel n’est alloué par std::variant lors des différentes opérations.
D’une manière général, std::variant est un choix judicieux dès lors que vous savez que vous aller manipuler un ensemble fini de types. Si l’ensemble est potentiellement infini, dirigez-vous plutôt vers std::any.
std::variant est donc à considérer comme une union type-safe, qui gère la durée de vie des objets en appelant les destructeurs au bon moment.
Je vous remercie pour votre lecture et je vous dis à la prochaine fois =).
You must log in to post a comment.