3
\$\begingroup\$

While solving an online excersise I have written some basic implementation for a visit function that works with std::any.
The idea is to facilitate the declarative approach for handling std::any instead of using switch of nested if else constructions which are very hard to maintain.

Unfortunatelly, C++ lacks reflection features to deduce a list of underlying types of std::any object from a dispatcher callable, so one have to provide a template list of underlying types that std::any can hold.

Here is an example usage for solving the excercise:

int productSum(std::vector<any> array) 
{
    struct
    {
        int operator()(int i, size_t)
        {
            return i;
        }
        
        int operator()(const std::vector<any>& array, size_t level = 1)
        {
            int sum = 0;
            for (const auto& a : array)
                sum += level * std::get<int>(impl::visit<int,
                                                     std::vector<any>>(*this, a, level + 1));
            return sum;
        }
        
    } dispatcher{}; 

    return dispatcher(array);
}

And the implementation itself:

#include <utility>
#include <variant>
#include <type_traits>

namespace impl
{
    template <typename T, typename... Ts>
    struct unique{ using type = T; };

    template <typename... Ts, typename U, typename... Us, template <typename...> class TTupleT>
    struct unique<TTupleT<Ts...>, U, Us...> : 
            std::conditional_t<std::disjunction_v<std::is_same<U, Ts>...>, 
                    unique<TTupleT<Ts...>, Us...>, 
                    unique<TTupleT<Ts..., U>, Us...>> 
    {};
    
    template <typename ... T>
    using unique_variant = typename unique<std::variant<>, T...>::type;
    
    // TODO: remove duplicate return values from variant
    template <typename ... DispatchT, typename CallableT, typename ... ArgsT>
    auto visit(CallableT &&callable, const std::any& any, ArgsT &&... args)
    {
        using retval_t = unique_variant<decltype(std::declval<CallableT>()(std::declval<DispatchT>(),
                                                                                                                                            std::declval<ArgsT>()...))...>;
        retval_t ret{};
        
        // typeid can't be used within a fold expression
        (void)std::initializer_list<int>{(any.type() == typeid(DispatchT) && 
                                                                            (ret = std::forward<CallableT>(callable)(
                                                                                std::any_cast<DispatchT>(any), 
                                                                                std::forward<ArgsT>(args)...
                                                                            ), 
                                                                             false),0)...};
        return ret;
    }
}

I know that most certainly there are problems with type qualifiers, so I would appreciate any advice.

\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

