Search code examples
c++c++20template-meta-programmingperfect-forwarding

How to Ensure Type Correctness When Implementing Partial Application


As someone who isn't too familiar with the ins and outs of template metaprogramming yet, I wanted to take a shot at implementing a (naive) basic partial-application helper that binds arguments to the front (à la std::bind_front). This is quick-and-dirty for personal training reasons, and not for production (hence using a lambda instead of a named functor, etc).

The first naive (and wrong) implementation I came up with is:

template<typename Func, typename... Args1>
auto partial(const Func& func, Args1&&... args1)
{
    return [&]<typename... Args2>(Args2&&... args2) {
        return std::invoke(func, std::forward<Args1>(args1)..., std::forward<Args2>(args2)...);
    };
}

(^ NOT Minimum reproducible example)

This is probably the most "straightforward" approach, but it is wrong because it can result in dangling references to the args1 arguments of the outer function. The same can happen if I "cache" the arguments with std::forward_as_tuple(args1...).

So, to "fix" this, I decided to std::move all args1 arguments into a tuple and use a tuple-based approach instead. This is what I came up with:

template<typename Func, typename... Args1>
auto partial(const Func& func, Args1&&... args1)
{
    return [=, bound_args = std::tuple{ std::move(args1)... }]<typename... Args2>(Args2&&... args2) {
        return std::apply(func, std::tuple_cat(std::move(bound_args), std::forward_as_tuple(args2...)));
    };
}

(^ Minimum reproducible example)

This passes basic use-cases, but it erases reference semantics and qualifiers and also always std::moves the caller's lvalues. It also means all the arguments stored in the std::tuple are copied before being fed to the user's function; something like partial(fn, std::make_unique<int>(3))() may not work anymore, and you can't bind to any function that accepts an lvalue reference. This implementation is just plain wrong.

Usually, I'd just go and read an implementation and glean these things from there, but std::bind_front's in particular just flies over my head, so I'd hugely appreciate an answer that does the minimum amount of changes to address these issues. This is one of those situations where my intuition about semantics just completely fails me.

What should I be doing? Copying? Moving? Perfect forwarding? Casting? A mixture? In cases like this, how can I preserve reference semantics and qualifiers without dangling references and without needlessly copying or moving elements beyond what is absolutely needed?


Solution

  • You can explicitly specify the type of the bound arguments, i.e. std::tuple<Func, Args1...>, to enable reference capture for lvalues ​​and value construction for rvalues (​​Args1 is an lvalue when Args1&& is an lvalue, and Args1 is a value type when Args1&& is an rvalue). For example:

    #include <memory>
    #include <tuple>
    #include <functional>
    
    template<typename Func, typename... Args1>
    constexpr decltype(auto) partial(Func&& func, Args1&&... args1)
    {
      return [func_args1_tuple = 
              std::tuple<Func, Args1...>(
                std::forward<Func>(func),
                std::forward<Args1>(args1)...
              )]<typename... Args2>(Args2&&... args2) mutable -> decltype(auto) {
                return std::apply(
                  [&](auto& func, auto&... args1) -> decltype(auto) {
                    return std::invoke(std::forward<Func>(func), 
                                       std::forward<Args1>(args1)...,
                                       std::forward<Args2>(args2)...);
                  }, func_args1_tuple);
             };
    }
    

    Demo