Dynamic Data Types using std::variant and Unions

A guide to storing one of several different data types within the same container, using unions and variants
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

3D Character Concept Art
Ryan McCombe
Ryan McCombe
Posted

Often, we’ll come across scenarios where we need a variable to contain one of several possible data types. For example, we may need a variable that can store either an int or a float.

We could solve this problem with a class or struct, that simply stores our types as different members:

struct Number {
  int i;
  float f;
};

But this is a little wasteful of resources. Creating an object from this struct requires the compiler to allocate enough memory to store both an int and a float. But our use case doesn’t require that - we only need to store either an int or a float.

Allocating memory for the combination of all the types is unnecessary, and it gets increasingly wasteful when our struct is declaring more, or larger, types.

Instead, we solve problems like these using unions.

Unions

To solve this, the concept of a union was conceived. The syntax of a union is very similar to that of a struct or class:

union Number {
  int i;
  float f;
};

We then construct and use a union type in the same way we would a class or struct:

#include <iostream>

union NumberUnion {
  float f;
  int i;
};

int main(){
  NumberUnion Number;

  Number.f = 9.8;
  std::cout << "Float: " << Number.f;

  Number.i = 42;
  std::cout << "\nInt: " << Number.i;
}
Float: 9.8
Int: 42

Unions can even have member functions, including constructors and destructors, although in practice that is rarely used.

The main purpose of a union is what we introduced - the ability to store a variable of a dynamic type, without using unnecessary memory.

Reasons to Avoid Unions (Type Safety)

Unions solve the memory problem by only allocating enough memory for one of the types we have in the declaration. If our types have different sizes, the compiler will allocate enough memory to store the largest type, to ensure any object we assign to the union can be accommodated.

This shared memory concept can be problematic. If the memory is currently being used to store one of the types in our union, but we access another type, problems can ensue.

Below, we’ve deleted a line of code from our previous example. As a result, we’re now accessing the int form of our variant, when what is stored in the memory address is intended to represent a float:

#include <iostream>

union NumberUnion {
  float f;
  int i;
};

int main(){
  NumberUnion Number;

  Number.f = 9.8;
  std::cout << "Float: " << Number.f;

  Number.i = 42; 
  std::cout << "\nInt: " << Number.i;
}

The compiler gives us no protection here - instead, our code just runs in a way that could be dangerously wrong:

Float: 9.8
Int: 1092406477

Because of this, the language’s native implementation of unions is said not to be type-safe. We should instead use type-safe unions where possible.

Type-safe unions also use the fundamental shared-memory concept, but incorporate guard rails that make the class of bug from our previous example much less likely to occur.

The standard library’s implementation of a type-safe union is std::variant, which is a template class we spend the rest of this lesson covering.

Constructing Variants

To construct a std::variant, we #include the <variant> header, and then invoke the template in the usual way. The arguments we pass to the template are the various types our variable can have. Within the std::variant, these types are referred to as alternatives.

Below, we create a variant that can contain an int, a float, or a bool.

#include <variant>

int main(){
  std::variant<int, float, bool> MyVariant;
}

When default-constructed, the initial state of the std::variant is set by default-constructing the first type in the template argument list.

In the above example, the initial state of MyVariant is derived from default-constructing an int. As such, it will contain an int with a value of 0.

If the first type in the template argument list is not default-constructible, the std::variant will also not be default-constructible:

#include <variant>

struct MyType {
  MyType() = delete;
};

int main(){
  std::variant<MyType, int, bool> MyVariant;
}
error: std::variant<MyType, int, bool> MyVariant;
no appropriate default constructor available

Assigning Variant Values

We can provide an initial type and value for our variant by passing it as a constructor argument. Below, our variant will be a float with a value of 9.8:

#include <variant>

int main(){
  std::variant<int, float, bool> Variant{9.8f};
}

We can update the variant, including its type if needed, using the basic assignment operator =:

#include <variant>

int main(){
  std::variant<int, float, bool> Variant{9.8f};

  Variant = 42;
  Variant = true;
}

We also have access to the emplace() method. Similar to other containers, emplace() constructs an object directly into our container.

With more complex types, this is more performant than constructing an object outside of the container, and then moving or copying it into the std::variant.

