The C++ logo, by Jeremy Kratz, licensed under CC0 1.0 Universal Public Domain Dedication

Prefer function overloads to std::variant

Notes published the
6 - 8 minutes to read, 1586 words

Recently I saw a class similar to the following one

#include <variant>


struct a{int j;};
struct b{int i; char c;};
struct c{char a,b,c;};
struct d{float d;};

using myvariant = std::variant<a,b,c,d>;

struct myclass{
	explicit myclass(const myvariant& v);

	// member variables and member functions
	// but there is no member variable of type myvariant(!!!)
};

Since myvariant is not a member variable, does using a constructor with a variant parameter offer any advantage over a set of overloads?

The answer is "no".

Boilerplate Code

Compare the following two classes

#include <variant>

struct a{int j;};
struct b{int i; char c;};
struct c{char a,b,c;};
struct d{float d;};

using myvariant = std::variant<a,b,c,d>;

template<class>
inline constexpr bool always_false_v = false;

struct myclass1{
    explicit myclass1(const myvariant& v):i(std::visit([](auto&& arg) {
            using T = std::decay_t<decltype(arg)>;
            if constexpr (std::is_same_v<T, a>)
                 return arg.j;
            else if constexpr (std::is_same_v<T, b>)
                return arg.i;
            else if constexpr (std::is_same_v<T, c>)
                return arg.a+arg.b+arg.c;
            else if constexpr (std::is_same_v<T, d>)
                return int(arg.d);
            else
                static_assert(always_false_v<T>, "non-exhaustive visitor!");
        }, v)){
    }
    void foo();
    int i;
};
void bar1(){
    auto var = myclass1(b{});
    var.foo();
}

struct myclass2{
    explicit myclass2(const a& v):i(v.j){
    }
    explicit myclass2(const b& v):i(v.i){
    }
    explicit myclass2(const c& v):i(v.a+v.b+v.c){
    }
    explicit myclass2(const d& v):i(v.d){
    }
    void foo();
    int i;
};
void bar2(){
    auto var = myclass2(b{});
    var.foo();
}

Note that myclass1 requires more boilerplate code (there are cases in which this is not true).

One needs to provide one or more appropriate lambda or functions to std::visit, and something like always_false_v to detect at compile time that there are no mismatches. If only one lambda is provided then detect the appropriate type with if constexpr for doing what normally function overloading does for us for free.

Note that in this example I wrote myclass1 constructor very compact.

The only upside is that one needs to write explicit myclass1 only once.

In the case of multiple constructors, there is no need to static_assert that some types are not covered, and no need to keep the types inside std::variant and those matched by std::visit synchronized. Those verifications are already done at compile-time for free!

Since one constructor can also call another, in both cases it is possible to reuse some code without writing additional functions.

Less error-prone API

myclass2 is less more error-prone to use than myclass1, simply because myclass1({}) compiles, while myclass2({}) does not.

In the case of myclass1({}), it is not obvious which type is constructed, especially if the order of the types in myvariant changes, or some type is added. In the case of myclass2, reordering the constructor declaration has no visible effect on the end user.

Conversions

All (to me) known conversion issues have been fixed 🗄️, but what if we want to avoid some conversions?

For example, we do want our class to be constructible with int, string_view, but not bool.

Consider following classes

#include <variant>
#include <string_view>

using myvariant = std::variant<int, std::string_view>;
struct s1 {
    explicit s1(bool) = delete;
    explicit s1(myvariant v);
};

struct s2{
    s2(bool) = delete;
    explicit s2(int);
    explicit s2(std::string_view);
};

void bar1(){
    auto instance1 = s1(12);   // BAD, fails to compile, use of deleted function 's1::s1(bool)'
    auto instance2 = s1(true); // GOOD, fails to compile, use of deleted function 's1::s1(bool)'
}

void bar2(){
    auto instance1 = s2(12);   // GOOD, compiles
    auto instance2 = s2(true); // GOOD, fails to compile, use of deleted function 's2::s2(bool)'
}

note that removing the deleted bool constructor would mean that auto instance11 = s1(true); compiles, as true is convertible to int.

Without std::variant, the overload of constructors is the only place where a conversion can happen, and the author of the class has full control over it.

In the case of std::variant, that’s not the case.

