0

Is it possible to implement function currying via coroutines? How would you do it?

Normally, if I need to curry a function, I use boost::hana::curry, like this, but I'm curious to know if C++20's coroutines can serve the same task as well.

Enlico
  • 23,259
  • 6
  • 48
  • 102
  • 5
    Coroutines provide concurrency, currying is a functional programming paradigm. Why do you think the two are related? – nurettin Apr 03 '23 at 07:28
  • 1
    @nurettin, coroutines provide concurrency? I don't see any concurrency in [coroutines used to define generators](https://en.cppreference.com/w/cpp/coroutine/generator). Nor I see concurrency when they are used for [composing operations returning optional](https://youtu.be/mlP1MKP8d_Q?t=1877). The latter is typical of functional programming, by the way. – Enlico Apr 03 '23 at 07:36
  • Actually both of the usecases I mentioned above are typical of functional programming. – Enlico Apr 03 '23 at 07:42
  • coroutines are asynchronous pieces of code that are suspended when they are blocked by an IO operation and the event manager continues with the execution of the next coroutine. The generator is there to organize code with the help of yield. It has nothing to do with currying parameters. – nurettin Apr 03 '23 at 07:55
  • @nurettin, I've not said that a generator has to do with currying. I've said that saying that coroutines are for concurrency is incorrect. Generators have nothing to do with concurrency, and the optional monad has nothing to do with concurrency, yet they can both be modelled with couroutines. Concurrency is _one_ application field of coroutines. The statement that _coroutines are asynchronous pieces of code_ is just wrong. If you don't put asynchronicity in coroutines they're just not asynchrounous at all. – Enlico Apr 03 '23 at 08:33
  • I did not say that generators provide concurrency. Just read one statement above. And coroutines do provide concurrency, and there is no other use case for them. I am still interested in why you think coroutines have to do with currying, as generators have nothing to do with currying, but you responded with a generator example to my earlier question. – nurettin Apr 03 '23 at 09:14
  • _And coroutines do provide concurrency, and there is no other use case for them_. Show me where in the standard it's written that coroutines are for concurrency. They provide more than that, e.g. the two examples I mentioned above. As far as currying is concerned, I am wondering **_if_** they also allow to implement currying. If I knew they could or they couldn't... I wouldn't have asked the question, don't you think? – Enlico Apr 03 '23 at 09:25
  • 1
    Yes, there is no other logic for suspending the execution of a function other than running another one. I don't know what was in your mind when you asked how to bake a cake with stones. But there has to be a hint or reasoning behind asking questions about relationships otherwise we would only get garbage. So I want to know if this is garbage or not. – nurettin Apr 03 '23 at 10:04
  • @nurettin, can you tell where concurrency is in the examples I linked? If there is indeed concurrency there, please, show how. If not, that means that concurrency is not the only usecase of coroutines. – Enlico Apr 03 '23 at 10:14
  • 2
    I think the answer is yes, but it's going to look something like `curry_t curry(invocable f) { f( co_yield... ); }`, where `curry_t::promise_type::yield_value` returns an awaiter that returns the next supplied argument. I'm not sure that doesn't need an ordinary curry implementation in the middle of that. – Caleth Apr 03 '23 at 10:17
  • @Caleth, do you mean that the coroutine would only take care of accepting the args one by one, but the mechanism for storing them before they're all available would still be a pre-existing currying facility itself? – Enlico Apr 03 '23 at 10:19
  • 1
    @Enlico yes, you have to store the existing parameters somewhere. It would also be limited to one call to the underlying function, because coroutines are single-pass – Caleth Apr 03 '23 at 10:23
  • @nurettin, _`co_await` certainly will_ is a false statement as well, at least because some `co_await`s come precisely from `co_yield`, even though the compiler writes them and not the programmer. – Enlico Apr 03 '23 at 10:27
  • @Enlico couroutines without concurrency makes no sense to me, and I'm not sure why anyone on earth would even want to argue this nonsense, but I do hope you find your answer. – nurettin Apr 03 '23 at 10:36
  • @nurettin, genereators have nothing to do with concurrency, but they are a valid usecase for coroutines. What's nonsense about it? – Enlico Apr 03 '23 at 11:03
  • @Enlico your question didn't mention generators. All your comments did. So maybe the question in your head which you didn't bother to write is really about using generators to curry function arguments. – nurettin Apr 03 '23 at 11:06
  • 1
    @nurettin: In C++20, the term "generators" refers to a *kind* of coroutine. – Nicol Bolas Apr 03 '23 at 13:23

