Introduction

std::variant is a new feature of C++17. This is C++’s take on a sum type, which is a type that hold one of several legal values, which may be different types. Now, if you’re thinking, wait, C++ has union types, you’d be correct. Unfortunately, union types in C++ are absolutely horrible, for reasons we will discuss. To cut to the punchline: despite the short-comings of std::variant, union types have no place in modern C++ (with the possible exception of code needing to interface with C code).

Union Types: How they didn’t fit in with C++

I’ll start with some code that is valid C containing a union type. This is pretty typical case of a sum type: I want to be able to express that the value is either present or not, and if it is not, I’d like to be able to get an error message with some kind of description of information on why. In this case, we’ll just say the error message is encoded by a string, but you could image something more complicated with a error code as well as some string or debug information.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <stdbool.h>
#include <stdio.h>

const char* divide_by_zero_error = "Division by 0";

union IntOrError {
  const char* error_message;
  int value;
};

struct TaggedIntOrError {
  union IntOrError optional_value;
  bool contains_value;
};

typedef struct TaggedIntOrError TaggedIntOrError;

TaggedIntOrError Divide(int x, int y) {
  TaggedIntOrError return_value;
  if (y == 0) {
    return_value.contains_value = false;
    return_value.optional_value.error_message = divide_by_zero_error;
  } else {
    return_value.contains_value = true;
    return_value.optional_value.value = x / y;
  }
  return return_value;
}

void PrintValue(TaggedIntOrError possible_int) {
  if (possible_int.contains_value) {
    printf("Good value: %d\n", possible_int.optional_value.value);
  } else {
    printf("Bad: %s\n", possible_int.optional_value.error_message);
  }
}

int main() {
  PrintValue(Divide(10, 5));
  PrintValue(Divide(0, 100));
  PrintValue(Divide(100, 0));
}

The advantage of this, rather than a struct containing both a const char* and a int are twofold. Primarily, it guides the consumer to do the correct thing. As documented by the union type, it only contains one of the values, therefore the consumer knows both of the fields are not meaningful together. Secondly, it is more efficient as space as the union only needs to be large enough to store its largest element. So, in this case, the union is likely 8 bytes on a 64 bit machine, but a struct would be larger.

Now, this above example is really meant to be C code. It will compile in C++, but it is not idiomatic C++. One reason is the error_message field is a const char*, which isn’t really the type used for strings in C++: usually std::string is used. Can this be done in C++?

Well, not simply. std::string has a destructor, which cleans up allocated memory. Imagine the code that a C++ compiler would have to generate for a union variable. It must statically dispatch a destructor call to the std::stringif it is present, but it doesn’t have this information at compile time. Even if you image a run-time call to the right constructor, the union lacks that information as it does not remember which field is populated. The union is just data. Until C++11 this would have been a compile time error about std::string having a non-trivial copy constructor and destructor. However, C++11 allows it, albeit with the caveat that you’re on your own as far as managing the memory it contains: you have to manually call the destructor.

Which means, you need to put in the logic of the handling the tagging, writing a destructor which cases by its tag and dispatches the correct function. Once again, this is an example of having to think like a compiler to write code, which is an all too familiar experience in C++. Here is an implementation of the above in C++.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
#include <iostream>
#include <memory>
#include <string>

class IntOrError {
 public:
  IntOrError(int value) : contains_value_(true) {
    optional_value_.value_ = value;
  }

  IntOrError(std::string error_message) : contains_value_(false) {
    new (&optional_value_.error_message_) std::string(std::move(error_message));
  }

  IntOrError(const IntOrError& to_copy) {
    if (to_copy) {
      // In the value case, we just copy the value.
      contains_value_ = true;
      optional_value_.value_ = to_copy.optional_value_.value_;
    } else {
      // Here, we need to do placement new to properly initialize the memory
      // as a string.
      contains_value_ = false;
      new (&optional_value_.error_message_)
          std::string(to_copy.optional_value_.error_message_);
    }
  }