It is possible to avoid the issue by changing the types contained in std::variant, for example

#include <variant>
#include <string_view>

struct myint{
    int i;
    myint(int i);
    myint(bool) = delete;
};
using myvariant = std::variant<int, std::string_view>;
struct s1 {
    explicit s(myvariant v);
};

void bar1(){
	s1 instance1(12); // GOOD, compiles, as long as myint(int i) is implicit
	s1 instance1(true); // GOOD, fails to compile
}

While it might make sense to define an integral type that does not convert implicitly from bool, in this case, it is just more boilerplate code.

As a side effect, it also makes the compiler error message less clear.

Pass by copy and rvalue

Different overloads of a given set can take, for example, parameters by const-ref or copy.

This can be useful for avoiding unnecessary copies; for example

void foo(std::string_view); // always by copy, as string_view is lightweight
void foo(std::string&&);    // additional overload, just in case the user has a temporary or uses std::move on std::string
                            //   in this case foo can reuse the memory
void foo(const char*);      // after adding "foo(std::string&&)", foo("abc") is ambiguous, this removes the ambiguity when using a literal

As far as I know, such API cannot be replicated directly with a std::variant (also note that std::variant cannot hold a lvalue or rvalue directly).

Array

A std::variant cannot hold an array.

In practice, thanks to std::span and std::array, this might not be a big limitation.

Code bloat

If you try the first example on compiler explorer, you might have noticed how much code is generated for the class with std::variant.

Yes, less assembly does not mean faster code, and with GCC and clang with optimization enabled (-Og excluded), everything is optimized out and the generated code is identical for both classes.

But this is the exception, as everything is on the same file, and not the rule. In the general case, using a variant will have an overhead, probably not measurable in performance, but in code bloat.

Compile times

After noticing the generated code bloat, the obvious follow-up question is if using std::variant will take more time to compile.

In this specific case, it is 1.6 times slower to compile, and if one removes #include <variant> when preferring overloads, then using an overloaded constructor is 26 times faster to compile.

Take the results with a grain of salt, in a bigger project it is hard to predict how relevant those numbers are.

Nevertheless, many projects suffer from a "death by a thousand cuts"; it is not a single piece of code that is extremely slow to compile, but the cruft accumulates and the whole build is slowed down.

Conclusion

Using a variant instead of a function overload not only does not offer any advantage, but it does have several drawbacks.

All examples were made with overloads of constructors, but everything holds for "normal" functions. lambda and member functions too.

Is there any scenario where a constructor (or functions) with a variant should be preferred to a set of overloads?

Yes; when the following conditions hold

  • the parameter is assigned or used for constructing a std::variant of the same types

  • you are not concerned about conversions

  • the header <variant> is already included

For example

#include <variant>
using myvariant = std::variant< /* types */ >;
struct s{
	myvariant v;
	explicit s(myvariant v) : v(std::move(v)){}
};

Since the structure has a std::variant member variable the user of the class is already "paying" for the #include <variant>, and using a variant as a constructor parameter leads to less code.

On the other hand, if the member variable does not appear in the header file, for example with the PIMPL technique, then I would personally prefer to use an overload set.

After all, the point of using PIMPL was to hide the implementation detail, why leak it just for the constructor?

An example:

#include <memory>

struct impl;
struct s{
	std::unique_ptr<impl> pimpl;
	explicit s(typeA v);
	explicit s(typeB v);
	explicit s(typeC v);
	~s();
};

// cpp file
#include <variant>

struct impl{
	std::variant<typeA, typeB, typeC> v;
	explicit impl(std::variant<typeA, typeB, typeC> v) : v(std::move(v)){}
};

s::~s()=default;

s::s(typeA v) : pimpl(std::make_unique<impl>(std::move(v))){}
s::s(typeB v) : pimpl(std::make_unique<impl>(std::move(v))){}
s::s(typeC v) : pimpl(std::make_unique<impl>(std::move(v))){}

One use-case where std::variant has a functional advantage is function pointers:

void foo(std::variant<int,std::string>);
auto fp = &foo;


void bar(int);
void bar(std::string);
auto fp = &bar; // does not compile

but this would be another topic, as it is not possible to take the address of a constructor.


Do you want to share your opinion? Or is there an error, some parts that are not clear enough?

You can contact me anytime.