Template selection on argument type of passed function object without violating the DRY prinicple

120 Views Asked by At

Here I present a first cut of two variants of the template function over(vec, f).

Both versions iterate over a vector-like object and call a function object for each element.

One version calls the function object with two arguments - an element reference and an index - the second with just the element reference.

The idea is to get the compiler to select the version that matches the passed-in lambda, so the user can express intent in the lambda signature without having to select a differently-named free function.

here's the code:

#include <vector>
#include <iostream>

template<typename... Ts> struct make_void { typedef void type;};
template<typename... Ts> using void_t = typename make_void<Ts...>::type;


template<class Vector, class F>
auto over(Vector &&vec, F &&f)
-> void_t<decltype(f(vec.operator[](std::declval<std::size_t>()), std::declval<std::size_t>()))>
{
    const auto size = vec.size();
    for (std::size_t i = 0; i < size; ++i) {
        f(vec[i], i);
    }
}


template<class Vector, class F>
auto over(Vector &&vec, F &&f)
-> void_t<decltype(f(*vec.begin()))>
{
    for (auto &&x : vec) {
        f(x);
    }
}

int main() {
    std::vector<float> vf = {1.0, 1.1, 1.2};

    std::cout << "two-argument form:\n";
    over(vf, [](auto &&val, auto &&index) {
        std::cout << index << " : " << val << std::endl;
    });

    std::cout << "\none-argument form:\n";
    over(vf, [](auto &&val) {
        std::cout << val << std::endl;
    });
}

Question:

You will see that the clause inside the void_t<> return type generator knows all about the implementation of the function. I am displeased by this as:

a) it's leaking implementation details in the interface, and

b) it's not DRY.

Is there a better way to achieve this which:

a) allows the implementation to change without changing the template-enabler,

b) doesn't look like my dogs had a play-fight on my keyboard?

3

There are 3 best solutions below

2
On

In C++17, you might use SFINAE based on std::is_invocable, something similar to:

template <class Vector, class F>
std::enable_if_t<std::is_invocable<F,
                                   typename Vector::value_type,
                                   std::size_t>::value>
over(const Vector& vec, F&& f)
{
    const auto size = vec.size();
    for (std::size_t i = 0; i < size; ++i) {
        f(vec[i], i);
    }
}

template <class Vector, class F>
std::enable_if_t<std::is_invocable<F, typename Vector::value_type>::value>
over(const Vector& vec, F&& f)
{
    const auto size = vec.size();
    for (const auto& e : vec) {
        f(e);
    }
}
4
On

For this example, avoiding the "repetition" is going to be way more work/complexity than the repetition itself, but the basic idea is to count the ar-iness of the function, and then dispatch appropriately. A very similar problem is discussed here: Call function with part of variadic arguments. Using the implementation of function_traits you can can implement a function called dispatch (I called it foo in my answer to that question):

template<typename F, std::size_t... Is, class Tup>
void dispatch_impl(F && f, std::index_sequence<Is...>, Tup && tup) {
    std::forward<F>(f)( std::get<Is>(std::move(tup))... );
}

template<typename F, typename... Args>
void dispatch(F && f, Args&&... args) {
    dispatch_impl(std::forward<F>(f),
             std::make_index_sequence<function_traits<F>::arity>{},
             std::forward_as_tuple(args...) );
}


template<class Vector, class F>
void over(Vector &&vec, F &&f)
{
    std::size_t i = 0;
    for (auto &&x : vec) {
        dispatch(std::forward<F>(f), x, i);
        ++i;
    }
}

This answer is 14 compliant as well. Live example: http://coliru.stacked-crooked.com/a/14750cef6b735d7e.

Edit: This approach does not work with generic lambdas. So another approach would be to implement dispatch this way:

template<typename F, typename T>
auto dispatch(F && f, T && t, std::size_t i) -> decltype((std::forward<F>(f)(std::forward<T>(t)),0)) {
    std::forward<F>(f)(std::forward<T>(t));
    return 0;
}

template<typename F, typename T>
auto dispatch(F && f, T && t, std::size_t i) -> decltype((std::forward<F>(f)(std::forward<T>(t), i),0)) {
    std::forward<F>(f)(std::forward<T>(t),i);
    return 0;
}
0
On

OK, here's my first serious attempt.

Is there something better than this?

#include <vector>
#include <iostream>
#include <string>

namespace notstd
{
    /* deduce the traits of a container argument, even if it's an rvalue-reference */
    template<class T>
    struct container_traits
    {
        static_assert(not std::is_pointer<T>(), "");
        using without_reference_type = std::remove_reference_t<T>;
        using base_type = std::remove_cv_t<without_reference_type>;