The emplace() method accepts the type we’re constructing as a template argument and a list of function arguments to forward to the constructor of that type.

#include <variant>
#include <string>

class Character {
public:
  Character(std::string Name){/* ... */}
};

int main(){
  std::variant<int, Character> Variant;
  Variant.emplace<Character>("Roderick");
}

Instead of passing a type as the template argument, we can alternatively pass a zero-based index. This index maps to the types we specified in the template arguments of our std::variant.

In this example, our std::variant template parameters are int followed by Character. So, int corresponds to the index 0 whilst Character corresponds to 1.

As such, the following example has updated our previous code by replacing the type Character with the index 1, which yields the same output:

#include <variant>
#include <string>

class Character {
public:
  Character(std::string Name){/* ... */}
};

int main(){
  std::variant<int, Character> Variant;
  Variant.emplace<1>("Roderick");
}

Getting Variant Values using std::get()

To retrieve the value in our std::variant, we use the std::get function. We pass the type we expect to get as a template argument, and our variant as a function argument:

#include <variant>
#include <iostream>

int main(){
  std::variant<int, float, bool> Variant{9.8f};
  std::cout << std::get<float>(Variant);
}
9.8

This provides type safety as our program will ensure the type we passed as a template argument to std::get() matches the type currently stored in our variant.

If they don’t match, an exception is thrown.

Below, we update our variant to contain an int, and then use it as if it were still holding a float:

#include <variant>
#include <iostream>

int main(){
  std::variant<int, float, bool> Variant{9.8f};
  Variant = 2;

  std::cout << std::get<float>(Variant);
}

Exception: std::bad_variant_access

The specific exception type that is thrown when access to a std::variant fails is std::bad_variant_access.

We cover exceptions in detail in a dedicated chapter later in the course. For now, the following code gives us a simple example of how to detect a std::bad_variant_access exception using try and catch blocks:

#include <variant>
#include <iostream>

int main(){
  std::variant<int, float, bool> Variant{9.8f};
  Variant = 2;

  try {
    std::cout << std::get<float>(Variant);
  } catch (const std::bad_variant_access& e) {
    std::cout << "It wasn't storing a float";
  }
}
It wasn't storing a float

Getting Pointers to Variant Values using get_if()

The std::get_if() function gives us a type-safe way to generate a pointer to the value stored in our variant. The function receives the type we’re expecting as a template argument, and the address of our variant as a function argument.

std::get_if() will not throw an exception if our type is incorrect. Instead, it will generate a nullptr which we can test for in the usual ways:

#include <iostream>
#include <variant>

void Log(float* ptr){
  if (ptr) {
    std::cout << "Float: " << *ptr;
  } else {
    std::cout << "\nNot a float - got nullptr";
  }
}

int main(){
  std::variant<int, float, bool> Variant{9.8f};
  Log(std::get_if<float>(&Variant));

  Variant = 2;
  Log(std::get_if<float>(&Variant));
}
Float: 9.8
Not a float - got nullptr

Checking if our variant matches a specific type using std::holds_alternative()

We can directly test if our variant is currently holding a specific type using the std::holds_alternative() function. It receives the type we want to test for as a template argument, and the variant we’re testing as a function argument:

#include <variant>
#include <iostream>

int main(){
  std::variant<int, float, bool> Variant{9.8f};
  Variant = 2;

  if (std::holds_alternative<int>(Variant)) {
    std::cout << "It's holding an int";
  }

  if (!std::holds_alternative<float>(Variant)) {
    std::cout << ", not a float";
  }
}
It's holding an int, not a float

Getting what type our variant is storing using index()

std::variant objects also have access to the index() method, which lets us determine what type it’s currently storing.

The value returned from index() is a numeric, zero-based index that corresponds to the argument list provided when constructing our std::variant.

In the following example, our variant initially holds an int, and int is the first argument we provided to the template, so index() returns 0.

When we update our variant to hold a float - the second index in our argument list - index() returns 1:

#include <variant>
#include <iostream>

int main(){
  std::variant<int, float, bool> Variant;
  std::cout << "Index: " << Variant.index();
  Variant = 3.14f;
  std::cout << "\nIndex: " << Variant.index();
}
Index: 0
Index: 1

