C ++ 14中的咖喱和部分应用

在本文中,我将向您介绍我个人最喜欢的C ++函数的一些可变选项和部分应用。 我还将展示我自己对此事的初步实现,并解释了无需复杂数学公式即可进行计算的关键点,这对您来说真的很简单。 我们还将看到kari.hpp库的内幕 ,该库将用于currying函数。 无论如何,里面有很多有趣的东西,欢迎您!


咖喱


那么,这是什么? 我猜这是您一直从Haskell程序员那里听到的那些话之一(当然,在monad之后)。 从本质上讲,该术语的定义非常简单,因此,那些已经使用ML类型语言或Haskell编写过代码的读者,或者从其他地方知道其含义的读者,可以随时跳过此部分。


Currying-是将一个函数转换为一个函数,该函数将N个参数转换为一个函数,该函数将单个参数转换为下一个参数的函数,然后继续执行,直到返回最后一个参数的函数为止。总体结果。 我向您展示示例对我有帮助:


int sum2(int lhs, int rhs) { return lhs + rhs; } 

这里我们有一个二进制加法函数。 如果我们想将其变成单变量函数呢? 实际上非常简单:


 auto curried_sum2(int lhs) { return [=](int rhs) { return sum2(lhs, rhs); }; } 

不,我们做了什么? 我们基于一个称为lambda的参数获取了一个值,该参数又接受了第二个参数并自行执行加法。 结果,我们可以将咖喱函数curried_sum2应用于我们的参数:


 // output: 42 std::cout << sum2(40, 2) << std::endl; std::cout << curried_sum2(40)(2) << std::endl; 

实际上,这就是所有操作的重点。 当然,可以使用任何工具的功能来实现它-它的工作方式完全相同。 每当我们从另一个参数中获取值时,我们将返回一个N-1个参数的咖喱函数:


 auto sum3(int v1, int v2, int v3) { return v1 + v2 + v3; } auto curried_sum3(int v1) { return [=](int v2){ return [=](int v3){ return sum3(v1, v2, v3); }; }; } // output: 42 std::cout << sum3(38, 3, 1) << std::endl; std::cout << curried_sum3(38)(3)(1) << std::endl; 

部分申请


部分应用程序 -一种调用N个参数的函数的方法,当它们仅接收一部分参数并返回其余参数的另一个函数时。


在这方面,应该注意的是,在类似Haskell的语言中,此过程在程序员的支持下自动运行。 我们在这里要做的是显式地执行它, sum3像这样调用sum3函数: sum3(38,3)(1)或也许这样: sum3(38)(3,1) 。 最重要的是,如果一个函数返回了另一个已被管理的函数,则也可以使用第一个函数的参数列表来调用它。 让我们来看一个例子:


 int boo(int v1, int v2) { return v1 + v2; } auto foo(int v1, int v2) { return kari::curry(boo, v1 + v2); } // output: 42 std::cout << kari::curry(foo)(38,3,1) << std::endl; std::cout << kari::curry(foo)(38,3)(1) << std::endl; std::cout << kari::curry(foo)(38)(3,1) << std::endl; 

实际上,我们在这里展示了kari.hpp用法的示例,因此它确实领先一步


设定目标


在我们写点东西之前,有必要(或希望)理解我们最终想要拥有的东西。 而且我们希望有一个机会来咖喱和部分应用可以在C ++中调用的任何函数。 分别是:


  • Lambda(包括通用Lambda)
  • 功能对象(功能部件)
  • 任何工具的功能(包括模板)
  • 可变函数
  • 一类的方法

可变参数函数可以通过指定我们想要使用的参数的确切数目来使用。 与std :: bind及其结果的标准交互也是可取的。 当然,我们需要一个机会来应用多个变量函数并调用嵌套函数,这样看来我们一直在使用一个库里函数。


我们也不能忘记性能。 我们需要最小化包装程序,参数传递及其存储的计算成本。 这意味着我们必须移动而不是复制,仅存储我们真正需要的东西,并尽可能快地返回(进一步删除)数据。


作者,您一直在尝试发明std::bind再次std::bind一个!


是的,没有。 std::bind无疑是一个功能强大且久经考验的工具,我无意编写其杀手or或其他替代品。 是的,它可以用于周期性和显式的局部应用(确切指定我们要应用的参数,应用的位置和数量)。 但这肯定不是最方便的方法,更不用说它并不总是适用,因为我们必须了解功能的多样性并据此编写特定的绑定。 例如:


 int foo(int v1, int v2, int v3, int v4) { return v1 + v2 + v3 + v4; } // std::bind auto c0 = std::bind(foo, _1, _2, _3, _4); auto c1 = std::bind(c0, 15, _1, _2, _3); auto c2 = std::bind(c1, 20, 2, _1); auto rr = c2(5); std::cout << rr << std::endl; // output: 42 // kari.hpp auto c0 = kari::curry(foo); auto c1 = c0(15); auto c2 = c1(20, 2); auto rr = c2(5); std::cout << rr << std::endl; // output: 42 

