导航:

lambda函数介绍和个人理解 (1) 初识lambda

lambda函数介绍和个人理解 (2) lambda与仿函数

lambda函数介绍和个人理解 (3) lambda的语法甜点

其实,与其说这是一篇介绍lambda语法甜点的文章,不如说是一篇教大家使用lambda函数的一篇文章。当然不可避免的会用到一些有趣的实验。文章略长,大家耐心耐心看吧!当然,这也是本人写的关于lambda函数的最后一篇博文了,如果大家有其他更好的想法或者更深入的理解,请联系我~

本文大概讲这些内容:基础使用,按值传递与引用传递,函数指针与lambda,常量性与mutable,lambda与STL。

一、基础使用

从语法角度上讲,lambda和平常我们使用的函数没什么大不同,但从实际编写角度上讲,至少我个人觉得,在熟悉了lambda的用法之后,你就会发现很大程度上,lambda函数的编码量是比较小的。这个时候,你可能会问,那么,lambda函数到底怎么好好的使用呢?我们先从基础的使用讲起。正如我所言,在完成相同的任务量的前提下,lambda函数的编写难度普遍比较小。比如说我们来看这个例子:

//...
#include <iostream>
using namespace std;

#define TODO
#define DEBUG
#define OTHER_FILE_VAR
OTHER_FILE_VAR extern int    z = 0;
OTHER_FILE_VAR extern double c = 0.0;
TODO void Calc(int& ,int ,double& ,double){}
DEBUG void TestCalc(){
    int    x = 3, y = 3;
    double a = 4.0, b = 4.0;
    int    success = 0;
    auto validate = [&]() -> bool{
        if ((x == y + z) && (a == b + c))
            return 1;
        else
            return 0;
    };

    Calc(x, y, a, b);
    success += validate();
    cout << "success = " << success << endl;
    success = 0;

    y = 1024;
    b = 1e13;

    Calc(x, y, a, b);
    success += validate();
    cout << "success = " << success << endl;

    return;
}

int main(){
    TestCalc();
    return 0;
}
// 编译选项: g++ -std=c++11 lambda06.cpp

在这个例子中,程序员试图使用自己编写的函数TestCalc进行测试。这里使用了一个由auto关键字推导出了validate变量的类型为匿名lambda函数。在这个函数中,我们成功的直接访问了TestCalc中的局部变量来完成这个工作。

然而在没有lambda之前,我们为了完成上述测试需求,不得不在另外声明一个函数,并且把TestCalc中的变量当做参数进行传递。而且众所周知,因为函数作用域的变化还有对整体的运行效率的改变,我们又不得不加上关键字inline和static。然而,lambda函数则不需要这么多“废话”(虽然这些确实不是废话),就地声明,就地使用,在保证正确使用的同时,保证了代码的可读性,方便进一步的调试。简单地讲,其实就是把被C/C++抛弃的局部函数借用lambda的名字复活了,在实际效果上,lambda函数与“局部函数”并没有太大区别。

当然,在实际测试中,其实两者的时间空间性能并无二样,可以说和原来的static inline写法没什么区别,话句话说,怎么写都一样。就目前而言,当前的编译器都把lambda默认内联,也就是说和前面加一个inline的性能没啥区别。但是,考虑到实际的使用需要,二者的差别还是有的。通俗的讲就是“按需使用”。直观上讲,由于lambda就在你这个函数的里面,不论是自行调试也好,还是他人阅读也好,一般来讲,短短几行的代码如果就在所需要的地方,就像你想吃苹果的时候碰巧手边有一个苹果一样,非常的方便。也许你可以耐心的翻三四十行代码去查阅一个简单的小函数,但是久而久之这种耐心的查阅导致的将是效率的大大降低,就像明明可以上网查阅词汇你不用,偏偏要翻厚厚的字典一样。这一点是其他的方法做不到的。另外一点,在大规模的软件开发中,很多时候为了调试某一个组件,某一个类,往往程序员们只是想让控制台将当前的信息通过最简单的输入输出汇报出来,通俗地讲就是把log及时输出,这样程序员就可以简单地进行调试工作而不是调用庞大的IDE的调试功能费心费力了(大项目开发和普通一个人的自娱自乐还是有区别的)。因此,一个组件少说就需要一个debug函数。再加上考虑各类的参数传递,哪些参数是引用,哪些是指针,还有那些丧心病狂的变量名字,关键在于,这个调试工具是你这个组件独有的,独有的,没有代码重用的可能性,更别提参考他人了。这个时候,为了完成这些重复的“局部”功能,诸如打印当前变量表,或是对一些变量进行一些固定的操作,lambda的捕捉列表功能就可以大发神威了。这就是为什么有人说“lambda可以改变世界”的原因,lambda其实改变不了世界,但是lambda改变了软件开发。

