Sum Types and You

Let’s talk about a simple, yet powerful concept in programming: sum types.

A sum type, also called a discriminated union, can hold one (and only one) of several types of things. For example, consider some settings in an INI-like configuration file. Let’s say that each setting must be a string, an integer, or a Boolean value. If we wanted to roll our own solution in C++, we might write something resembling:

struct Setting { union { string str ; int num ; bool b ; }; enum Type { Str , Int , Bool }; Type tag ; }; // Map settings to their names. using Settings = unordered_map < string , Setting > ;

Here be dragons, though, since we must always remember to:

Update tag whenever assigning a new value.

Only retrieve the correct type from the union (according to tag ).

Call constructors and destructors at appropriate times for all non-trivial types. ( string is the only one here, but you could imagine similar scenarios with others.)

If a step is ever forgotten, the object falls into an inconsistent state and there shall be wailing and gnashing of teeth. You could encapsulate all this trickery and interact with the type through a series of methods—e.g., getType() , asBool() , asString() , and so on—but this is quite verbose. It also just shifts the problem onto whoever implements these methods; they still need to carefully maintain the invariants with no help from the language.

It would be much nicer if a general-purpose sum type was provided by the standard library. In C++17, we finally get one! It’s called std::variant . Let’s take a look.

Using std::variant

variant is a class template that takes, as template parameters, the types it could hold. For the example above, we could define a setting as a variant<string, int, bool> . Assigning a value to a variant works just like you might expect:

variant < string , int , bool > mySetting = string ( "Hello!" ); // Or, mySetting = 42 ; // Or, mySetting = false ;

Once we put a value into a variant , we’ll eventually want to look at what that value is, and just as importantly, what the type of the value is. This is where the fun begins. Some languages offer dedicated pattern matching syntax for the task, such as:

match ( theSetting ) { Setting :: Str ( s ) => println! ( "A string: {}" , s ), Setting :: Int ( n ) => println! ( "An integer: {}" , n ), Setting :: Bool ( b ) => println! ( "A boolean: {}" , b ), };

but this didn’t make the cut for C++17. Instead we’re given a companion function called std::visit . It takes the variant you want to examine, along with some visitor that is callable for each type in the variant.

How do we define such a visitor? One way is to create an object that overloads the call operator for relevant types:

