Using std::variant
A std::variant is a closed-discriminated union. Variants simply have internal memory for maximum size of the underlying types plus a fixed overhead to manage which alternative is used. No heap memory is allocated. The resulting object has value semantics. Copying a variant is implemented as a deep-copy, it creates a new variant object with the current value of the alternative in its own memory.
#include <iostream>
#include <variant>
#include <string>
int main(){
// initialized with string alternative
std::variant<int, std::string> var{"hi"};
std::cout << var.index() << "\n";
// now holds int alternative
var = 42;
std::cout << var.index() << "\n";
try{
int i = std::get<0>(var); // access by index
int j = std::get<int>(var); //access by type
std::string s = std::get<std::string>(var); //error
}catch(const std::bad_variant_access& e){
std::cerr << "Exception: " << e.what() << "\n";
}
return 0;
}Output:
The default constructor for std::variant always initializes the first type with the default constructor. If there is no default constructor for the first type, calling the default constructor for the variant is a compile-time error.
If we still want to have a std::variant, we can use the helper type std::monostate. They serve as a first alternative type to make the variant type default constructible. Objects of type std::monostate only have \(1\) state, so they always compare equal. To an extent, you can interprete this state as signalling emptiness.
// Ref: C++17 - The complete guide
// Nikolai Josuttis
#include <variant>
#include <print>
struct NoDefConstr{
NoDefConstr() = delete;
};
int main(){
// different ways to check for monostate
std::variant<std::monostate, NoDefConstr, int> v;
if(v.index() == 0){
std::println("has monostate");
}
if(!v.index()){
std::println("has monostate");
}
if(std::holds_alternative<std::monostate>(v)){
std::println("has monostate");
}
// get_if accepts a pointer to a variant and returns
// a `T*` if the alternative is T, else return
// nullptr
if(std::get_if<0>(&v)){
std::println("has monostate");
}
if(std::get_if<std::monostate>(&v)){
std::println("has monostate");
}
return 0;
}You can assign any other alternative and even assign std::monostate to the variant again.
std::variant types and operations
Construction
There are several different ways to construct and initialize a std::variant.
#include <variant>
#include <print>
#include <complex>
#include <set>
#include <initializer_list>
#include <vector>
struct NoCopyConstr{
NoCopyConstr(const NoCopyConstr&) = delete;
};
int main(){
// construction
// sets first int to 0, index() == 0
std::variant<int, int, std::string> v1;
// if a value is passed for initialization,
// the best matching type is used
std::variant<long,int> v2{42};
std::println("v2.index() = {}", v2.index());
// the call is ambiguous, if two types match equally well
//std::variant<long, long> v3{42}; // error
// 42.3 is a double and requires narrowing conversion
// when assigned to int or float.
// Neither is better.
//std::variant<int, float> v4{42.3}; // error
//std::variant<std::string, std::string_view> v5{"hello"}; //error
// std::in_place_type or std::in_place_index
// is used to pass more than one value for initialization
std::variant<std::complex<double>> v6{
std::in_place_type<std::complex<double>>, 3.0, 4.0
};
std::variant<std::complex<double>> v7{
std::in_place_index<0>, 1.0, -1.0
};
// in_place_index tags can be used to resolve
// ambiguities or overrule priorities during
// initialization
std::variant<int, int> v8{std::in_place_index<1>, 77};
std::println("v8.index() = {}", v8.index());
// pass an initializer list followed by additional
// arguments
auto sc = [](int x, int y){
return std::abs(x) < std::abs(y);
};
std::variant<std::vector<int>,
std::set<int,decltype(sc)>> v9{
std::in_place_index<1>, {4, 8, -7, -2, 0, 5}, sc
};
// there is no make_variant convenience function
// you can copy variants provided all alternatives
// support copying.
std::variant<int, double> v10{42.3};
std::variant<int, double> v11{v10};
std::variant<int, NoCopyConstr> v12;
// std::variant<int, NoCopyConstr> v13{v12};
return 0;
}Accessing the stored value
Changing the value
There are \(4\) ways to change the current value of the variant:
- the assignment operator
emplacegetand then assign a new value for the currently active type.- a visitor.
#include <iostream>
#include <variant>
#include <string>
int main(){
// initialized with string alternative
std::variant<int, std::string> var{"hi"};
std::cout << var.index() << "\n";
// now holds int alternative
var = 42;
std::cout << var.index() << "\n";
try{
int i = std::get<0>(var); // access by index
int j = std::get<int>(var); //access by type
std::string s = std::get<std::string>(var); //error
}catch(const std::bad_variant_access& e){
std::cerr << "Exception: " << e.what() << "\n";
}
// Changing the value
// assignment and emplace() operations can be used to
// modify the value in the variant.
// operator=() directly assigns the new value,
// if the variant currently holds the alternative.
// emplace() first destroys the old value and then assigns
// the new value.
//sets first int to 0, index() == 0
std::variant<int, int, std::string> var2;
var2 = "hello";
var2.emplace<1>(42);
// get<>() or get_if<>() can also be used to assign
// a new value to the current alternative.
std::get<0>(var2) = 77;
std::get<1>(var2) = 99;
return 0;
}Comparisons
#include <iostream>
#include <variant>
#include <string>
int main(){
// for 2 variants of the same type (i.e. having
// the same alternatives), you can use the usual
// comparison operators. The following rules apply.
// - a variant with a value of an earlier alternative
// is less than a variant with value with a later alternative.
// if two variants have the same alternative, the corresponding
// operators for the type of the alternatives is invoked.
std::variant<std::monostate, int, std::string> v1, v2{"hello"}, v3{42};
std::variant<std::monostate, std::string, int> v4;
//std::cout << v1 == v4; // compile-time error
std::cout << "\n" << (v1 == v2); // false
std::cout << "\n" << (v1 < v2); // true
std::cout << "\n" << (v1 < v3); // true
std::cout << "\n" << (v2 < v3); // false
v1 = "hello";
std::cout << "\n" << (v1 == v2); // false
v2 = 41;
std::cout << "\n" << (v2 < v3);
return 0;
}Visitors
Visitors are simply objects that provide a function call operator operator=() for each possible type. When a visitor object visits a variant, they invoke the best matching function call operator for the actual value of the variant.
#include <variant>
#include <string>
#include <iostream>
struct MyVisitor{
void operator()(int i) const{
std::cout << "int: " << i << "\n";
}
void operator()(std::string s) const{
std::cout << "string: " << s << "\n";
}
void operator()(double d) const{
std::cout << "double: " << d << "\n";
}
};
int main(){
std::variant<int, std::string, double> var(42);
std::visit(MyVisitor(), var); // calls operator() for int
var = "hello";
std::visit(MyVisitor(), var);
var = 42.7;
std::visit(MyVisitor(), var);
return 0;
}Using generic lambdas as visitors
#include <variant>
#include <string>
#include <iostream>
auto printVisitor = [](const auto& v){
std::cout << "\n" << v;
};
int main(){
std::variant<int, std::string, double> var(42);
std::visit(printVisitor, var); // calls operator() for int
var = "hello";
std::visit(printVisitor, var);
var = 42.7;
std::visit(printVisitor, var);
return 0;
}The cracked-man’s match
#include <variant>
#include <string>
#include <iostream>
template<typename... Ts>
struct overload : Ts...{
using Ts::operator()...;
};
// base types are deduced from the passed arguments
template<typename... Ts>
overload(Ts...) -> overload<Ts...>;
int main(){
std::variant<int, std::string> var(42);
std::visit(overload{ // calls best matching lambda for current alternative
[](int i) { std::cout << "int: " << i << '\n'; },
[](const std::string& s) {
std::cout << "string: " << s << '\n'; },
},
var);
}You might wonder why we need to bring the function call operators of the closure types (__my_lambda_with_int_param_struct and __my_lambda_with_string_param_struct) in scope.
As per overload resolution rules, when we have a Derived class as a subtype of two base classes Base1 and Base2, and Base1 implements operator()(int), whilst Base2 implements operator()(std::string), both these method are inherited by Derived. Whilst both these candidates are a part of the overload set, overload resolution for overloaded operators differs from regular functions. In the case of overloaded operators, the argument list for the purpose of overloaded resolution has the implied object argument of type cv Derived. In which case, all base class methods will also be present, when the candidate set is trimmed to the viable set, and the result is ambiguous.
Polymorphism and inhomogenous collections with std::variant
std::variant enables a new form polymorphism and allows dealing with inhomogenous collections. It is a form of compile-time polymorphism with a closed set of data-types.
#include <variant>
#include <string>
#include <iostream>
#include <vector>
#include <print>
struct Point{
double x;
double y;
};
struct Line{
Point head;
Point tail;
void draw() const { std::println("Rendering line"); }
};
struct Circle{
Point center;
double radius;
void draw() const { std::println("Rendering circle"); }
};
struct Rectangle{
Point origin;
Point size_vector;
void draw() const { std::println("Rendering rectangle"); }
};
// common type of all geometric objects
using GeoObj = std::variant<Line, Circle, Rectangle>;
std::vector<GeoObj> createFigure(){
std::vector<GeoObj> fig{};
fig.push_back(Line{
.head = Point{1,2},
.tail = Point{3,4}
});
fig.push_back(Circle{
.center = Point{5,5},
.radius = 2.0
});
fig.push_back(Rectangle{
.origin = Point{3,3},
.size_vector = Point{6,4},
});
return fig;
}
int main(){
std::vector<GeoObj> figure = createFigure();
for(const GeoObj& geoobj : figure){
std::visit([](const auto& obj){
obj.draw();
}, geoobj);
}
}The three types Line, Circle and Rectangle don’t have any special relationship. In fact, they do not need to have a common base class, no virtual functions and their interfaces might even differ.
The code above would not be possible with runtime polymorphism, because then the types would need to have GeoObj as a common base class and we would need a vector of pointers of GeoObj elements. Moreover, runtime polymorphism is costly as memory needs to be dynamically allocated and there are two levels of indirection.
While compiling the std::visit() call, t the lambda gets instantiated into \(3\) separate functions:
The pros and cons of std::variant polymorphism
Pros:
- You don’t need common base types.
- You don’t have to use pointers for inhomogenous collections.
- No need for
virtualmember functions. - Value semantics(no access of freed memory or memory leaks).
- Elements in a vector are stored in contiguous memory locations. You don’t have to chase pointers in heap memory, resulting in fewer cache misses.
Constraints and drawbacks:
- A closed set of types(you have to know all the alternatives at compile-time).
- Elements of the vector all have the size of the biggest element type(an issue if the element sizes differ a lot).
- Copying elements might be more expensive.