下面再来看一个“常量”的例子。在编写程序的时候,程序员通常会发现自己需要一些“常量”,不过,这些常量的值却是由自己初始化状态决定的。

//...
#include <iostream>
using namespace std;

int Prioritize(int t){
    if (t & 2) {
        throw "t & 2 == 0";
        return 0;
    }
    else return 1;
}

void Allworks(int times){
    int i = 0;
    int x;
    try {
        for (int i = 1; i <= times; ++i)
            x += Prioritize(i);
    }
    catch(...) {
        x = 0;
    }

    const int y = [=]{
        int i = 0;
        int val = 0;
        try {
            for (int i = 1; i <= times; ++i)
                val += Prioritize(i);
        }
        catch(...) {
            val = 0;
        }
        return val;
    }();

    cout << "x = " << x << endl;
    cout << "y = " << y << endl;

    return;
}

int main(){
    Allworks(5);
    Allworks(1);
    return 0;
}
// 编译选项: g++ -std=c++11 lambda07.cpp

在这份代码中,我们对x和y的初始化方法都是一致的,都是循环调用Prioritize,一旦抛出异常对变量赋默认值0。

在不使用函数的情况下,由于初始化要在运行时修改x的值,因此,虽然x在初始化之后仍然是一个“常量”,却不能被声明为const。而在定义y的时候,我们就地定义lambda函数并且调用,y仅仅使用了它的返回值,保证了常量性。

可能我们以前的固有观念是另外写一个函数init来对“常量”初始化。不过,和lambda的“可读性强,代码量短,就地声明,就地使用”这一优点比起来显然是有点心有余而力不足了。其实,这也是lambda的优势所在。

二、按值传递与引用传递

在使用lambda函数的时候,步骤列表的细微变化都有可能带来不同的结果。具体的讲,按值方式传递捕捉列表和按引用方式传递捕捉列表的效果是不一样的。对于按值方式传递的捕捉列表,其传递的值在lambda定义的时候就已经决定了。而按引用传递的捕捉列表,其传递的值则等于lambda函数调用的值。我们可以看一下这个例子。

#include <iostream>
#include <fstream>
using namespace std;

ofstream fout("out.txt");

int main(){
    int j = 12;
    auto by_val_lambda = [=]{ return  j + 1; };
    auto by_ref_lambda = [&]{ return  j + 1; };
    auto print = [&]{
        fout << "by val lambda: " << by_val_lambda() << endl;
        fout << "by ref lambda: " << by_ref_lambda() << endl;
    };

    print();
    j++;
    print();

    return 0;
}
// 编译选项: g++ -std=c++11 lambda08.cpp

完成编译之后,结果如下:

by val lambda: 13
by ref lambda: 13
by val lambda: 13
by ref lambda: 14

大家一定很好奇,我明明j++了,为什么第三行结果还是13?难道说,他计算的是12 + 1?对的!他的确计算的是12 + 1。为什么呢?因为在by_val_lambda中,j被视为了一个常量,一旦初始化之后不会在改变,也就是说,哪怕你之后怎么改变j,只要使用by_val_lambda,结果都是13不是其他值。我们可以姑且认为这是一个和父作用域同名的一个变量。当然,如果是引用传递,将不会出现这种问题。

我们可以这样去理解:按值传递的话,传递的值被定义为lambda的“常量”,按引用传递的话,传递的值被定义为lambda的变量,并且任何对其的改动都会在原变量上呈现。

三、函数指针与lambda

首先剧透一下,很多人好奇当前的编译器是如何做到使用lambda的,答案是仿函数。这也是我为什么要先讲解仿函数与lambda的关系一样。至少我们通用的编译器GCC、Clang、XL C/C++还有微软的MSVC都是借仿函数之春风,先把lambda整理,然后再写链接文件,最后生成可执行文件。你可能会说,你为什么非得先剧透一个,太伤感情了。好吧,其实我只是想让你注意下几乎每一个lambda前都会有的声明,没错,auto。

auto是什么?auto就是一个自动类型判断器。简单讲,很多编程语言没有这么多类型限制,比如说大名鼎鼎的Python还有Perl,甚至是matlab,他们都没有所谓的数据类型,都是拿来就用。那么C++11终于支持了这个简单的惠民政策,那就是auto,自动推导类型,这一点尤其是在书写STL中的各类迭代器显得非常的边界,长长的模板代码仅仅4个字母就全部代替了,可以说大大的减轻了程序员的工作量。当然唯一的缺点就是对于阅读代码而言,大量的auto会让人感到绝望,不知所云。

这不,说着说着auto的问题就来了,那个天才的lambda的类型到底是个啥?

函数指针?还是仿函数?还是用函数模板的那种冗长的模板头呢?