2 Answers2

2

Currying in C++ more or less needs to look something like this.

There is an initiating function which takes a callable of some kind and returns a currying object:

auto curry = curry_func(some_function);

The currying object has an operator() overload that takes the next parameter and returns either a new currying object (either copying from or moving from the current object) or calls the function and returns its value. That last part requires some if constexpr gymnastics (or some equivalent). This means that the currying object needs to be some kind of template that knows at least the number of arguments it expects. So it may as well know their types.

So really, curry_func probably is curry_func<RetyrnType(Params...)>.

So what is going on in the currying object? It needs some way to store all of Params so that it can eventually invoke the curried call. It needs to store them in order, and it needs to then use them to invoke the callable. Also, we're creating new currying objects with each operator() invocation.

Can coroutines be used in a currying implementation? Yes, but not without a significant caveat.

Each curry function object (the things you call with a parameter) can only be called once. The elements of coroutine state cannot be copied, so if a curry function could be copied, it would have to be a shallow copy.

But there are some benefits. Most C++ curring implementations are horrifying nightmares of metaprogramming nonsense. Even at the highest level of how to apply the parameters to the function call, the code can get pretty ugly.

A coroutine currying implementation, by contrast, is pretty readable in the place where the parameters get applied. For example, this is what it looks like in the code I'm about to introduce:

template<std::size_t Sz>
using int_const = std::integral_constant<std::size_t, Sz>;

template<typename Ret, typename ...Params, std::size_t ...Ixs>
curried_function<0, Ret, Params...> curry_func_detail(Ret(*some_func)(Params...), std::index_sequence<Ixs...>)
{
  std::tuple args{ (co_yield int_const<Ixs>{})...};

  co_return std::apply(some_func, args);
}

template<typename Ret, typename ...Params>
curried_function<0, Ret, Params...> curry_func(Ret(*some_func)(Params...))
{
  return curry_func_detail(some_func, std::make_index_sequence<sizeof...(Params)>{});
}

curry_func_detail is pretty straightforward: an unpacked series of co_yields of integer constants. All of the complexity is hidden away in curried_function and its attendant types.

What this means is that you can relatively easily use the exact same coroutine machinery (curried_function and its ilk) to start playing different games. For example, this is what it would take to curry into two different functions:

template<std::size_t Sz>
using int_const = std::integral_constant<std::size_t, Sz>;

template<typename Ret1, typename ...Params1, std::size_t ...Ixs1, typename Ret2, typename ...Params2, std::size_t ...Ixs2>
curried_function<0, std::tuple<Ret1, Ret2>, Params1..., Params2...> double_curry_func_detail(
  Ret1(*func1)(Params1...), , std::index_sequence<Ixs1...>,
  Ret2(*func2)(Params2...), std::index_sequence<Ixs2...>)
{
  std::tuple args1{ (co_yield int_const<Ixs1 + 0>{})...};
  auto ret1 = std::apply(func1, args);

  std::tuple args2{ (co_yield int_const<Ixs2 + sizeof...(Params1)>{})...};
  auto ret2 = std::apply(func2, args);

  co_return std::tuple{ret1, ret2};
}

template<typename Ret1, typename ...Params1, typename Ret2, typename ...Params2>
auto double_curry_func(
  Ret1(*func1)(Params1...), Ret2(*func2)(Params2...))
{
  return double_curry_func_detail(func1, std::make_index_sequence<sizeof...(Params1)>{},
    func2, std::make_index_sequence<sizeof...(Params2)>{});
}

By contrast, taking an existing currying system and making it do something as relatively simple as this is... difficult. We could even start doing things like transforming parameters or something before passing them to the curried function.

The overall point is that the code that accumulates values and calls the function is distinct from the code that shepherds the values from the caller to the accumulation code. And the former is written in a fairly straightforward way.

That's really the only advantage of using coroutines.


So, what is actually going on here, implementation-wise? Here's a working example, so let's step through it.

co_yield e is a mechanism for transporting values between the coroutine function and the outside world, using the promise object as an intermediary. e is given to the promise via a call to yield_value. But the return value of that call is an awaitable that gets co_awaited on. This not only allows us to suspend the coroutine on each co_yield, it also allows us to use the awaitable's await_resume function to make co_yield e generate the parameter value.