struct SettingVisitor { void operator ()( const string & s ) const { printf ( "A string: %s

" , s . c_str ()); } void operator ()( const int n ) const { printf ( "An integer: %d

" , n ); } void operator ()( const bool b ) const { printf ( "A boolean: %d

" , b ); } };

This seems terribly verbose, and it gets even worse if we want our visitor to capture or modify some other state. Hmm—lambdas are perfect for capturing state. What if we could build a visitor from those?

make_visitor ( [ & ]( const string & s ) { printf ( "string: %s

" , s . c_str ()); // ... }, [ & ]( const int d ) { printf ( "integer: %d

" , d ); // ... }, [ & ]( const bool b ) { printf ( "bool: %d

" , b ); // ... } )

That’s a bit better, but the standard library doesn’t provide any sort of make_visitor to combine the lambdas into a callable object for us. We’ll need to define it ourselves.

template < class ... Fs > struct overload ; template < class F0 , class ... Frest > struct overload < F0 , Frest ... > : F0 , overload < Frest ... > { overload ( F0 f0 , Frest ... rest ) : F0 ( f0 ), overload < Frest ... > ( rest ...) {} using F0 :: operator (); using overload < Frest ... >:: operator (); }; template < class F0 > struct overload < F0 > : F0 { overload ( F0 f0 ) : F0 ( f0 ) {} using F0 :: operator (); }; template < class ... Fs > auto make_visitor ( Fs ... fs ) { return overload < Fs ... > ( fs ...); }

Here we use C++11’s variadic templates. They must be defined recursively, so we create some base case F0 , then use that to define a cascading set of constructors for overload , each of which peels off a lambda argument and adds it to the type as a call operator.

If this seems troublesome, fear not! C++17 will offer a new syntax that reduces all of the above to:

template < class ... Ts > struct overloaded : Ts ... { using Ts :: operator ()...; }; template < class ... Ts > overloaded ( Ts ...) -> overloaded < Ts ... > ;

Easy, right? But if don’t like any of these options, you could use C++17’s compile-time conditionals instead:

[]( auto & arg ) { using T = std :: decay_t < decltype ( arg ) > ; if constexpr ( std :: is_same_v < T , string > ) { printf ( "string: %s

" , arg . c_str ()); // ... } else if constexpr ( std :: is_same_v < T , int > ) { printf ( "integer: %d

" , arg ); // ... } else if constexpr ( std :: is_same_v < T , bool > ) { printf ( "bool: %d

" , arg ); // ... } }

Much better, no?

No.

The rigmarole needed for std::visit is entirely insane. We started with a simple goal: look at the contents of a sum type. To accomplish this meager mission, we had to:

Define a function object, which requires a lot of boilerplate, or Define our behavior with lambdas, which required: An understanding of variadic templates, in all their recursively-defined fun, or

A familiarity with variadic using declarations, fresh on the scene from C++17. or Use compile-time conditionals, which require you to know about—and grok—the new constexpr if syntax, along with type_traits fun like std::decay .

None of these concepts are too enigmatic if you’re an experienced C++ developer, but several are certainly “advanced” features of the language. Things have really gone sideways if we need to know so much to do something so simple.

How did we get here?

My goal isn’t to disparage the folks on the ISO C++ committee who picked this approach. I’ve had beers with some of them, and they’re smart, kind, hardworking people. I’m sure that I’m missing important context since I’ve never sat in on a standards meeting or read all of the relevant committee papers. But from an outsider’s perspective, the disparity in complexity between the problem being solved (“What’s in here?”) and the solutions is just nuts. How do you teach this without overwhelming a beginner with all this other… stuff? Is it expected to be common knowledge for your everyday programmer? (And if the goal of adding variant to the standard library isn’t to make it a tool for the masses, shouldn’t it be?) The very least C++17 could do—if the committee didn’t have the time or resources to get pattern matching into the language—is provide something akin to make_visitor . But that too is left as an exercise for the user.

If I had to guess how we ended up this way, I’d assume it comes down to confirmation bias. Maybe when a bunch of really smart people who know how SFINAE works offhand and don’t flinch when they see the likes of

template < typename F > typename std :: enable_if <! std :: is_reference < F >:: value , int >:: type foo ( F f ) { // ... }

get together, the result is something like std::visit . Nobody proclaims that the emperor has no clothes, or that it’s completely bonkers to expect the average user to build an overloaded callable object with recursive templates just to see if the thing they’re looking at holds an int or a string .

I’m also not here to claim that C++ is too complicated for its own good, but it’s certainly more complicated than it has to be. Scott Meyers, the guy who wrote Effective C++ and Effective Modern C++, has made similar noises in recent talks. To paraphrase Meyers, I’m sure each member of the committee cares very much about avoiding needless complexity and making the language easier to use. But if you look at the results of their work, it’s hard to tell. The accidental complexity just keeps stacking up.

Where are we headed?

There’s a reason C++ is so widely used, especially in systems programming. It can be incredibly expressive, yet gives you nearly full control of your hardware. The tooling around it is some of the most mature of any programming language out there, bar C. It supports a ridiculous number of platforms.

But even if you set aside all the historical baggage, it has some serious shortcomings. Spend any amount of time messing with D and you’ll quickly realize that metaprogramming needn’t require self-flagellation and insane syntax. Play with Rust and you’ll feel like unique_ptr and shared_ptr —which themselves have been a breath of fresh air—are a bad joke. The fact that we still handle dependencies in 2017 by literally copy-pasting files into each other with #include macros is obscene.

You get the impression, based on what ends up in the ISO standards and what you hear in conference talks, that those driving C++ are trying to eliminate some of these shortcomings by glomming nice bits from other languages onto it. That’s a great idea on its face, but these features often seem to arrive half-baked. While C++ isn’t going away any time soon, it feels like the language is constantly playing a clumsy game of catchup.