Best Way to Do Variant Visitation with Lambdas

best way to do variant visitation with lambdas

You could use variadic templates to take the lambdas, and build a variant visitor using inheritance. That would retain the compile time checks.

template <typename ReturnType, typename... Lambdas>
struct lambda_visitor : public static_visitor<ReturnType>, public Lambdas... {
lambda_visitor(Lambdas... lambdas) : Lambdas(lambdas)... {}
};

And a little helper function to use argument type deduction (required for lambdas):

template <typename ReturnType, typename... Lambdas>
lambda_visitor<ReturnType, Lambdas...> make_lambda_visitor(Lambdas... lambdas) {
return { lambdas... };
// you can use the following instead if your compiler doesn't
// support list-initialization yet
// return lambda_visitor<ReturnType, Lambdas...>(lambdas...);
}

Now you can make visitors like this:

auto visitor = make_lambda_visitor<int>([](int) { return 42; },
[](std::string) { return 17; },
[](std::vector<int>) { return 23; });

Note: due to a detail of the overload resolution process that I wasn't aware of, this elegant solution causes weird ambiguity errors :(

See the follow-up question for the fix.

Recursively visiting an `std::variant` using lambdas and fixed-point combinators

Let's pick a simpler example. We want to implement gcd using the fix-point combinator. First go might be something like:

auto gcd = bh::fix([](auto self, int a, int b) {
return b == 0 ? a : self(b, a%b);
});

std::cout << gcd(12, 18);

This fails to compile with gcc ultimately producing this error:

/usr/local/boost-1.61.0/include/boost/hana/functional/fix.hpp:74:50: error: use of 'main()::<lambda(auto:2, int, int)> [with auto:2 = boost::hana::fix_t<main()::<lambda(auto:2, int, int)> >]' before deduction of 'auto'
{ return f(fix(f), static_cast<X&&>(x)...); }
^

The lambda we're passing to fix() has a deduced return type. But how do we deduce it? There's only a single return statement, and that one is recursive! We need to give the compiler some help. Either we need to break apart our return statement so that one has a clear type:

auto gcd = bh::fix([](auto self, int a, int b) {
if (b == 0) {
return a;
}
else {
return self(b, a%b);
}
});

or simply provide the return type explicitly:

auto gcd = bh::fix([](auto self, int a, int b) -> int {
return b == 0 ? a : self(b, a%b);
});

Both of these options compile and work.

The same is true of your original example. If you just specify that the lambda returns void, everything works:

auto lambda_visitor = bh::fix([](auto self, const auto& x) -> void
// ^^^^^^^^
{
bh::overload(
[](int y){ std::cout << y << "i\n"; },
[](float y){ std::cout << y << "f\n"; },
[&self](const std::vector<my_variant_wrapper>& y)
{
for(const auto& z : y) std::visit(self, z._v);
})(x);
});

std::visit(lambda_visitor, v);

Polymorphic visitor with lambdas

Here's an (incomplete) solution that works with any function object that has an unary, non-overloaded, non-templated operator(). Firstly, let's create an helper type alias to retrieve the type of the first argument:

template <typename> 
struct deduce_arg_type;

template <typename Return, typename X, typename T>
struct deduce_arg_type<Return(X::*)(T) const>
{
using type = T;
};

template <typename F>
using arg_type = typename deduce_arg_type<decltype(&F::operator())>::type;

Then, we can use a fold expression in a variadic template to call any function object for which dynamic_cast succeeds:

template <typename Base, typename... Fs>
void visit(Base* ptr, Fs&&... fs)
{
const auto attempt = [&](auto&& f)
{
using f_type = std::decay_t<decltype(f)>;
using p_type = arg_type<f_type>;

if(auto cp = dynamic_cast<p_type>(ptr); cp != nullptr)
{
std::forward<decltype(f)>(f)(cp);
}
};

(attempt(std::forward<Fs>(fs)), ...);
}

Usage example:

int main()
{
std::vector<std::unique_ptr<Base>> v;
v.emplace_back(std::make_unique<A>());
v.emplace_back(std::make_unique<B>());
v.emplace_back(std::make_unique<C>());

for(const auto& p : v)
{
visit(p.get(), [](const A*){ std::cout << "A"; },
[](const B*){ std::cout << "B"; },
[](const C*){ std::cout << "C"; });
}
}

ABC

live example on wandbox

Trying to return the value from std::variant using std::visit and a lambda expression

I am trying to get the underlying value from a std::variant using
std::visit or std::get.

If what you want is indeed holding the underlying current value, then you must have the visitation support a specific handling of each one possible. For instance, like this:

std::visit([] (const auto& var) {
if constexpr (std::is_same_v<std::decay_t<decltype(var)>, int>) {
// Do something with var of type int
}
else if constexpr (std::is_same_v<std::decay_t<decltype(var)>, char>) {
// Do something with var of type char
}
else if constexpr (std::is_same_v<std::decay_t<decltype(var)>, double>) {
// Do something with var of type double
}
else if constexpr (std::is_same_v<std::decay_t<decltype(var)>, bool>) {
// Do something with var of type bool
}
else if constexpr (std::is_same_v<std::decay_t<decltype(var)>, std::string>) {
// Do something with var of type std::string
}
}, v);

This is becasue C++ is a static typed language, as in, all types of variables must be known in compile time. Thus, the compiler cannot allow you to just declare auto and be done with it when you want what could be one of various types that the std::variant may hold as the current value at that moment during run-time.