  IntOrError(IntOrError&& to_move) {
    if (to_move) {
      contains_value_ = true;
      optional_value_.value_ = to_move.optional_value_.value_;
    } else {
      contains_value_ = false;
      new (&optional_value_.error_message_)
          std::string(std::move(to_move.optional_value_.error_message_));
    }
  }

  IntOrError& operator=(int value) {
    Reset();                         // In case it contained an error before.
    optional_value_.value_ = value;  // Plain old data, so we can just set it.
    contains_value_ = true;
    return *this;
  }

  IntOrError& operator=(std::string error_message) {
    if (contains_value_) {
     // since this contains a value, we need to initialize a new string.
     new (&optional_value_.error_message_)
          std::string(std::move(error_message));
    } else {
      // here we can just use the assignment move operator since the
      // error_message_ field was initialized.
      optional_value_.error_message_ = std::move(error_message);
    }
    contains_value_ = false;
    return *this;
  }

  IntOrError& operator=(IntOrError to_move) {
    if (!to_move.contains_value_) {
      *this = std::move(to_move.optional_value_.error_message_);
    } else {
      *this = to_move.optional_value_.value_;
    }
  }

  ~IntOrError() { Reset(); }

  operator bool() const { return contains_value_; }

  const std::string& get_error_message() const {
    return optional_value_.error_message_;
  }

  int get_value() const { return optional_value_.value_; }

 private:
  void Reset() {
    if (!contains_value_) {
      // Here we clean up the string by explicitly calling the desconstructor.
      optional_value_.error_message_.~basic_string();
    }
  }

  // One could put a little more logic here, like some initializing
  // constructors. For the copy constructor above, we need to be able to
  // default construct this, so I wanted to be uniform about where all the
  // logic was contained.
  union OptionalValue {
    OptionalValue() {}
    ~OptionalValue() {}
    std::string error_message_;
    int value_;
  };
  OptionalValue optional_value_;
  bool contains_value_;
};

const char* divide_by_zero_error = "Division by 0";

IntOrError Divide(int x, int y) {
  if (y == 0) {
    return std::string(divide_by_zero_error);
  } else {
    return x / y;
  }
}

void PrintValue(const IntOrError& possible_int) {
  if (possible_int) {
    std::cout << "Good value: " << possible_int.get_value() << "\n";
  } else {
    std::cout << "Bad: " << possible_int.get_error_message() << "\n";
  }
}

int main() {
  PrintValue(Divide(10, 5));
  PrintValue(Divide(0, 100));
  PrintValue(Divide(100, 0));
}

Now, there’s probably some design choices above that were unnecessary, and some you would have done differently, but the bulk of the work was necessary. Namely, we need to keep track of the value which is populated, and we need to be very careful when setting new values since we need to make sure the destructor of std::string is called when we switch to a value and we need to make sure that the constructor of std::string is called whenever we switch to using a string.

Doing the above to use sum types is pretty absurd, and a pretty high barrier to use. Doing explicit constructor calls on particular memory (in the form of placement new) and explicit destructor calls violates modern sensibilities in C++.

std::variant: Not as bad

The above using std::variant is pretty easy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
#include <memory>
#include <string>
#include <variant>

using IntOrError = std::variant<int, std::string>;

const char* divide_by_zero_error = "Division by 0";

IntOrError Divide(int x, int y) {
  if (y == 0) {
    return std::string(divide_by_zero_error);
  } else {
    return x / y;
  }
}

void PrintValue(const IntOrError& possible_int) {
  if (std::holds_alternative<int>(possible_int)) {
    std::cout << "Good value: " << std::get<int>(possible_int) << "\n";
  } else {
    std::cout << "Bad: " << std::get<std::string>(possible_int) << "\n";
  }
}