Using the Visitor Pattern with std::visit()

The visitor pattern is a concept that involves separating algorithms from the objects those algorithms work on. This is easier to introduce with an example:

#include <iostream>
#include <variant>

struct Visitor {
  void operator()(int i){
    std::cout << "It's an integer: " << i;
  }

  void operator()(float f){
    std::cout << "It's a float: " << f;
  }

  void operator()(bool b){
    std::cout << "It's a bool: " << b;
  }
};

int main(){
  std::variant<int, float, bool> Variant{3.14f};
  std::visit(Visitor{}, Variant);
}
It's a float: 3.14

Here, we have a series of simple algorithms that simply log out different data types. These are defined within a Visitor struct. This struct overloads the () operator for various different argument types.

Because of this overloading, if we create an object from this struct, we can use the () operator with that object. This effectively lets us use it like we would a function:

struct Visitor {
  void operator()(int i){ /*...*/ }
};

int main(){
  Visitor FunctionObject;
  FunctionObject(42);
}

Such an object is called a function object or a functor. We cover this in more detail in our dedicated chapter on functions, later in the course.

Looking back at our original code using std::visit, we can note our Variant object can contain one of three types.

Additionally, the objects we create from our Visitor struct are “callable” with any of those types, as we’ve overloaded the () operator to handle each of them.

Therefore, we can pass both of those objects to std::visit()

This allows our code to be successfully decoupled - our function object and its algorithms aren’t dependent upon our variant, and our variant isn’t dependent upon our function object.

As such, they can both exist independently of the other. But, with the visitor pattern and std::visit, they can also interact with each other to create more complex behaviors.

Comparing Variants

When two std::variant objects are the same type - that is, they have the same template parameter list - we can compare them using the standard suite of comparison operators, such as ==, <, and >=.

Additionally, when the two variants we are comparing are currently storing the same type - that is index() would return the same value for both variants - the comparison call is forwarded to that underlying type.

Below, we’re effectively comparing two floats:

#include <iostream>
#include <variant>

int main(){
  std::variant<int, float, bool> A{9.8f};
  std::variant<int, float, bool> B{3.14f};
  if (A > B) { std::cout << "A > B"; }
}
A > B

Less usefully, when two variants are storing different types - ie their index() methods would return different values - the comparison operators compare those indices.

Below, variant A is storing a float, which is index 1. Variant B is storing an int, which is index 0. As such, A is “greater than” B:

#include <iostream>
#include <variant>

int main(){
  std::variant<int, float, bool> A{9.8f};
  std::variant<int, float, bool> B{10};
  if (A > B) { std::cout << "A > B"; }
}
A > B

Default Constructing Variants using std::monostate

Earlier in this lesson, we saw how a std::variant could not be default-constructed if the first type in its template argument list is not default-constructible.

Sometimes, we need a way to bypass that. One option is to simply include an additional, default-constructible type as the first argument.

Any type could work, but std::monostate was specifically introduced for this purpose. It’s a simple type, which is widely understood to denote an “empty” type for use in variants:

#include <variant>

struct T1 {
  T1() = delete;
};

struct T2 {
  T2() = delete;
};

int main(){
  std::variant<std::monostate, T1, T2> Variant;
}

std::monostate is a type like any other. As such, we have to account for its existence when using methods like index(). Similarly, when using the visitor pattern, our callable needs to be able to handle the possibility that our variant is “empty”:

#include <iostream>
#include <variant>

struct T {
  T() = delete;
};

struct Visitor {
  void operator()(const std::monostate&){
    std::cout << "It's empty";
  }

  void operator()(const T&){
    std::cout << "It's a T";
  }
};

int main(){
  std::variant<std::monostate, T> Variant;
  std::visit(Visitor{}, Variant);
}
It's empty

Valueless Variants By Exception

In addition to the std::monostate scenario, our variants can also become “valueless” by way of an exception. This is sometimes caused when an exception is thrown in the process of updating our variant’s value.

In this valueless state, std::get() and std::visit() will throw a std::bad_variant_access exception. Additionally, the index() method will return an object that is equal to std::variant_npos.

