【C++深陷】之“lambda表达式”

    技术2025-07-27  13

    0. 什么是lambda表达式

    《C++ Primer(第5版)》对 lambda表达式(lambda expression) 的定义为:

    一个lambda表达式表示一个可调用的代码单元。我们可以将其理解为一个未命名的内联函数。

    lambda表达式的形式如下:

    [capture list](parameter list) -> return type { function body } 捕获列表,capture list参数列表,parameter list返回类型,return type函数体,function body

    lambda表达式是可调用对象(callable object) 的一种。

    1. 如何使用lambda表达式

    本节将宏观的介绍几种lambda表达式的用法,对整体有一个认知。

    首先介绍谓词——lambda最广泛的应用之一,然后介绍如何向谓词传递lambda表达式以及简单的使用捕获列表。

    1.1 向函数传递谓词

    谓词(predicate) 是一个可调用的表达式,其返回结果是一个能用作条件的值。

    C++标准库中的泛型算法大多使用了谓词,允许我们自己定义一些操作。例如:

    bool shorter(const string &s1, const string &s2) { return s1.size() < s2.size(); } // 假设A是一个包含string的vector std::sort(A.begin(), A.end(), shorter) // sort会根据A中每个string的size来排序

    std::sort的第三个参数,就是一个谓词类型。我们传递给std::sort的是函数名shorter,因为函数也是一个可调用对象。

    std::sort会使用shorter定义的操作代替<,来比较容器中的元素进行排序。

    从一定程度上说,谓词帮助我们拓展了函数的功能,定制操作。若按<来排序string,仅仅是按照字母表排序;现在可以使用字符串的长度来排序,甚至是先按照长度,再按照字母表。

    谓词分为一元谓词和二元谓词,即在调用谓词时传递给谓词的参数是1个还是2个。

    谓词和可调用对象的区别就是,谓词是一个约束了参数列表和输出类型的可调用对象,在向谓词传递可调用对象时必须符合谓词的约束。比如必须传递两个参数,返回布尔类型(这一点和运算符的感觉比较像)。

    1.2 lambda表达式可以作为谓词

    可调用对象一共有四种,因此向谓词传递函数、函数指针、重载了函数调用运算符的类和lambda表达式都可以。本文的重点是lambda表达式。

    我们使用lambda表达式将shorter函数重写:

    std::sort(A.begin(), A.end(), [](const string &a, const string &b) -> bool { return a.size() < b.size(); });

    我们列出第0节介绍的lambda表达式的四部分:

    捕获列表,这里为空[]参数列表,两个参数,const string &a, const string &b返回类型,bool类型函数体,return a.size() < b.size();

    OK,lambda表达式就是这么写。

    当然还可以举一个传递一元谓词的例子,比如find_if:

    // 代码获得A中第一个长度>=3的元素的迭代器 auto res = std::find_if(A.begin(), A.end(), [](const string &a) -> bool { return a.size() >= 3; }); 捕获列表,这里为空[]参数列表,一个参数,const string &a返回类型,bool类型函数体,return a.size() >= 3;

    1.3 省略更优美

    进一步,就是lambda表达式的省略形式:

    参数列表可以省略返回类型可以省略(通常都省略) // 省略参数列表 auto f = [] { return 42; }; // 省略返回类型 auto res = std::find_if(A.begin(), A.end(), [](const string &a) { return a.size() >= 3; });

    这里假如我想返回的是长度大于等于某个变量sz的值,该怎么修改?

    1.4 使用捕获列表

    写成return a.size() >= sz;?

    sz哪里来的呢?用参数传递进来可以吗?写成下面这样试试:

    [](const string &a, int sz) -> bool { return a.size() >= sz; }

    这样定义一个lambda表达式没问题,只不过这个表达式不能在find_if里面使用了,因为find_if约束,最后一个谓词类型的参数列表只能有一个参数。

    此时就要用到捕获列表了。

    通过捕获列表,我们可以在lambda表达式中,使用上层作用域的局部非static变量。

    还是find_if:

    vector<string>::iterator find_sz_bigger(vector<string> &A, int sz) { return std::find_if(A.begin(), A.end(), [sz](const string &a) -> bool { return a.size() >= sz; }); }

    这儿的重点就是sz。

    在上层作用域find_sz_bigger函数中,我们有一个形参sz,它是一个局部变量,函数调用结束后它就被销毁了。

    在lambda表达式的捕获列表中,我们填写了一个sz,意思就是捕获了find_sz_bigger作用域中名为sz对象的值,在lambda表达式的函数体中,我们可以使用sz。

    注意此处的捕获方式,是把sz的值拿了过来,即按值传递(当然还有别的捕获方式,见第2节)。

    1.5 小结

    到目前你已经学会了如何简单的使用lambda表达式了。

    不仅如此,你还了解了如下内容:

    谓词,std中的算法很多都使用,如find_if,sort可调用对象,只有四种lambda表达式,一种可调用对象,给std中的算法传递谓词很方便捕获列表,这是lambda表达式和其他的可调用对象区别比较大的地方省略形式更优美

    接下来我们来看看lambda表达式的细节,以及注意事项。

    2. 深入捕获列表

    捕获列表指引lambda在其内部包含访问局部变量所需的信息。

    捕获列表捕获变量的方式有三种:

    值捕获引用捕获隐式捕获

    2.1 值捕获

    与函数调用值传递类似,采用值捕获的前提是变量可以拷贝。与函数参数不同,被捕获的变量的值是在lambda创建时拷贝,而不是调用时拷贝。

    例如:

    void fcn1() { size_t v1 = 42; auto f = [v1] { return v1; }; v1 = 0; auto j = f(); // j为42 }

    在lambda创建时拷贝,随后我们修改v1的值,不会影响f()调用的结果。

    值传递还包括指针的情况,因此若捕获的是某个对象的指针,一定要注意指针的指向一定是有效的。

    使用值捕获得到的对象(例如v1),我们不能在lambda函数体中修改它的值。

    若希望修改它的值,必须使用mutable关键字修饰lambda表达式。

    void fcn2() { size_t v1 = 42; auto f = [v1]() mutable { return ++v1; }; v1 = 0; auto j = f(); // j为43 }

    此时的lambda称为可变lambda,形参列表不能省略()。

    2.2 引用捕获

    一个以引用方式捕获的变量与其他任何类型的引用的行为类似:

    void fcn3() { size_t v1 = 42; auto f = [&v1] { return v1; }; v1 = 0; auto j = f(); // j为0 }

    如果我们采用引用方式捕获一个变量,就必须确保被引用的对象在lambda执行的时候是存在的。

    lambda捕获的都是局部变量,在上层代码块结束后局部变量就会被销毁,因此使用lambda表达式捕获引用变量一定要注意。

    引用捕获的必要性在于捕获那些不能拷贝的对象,例如ostream。

    使用引用捕获得到的对象,可不可以修改要根据源对象是否有const。

    2.3 隐式捕获

    除了上述2种方式,我们显示的在捕获列表中说明捕获的对象(显示捕获),还可以让编译器根据lambda函数体中的代码来推断我们要使用哪些变量,这就是隐式捕获。

    为了指示编译器推断捕获列表,需要在捕获列表中写一个=或者&,分别代表函数体中使用的变量捕获的方式为值捕获或者引用捕获。

    例如之前的例子:

    int sz = 3; // 函数体中出现了sz // 捕获列表中使用了 = // 函数体中sz使用值捕获 auto it = std::find_if(A.begin(), A.end(), [=](const string &a) { return a.size() >= sz; });

    还可以混合显示捕获和隐式捕获:

    捕获列表第1个参数放置=或者&,代表默认捕获方式;捕获列表其余的参数,均采用和默认捕获方式相异的方式。

    例如:

    ostream &os = std::cout; char c = ' '; // os采用默认的引用捕获 // c采用值捕获 std::for_each(A.begin(), A.end(), [&, c](const string &s) { os << s << c; }); // c采用默认的值捕获 // os采用引用捕获 std::for_each(A.begin(), A.end(), [=, &os](const string &s) { os << s << c; });

    注意:引用捕获不论何时都需要&标志,如上面的第二个例子,即使默认是值捕获,其后的参数也要声明&。

    2.4 小结

    捕获方式分为显示捕获、隐式捕获和混合式捕获。

    显示捕获包括值捕获和引用捕获。

    隐式捕获使用=或者&设置函数体中所有对象的捕获方式为值捕获或者引用捕获。

    混合式捕获第一个参数放置默认捕获方式,其余参数与默认捕获方式相异。

    值捕获得到的对象在lambda函数体中不可以修改,可以使用mutable。

    尽量避免引用捕获。

    3. 深入返回类型

    3.1 省略时一定只有return语句

    lambda表达式的返回类型通常都是省略的,这是因为lambda表达式面向的编程需求,就是函数体尽可能的短小精悍(若函数功能复杂,尽量使用其他可调用对象来实现)、一目了然的。

    但是注意,如果我们一边省略了返回类型,一边在lambda函数体中包含了return语句之外的任何语句,编译器会假定此lambda返回void。

    例如:

    // 假设vi是vector<int> // 这是一个正确的例子 std::transform(vi.begin(), vi.end(), vi.begin(), [](int i) { return i < 0 ? -i : i; });

    上述代码实现了将vi中的所有元素取绝对值。

    三元运算符的等价形式是if else语句:

    // ERROR: 这是一个错误的例子 std::transform(vi.begin(), vi.end(), vi.begin(), [](int i) { if (i < 0) return -i; else return i; });

    上述代码产生编译错误,错误的原因是transform的第四个参数类型不匹配谓词。

    不匹配的原因是lambda表达式的返回结果是void。

    返回是void的原因是我们省略了返回类型,在函数体中书写了return语句之外的内容,编译器推断lambda的返回类型为void。

    此时必须显示定义返回类型:(尾置形式)

    // 这是一个正确的例子 std::transform(vi.begin(), vi.end(), vi.begin(), [](int i) -> int { if (i < 0) return -i; else return i; }); // 但是有三元运算符, // 尽量不要使用if else分支 // 让程序执行效率更高

    上述代码是对的,但是尽量使用三元运算符。

    返回类型可以是值类型,也可以是引用类型。

    返回引用类型也需要注意调用点处返回的对象必须要存在。

    4. 编译器如何看待lambda

    4.1 两个“未命名”

    我们看到的lamdba表达式是由[]、()、->以及{}组合起来的漂亮形式;编译器却为lambda表达式生成了一个对应的新的未命名类类型,并且把它变成了一个该未命名类类型的未命名函数对象。

    这里的关键词,我们逐个解析:

    未命名类类型:类类型都知道,就是class未命名函数对象:如果一个类定义了调用运算符,那么该类的对象就称作函数对象

    现在明白了,回想可调用对象,其中有一个就是重载了函数调用运算符的类,它的官名就是函数对象。

    意思就是编译器将lambda编译成了一个新类型,和新类型的对象。

    我们可以将之前的例子改写一下,例如:

    std::sort(A.begin(), A.end(), [](const string &a, const string &b) { return a.size() < b.size(); });

    它的未命名类类型是:

    class XXXXXXX { public: bool operator() (const string &a, const string &b) const { return a.size() < b.size(); } } std::sort(A.begin(), A.end(), XXXXXXX());

    虽然定义上说lambda表达式可以被理解为“一个未命名的内联函数”,但实际编译器却使用函数对象来实现。

    现在真相大白了。

    4.2 捕获列表是什么

    捕获列表是如何变身为函数对象中的内容的?

    引用捕获不需要变为函数对象中的数据成员,在调用lambda时,编译器直接将引用源的值拿来使用(这也是引用的意义,外部改变了lamdba内部也要改变)。

    值捕获会创建一个数据成员,以及初始化该数据成员的构造函数。

    例如:

    int sz = 3; auto it = std::find_if(A.begin(), A.end(), [sz](const string &a) { return a.size() >= sz; });

    它的未命名类类型是:

    class XXXXXXX { private: int sz; public: XXXXXXX(int x1): sz(x1) { } bool operator() (const string &a, const string &b) const { return a.size() < b.size(); } } auto it = std::find_if(A.begin(), A.end(), XXXXXXX(sz));

    这也是值捕获的意义,在创建时刻捕获到程序中某些值,不论外部如何修改,我只需要那个时刻的值。

    因此在2.4节中提到的“尽量避免引用捕获”也是出于此分析。

    现在真相大大白了。

    4.3 合成的函数还有吗

    我们知道,若一个类没有默认构造函数、析构函数、以及赋值运算符,C++编译器会为类合成默认的相关函数。

    但是lambda表达式产生的类没有。

    它是否含有默认的拷贝构造函数、移动构造函数会根据捕获的数据成员类型而定。

    4.4 mutable lambda

    使用mutable修饰的lambda表达式,在转换为函数对象时,只需要把调用运算符的const去掉即可。

    5. 总结

    一个lambda表达式表示一个可调用的代码单元。它由捕获列表、参数列表、返回类型、函数体四部分组成。

    捕获列表可以捕获上层作用域中的对象的内容。

    参数列表决定调用lambda表达式时所需的参数。

    返回类型通常省略,但是注意除return语句之外的情况。

    函数体通常精悍短小、一目了然。

    可以使用mutable修饰lamdba表达式,表明可以修改值捕获的对象的值。

    lambda表达式是可调用对象的一种,编译器把它转换为未命名类类型的未命名函数对象来实现其功能。

    Processed: 0.011, SQL: 9