API


 namespace kari { template < typename F, typename... Args > constexpr decltype(auto) curry(F&& f, Args&&... args) const; template < typename F, typename... Args > constexpr decltype(auto) curryV(F&& f, Args&&... args) const; template < std::size_t N, typename F, typename... Args > constexpr decltype(auto) curryN(F&& f, Args&&... args) const; template < typename F > struct is_curried; template < typename F > constexpr bool is_curried_v = is_curried<F>::value; template < std::size_t N, typename F, typename... Args > struct curry_t { template < typename... As > constexpr decltype(auto) operator()(As&&... as) const; }; } 



kari::curry(F&& f, Args&&... args)


返回具有curry_t类型的函数对象(一个curry_t函数),该函数对象应用了可选参数args或将参数应用于给定函数f (该函数为空值,或者所传递的参数足以调用它)。


如果f参数包含已被调用的函数,则它将返回其副本并应用参数args




kari::curryV(F&& f, Args&&... args)


允许使用可变数量的参数来处理函数。 之后,可以使用不带参数的()运算符来调用这些函数。 例如:


 auto c0 = kari::curryV(std::printf, "%d + %d = %d"); auto c1 = c0(37, 5); auto c2 = c1(42); c2(); // output: 37 + 5 = 42 

如果f参数包含已被调用的函数,则它将为应用了args参数的可变数量的参数返回其副本,该副本的应用程序类型已更改。




kari::curryN(F&& f, Args&&... args)


通过指定我们要应用的确切数量N的参数( args给出的args除外),允许使用可变数量的参数来处理函数。 例如:


 char buffer[256] = {'\0'}; auto c = kari::curryN<3>(std::snprintf, buffer, 256, "%d + %d = %d"); c(37, 5, 42); std::cout << buffer << std::endl; // output: 37 + 5 = 42 

如果f参数包含已被调用的函数,则它将返回其副本,其中应用了args N个参数的应用程序类型已更改。




kari::is_curried<F>, kari::is_curried_v<F>


一些辅助功能,用于检查功能是否已被处理。 例如:


 const auto l = [](int v1, int v2){ return v1 + v2; }; const auto c = curry(l); // output: is `l` curried? no std::cout << "is `l` curried? " << (is_curried<decltype(l)>::value ? "yes" : "no") << std::endl; // output: is `c` curried? yes std::cout << "is `c` curried? " << (is_curried_v<decltype(c)> ? "yes" : "no") << std::endl; 



kari::curry_t::operator()(As&&... as)


允许全部或部分应用咖喱函数的操作员。 返回初始函数F的剩余参数的curried函数,或者返回该函数在旧参数和新参数的积压后由其应用获得的值。 例如:


 int foo(int v1, int v2, int v3, int v4) { return v1 + v2 + v3 + v4; } auto c0 = kari::curry(foo); auto c1 = c0(15, 20); // partial application auto rr = c1(2, 5); // function call - foo(15,20,2,5) std::cout << rr << std::endl; // output: 42 

如果使用curryVcurryN调用不带任何参数的curryV curryN ,则在有足够的参数时将调用该函数。 否则,它将返回部分应用的函数。 例如:


 auto c0 = kari::curryV(std::printf, "%d + %d = %d"); auto c1 = c0(37, 5); auto c2 = c1(42); // force call variadic function std::printf c2(); // output: 37 + 5 = 42 

实施细节


在向您提供实现的详细信息时,我将使用C ++ 17,以使文章简短,避免不必要的解释和堆积的SFINAE ,以及我必须在C ++中添加的实现示例。 14标准。 您可以在项目存储库中找到所有这些文件,也可以将它们添加到收藏夹中:)




make_curry(F&& f, std::tuple<Args...>&& args)


创建函数对象curry_t或将给定函数f应用于参数args的辅助函数。


 template < std::size_t N, typename F, typename... Args > constexpr auto make_curry(F&& f, std::tuple<Args...>&& args) { if constexpr ( N == 0 && std::is_invocable_v<F, Args...> ) { return std::apply(std::forward<F>(f), std::move(args)); } else { return curry_t< N, std::decay_t<F>, Args... >(std::forward<F>(f), std::move(args)); } } template < std::size_t N, typename F > constexpr decltype(auto) make_curry(F&& f) { return make_curry<N>(std::forward<F>(f), std::make_tuple()); } 

现在,关于此函数有两个有趣的事情:


  • 仅当可调用这些参数并且应用程序计数器N为零时,我们才将其应用于参数
  • 如果该函数不可调用,则将该调用视为部分应用程序,并创建一个包含该函数和参数的函数对象curry_t



struct curry_t


应该存储该函数的待办事项的函数对象以及最终应用该函数时将调用的函数。 这个对象就是我们将要调用并部分应用的对象。


 template < std::size_t N, typename F, typename... Args > struct curry_t { template < typename U > constexpr curry_t(U&& u, std::tuple<Args...>&& args) : f_(std::forward<U>(u)) , args_(std::move(args)) {} private: F f_; std::tuple<Args...> args_; }; 