... but I need a way to return the contained value.

Being statically typed, there's no way in C++ to do so without going through the possible cases. If you want a call that takes an instance of such std::variant and returns, say, a std::string, then you can modify the above code to return std::to_string(var) for each case in the above if/else statement.

  • Note this makes use of the constexpr if keyword. Worth reading into in case it is unclear why this is needed here. The use of std::decay_t template is needed to make sure the type compared in the std::is_same_v template is of the basic (non-const and non-reference qualified) type.

Taken from the comments:

Then why does std::visit([](auto&& arg){std::cout << arg;}, v); work?

This works because you're not trying to assign/copy the variable of the underlying type into a variable of your own. This, again, would have required knowing the type for such a variable during compilation. But when std::variant is being required to provide a string representation of its currently held value -- for example due to operator << of std::cout -- then internally what it does is of the same semantics as our if-else switch above, i.e. handling differently for each possible underlying type of this variant instance.

Clarification: There is obviously more than one way to specify handling of the different possibilities of what the std::variant instance might currently be holding. For example, as shown in the std::visit cppreference page, you could be using the template deduction guides based std::visit(overloaded { ... way of doing it, that while arguably makes for better and shorter code, it takes some deeper explaining to understand the mechanics of, the way I see it, as it includes inheriting from a lambda, among other things, and so I figured it to be beyond the explanatory scope of this answer in regards to how I understand the question being asked. You can read all about it here and here. Or easier see the usage code example in another answer to this question.


Regarding the compilation errors: This will compile for you just fine, but it doesn't achieve what you wanted:

using your_variant = std::variant<int,char,double,bool,std::string>;
your_variant v;

auto k = std::visit([](auto arg)-> your_variant {return arg;}, v);

Your lines didn't compile as the lambda needs to declare it's return type by -> your_variant explicitly as the compiler has no way of inferring it from the lambda.

Another valid syntax to solve the same problem is just declaring the parameter type, so the compiler can know what it's returning as if it was a function returning auto:

auto k2 = std::visit([](your_variant arg) {return arg;}, v);

The compilation problem with doing this:

constexpr size_t idx = v.index();
auto k = std::get<idx>(v);

is again, due to static typing, that v could hold any single one of its indices at run-time, and the template argument for std::get() needs to be known at compile time.

How does std::visit work with std::variant?

What I think, is that under the hood std::visit builds an array of function pointers (at compile time) which consists of instantiated function pointers for each type. The variant stores a run-time type index i (intgeral number) which makes it possible to select the right i-th function pointer and inject the value.

You might wonder how can we store function pointers with different argument types in a compile time array? -> This is done with type-erasure (I think), which means
one stores functions with e.g. a void* argument, e.g. &A<T>::call:

template<typename T>
struct A
{
static call(void*p) { otherFunction(static_cast<T*>(p)); }
}

where each call dispatches to the correct function otherFunction with the argument (this is your lambda at the end).
Type erasure means the function auto f = &A<T>::call has no more notion of the type T and has signature void(*)(void*).

std::variant is really complicated and quite sophisticated as lots of powerful fancy metaprogramming tricks come into play. This answer might only cover the tip of the iceberg :-)

What else do I need to use variadic template inheritance to create lambda overloads?

You didn't actually recurse.

// primary template; not defined.
template <class... F> struct overload;

// recursive case; inherit from the first and overload<rest...>
template<class F1, class... F>
struct overload<F1, F...> : F1, overload<F...> {
overload(F1 f1, F... f) : F1(f1), overload<F...>(f...) {}

// bring all operator()s from the bases into the derived class
using F1::operator();
using overload<F...>::operator();
};

// Base case of recursion
template <class F>
struct overload<F> : F {
overload(F f) : F(f) {}
using F::operator();
};

Why must std::visit have a single return type?

The return type of std::visit depends only on the types of the visitor and the variant passed to it. That's simply how the C++ type system works.

If you want std::visit to return a value, that value needs to have a type at compile-time already, because all variables and expressions have a static type in C++.

The fact that you pass a Variant(4.5) (so "clearly the visit would return a double") in that particular line doesn't allow the compiler to bend the rules of the type system - the std::visit return type simply cannot change based on the variant value that you pass, and it's impossible to decide on exactly one return type only from the type of the visitor and the type of the variant. Everything else would have extremely weird consequences.

This wikipedia article actually discusses basically the exact situation/question you have, just with an if instead of the more elaborate std::visit version:

For example, consider a program containing the code:

if <complex test> then <do something> else <signal that there is a type error>

Even if the expression always evaluates to true at run-time, most type checkers will reject the program as ill-typed, because it is difficult (if not impossible) for a static analyzer to determine that the else branch will not be taken.


If you want the returned type to be "variant-ish", you have to stick with std::variant. For example, you could still do:

auto rotateTypes = [](auto&& variant) {
return std::visit(
[](auto&& arg) -> std::variant<int, float, double> {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, int>) {
return float(arg);
} else if (std::is_same_v<T, float>) {
return double(arg);
} else {
return int(arg);
}
},
variant);
};

The deduced return type of std::visit then is std::variant<int, float, double> - as long as you don't decide on one type, you must stay within a variant (or within separate template instantiations). You cannot "trick" C++ into giving up static typing with an identity-visitor on a variant.



Related Topics



Leave a reply



Submit