(Here's your code in Godbolt: https://godbolt.org/z/snTPjb6zW )

You have some crazy deep indentation (off the right side of my browser window). Consider switching to "Python-style" indentation, e.g. instead of

        // typeid can't be used within a fold expression
        (void)std::initializer_list<int>{(any.type() == typeid(DispatchT) && 
                                                                            (ret = std::forward<CallableT>(callable)(
                                                                                std::any_cast<DispatchT>(any), 
                                                                                std::forward<ArgsT>(args)...
                                                                            ), 
                                                                             false),0)...};
        return ret;

you could write something like

        (void)std::initializer_list<int>{
            (
                any.type() == typeid(DispatchT) &&
                (
                    ret = std::forward<CallableT>(callable)(
                        std::any_cast<DispatchT>(any),
                        std::forward<ArgsT>(args)...
                    ),
                    false
                ), 0
            )...
        };
        return ret;

Of course this gets way easier with C++11 lambdas. Notice that my indentation style doesn't change:

        int dummy[] = {
            [&]() {
                if (any.type() == typeid(DispatchT)) {
                    ret = std::forward<CallableT>(callable)(
                        std::any_cast<DispatchT>(any),
                        std::forward<ArgsT>(args)...
                    );
                }
                return 0;
            }() ...
        };
        (void)dummy;
        return ret;

// typeid can't be used within a fold expression

That's not true at all. Maybe you initially forgot the parens in (x && ...)?


Design-wise, it would be easier to use (and also more performant) if you eliminated the dependency on std::variant. Instead of accepting a callable that returns X-or-Y-or-Z and then wrapping it up yourself in a std::variant<X,Y,Z> which your user then has to unwrap, it would be a ton easier to accept a callable that returns T, and then return T yourself, and let your user just use the T that you return. Like this: https://godbolt.org/z/3fEbe5s7n

    template<class T, class... Us>
    struct all_the_same {
        static_assert((std::is_same_v<T, Us> && ...));
        using type = T;
    };
    
    template <class... Ts, class Callable, class... Args>
    auto visit(Callable&& callable, const std::any& any, Args&&... args)
    {
        using R = typename all_the_same<
            decltype(std::declval<Callable>()(std::declval<Ts>(), std::declval<Args>()...))...
        >::type;
        R ret{};

Your use of std::declval<DispatchT>() is questionable. You seem to want each DispatchT to be a simple prvalue class type like std::vector<int> or int, which means that std::declval<DispatchT>() would be an xvalue (a DispatchT&&). But what you're actually receiving to dispatch on is a const std::any&, which means the thing you pull out of it ought to be a const DispatchT&. You should adjust your declval and your any_cast accordingly.


Naming-wise, I strongly recommend to use plural names for packs and singular names for non-packs. So your DispatchT... should become DispatchTs... (or in fact just Ts...); your ArgsT... should become Args...; and so on. This convention makes it easier to reason about pack-expansions at a glance. Compare your

            [&]() {
                if (any.type() == typeid(DispatchT)) {
                    ret = std::forward<CallableT>(callable)(
                        std::any_cast<DispatchT>(any),
                        std::forward<ArgsT>(args)...
                    );
                }
                return 0;
            }() ...

to my preferred

            [&]() {
                if (any.type() == typeid(Ts)) {
                    ret = std::forward<Callable>(callable)(
                        std::any_cast<const Ts&>(any),
                        std::forward<Args>(args)...
                    );
                }
                return 0;
            }() ...

I claim that mine is easier to read at a glance, because you can "brace-match" the plural nouns with the ... suffixes, without even knowing what their declarations look like. We know Ts and Args are packs; we know Callable is a single type. (We also know that Callable is the type of callable and Args... are the types of args..., without being told.)


Efficiency-wise, notice that you're not short-circuiting: you're going to evaluate any.type() == typeid(T) for every T in the pack, even if the first one is immediately a match. This is probably fine because typeid is super cheap (on Itanium-ABI platforms — I think it's more expensive on Windows), but you might consider adding a boolean has_value so you can bail early. Alternatively — better? — pull const auto& type = any.type() out into a local variable; the compiler might be able to tell that it's not modified after that point, and thus short-circuit the comparisons for you.

(The compiler cannot inline or combine repeated calls to any.type() itself, though, because it's type-erased. The whole point of type-erasure is that the compiler won't be able to statically tell what that function does.)


Compare to https://quuxplusone.github.io/blog/2020/09/29/oop-visit/


EDITED TO REPLY — You commented:

R = typename all_the_same presumes that all the return types must be the same. std::visit [...] implies that R must be the same. I just decided to [return a] std::variant, so one could chain-pass it to another visitor.

Right, std::visit basically takes many-possible-input-types and flattens them down to one-possible-output-type: the "visitor" callable must return the same type for each possible input type. Of course the programmer is free to make that single return type something complicated like std::variant<int, double> if they want to; but the STL doesn't force the programmer into that kind of complication if they don't want it. Compare:

std::variant<int, double> v = 42;
double r = std::visit(
    [](auto x) { return double(x + 1); },
    v
);

versus what they could have done:

std::variant<int, double> v = 42;
std::variant<int, double> r = std::visit(
    [](auto x) { return x + 1; },
    v
);
std::visit([](auto x) { std::cout << x; }, r);  // chaining

and versus what the programmer can do if they feel like it:

std::variant<int, double> v = 42;
std::variant<int, double> r = std::visit(
    [](auto x) { return std::variant<int, double>(x + 1); },
    v
);
std::visit([](auto x) { std::cout << x; }, r);  // chaining

With my proposed API for your code,

// the programmer can still deal in variants if they _want_ to...
std::any v = 42;
std::variant<int, double> r = impl::visit<int, double>(
    [](auto x) { return std::variant<int, double>(x + 1); },
    v
);
std::visit([](auto x) { std::cout << x; }, r);  // chaining

// ...they're just not _forced_ to.
std::any v = 42;
double r = impl::visit<int, double>(
    [](auto x) { return double(x + 1); },
    v
);
std::cout << r;  // flattening
\$\endgroup\$
6
  • \$\begingroup\$ Using of R = typename all_the_same presumes that all the return types must be the same. Documentation for std::visit states 1) The value returned by the selected invocation of the visitor., but implies that R must be the same. I just decided to make it possible to allow multiple return types via a deduced std::variant, so one could chain-pass it to another visitor. \$\endgroup\$ Apr 12, 2022 at 18:38
  • \$\begingroup\$ I wasn't sure how to properly deduce the return type of a callable's operator, so I wrote std::declval<DispatchT>(). I didn't say it was right. It just worked for the context of the excercise. \$\endgroup\$ Apr 12, 2022 at 18:43
  • \$\begingroup\$ "I wasn't sure how to properly deduce the return type of a callable's operator..." Just add decltype( ) around the expression you're interested in. Here the expression is std::forward<CallableT>(callable)(std::any_cast<DispatchT>(any), std::forward<ArgsT>(args)), so that's what you should use. Except that I still say that DispatchT should be const DispatchT& so you're not making unnecessary copies. \$\endgroup\$ Apr 12, 2022 at 19:18
  • \$\begingroup\$ You mean std::any_cast<const Dispatch&>(any)? \$\endgroup\$ Apr 12, 2022 at 19:29
  • \$\begingroup\$ "You mean std::any_cast<const Dispatch&>(any)?" Yes, that's right. \$\endgroup\$ Apr 14, 2022 at 16:13

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.