Search code examples
c++templatespipelinec++20functor

Generic multi-functor composition/pipelining in C++


Is it possible to achieve generic multi-functor composition/pipelining in C++ 20?

struct F{//1st multi-functor
  template<typename T> void operator()(const T& t){/*...*/}
};
struct G{//2nd multi-functor
  template<typename T> void operator()(const T& t){/*...*/}
};

F f;
G g;
auto pipe = f | g;//what magic should happen here to achieve g(f(...)) ? how exactly to overload the operator|()?

pipe(123);   //=> g(f(123);
pipe("text");//=> g(f("text");

EDIT: I tried both suggestions (from @Some_programmer_dude and @Jarod42) and I'm lost in errors:

  1. overloading operator|() like @Some_programmer_dude suggested
template<class Inp, class Out>
auto operator|(Inp inp, Out out){
    return [inp,out](const Inp& arg){
        out(inp(arg));
    };
}

generates:

2>main.cpp(71,13): error C3848: expression having type 'const Inp' would lose some const-volatile qualifiers in order to call 'void F::operator ()<F>(const T &)'
2>        with
2>        [
2>            Inp=F
2>        ]
2>        and
2>        [
2>            T=F
2>        ]
  1. using directly a lambda instead overloading operator|() like @Jarod42 suggested:
auto pipe = [=](const auto& arg){g(f(arg));};

generates:

2>main.cpp(86,52): error C3848: expression having type 'const F' would lose some const-volatile qualifiers in order to call 'void F::operator ()<_T1>(const T &)'
2>        with
2>        [
2>            _T1=int,
2>            T=int
2>        ]

Solution

  • So here is a quick little library.

    #define RETURNS(...) \
      noexcept(noexcept(__VA_ARGS__)) \
      -> decltype(__VA_ARGS__) \
      { return __VA_ARGS__; }
    
    namespace ops {
      template<class D>
      struct op_tag;
    
      template<class Second, class First>
      struct pipe_t;
    
      template<class D>
      struct op_tag {
        D const& self() const { return *static_cast<D const*>(this); }
        D& self() { return *static_cast<D*>(this); }
    
        auto operator()(auto&&...args) const
          RETURNS( self()(decltype(args)(args)...) )
        auto operator()(auto&&...args)
          RETURNS( self()(decltype(args)(args)...) )
      };
      
      template<class Second, class First>
      struct pipe_t:op_tag<pipe_t<Second, First>> {
        Second second;
        First first;
        pipe_t( Second second_, First first_ ):
          second(std::move(second_)),
          first(std::move(first_))
        {}
        auto operator()(auto&&...args)
          RETURNS( second(first(decltype(args)(args)...)) )
        auto operator()(auto&&...args) const
          RETURNS( second(first(decltype(args)(args)...)) )
      };
      template<class Second, class First>
      auto operator|(op_tag<First> const& first, op_tag<Second> const& second)
        RETURNS( pipe_t<Second, First>{ second.self(), first.self() } )
    }
    

    It is considered rude to overload operators in a greedy way. You only want your operator overloads to participate with types you specifically support.

    Here I require that types inherit from op_tag<T> to indicate they are interested in being an operation.

    We then modify your code a tiny bit:

    struct F:ops::op_tag<F>{//1st multi-functor
      template<typename T>
      auto operator()(const T& t){
          std::cout << "f(" << t << ")";
          return -1;
      }
    };
    struct G:ops::op_tag<G>{//2nd multi-functor
      template<typename T> auto operator()(const T& t){
          std::cout << "g(" << t << ")";
          return 7;
      }
    };
    

    adding the tag and return values (otherwise, f(g(x)) makes no sense unless g returns something).

    And the code you wrote now works.

    We can also add support for std::functions and even raw functions if you'd like. You'd add suitable operator| overloads in namespace ops, and require people to using ops::operator| to bring the operator into scope (or use it with a op_tag'd type).

    Live example.

    Output:

    f(123)g(-1)f(text)g(-1)