Constrained Dynamic Types using Unions and std::variant

Learn how to store dynamic data types in C++ using unions and the type-safe std::variant
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
Illustration representing computer hardware
Ryan McCombe
Ryan McCombe
Updated

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 could solve problems like these using unions.

Unions

The syntax of a union is very similar to that of a struct or class - we simply use the union keyword instead:

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 covered in the introduction - the ability to store a variable of a dynamic type, without using unnecessary memory.

Union 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 not 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 category of bugs 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

We cover a workaround for this later in the lesson, using std::monostate.

Assigning Values

We can provide an initial type and value for our variant by passing it as a constructor argument. Below, our variant will be initialized as 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;
}

Emplacing Values

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 name Character with the index 1, which results in the same behavior:

#include <variant>
#include <string>

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

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

Getting the Current Value

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);
}
Unhandled exception: std::bad_variant_access

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 covered exceptions and how to handle them in a dedicated chapter earlier in the course:

Below, we use these techniques to catch and handle the std::bad_variant_access exception when we’re wrong about the type our std::variant is currently holding:

#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 a Pointer

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

Getting the Current Type

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

The index() Method

std::variant objects also have access to the index() method, which gives us an alternative way to understand 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. Given int is the first argument we provided to the template, 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

The Visitor Pattern and 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 log out different data types. These are defined within a Visitor struct. This struct overloads the () operator for various 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 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

However, when two variants are not currently storing the same type, the behaviour is different, and somewhat unintuitive:

#include <iostream>
#include <variant>

int main() {
  if (9.0f == 9) {
    std::cout << "9.0f == 9";
  }

  std::variant<int, float, bool> A{9.0f};
  std::variant<int, float, bool> B{9};

  if (A != B) {
    std::cout << " but A != B";
  }
}
9.0f == 9 but A != B

Specifically, when the index() method of two variants returns 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 Construction and 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 supports. 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 of the generated struct.

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 instantiating a template with the std::variant<int, float, bool> type as an argument:

#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 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

As usual, 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.

In the following example, 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>;
}

This evaluation is performed at compile time, and we are free to use any other compile-time logic to build more complex behaviour. This can include C++20 concepts or other type traits.

Below, we use std::variant_alternative with some of the other 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

Summary

This lesson provides an in-depth look at using unions and std::variant in C++ to store dynamic data types. Key takeaways include:

  • Unions allow storing different data types in the same memory location, but are not type-safe
  • std::variant is a type-safe alternative to unions that can store one value from a specified list of types
  • Variants can be constructed, assigned values, and accessed using various methods like std::get(), std::get_if(), std::holds_alternative(), and the visitor pattern with std::visit()
  • Special cases like valueless variants and using std::monostate for default construction are covered
  • Variant type traits std::variant_size and std::variant_alternative provide compile-time information about variant types

Was this lesson useful?

Next Lesson

Void Pointers and std::any

Learn how to use void pointers and std::any to implement unconstrained dynamic types, and understand when they should be used
Illustration representing computer hardware
Ryan McCombe
Ryan McCombe
Updated
A computer programmer
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
Next Lesson

Void Pointers and std::any

Learn how to use void pointers and std::any to implement unconstrained dynamic types, and understand when they should be used
Illustration representing computer hardware
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved