仿函数&lambda表达式——把函数当参数传递!

· · 个人记录

0x0. 前言

蒟蒻前两天写优先队列的时候发现其模板的第三个参数使用不是很方便,就查阅了一下相关资料,了解到了 C++11 中有一类称为仿函数的用法,可以将函数封装直接传参,故撰稿与大家分享学习。以下代码也均为 C++ 代码。

upd 11.19

感谢 @jijidawang 提出的想法。

0x1. 回调函数

这里略跑个题,因为经典的将函数作为参数传递的方法是利用回调函数和 callback 关键字,但是其使用起来不是很方便,同时由于其传递的是函数指针,所以容易爆 UB (环境错误)和一些奇奇怪怪的误访问问题,所以这里提供了“仿函数”作为替代方案,同时回调函数相关部分也就不过多赘述了。

0x2. 仿函数

严格来说,仿函数在 C++11 已经不是什么新鲜玩意,它的原理是将待执行的函数重载进入某个类的 () 方法,实例化该类型后直接把该实例当成函数使用。一句话总结:实例化某重载 () 符号为待执行函数的类然后直接用实例运行构造函数的形式调用该函数。其代码片段如下:

0x20. 定义

class A
{
    public:
        void operator ()(){cout<<"Hello Functor!";}
};

0x21. 声明

A a;

0x22. 调用

a();

之所以称其“不是什么新鲜玩意”,是因为 STL 库中早已存在这类函数,比方说引发这个探究的 lessgreater 就是典型的仿函数,当然现在我们也可以自己实现一个简单的比较函数了,这里以比较整型的大小为例:

#include <iostream>
#include <cstdio>
using namespace std;
class less_to
{
    public:
        bool operator ()(int x,int y)
        {
            return x<y;
        }
}myless;
int main()
{
    if(myless(114,514)) cout<<"HO";
    else cout<<"MO";
    return 0;
}

程序能运行出被期望的结果:HO

同样地,我们也大可以重载其他的操作符,以实现更多的需求,这里就不过多赘述。

0x3. 头文件 functional

这里笔者再引入一个新的头文件 functional ,这个头文件中封装了仿函数(以及后文要提到的lambda表达式)的容器—— function所以说现在 function 都成关键字了……

我们先来看一段头文件中的代码:

  template<typename _Res, typename... _ArgTypes>
    class function<_Res(_ArgTypes...)>
    : public _Maybe_unary_or_binary_function<_Res, _ArgTypes...>,
      private _Function_base
    {
      typedef _Res _Signature_type(_ArgTypes...);

      template<typename _Functor>
    using _Invoke = decltype(__callable_functor(std::declval<_Functor&>())
                 (std::declval<_ArgTypes>()...) );
      // Used so the return type convertibility checks aren't done when
      // performing overload resolution for copy construction/assignment.
      template<typename _Tp>
    using _NotSelf = __not_<is_same<_Tp, function>>;

      ...
    }

这玩意……看起来……挺抽象的。但是我们这里注意到三个东西就足够我们会用它了——

  1. 其存在模板,且模板为可变参,而 _Res(_ArgTypes...) 暴露了它参数是函数的返回值和形参列表。
  2. 其接受回调函数,意味着我们可以将函数指针直接传入,然后只管使用仿函数, STL 封装的这玩意总比自己手撕那么一些回调函数舒服。
  3. 其为一个类,故应该实现方式也为仿函数。

所以我们可以轻松写出如下代码:

#include <iostream>
#include <cstdio>
#include <functional>
using namespace std;
int F(int x,int y){return x+y;}
int main()
{
    function<int(int,int)> f=F;
    cout<<f(114,514);
    return 0;
}

同时能得到十分甚至九分优秀的输出:628 (赞赏)。这里我们只是初步地实现了一个加法的操作,不妨加入更多操作,这样原本可能因为代码中 ++-- 小小的不同,本来需要复制重改函数的,既让代码看起来又臭又长,又容易出锅,而现在只需要把对应的修改函数传进去即可。同时我们甚至可以利用仿函数弄出完全不逊于函数指针数组的若干函数块,然后实现自动化调用!

这里有一个细节,它能够访问外部变量吗?答案是肯定的,不妨设计如下代码验证:

#include <iostream>
#include <cstdio>
#include <functional>
using namespace std;
const int z=1919;
int F(int x,int y){return x+y;}
int main()
{
    function<int(int,int)> f=F;
    cout<<f(114,z);
    return 0;
}

运行发现能得到正确的结果,同时我们还不必担心一个问题,声明函数之后变量内容发生变化,而函数却输出不变,但是接下来的 lambda 表达式中,我们就需要注意解决这样一个问题。

0x4. lambda 表达式

先看一段令人炸裂的东西:

int offset=8,origin_data;
auto corrector=[&,offset] (int x,int y) mutable
    ->double
    {
        origin_data*=(x<<8|-y)+offset;
        offset|=x^y;
        origin_data-=offset;
        return 1.0*offset/origin_data;
    }
...
double d=corrector(114514,1919810);

这是个啥?虽然其内部干的事儿是真的没啥意义,但是,上面那一串是什么东西啊啊啊!其本质上还是一个仿函数,下面马上揭晓:

0x40. 组成结构

我们将从如上标号的六个方面进行解释。

0x400. ① capture 子句, C++ 中又称 Lambda 引导

它用于从外界拷贝或引用变量到表达式内部,否则表达式是无法访问外界的变量的,格式有如下五类:

  1. [] ,表示不需要从外界做任何操作。
  2. [=] ,以拷贝的形式访问外界所有的变量,相当于在内部声明同名变量并赋值。同时,当外界变量在该表达式申明之后修改将不会带来其内部的修改。
  3. [&] ,以引用的形式访问外界所有的变量,此时在表达式内部使用变量与直接使用无异。
  4. [=x] ,拷贝且仅拷贝外界变量 x
  5. [&x] ,引用且仅引用外界变量 x

