C++ std::visit
carelessness
The second in a possibly never-ending series of reflections on what makes C++
a terrible choice for programming.
The problem
It’s useful sometimes to list all possible values
for some object in your problem.
Here are two examples that might arise when navigating a maze.
- Whenever you’s in an empty chamber, you might choose from
directions of travel: north, south, east, west.
- A non-empty chamber can contain orcs, gnomes, pits, and gems.
A straightforward implementation represents the maze as an array,
where each entry’s value indicates the object in that location.
As a player navigates the maze, the program inspects the value.
The safe, reliable solution: enumerated types
Ada
In Ada one describes this situation with an enumerated type:
type Directions = (North, South, East, West);
type Object = (Empty, Orc, Gnome, Pit, Gem);
-- to test the location
-- Ada infers the variant
case Maze (Row, Col) is
when Empty => Keep_Moving;
when Orc | Gnome => Flee;
when Pit => Die;
when Gem =>
Pick_It_Up;
Keep_Moving;
end case;
Rust
Rust also obliges with a simple way of doing this:
enum Directions {
North,
South,
East,
West,
}
enum Object {
Empty,
Orc,
Gnome,
Pit,
Gem
}
// to test the location
// rust requires you to scope the variant or to use it
match maze[row][col] {
Object::Empty => keep_moving(),
Object::Orc | Object::Gnome => flee(),
Object::Pit => die(),
Object::Gem => {
pick_it_up();
keep_moving();
}
}
Results
Both are readable and, importantly, both are
safe,
in that if later you add a new object (
Sidekick
, say)
then you
must add logic to consider the new variant
to every
every case
/
match
.
If you don’t, the program
simply won’t compile.
For example, suppose I had forgotten to consider the
Gem
type.
The GNAT Ada compiler complains:
test_std_visit.adb:20:01: error: missing case value: "Gem"
Whereas rustc complains:
error[E0004]: non-exhaustive patterns: `Object::Gem` not covered
--> test_std_visit.rs:20:10
|
20 | match maze[row][col] {
| ^^^^^^^^^^^^^^ pattern `Object::Gem` not covered
|
note: `Object` defined here
--> test_std_visit.rs:7:4
|
2 | enum Object {
| ------
...
7 | Gem,
| ^^^ not covered
= note: the matched value is of type `Object`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
23 ~ Object::Pit => die(),
24 ~ Object::Gem => todo!(),
|
The Ada error is one line long and tells you everything you need to know.
The Rust error is longer, but is quite detailed and offers you a solution.
The various C++ approaches
How does C++ address this need?
Unsurprisingly,
it follows the primary C++ design criterion,
You pay for what you forget to use. Dearly.
Every iteration of C++ provides some solution that fails,
hard,
in at least one respect.
Pre-C++98
Before C++98, C++ programmers fell back on the C approach to this problem,
which was to use the preprocessor. For the example above:
#define NORTH 0
#define SOUTH 1
#define EAST 2
#define WEST 3
#define EMPTY 0
#define ORC 1
#define GNOME 2
#define PIT 3
#define GEM 4
// to test the location
// if you're a *really* good boy, maze will be an int * and
// you’ll see pointer arithmetic instead of indices on the line below
switch (maze[row][col]) {
case EMPTY: keep_moving(); break;
case ORC: case GNOME: flee(); break;
case PIT: die(); break;
case GEM: pick_it_up(); keep_moving(); break;
}
Problems with this approach include, but are not limited to:
- You store an
int
in the maze,
or an unsigned int
if your C++ programmer
is somewhat enlightened.
But nothing stops you from accidentally assigning inappropriate values like,
say, NORTH
or 10837624986
or some other garbage
that occurs because you forgot to initialize a variable.
(Remember, uninitialized variables are a feature of C and C++,
not a bug!)
- When you add the
SIDEKICK
variant,
you have to define a new number for it, and
you have to hunt down every switch
statement
and add a new case
statment…
because the compiler won’t tell you.
Indeed, the compiler has no way of knowing!
C++98
With C++98, developers could take advantage of
an
enum
type:
enum Directions { north, south, east, west };
enum Object { empty, orc, gnome, pit, gem };
// to test the location
switch (maze[row][col]) {
case empty: keep_moving(); break;
case orc: case gnome: flee(); break;
case pit: die(); break;
case gem: pick_it_up(); keep_moving(); break;
}
This has the merit of making the code somewhat more readable.
In addition, you no longer have to define integer values for the variants
if you don’t need them.
If you define
maze
to be of type
Object maze[10][20];
,
it will even catch, at compile time,
the attempt to assign a non-
Object
type;
that is,
maze[5][7] = north;
will generate an error.
Heck, to the untrained eye it’s indistinguishable from the Ada and Rust!
In reality, it’s still unsafe and unreliable.
Indeed, it introduces a completely new problem!
- You don’t have to define
maze
of type
Object maze[10][20];
.
You can still define it to be of type
int maze[10][20];
, and
you can assign Object
or int
to it.
So the first problem above remains.
- Even if you declare
maze
to be of type
Object maze[10][20];
,
you can circumvent the attempt at type-safety via unsafe casting.
Heck, even “safe” casting won’t catch the error
in static_cast⟨Object⟩(10837624986)
.
- C++ isn’t smart enough to distinguish
orc
as a value
from orc
as a variable.
If you try to define a variable named orc
in the same scope as Object
, it will report an error.
As we saw above, Rust avoids this problem by requiring namescoping or import,
while Ada is smart enough to distinguish values from variables.
C++11
C++11 added an
enum class
type:
enum class Directions { north, south, east, west };
enum class Object { empty, orc, gnome, pit, gem };
// to test the location
switch (maze[row][col]) {
case Object::empty: keep_moving(); break;
case Object::orc: case Object::gnome: flee(); break;
case Object::pit: die(); break;
case Object::gem: pick_it_up(); keep_moving(); break;
}
Things have improved!
The symbol
orc
must now be namescoped or imported, as in Rust,
and we can no longer use
int
or
Directions
as potential values for
Object
s.
However, we
still can’t rely on the compiler to verify
that we have handled every case correctly:
if we add a
sidekick
variant, or if we comment out the last
case
statement,
the compiler chugs along happily, not informing us that we’ve overlooked
a variant.
C++14/C++17
The C++ committee adds a new tool we can use for this problem in C++14,
and streamlines it slightly in C++17.
To attack this problem, a C++ developer must
- use a new type,
std::variant
; and
- use a new template,
std::visit
.
Here’s how we would solve our problem in a C++17 program:
#include ⟨variant⟩
// this next line is for some reason load-bearing, is always the same,
// and is immensely useful... yet the standard library does not define it...
// it's possible to get around it, but prepare for more pain
// and before you complain: until C++17 you had to add another line, too!
template struct overload : Ts... { using Ts::operator()...; };
// why yes, your enum variants are now structs; did you not want that?
// you can also use a different type, but it gets... even more complicated
struct empty {};
struct orc {};
struct gnome {};
struct pit {};
struct gem {};
using Object = std::variant⟨empty, orc, gnome, pit, gem⟩;
// to test the location
std::visit(
overload {
[](const empty arg) { keep_moving(); },
[](const orc arg) { flee(); }, // if there's a way to put these
[](const gnome arg) { flee(); }, // in the same case, i'm unaware
[](const pit arg) { die(); },
[](const gem arg) { die(); },
},
maze[row][col] // why is this at the end?
);
What do we notice from this? On the plus side,
- It is typesafe! I cannot use integers in place of variants.
- It is reliable! The program will not compile if we forget to list
a variant. …but there’s a catch; see below.
On the other hand,
- This is absolutely unreadable. I’ve been using this for over a year,
and I remain baffled that anyone thought this was a good idea.
- I am by no means the first to notice
that this is overwrought. Solving this simple problem requires me
to define
struct
s, to use a new type,
to use a new function / template / whatever, to rely on lambda expressions
and to worry about whether I need to capture this, that, or tother, …
and that’s not even close to the end of it.
- In the fine tradition of C++, error messages are at best useless,
and more often misleading:
- Try forgetting the commas at the end of one of the
non-terminal branches of the visit. The
g++
error message
is, like far too many C++ error messages, unhelpful:
test_std_visit_on_object.cpp: In function ‘int main()’:
test_std_visit_on_object.cpp:25:13: error: missing template arguments before ‘{’ token
25 | overload{
| ^
- Sure, the compiler rejects a “visit” that doesn’t
consider each variant, but how does it do that? Take a look at what happens
when I forget the
gem
variant.
In file included from /usr/include/c++/13/variant:37,
from test_std_visit_on_object.cpp:1:
/usr/include/c++/13/type_traits: In substitution of ‘template using std::invoke_result_t = typename std::invoke_result::type [with _Fn = overload, main()::, main()::, main():: >; _Args = {gem&}]’:
/usr/include/c++/13/variant:1103:14: required from ‘constexpr bool std::__detail::__variant::__check_visitor_results(std::index_sequence<_Ind ...>) [with _Visitor = overload, main()::, main()::, main():: >; _Variant = std::variant&; long unsigned int ..._Idxs = {0, 1, 2, 3, 4}; std::index_sequence<_Ind ...> = std::integer_sequence]’
/usr/include/c++/13/variant:1844:44: required from ‘constexpr std::__detail::__variant::__visit_result_t<_Visitor, _Variants ...> std::visit(_Visitor&&, _Variants&& ...) [with _Visitor = overload, main()::, main()::, main():: >; _Variants = {variant&}; __detail::__variant::__visit_result_t<_Visitor, _Variants ...> = void]’
test_std_visit_on_object.cpp:24:11: required from here
/usr/include/c++/13/type_traits:3073:11: error: no type named ‘type’ in ‘struct std::invoke_result, main()::, main()::, main():: >, gem&>’
3073 | using invoke_result_t = typename invoke_result<_Fn, _Args...>::type;
| ^~~~~~~~~~~~~~~
/usr/include/c++/13/variant: In instantiation of ‘constexpr std::__detail::__variant::__visit_result_t<_Visitor, _Variants ...> std::visit(_Visitor&&, _Variants&& ...) [with _Visitor = overload, main()::, main()::, main():: >; _Variants = {variant&}; __detail::__variant::__visit_result_t<_Visitor, _Variants ...> = void]’:
test_std_visit_on_object.cpp:24:11: required from here
/usr/include/c++/13/variant:1844:44: in ‘constexpr’ expansion of ‘std::__detail::__variant::__check_visitor_results, main()::, main()::, main():: >, std::variant&, 0, 1, 2, 3, 4>((std::make_index_sequence<5>(), std::make_index_sequence<5>()))’
/usr/include/c++/13/variant:1843:26: error: ‘constexpr’ call flows off the end of the function
1843 | constexpr bool __visit_rettypes_match = __detail::__variant::
| ^~~~~~~~~~~~~~~~~~~~~~
/usr/include/c++/13/variant:1848:29: error: non-constant condition for static assertion
1848 | static_assert(__visit_rettypes_match,
| ^~~~~~~~~~~~~~~~~~~~~~
/usr/include/c++/13/variant: In instantiation of ‘static constexpr decltype(auto) std::__detail::__variant::__gen_vtable_impl, std::integer_sequence >::__visit_invoke(_Visitor&&, _Variants ...) [with _Result_type = std::__detail::__variant::__deduce_visit_result; _Visitor = overload, main()::, main()::, main():: >&&; _Variants = {std::variant&}; long unsigned int ...__indices = {4}]’:
/usr/include/c++/13/variant:1795:5: required from ‘constexpr decltype(auto) std::__do_visit(_Visitor&&, _Variants&& ...) [with _Result_type = __detail::__variant::__deduce_visit_result; _Visitor = overload, main()::, main()::, main():: >; _Variants = {variant&}]’
/usr/include/c++/13/variant:1854:34: required from ‘constexpr std::__detail::__variant::__visit_result_t<_Visitor, _Variants ...> std::visit(_Visitor&&, _Variants&& ...) [with _Visitor = overload, main()::, main()::, main():: >; _Variants = {variant&}; __detail::__variant::__visit_result_t<_Visitor, _Variants ...> = void]’
test_std_visit_on_object.cpp:24:11: required from here
/usr/include/c++/13/variant:1032:31: error: no matching function for call to ‘__invoke(overload, main()::, main()::, main():: >, gem&)’
1032 | return std::__invoke(std::forward<_Visitor>(__visitor),
| ~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1033 | __element_by_index_or_cookie<__indices>(
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1034 | std::forward<_Variants>(__vars))...);
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In file included from /usr/include/c++/13/variant:41:
/usr/include/c++/13/bits/invoke.h:90:5: note: candidate: ‘template constexpr typename std::__invoke_result<_Functor, _ArgTypes>::type std::__invoke(_Callable&&, _Args&& ...)’
90 | __invoke(_Callable&& __fn, _Args&&... __args)
| ^~~~~~~~
/usr/include/c++/13/bits/invoke.h:90:5: note: template argument deduction/substitution failed:
/usr/include/c++/13/bits/invoke.h: In substitution of ‘template constexpr typename std::__invoke_result<_Functor, _ArgTypes>::type std::__invoke(_Callable&&, _Args&& ...) [with _Callable = overload, main()::, main()::, main():: >; _Args = {gem&}]’:
/usr/include/c++/13/variant:1032:24: required from ‘static constexpr decltype(auto) std::__detail::__variant::__gen_vtable_impl, std::integer_sequence >::__visit_invoke(_Visitor&&, _Variants ...) [with _Result_type = std::__detail::__variant::__deduce_visit_result; _Visitor = overload, main()::, main()::, main():: >&&; _Variants = {std::variant&}; long unsigned int ...__indices = {4}]’
/usr/include/c++/13/variant:1795:5: required from ‘constexpr decltype(auto) std::__do_visit(_Visitor&&, _Variants&& ...) [with _Result_type = __detail::__variant::__deduce_visit_result; _Visitor = overload, main()::, main()::, main():: >; _Variants = {variant&}]’
/usr/include/c++/13/variant:1854:34: required from ‘constexpr std::__detail::__variant::__visit_result_t<_Visitor, _Variants ...> std::visit(_Visitor&&, _Variants&& ...) [with _Visitor = overload, main()::, main()::, main():: >; _Variants = {variant&}; __detail::__variant::__visit_result_t<_Visitor, _Variants ...> = void]’
test_std_visit_on_object.cpp:24:11: required from here
/usr/include/c++/13/bits/invoke.h:90:5: error: no type named ‘type’ in ‘struct std::__invoke_result, main()::, main()::, main():: >, gem&>’
You might identify the error if you read this carefully.
Once you’re accustomed to this error, it will even be relatively easy.
But compare that horrific error message
to the corresponding error messages in Ada and Rust given above.
Conclusion
For a problem this simple and fundamental, I’d’a thunk
the C++ committee could simply extend
the
switch
keyword for an
enum class
argument —
or even for a plain
enum
argument! —
to check that every variant is covered.
Instead of doing that, they provide
std::visit
,
as if to prove that there’s no problem so simple
that templates can’t wreck it.
After 40-odd years of existence,
C++
still doesn’t offer an ergonomic tool
to handle something as fundamental as checking every variant of an enumerated type.
Lots of languages offered proper enumeration types before C++ came along.
(Pascal, Modula-2, and Ada come to mind; I doubt they were alone.)
So why didn’t C++ offer it?
I honestly don’t know.
But the current “best” approach is so bad
that, judging from the compiler’s confused error messages,
even the C++ compilers can’t figure it out!