The valueless_by_exception() method lets us specifically test for this scenario, returning true if we’re in the error state.

The following example creates this situation by throwing an exception during an emplace() call:

#include <variant>
#include <iostream>

struct GetInt {
  operator int(){ throw 0; }
};

int main(){
  std::variant<int> Variant;
  try {
    Variant.emplace<int>(GetInt());
  } catch (...) { }

  if (Variant.valueless_by_exception()) {
    std::cout << "Variant is valueless";
  }

  if (Variant.index() == std::variant_npos) {
    std::cout << "\nindex() returned npos";
  }

  try {
    std::get<int>(Variant); 
  }  catch (const std::bad_variant_access& e) {
    std::cout << "\nstd::get threw exception";
  }

  Variant = 3;
  if (!Variant.valueless_by_exception()) {
    std::cout << "\n\nVariant has a value now";
  }
}
Variant is valueless
index() returned npos
std::get threw exception

Variant has a value now

Variant Type Traits

When creating advanced templates for use with variants, the <variant> header includes two type traits we may find useful.

These type traits follow the same principles and patterns as those that we covered earlier in the course:

std::variant_size()

The std::variant_size() type trait returns the number of types (or “alternatives”) a variant type contains. If our variant includes a std::monostate type, this is included in the count.

To access this count, we pass the type as a template argument and then access the static value variable.

Alternatively, we can access the value directly using std::variant_size_v. The following two expressions are equivalent:

template <typename T>
void Handle(T Variant){
  std::variant_size<T>::value;
  std::variant_size_v<T>;
}

Below, we show this in use, by invoking our template with the std::variant<int, float, bool> type:

#include <variant>
#include <iostream>

template <typename T>
void Handle(T Variant){
  std::cout << "Number of alternatives: "
    << std::variant_size_v<T>;
}

int main(){
  std::variant<int, float, bool> Variant;
  Handle(Variant);
}
Number of alternatives: 3

std::variant_alternative()

The std::variant_alternative type trait lets us determine each individual alternative type in our variant’s template parameter list. We pass two template arguments:

  • The zero-based index of the alternative we want to check
  • The type of the variant

The type is available within the type static member, or directly using the std::variant_alternative_t shortcut.

Below, we get the first, second, and third alternatives of the variant type that was used to instantiate our template:

template <typename T>
void Handle(T Variant) {
  std::variant_alternative<0, T>::type;
  std::variant_alternative_t<1, T>;
  std::variant_alternative_t<2, T>;
}

When using this, we also typically need to use the std::variant_size type trait, to discover how many alternatives there are.

For example, below, we get the type of the last alternative in our variant. Given the indexing is zero-based, the last index is one less than the size of the alternative list:

template <typename T>
void Handle(T Variant){
  std::variant_alternative_t<
    std::variant_size_v<T> - 1, T>;
}

Here, we use this trait with some of the type traits we introduced earlier in the course, such as std::is_same, std::is_arithmetic, and std::is_convertible.

This lets us discover some characteristics of the std::variant<int, float, bool> that was used to invoke our template:

#include <variant>
#include <iostream>

template <typename T>
void Handle(T Variant){
  if constexpr (std::is_same_v<
    std::variant_alternative_t<0, T>, int>) {
    std::cout << "The first type is an int";
  }
  if constexpr (std::is_arithmetic_v<
    std::variant_alternative_t<1, T>>) {
    std::cout <<
      "\nThe second is arithmetic";
  }
  if constexpr (std::is_convertible_v<
    std::variant_alternative_t<2, T>, int>) {
    std::cout <<
      "\nThe third is convertible to an int";
  }
}

int main(){
  std::variant<int, float, bool> Variant;
  Handle(Variant);
}
The first type is an int
The second is arithmetic
The third is convertible to an int

Was this lesson useful?

Edit History

  • — First Published

Ryan McCombe
Ryan McCombe
Posted
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

7a.jpg
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, unlimited access!

This course includes:

  • 106 Lessons
  • 550+ Code Samples
  • 96% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Void Pointers and std::any

An overview of how to create variables that can store any date type, using void pointers and std::any
3D Character Concept Art
Contact|Privacy Policy|Terms of Use
Copyright © 2023 - All Rights Reserved