        static constexpr auto is_const = std::is_const<without_reference_type>::value;
        static constexpr auto is_volaile = std::is_volatile<without_reference_type>::value;

        using base_value_type = typename base_type::value_type;
        using value_type = std::conditional_t<is_const, std::add_const_t<base_value_type>, base_value_type>;
    };

    template<class Function, class...Args>
    struct is_compatible_function
    {
        template<class FArg> static auto test(FArg&& f) -> decltype(f(std::declval<Args>()...), void(), std::true_type());
        static auto test(...) -> decltype(std::false_type());

        static constexpr auto value = decltype(test(std::declval<Function>()))::value;
    };
}

/**
 * define the 2-argument algorithm, plus provide function compatibility checks
 */
template<class Vector, class Function>
struct over_op_2
{
    using arg_1_type = std::add_lvalue_reference_t<typename notstd::container_traits<Vector>::value_type>;
    using arg_2_type = std::size_t;

    static constexpr auto is_compatible_function = notstd::is_compatible_function<Function, arg_1_type, arg_2_type>::value;

    template<class VectorArg, class FunctionArg> 
    void operator()(VectorArg&& vec, FunctionArg&& f) const
    {
        std::size_t i = 0;
        for (auto &&x : vec) {
            f(x, i);
            ++i;
        }
    }
};

/**
 * define the 1-argument algorithm, plus provide function compatibility checks
 */
template<class Vector, class Function>
struct over_op_1
{
    using arg_1_type = std::add_lvalue_reference_t<typename notstd::container_traits<Vector>::value_type>;

    static constexpr auto is_compatible_function = notstd::is_compatible_function<Function, arg_1_type>::value;

    template<class VectorArg, class FunctionArg> 
    void operator()(VectorArg&& vec, FunctionArg&& f) const
    {
        for (auto &&x : vec) {
            f(x);
        }
    }
};

/**
 * Choose op_2 if the Function type will allow it, otherwise op_1 if that's possible, otherwise void (error)
 */
template<class Vector, class Function>
struct select_over_op
{
    using op_1 = over_op_1<Vector, Function>;
    using op_2 = over_op_2<Vector, Function>;
    using type = std::conditional_t
    <
        op_2::is_compatible_function, 
        op_2, 
        std::conditional_t
        <
            op_1::is_compatible_function, 
            op_1,
            void
        >
    >;

    static_assert(not std::is_same<type, void>(), "function signatures are incompatible");
                                                                                                                ;
};

/**
 * iterate over a vector-like container, calling f(elem, i) if available or f(elem) if not.
 * @param vec is a reference to a vector-like object
 * @param f is a function which is compatible with one of:
 *        void([const]value_type&, std::size_t), or
 *        void([const]value_type&)
 */
template<class Vector, class F>
decltype(auto) over(Vector &&vec, F &&f)
{
    auto op = typename select_over_op<decltype(vec), decltype(f)>::type();
    return op(std::forward<Vector>(vec), std::forward<F>(f));    
}



int main() {
    std::vector<double> v{4.1,5.1,6.1};
    over(v, [] (auto x, auto y) { std::cout << x << ", " << y << "\n"; });
    over(v, [] (auto && x, auto&& y) { std::cout << x << ", " << y << "\n"; });
    over(v, [] (auto const& x, auto const& y) { std::cout << x << ", " << y << "\n"; });
    over(v, [] (auto x, auto&& y) { std::cout << x << ", " << y << "\n"; });
    over(v, [] (auto && x, auto&& y) { std::cout << x << ", " << y << "\n"; });
    over(v, [] (auto const& x, auto&& y) { std::cout << x << ", " << y << "\n"; });
    over(v, [] (auto x) { std::cout << x << "\n"; });
    over(v, [] (auto const& x) { std::cout << x << "\n"; });
    over(v, [] (auto && x) { std::cout << x << "\n"; });

    // converting to int ok (but meh)
    over(v, [] (int x) { std::cerr << x << "\n"; });

    // converting to string correctly fails
    // over(v, [] (std::string x) { std::cerr << x << "\n"; });

    // const vector...
    const std::vector<double> vc{4.1,5.1,6.1};
    over(vc, [] (auto && x, auto&& y) { std::cout << x << ", " << y << "\n"; });

    // breaking const contract on the value_type also fails
    // over(vc, [] (double& x, auto&& y) { std::cout << x << ", " << y << "\n"; });

    return 0;
}

http://coliru.stacked-crooked.com/a/cab94488736b75ed