int main() {
  PrintValue(Divide(10, 5));
  PrintValue(Divide(0, 100));
  PrintValue(Divide(100, 0));
}

Note, if you’re wondering what happens if your variant has the same type more than once, you can get the index (using std::variant::index()) and using std::get you can access by index or type. You can also use std::get_if() to access and return a pointer which is null if the access fails.

So this code is clean-ish, but the accessing notation is a bit rough. The cleanest notation for this would be pattern matching. Unfortunately, pattern matching is still a bit rough around the edges here. For the closest thing, you’re stuck doing one of 2 things: writing a functor that handles each case (which is, each possible type) explicitly, or writing a very generic function that handles each case uniformly using parametric polymorphism (templates). Of course mixing and matching is possible as well. Here’s an example:

1
2
3
4
5
6
7
8
9
10
11
12
13
class IntOrErrorPrintValueVisitor {
 public:
  void operator()(int value) {
    std::cout << "Good value: " << value << "\n";
  }
  void operator()(const std::string& error_message) {
    std::cout << "Bad: " << error_message << "\n";   
  }
};

void PrintValue(const IntOrError& possible_int) {
  std::visit(IntOrErrorPrintValueVisitor(), possible_int);
}

You could actually accomplish this with lambdas and some generic helper class that is able to wrap all the lambdas up into 1 functor.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// This class will be able to act like a bunch of different lambdas.
template<typename ...T> struct lambdas : T... { using T::operator()...; };
// This is a deduction guide, another C++17 feature that allows us to guide
// the types deduced when instantiating a templated class.
template<typename ...T> lambdas(T...) -> lambdas<T...>;

void PrintValue(const IntOrError& possible_int) {
  std::visit(
      lambdas{
          [](const int value) { std::cout << "Good value: " << value << "\n"; },
          [](const std::string& error_message) {
            std::cout << "Bad: " << error_message << "\n";
          }}, 
      possible_int);
}

This doesn’t work well if you can’t identify the case up to type. You can always wrap the value in a struct to differentiate different cases, and this does have other benefits (having types work for you in static analysis to prove correctness is nice). Perhaps it is true in general if you find that you are always running into this trouble with sum types that you are unable to discern the case with the type that you are using too many primitive types. I haven’t seen enough of this to know for sure, but I could imagine that being a symptom.

Appendix: std::optional

Sum types are pretty useful, but there is one common use case: you’ll want to express that the value is either there or is not there.

One could go through the typical construction in languages with sum types, and make it a std::variant of the type and the unit type. In C++, the unit type is void, but because of historical hackiness in early C language design, you can’t actually create instances of void in C++ (despite functions being able to return void). The standard library has provided std::monostate as a unit type.

Doing this construction, however, is unnecessary, as C++ provides std::optional which has a more natural interface to optional values in C++. You can treat it by in large as a pointer which could be null. In fact, you can treat std::optional, raw pointers, std::unique_ptr, and std::shared_ptr uniformly in interfaces as all have overloaded bool conversion, dereferences, and indirect member access operators that have the same semantics.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <optional>

std::optional<int> Divide(int x, int y) {
  if (y == 0) {
    return std::nullopt;  // akin to nullptr.
  } else {
    return x / y;
  }
}
void PrintValue(const std::optional<int> possible_int) {
  if (possible_int) {
    std::cout << "Good value: " << *possible_int << "\n";
  } else {
    std::cout << "This is bad. I don't know why!\n";
  }
}
int main() {
  PrintValue(Divide(10, 5));
  PrintValue(Divide(0, 100));
  PrintValue(Divide(100, 0));
}

Conclusion

std::variant provides C++ which a much needed sum type. It’s very evidently not perfect. Pattern matching is not a language feature, but just something shoved into the standard library that feels much like a hack, particularly in contrast with sum types in languages like ML and Rust. I look forward to there being more syntactic sugar added around to make std::variant feel more like a first class citizen of C++, as tuples, pairs, and function type continually do.