我们将参数args_的积压存储在std :: tuple中有很多原因:


1)带有std :: ref的情况会在需要时自动处理以存储引用,默认情况下基于值
2)根据函数的参数方便地应用函数( std :: apply
3)它是现成的,所以您不必从头开始编写它:)


我们还存储了我们调用的对象和函数f_的值,并在创建一个对象(在下面的问题中对此进行扩展),移动或使用通用引用复制时选择类型时要小心。构造函数。


模板参数N用作可变函数的应用计数器。




curry_t::operator()(const As&...)


当然,使之全部起作用的是-调用函数对象的运算符。


 template < std::size_t N, typename F, typename... Args > struct curry_t { // 1 constexpr decltype(auto) operator()() && { return detail::make_curry<0>( std::move(f_), std::move(args_)); } // 2 template < typename A > constexpr decltype(auto) operator()(A&& a) && { return detail::make_curry<(N > 0 ? N - 1 : 0)>( std::move(f_), std::tuple_cat( std::move(args_), std::make_tuple(std::forward<A>(a)))); } // 3 template < typename A, typename... As > constexpr decltype(auto) operator()(A&& a, As&&... as) && { return std::move(*this)(std::forward<A>(a))(std::forward<As>(as)...); } // 4 template < typename... As > constexpr decltype(auto) operator()(As&&... as) const & { auto self_copy = *this; return std::move(self_copy)(std::forward<As>(as)...); } } 

调用操作员有四个功能已重载。


  1. 没有参数的函数不允许开始应用可变函数(由curryVcurryN创建)。 在这里,我们将应用程序计数器递减为零,以明确表明该函数已准备就绪,可以应用了,然后将make_curry函数所需的所有内容提供给了该函数。


  2. 单个参数的功能,将应用程序计数器减1(如果不为零),并将我们的新参数a放入参数args_的积压中,并将所有这些都传递给make_curry


  3. 可变参数函数,实际上是部分应用各种参数的技巧。 它的作用是将它们递归地一对一地应用。 现在,有两个原因导致无法一次全部应用它们:


    • 在没有剩余参数之前,应用程序计数器可以降为零
    • 可以更早地调用函数f_并返回另一个咖喱函数,因此所有下一个参数都将用于该函数

  4. 最后一个函数充当使用lvalue调用curry_t和使用rvalue调用函数之间的桥梁。



带有ref限定功能的标签使整个过程几乎神奇。 简而言之,在他们的帮助下,我们知道使用右值引用调用了一个对象,我们可以移动参数,而不必在最终调用函数make_curry 。 否则,我们将不得不复制参数,以便仍然有机会再次调用此函数,确保参数仍然存在。


红利


在得出结论之前,我想向您展示他们在kari.hpp中具有的语法糖的几个示例,这些示例可以作为奖励。


操作员部分


已经与Haskell进行过合作的程序员应该熟悉运算符部分,以便对所使用的运算符进行简短描述。 例如,结构(*2)生成一个单参数函数,返回该参数乘以2的结果。因此,我想要的是尝试用C ++编写类似的东西。 快说不做!


 using namespace kari::underscore; std::vector<int> v{1,2,3,4,5}; std::accumulate(v.begin(), v.end(), 0, _+_); // result: 15 std::transform(v.begin(), v.end(), v.begin(), _*2); // v = 2, 3, 6, 8, 10 std::transform(v.begin(), v.end(), v.begin(), -_); // v = -2,-3,-6,-8,-10 

功能组成


当然,如果我没有尝试写函数组合的话,我也不会是一个完整的人。 作为合成运算符,我选择了operator *作为该合成符号在数学中可用的所有符号中最接近的符号(从外观看)。 我也使用它来将结果函数应用于参数。 所以,这就是我得到的:


 using namespace kari::underscore; // 1 std::cout << (_*2) * (_+2) * 4 << std::endl; // output: 12 // 2 std::cout << 4 * (_*2) * (_+2) << std::endl; // output: 10 

  1. 函数(*2)(+2)的组合应用于4(4 + 2) * 2 = 12
  2. 函数(*2)应用于4 ,然后我们将(+2)应用于结果。 (4 * 2 + 2) = 10

您可以使用无点样式构建非常复杂的合成的相同方法,但请记住,只有Haskell程序员才能理解这些内容:)


 // (. (+2)) (*2) $ 10 == 24 // haskell analog std::cout << (_*(_+2))(_*2) * 10 << std::endl; // output: 24 // ((+2) .) (*2) $ 10 == 22 // haskell analog std::cout << ((_+2)*_)(_*2) * 10 << std::endl; // output: 22 

结论


我认为很明显,在实际项目中无需使用这些技术。 但是,我必须提一下。 毕竟,我的目标是证明自己并检查新的C ++标准。 我可以这样做吗? 而C ++吗? 好吧,我想,您刚刚看到我们俩都做到了。 我真的很感谢阅读整本书的所有人。

Source: https://habr.com/ru/post/zh-CN436488/


All Articles