同时我们还可以在其中参入逗号,以做到混合使用,以下是一些可以被接受的内容:

0x401. ② 参数列表,又称Lambda声明符

用于表达该仿函数的形参,用法类似普通函数。

0x402. ③ mutable规范(可选)

如果要修改 []拷贝的参数,则带上这个标识,而引用的参数则无所谓带不带。而注意上文提到的,拷贝的参数即使带了这个标识,也只能实现在表达式内部修改,而在外界该变量是不会改变的。如果没有 mutable 标识而误修改变量内容,则会导致编译失败。同时经笔者验证,试图使用指针修改也会导致报错:

#include <iostream>
#include <cstdio>
#include <cctype>
#include <functional>
using namespace std;
int main()
{
    int x=3;
    function<void()> f=[=](){x++;};
    f();
    cout<<x;
    return 0;
}
#include <iostream>
#include <cstdio>
#include <cctype>
#include <functional>
using namespace std;
int main()
{
    int x=3;
    function<void()> f=[=](){(*(&x))++;};
    f();
    cout<<x;
    return 0;
}

编译器均报错: [Error] increment of read-only location 'x'

0x403. ④ exception-specification(可选)

如果表达式内部无需抛出异常,则填入 noexcept ,否则不用填任何信息,但是事实上,因为异常抛出操作在现在并不常见,所以都不填就好啦,同时这里不过多赘述了。

0x404. ⑤ trailing-return-type(可选)

说白了就是 -> 加上一个类型,表示函数返回类型。

0x405. ⑥ Lambda体

函数体……

0x41. 声明及储存方式

我们可以继续使用上文专门介绍的 STL 容器 function

#include <iostream>
#include <cstdio>
#include <functional>
using namespace std;
int main()
{
    function<int(int,int)> f=[](int x,int y)->int{return x+y;};
    cout<<f(1,2);
    return 0;
}

程序正常输出 3

同样,上文的代码中也出现了另一种写法:

#include <iostream>
#include <cstdio>
#include <functional>
using namespace std;
int main()
{
    auto f=[](int x,int y)->int{return x+y;};
    cout<<f(1,2);
    return 0;
}

应该不用解释了吧……

0x42. 与仿函数的关系

本段开头提到的代码是可以写成仿函数的形式的,那么 lambda 表达式和仿函数的关系也就不言而喻了,下面是等价的代码。

int offset=8,origin_data;
class corrector_
{
    private:
        int offset_=offset;
        int &origin_data_=origin_data;
    public:
        double operator ()(int x,int y)
        {
            origin_data_*=(x<<8|-y)+offset_;
            offset_|=x^y;
            origin_data_-=offset_;
            return 1.0*offset_/origin_data_;
        }
}corrector;
...
double d=corrector(114514,1919810);

0x5. decltype和应用

写到这里就不得不提到同样是 C++11 新更的关键字 decltype(x) 了。它用来解析填入的 x 的类型。这方面的典型应用就体现在开篇所引出的问题:自己写一个有针对性的比较函数的使用上。

欲使用 priority_queue 传入仿函数的构造函数,需要在其模板(即 <> )的第三个参数传入仿函数的类型,这里我们就使用了 decltype 关键字解析其类型,下面是一种实现方式,代码摘自笔者的 Dijkstra 的模板。

auto cmp=[](node a,node b){return a.d>b.d;};
priority_queue<node,vector<node>,decltype(cmp)> q(cmp);

虽然……这样写显得有点多余——大可以直接在 node 中重载运算符嘛。但是……笔者最开始准备不把点封装到一个结构体的……虽然那样写 Dijk 是错的 QAQ 。

同理可得,我们仍然可以使用上文所述的 function 类来手动创造这个类型,代码如下:

auto cmp=[](node a,node b)->bool{return a.d>b.d;};
priority_queue<node,vector<node>,function<bool(node,node)>> q(cmp);

两份代码理论上是等价的。

再在文末点个题,实现把函数当参数传递。想必各位奆佬读者们应该都可以轻松写出了:

#include <iostream>
#include <cstdio>
#include <cctype>
#include <functional>
using namespace std;
int x=3;
int F(function<void(int&)> f){f(x);}
int main()
{
    F([](int &x){x++;});
    cout<<x<<endl;
    F([](int &x){x--;});
    cout<<x<<endl;
    return 0;
}

观察到输出十分正确!而由此我们也可以知道,当函数需要一个仿函数作为传参的时候,我们可以直接写一个 lambda 表达式扔里面,这下我们就能十分舒畅地实现类似回调函数的功能、甚至弄出一个“函数数组”出来啦!

0ex6 Lambda 表达式的递归调用

上文已经探讨了 Lambda 表达式的正常用法,接下来我们将介绍它的递归方法。

因为 Lambda 表达式是省略名称(匿名)的,所以我们只能用 function 模板将其储存再使用接受参数的方式在内部调用即可。

0ex61 C++11 实现

下面是一个递归实现求斐波那契级数的例子:

function<int(int)> fib=[&](int x)->int{return x<2?x:fib(x-1)+fib(x-2);};

但是这里不能使用 auto 声明,必须明确其定义,否则编译器会认为内部的 fib 返回值为 autoint 对不上。报错: [Error] use of 'fib' before deduction of 'auto'

0ex62 C++14 实现

如果不方便全局引用,则可以运用上文所述的,将函数当参数传递,但此时我们就不得不借助 C++14 提供的 auto 充当可变形参:

auto fib=[](auto &&fib,int x)->int{return x<2?x:fib(x-1)+fib(x-2);};

END