Next, when we co_yield, we need to tell the promise which parameter we're trying to access. And we need to do that in a way that the promise can return different types (since the return type will dictate how co_await unpacks it).

That's the point of the integral_constant trick. We're sending a compile-time constant through a function parameter by template argument deduction. This allows yield_value to return await_for_index<I>, where I is the index passed. In turn, this allows await_for_index<I>::await_resume to return different values based on I. This compile-time index is forwarded to the promise so that it can return the right type as well.

So how do we actually shepherd the value? curried_function itself has an index in addition to the parameter types and return value. This tells us which parameter index we're talking about. This functor is neither copyable nor moveable (it can be made moveable, but I'll leave that as an exercise for the reader). Its operator() overload returns either another curried_function for the next parameter or the function's actual return value.

    auto operator()(parameter_t<ParamIndex, Params...> param) &&
    {
        promise_type &promise = hdl_.promise();
        promise.template set_param_value<ParamIndex>(param);

        hdl_.resume();

        if constexpr(ParamIndex + 1 == sizeof...(Params))
        {
            return promise.get_return_value();
        }
        else
        {
            return curried_function<ParamIndex + 1, Ret, Params...>(std::move(*this));
        }
    }

The function gives the promise the appropriate parameter, then resumes the coroutine. If that was the last parameter, it assumes the coroutine co_returned and returns the return value. Otherwise, it returns a new curried_function that services the next parameter.

Note that it is declared &&. This is important, as it does a de-facto move from *this into the new curry function. So if you don't call it as a prvalue, it must be called with an explicit std::move. This makes it difficult to call it more than once, and makes sure that everyone understands visually that you are, in fact, moving *this and therefore should not touch it after this operation.

This also means that there is exactly and only one object with a valid coroutine_handle at any one time.

Note that it would not be too difficult to make a multi-parameter version of this operator() overload.

The last part to touch on is how the promise shepherds the parameter. It stores it internally in a variant. This is done to keep the promise from having to store all of the parameters and to ensure that the parameters don't have to be default constructible.

Note that we don't unpack co_yield within the actual function call because in C++, there is no guarantee that parameters are evaluated in order. And we need in-order evaluation here, as the coroutine and the machinery need to be on the same page with regard to which parameter is currently being retrieved. So we instead unpack them in a brace-init-list inside of the initialization a std::tuple.


So here's the full code:

#include <iostream>
#include <utility>
#include <coroutine>
#include <tuple>
#include <variant>
#include <optional>

template<std::size_t ParamIndex, typename ...Params>
using parameter_t = std::tuple_element_t<ParamIndex, std::tuple<Params...>>;

template<std::size_t ParamIndex, typename Ret, typename ...Params>
class curried_function;

template<typename Ret, typename ...Params>
class curry_promise
{
    public:
        curry_promise() = default;

        template<std::size_t Ix>
        void set_param_value(parameter_t<Ix, Params...> param)
        {
            curr_param_.template emplace<Ix + 1>(param);
        }

        template<std::size_t Ix>
        parameter_t<Ix, Params...> get_curr_param()
        {
            return std::get<Ix+1>(curr_param_);
        }

        template<std::size_t I>
        auto yield_value(std::integral_constant<std::size_t, I>)
        {
            return await_for_index<I>(*this);
        }

        auto initial_suspend() noexcept {return std::suspend_never{};}
        auto final_suspend() noexcept {return std::suspend_always{};}

        curried_function<0, Ret, Params...> get_return_object();

        void unhandled_exception() { std::terminate(); }

        void return_value(Ret ret)
        {
            ret_.emplace(ret);
        }

        Ret get_return_value() const
        {
            return *ret_;
        }

        template<std::size_t Ix>
        class await_for_index
        {
        public:
            await_for_index(curry_promise &promise) : promise_{promise} {}

            bool await_ready() noexcept {return false;}

            void await_suspend(std::coroutine_handle<curry_promise> h) noexcept {}

            parameter_t<Ix, Params...> await_resume() noexcept
            {
                return promise_.template get_curr_param<Ix>();
            }

        private:
            curry_promise &promise_;
        };

    private:
        std::variant<std::monostate, Params...> curr_param_;
        std::optional<Ret> ret_;
};


template<std::size_t ParamIndex, typename Ret, typename ...Params>
class curried_function
{
public:
    using promise_type = curry_promise<Ret, Params...>;