事实上,在C++11的定义上,lambda被定义为一个closure,每一个lambda表达式都会产生一个closure类型的临时对象,也就是我们平常说的右值。也就是说,严格上讲,lambda不是函数指针,也不是仿函数,更不是函数模板那种天才的冗长的模板头(别小看这个!这个东西可以实现lambda唯一实现不了的递归调用)。说得太多大家可能都晕了,代码例子奉上:

#include <iostream>
using namespace std;

int main(){
    int a = 3, b = 4;
    auto total = [](int x, int y)->int{ return x+y; };
    typedef int (*twoVar)(int x,int y);
    typedef int (*oneVar)(int x);
    twoVar fun1 = total;
    //oneVar fun2 = total;//编译失败,参数必须一致

    decltype(total) all1 = total;
    //decltype(total) all2 = fun1;//编译失败,指针无法转换

    return 0;
}
// 编译选项: g++ -std=c++11 lambda09.cpp

在这份代码中,我们简单地测试了三个环节:lambda转换成函数指针,lambda需要与函数指针有相同的参数才能转换,指针无法转化成lambda。但是请注意,其实C++11的标准中并没有明确地说明指针不能转化成lambda,只不过可能目前的编译器尚未实现而已。

当然,如果你有强迫症,想试着去找出lambda的函数类型,就可以通过decltype的方式来获得lambda的类型。

四、常量性与mutable

首先请看一个例子:

#include <iostream>
using namespace std;
/*
class _const_val_lambda{
    public:
        _const_val_lambda(int v):val(v){}
        void operator()()const{val = 3; }
    private:
        int val;
}
*/

int main(){
    int val = 6;
    auto print = [&]{ cout << "val = " << val << endl; };
    print();
    //auto const_val_lambda = [=]{ val = 3; };
    //print();//编译失败
    auto mutable_val_lambda = [=]()mutable{ val = 3; };
    print();
    auto const_ref_lambda = [&]{ val = 3; };
    print();
    auto const_param_lambda = [&](int v){ v = 3; };
    const_param_lambda(val);
    print();
    return 0;
}
// 编译选项: g++ -std=c++11 lambda10.cpp

在这份代码中,我们定义了四种不同的lambda函数,这四种不同的lambda函数,其实代码逻辑都是一致的,修改val这个参数,仅此而已。不过const_val_lambda会报错。但是一旦有mutable声明,编译器便非常的安静。

在之前的定义中解释过,lambda默认为const函数。我们如果把lambda转化成一个完整的仿函数,那么函数体就被转化成一个常量的成员函数。也就成了那份代码前面的那个类的样子。然而,常量函数是不能改变类中的任何成员变量的,即便是public成员也不可以。所以说,编译错误也就理所应当了。

其实大家运行下我的代码就会发现,其实这几个lambda,都没有改变val的值=。=

而且,其实mutable声明也只是一个以防万一的东西而已,大多数情况下,我们倾向于使用不加mutable的lambda。

五、lambda与STL

其实,lambda最大的贡献在于STL库。我们对STL的算法使用因为lambda越发的方便了,而且更容易让大家读懂。

比如说for_each算法。for_each算法的原型为:

UnaryProc for_each(InputIterator beg, InputIterator end, UnaryProc op);

也就是说,这个算法需要三个参数:标记开始的迭代器,标记结束的迭代器,一个接受单个参数的“函数”(即一个函数指针、仿函数或者lambda)。

#include <iostream>
using namespace std;
int main(){
    vector<int> nums;
    for (int i = 0; i < 5; i++) nums.push_back(i);
    auto print = [&]{
        for (auto s: nums){cout<<s<<"t";}
        cout<<endl;
    };
    print();
    for_each(nums.begin(), nums.end(), [=](int &i){
                i += 10;
            });
    print();
    return 0;
}
//编译选项:: g++ -std=c++11 lambda11.cpp

当然STL的算法应用还有很多,lambda因为其关注的重点清晰而脱颖而出。为什么,众所周知我们对这些算法的要求只有一个,那便是最终的结果正确。但是在实际中部门还需考虑很多东西,比如循环的细节,循环计数器的类型等等,但是lambda函数的应用让我们把重心全部放在真正我们需要的地方,可以说极大地提高了编程效率。

总结

不知不觉我对lambda的分析也到此结束了,可以说lambda“可读性强,代码量短,就地声明,就地使用”的十六字特点正在改变着原有的编程的思维。当然,上帝仁慈的把刀枪赠与人类,具体用于正义还是用于战争全凭自己的意志。lambda的使用与否完全是由程序员的态度所决定的,但是无法否认,lambda正在加速软件开发,这是毋庸置疑的。

当然了如果大家有更好的想法都可以告诉我,毕竟这是一个开源的时代,信息的交流才是成长的主线!