visiting variants using lambdas - part 1

08 october 2016

While discussing upcoming C++17 features with other attendees at CppCon 2016, I was surprised to hear complaints about the fact that std::variant visitation requires an external callable object.

Even though std::visit requires an overloaded callable object as its first argument, it is possible to build such an object locally in the call site: this can easily be achieved by implementing something similar to std::overload, proposed in P0051R2.

The aforementioned task however becomes trivial when using boost::hana.

In this article, we're gonna take a look at:

  • "Traditional" variant visitation.

  • "Lambda-based" variant visitation using boost::hana.

  • "Fire-and-forget" variant visitation.

  • Future improvements/considerations.

boost::variant or std::variant?

Before looking at visitation techniques, I just wanted to mention that everything written in this article applies both to boost::variant and std::variant.

In fact, I've written a very simple wrapper for the upcoming examples that conditionally aliases vr::variant<Ts...> to std::variant<Ts...> if available, otherwise to boost::variant<Ts...>.

The vr::visit(xs...) function is similarly an alias for std::visit(xs...) if available, otherwise for boost::apply_visitor(xs...).

All the code snippets in this article are therefore compliant to the C++14 standard.

"Traditional" visitation

Visiting a variant is usually done by writing a visitor struct/class outside of the scope where the variant is actually being visited. The visitor contains a set of overloads that will match the types a variant can hold.

As an example, let's say that we have a variant type alias vnum that can hold one of several numerical types:

// Can hold either an `int`, a `float` or a `double`.
using vnum = vr::variant<int, float, double>;

We want to to visit this variant and print...

  • "$i" for integers.

  • "$f" for floats.

  • "$d" for doubles.

...where $ is the currently stored value.

To achieve that, it's sufficient to write a vnum_printer struct with an operator() overload for every type supported by our variant:

struct vnum_printer
{
    void operator()(int x)    { cout << x << "i\n"; }
    void operator()(float x)  { cout << x << "f\n"; }
    void operator()(double x) { cout << x << "d\n"; }
};

Afterwards, we can simply invoke vr::visit using an instance of vnum_printer as the first argument, and an instance of the vnum variant as the second one:

// Prints "0i".
vnum v0{0};
vr::visit(vnum_printer{}, v0);

// Prints "5f".
v0 = 5.f;
vr::visit(vnum_printer{}, v0);

// Prints "33.51d".
v0 = 33.51;
vr::visit(vnum_printer{}, v0);

This works, but requires us to define a new vnum_printer visitor type - this boilerplate can be avoided.

(You can find a similar example on GitHub.)

How does vr::visit work?

You might be asking...

How does vr::visit work?

The idea is very simple: by providing an overloaded callable object to vr::visit, it can internally do something like this:

// Warning: PSEUDOCODE!
// The code below is just an example for `vnum`.

using vnum = vr::variant<int, float, double>;

template <typename TVisitor>
auto visit(TVisitor visitor, vnum variant)
{
    // Everything in here would be generated by the compiler...
    
    if constexpr(variant.index() == 0)
    {
        return visitor(std::get<int>(variant));
    }
    else if constexpr(variant.index() == 1)
    {
        return visitor(std::get<float>(variant));
    }
    else if constexpr(variant.index() == 2)
    {
        return visitor(std::get<double>(variant));
    }
    else
    {
        static_assert(false, "Invalid variant state.");
    }
}

As you can see from the pseudocode above, all we need is a way of generating a set of overloads in order to build a valid visitor.

"Lambda-based" visitation

So, our task is to create an overload set from a variadic number of lambdas.

How can we do that?

We need a variadic make_overload(...) template function that takes any number of callable objects as input and returns a single callable object as its output.

The returned callable object will simply be an overload of all the input callable objects. Example:

auto x = make_overload
(
    [](int y){ cout << "int!\n"; },
    [](float y){ cout << "float!\n"; }
);

// Prints "int!".
x(0);

// Prints "float!".
x(0.f);

Covering the implementation of make_overload is out of the scope of this article (but an interesting idea for a future one). Therefore, you have a few options:

  1. Implement your own make_overload(...) function using online resources (e.g. the std::overload proposal).

  2. Copy-paste my vrm::core::make_overload(...) implementation.

  3. Use boost::hana::overload, a well-tested production-ready solution. This is the approach we're going to use for the article.

Once you get hold of a C++14-compliant compiler and #include <boost/hana.hpp>... you're pretty much done!

An overloaded callable object supporting a variant's types is a valid visitor. Here's a minimal example:

auto my_visitor = boost::hana::overload
(
    [](int){ std::cout << "int!\n"; },
    [](float){ std::cout << "float!\n"; }
);

vr::variant<int, float> my_variant{0};

// Prints "int!".
vr::visit(my_visitor, my_variant);

// Prints "float!".
my_variant = 5.f;
vr::visit(my_visitor, my_variant);

(You can find a similar example on GitHub.)

"Fire-and-forget" visitation

Sometimes you might want to visit a single variant with a "fire-and-forget" anonymous overload set of lambdas. That's very easy to implement, as well. We'll create a function that takes a variant as its first argument, then any number of callable objects:

template <typename TVariant, typename... TVisitors>
auto visit_in_place(TVariant&& variant, TVisitors&&... visitors)
{
    return vr::visit
    (
        boost::hana::overload(std::forward<TVisitors>(visitors)...),
        std::forward<TVariant>(variant)
    );
}

(Note: std::forward needs to be used in order to perfectly-forward variant and visitors, which are "forwarding references").

visit_in_place can be used as follows:

struct response_success
{
    payload _p;
    message _m;
};

struct response_failure
{
    message _m;
};

// ...

using http_response = vr::variant<response_success, response_failure>;
http_response send_http_request(/* ... */);

// ...

visit_in_place
(
    send_http_request("get_users", some_endpoint),
    [](const response_success& x)
    {
        cout << "Successfully received response:\n"
             << "\tMessage: " << x._m << "\n";

        update_user_list(x._p.as<std::vector<User>>());
    },
    [](const response_failure& x)
    {
        cerr << "Request failure:\n"
             << "\tError: " << x._m << "\n";
    }
);

As you can see from the example, visit_in_place is useful for very specific variant visits, where the logic is not reused and all boilerplate can be easily avoided.

Final thoughts, future improvements

"Lambda-based" visitation is a very elegant way of visiting variants with minimal boilerplate. "Fire-and-forget" visitation is even better when dealing with one-time specific variant visits.

Both of them, however, do not play nicely when a stateful visitor is required. In that case, writing a struct/class is probably the cleanest option. Making the visitor a local type or hiding it behind an implementation namespace is a good idea to keep it as local as possible.

Throughout the article we also did not cover recursive variant visitation: that will be the topic for my next article. It is possible to build a "lambda-based" and "fire-and-forget" visitation function for recursive variants as well, avoiding any extra overhead (e.g. std::function is not going to be used).

Something else that could be interesting to explore is linear overloading, provided by boost::hana::overload_linearly. This kind of overloading calls the first matching function in linear order - using this alongside SFINAE and generic lambdas could allow the creation of a visitor that uses a single function for a group of types sharing a common interface, or of a visitor that has a "default" case at the end (useful when you want to ignore some types). That will also be covered in a future article.

Thanks for reading!