Logo

Blog


Visiting a std::variant safely

I assume you all know C++17's type-safe replacement for unions: std::variant. Here you look at a great replacement for unions as it knows the active type and destructs that object on a new assignment or when the std::variant itself gets destroyed. Achieving all these things with plain unions requires effort.

The basics

Let's have a look at how you can visit a std::variant.

 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
A Helper to collect call operators from lambdas
template<class... Ts>
struct overload : Ts...
{
  B Make base class operators available
  using Ts::operator()...;
};

C C++17 CTAD
template<class... Ts>
overload(Ts...) -> overload<Ts...>;

int main()
{
  D Our variant
  const std::variant<int, bool> v{true};

  std::visit(
      E Using overload with lambdas
      overload{
          [](int val) { std::cout << val; },
          [](bool val) { std::cout << std::boolalpha << val; },
      },
      v);
}

What you see here is a common approach used with std::visit. In A, I start creating a class template that derives from all its parameters. The idea is that overload gets instantiated with a couple of lambdas. Thanks to inheritance, they all provide a call operator, which is also part of overload. B ensures that the call operators from the base class(es) are available in the derived class.

This help is completed by C, the deduction guide, which tells CTAD how to instantiate the class template overload.

Next, in D, I create a simple variant consisting of int and bool initialized with true. So far, so good.

Finally, in E, I use overload with std::visit and the variant from D to find and print the value of the active member, bool, in this case.

Type-safe they say

Well, you often hear, even from me, that a std::variant is type-safe and superior to a union. Let's test that. What happens if you forget to provide a lambda or, better, an overload for one of the cases? Let's say we forget the bool case. Maybe it is less forgetting and more a new type was added during a refactoring, making our code look the following:

1
2
3
4
5
6
7
D Our variant
const std::variant<int, bool> v{true};

std::visit(
    E Using overload with lambdas
    overload{[](int val) { std::cout << val; }},
    v);

What do you think happens? I'm not asking what you want to happen ;-)

Well, ... the code compiles :-/ and it runs ... but, the output is:

1
1

Granted, the output is very close to true, given that its equivalent is 1. Yet, the output is wrong.

Thanks to implicit integer conversions, this code compiles fine. Only at run-time, the only available visitor int val is picked. For me, type-safe means that this code should not have compiled. But we have to live with the implicit integer conversions. The question is, is there something you can do to avoid this situation? Yes, of course, write the correct code the first time. I'm talking about something more realistic.

Create a safety-net

There is something you can do. In the overload class template, you can catch all cases which would lead to implicit conversions by providing a function template that catches them:

 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
A Helper which becomes obsolete in C++23
template<class...>
constexpr bool always_false_v = false;

template<class... Ts>
struct overload : Ts...
{
  using Ts::operator()...;

  B Prevent implicit type conversions
  template<typename T>
  constexpr void operator()(T) const
  {
    static_assert(always_false_v<T>, "Unsupported type");
  }
};

template<class... Ts>
overload(Ts...) -> overload<Ts...>;

int main()
{
  const std::variant<int, bool> v{true};

  std::visit(overload{
                 [](int val) { std::cout << val; },
                 [](bool val) { std::cout << std::boolalpha << val; },
             },
             v);
}

I first need a helper, which you can see in A. With C++23, we finally have static_assert(false), making the helper obsolete. But I decided to use C++17 code for this post.

Now, the safety net is in B. We provide a function template that is the best match for all types not caught by the lambdas. The functions body then uses a static_assert for communicating the error, effectively preventing unwanted implicit conversions.

If you run the code without the lambda taking a bool, you will get a compile error. One positive side-effect of all this is that you also improved the error message for an unhandled type that isn't convertible.

How about C++23?

For the full picture below, you will find the C++23 version, which doesn't need that always_false helper nor CTAD and uses C++20's consteval for our mistake-catching overload.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<class... Ts>
struct overload : Ts...
{
  using Ts::operator()...;

  B Prevent implicit type conversions
  consteval void operator()(auto) const
  {
    static_assert(false, "Unsupported type");
  }
};

int main()
{
  const std::variant<int, bool> v{true};

  std::visit(overload{
                 [](int val) { std::cout << val; },
                 [](bool val) { std::cout << std::boolalpha << val; },
             },
             v);
}

[2023-09-11: Update of the example thanks to a comment from Bartlomiej Filipek using auto spares the template-head.]

Regardless of which standard you're using, please remember the pattern above when writing type-safe code!

Andreas