    auto operator()(parameter_t<ParamIndex, Params...> param) &&
    {
        promise_type &promise = hdl_.promise();
        promise.template set_param_value<ParamIndex>(param);

        hdl_.resume();

        if constexpr(ParamIndex + 1 == sizeof...(Params))
        {
            return promise.get_return_value();
        }
        else
        {
            return curried_function<ParamIndex + 1, Ret, Params...>(std::move(*this));
        }
    }

    //Non-copyable, non-moveable.
    curried_function(curried_function const&) = delete;

    ~curried_function() { hdl_.destroy(); }

    template<std::size_t Sz, typename Ret2, typename ...Params2>
    friend class curried_function;

    friend class curry_promise<Ret, Params...>;

private:
    template<std::size_t OtherIndex>
    curried_function(curried_function<OtherIndex, Ret, Params...> &&prev_func)
        : hdl_{std::exchange(prev_func.hdl_, nullptr)}
    {
    }

    curried_function(std::coroutine_handle<promise_type> hdl)
        : hdl_(hdl) {}

private:
    std::coroutine_handle<promise_type> hdl_;
};

template<typename Ret, typename ...Params>
curried_function<0, Ret, Params...> curry_promise<Ret, Params...>::get_return_object()
{
    return curried_function<0, Ret, Params...>(std::coroutine_handle<curry_promise>::from_promise(*this));
}

template<std::size_t Sz>
using int_const = std::integral_constant<std::size_t, Sz>;

template<typename Ret, typename ...Params, std::size_t ...Ixs>
curried_function<0, Ret, Params...> curry_func_detail(Ret(*some_func)(Params...), std::index_sequence<Ixs...>)
{
  std::tuple args
  { (co_yield int_const<Ixs>{})...};

  co_return std::apply(some_func, args);
}

template<typename Ret, typename ...Params>
curried_function<0, Ret, Params...> curry_func(Ret(*some_func)(Params...))
{
  return curry_func_detail(some_func, std::make_index_sequence<sizeof...(Params)>{});
}





float test(int a, float b, int c)
{
    return a + b + c;
}

void co_test()
{
}

int main()
{
    std::cout << test(1, 2.3, 3) << std::endl;
    std::cout << curry_func(test)(1)(2.3)(3) << std::endl;
    return 0;
}
Nicol Bolas
  • 449,505
  • 63
  • 781
  • 982
1

I'm going to say that no, you can't reasonably create a curry mechanism with C++ corotines, because the corotine state is not copyable, i.e. the following code can't work

int g(int x, int y, int z) { return x + y + z; }
auto f = curry(g);
auto f1 = f(10);
auto f2 = f1(12);
auto f3 = f1(25); // error, can't re-use f1
Caleth
  • 52,200
  • 2
  • 44
  • 75
  • Is it necessary for a curry function to be copyable? – Nicol Bolas Apr 03 '23 at 13:22
  • @NicolBolas as a baseline, invokable more than once, not necessarily copyable. We aren't copying `f1` in my example – Caleth Apr 03 '23 at 13:49
  • OK, but is this a necessary facility for a currying mechanism to provide. – Nicol Bolas Apr 03 '23 at 13:50
  • @NicolBolas It's such a massive gotcha that I would say yes, it is necessary. – Caleth Apr 03 '23 at 14:11
  • I dont' see much value in storying a partially applied function only for the purpose of passing it multiple alternatives of successive arguments. I mean, I could just write `auto f2 = curry(g)(10)(12); f3 = curry(g)(10)(25);`. After all, repeating `curry(g)(10)` shouldn't be a problem as no actual work is done anyway, no? This is to say that I think too that not being able to invoke those partially applied functions (or the `f` itself) more than once is that big of a problem. – Enlico Apr 03 '23 at 18:12
  • @Enlico what about passing `curry(g)(10)` to an algorithm? – Caleth Apr 04 '23 at 08:35
  • In that case wouldn't having the `curry(g)(10)` object be movable be enough? – Enlico Apr 04 '23 at 08:51
  • @Enlico no, because the coroutine state held in that object can only supply one parameter in that position to `g`. Once it has been called once, the coro has moved on in initialising the args tuple. In Nicol's `curry_func_detail` there's only one `args` object – Caleth Apr 04 '23 at 08:53
  • @Caleth, oh ok, I think I see. Basically when I evaluate `curry(g)(10)`, the coroutine is suspended in the middle of the evaluation of the initializers of the tuple, so the `args` tuple is not even constructed yet, let alone being a thing I could think copying or moving anywhere :/ – Enlico Apr 04 '23 at 17:24