C++ Lambda 的故事
C++98/03 中的 Lambda
作为开始,了解一些关于我们所讨论的主题的背景知识是很有必要的。为此,我们会转而回顾过去,看看那些不使用任何现代 C++ 技术的代码——即 C++98/03 规范下的代码。
在本章中,我们将会学习:
- 如何将旧式的函数对象传给 C++ 标准库中的各种算法。
- 函数对象类型的限制。
- 为什么辅助函数不够好。
- C++0x/C++11 中引入 lambda 的动机。
C++98/03 中的可调用对象
标准库的一个基本设计思想是对于像 std::sort,std::for_each,std::transform 等这样的泛型函数,能够接受任何可调用对象然后对输入容器中的每个元素依次调用它。然而,在 C++98/03 中,可调用对象只包括函数指针和重载了调用操作符的类类型(通常被称为“函子”)。
举例来说,我们有一个打印一个向量中所有元素的应用程序。
在第一个版本中,我们使用普通的函数来实现:
1 |
|
上面的代码使用 std::for_each 来迭代 vector(我们使用的是 C++98/03,所以没有基于范围的 for 循环!),然后它将 PrintFunc 作为一个可调用对象传递。
我们可以使用调用操作符将此函数转换为类类型:
1 |
|
这个例子定义了一个重载了 operator() 的结构体,因此你能够像普通函数一样去“调用”它:
Printer printer;
printer(); // 调用 operator()
printer.operator()(); // 等价调用
而非成员函数通常是无状态的(你可以在常规函数中使用全局变量或静态变量,但这不是最好的解决方案,这样的方法很难跨多个 lambda 调用组控制状态),函数式的类类型却可以持有非静态成员变量从而能够保存状态。一个典型的例子是记录一个可调用对象被一个算法调用的次数。解决方案通常需要维护一个计数器,然后在每次调用时更新它的值:
1 |
|
在上例中,数据成员 numCalls 被用在调用运算符重载中计数此函数的调用次数。std::for_each 返回我们传入的函数对象,因此我们能够得到该对象并获取其数据成员。
如你所料,我们能够得到以下输出:
1
2
num calls: 2
我们还可以从调用作用域“捕获”变量。为此,我们必须创建一个数据成员,并在构造函数中初始化它。
1 |
|
在这个版本中,PrinterEx 带有一个额外参数去初始化其数据成员。之后在调用运算符中使用这个变量,输出如下:
Elem: 1
Elem: 2
num calls: 2
何谓“函子”
在上面的小节中,我们有时将带有 operator() 的类类型叫做“函子”。虽然这个术语很方便,而且比“函数对象类类型”要短得多,但并不正确的。
从词源上来看,“函子”来自于函数式编程,它有不同的含义而不是 C++ 中的术语。引用 Bartosz Milewski 中对于函子的定义:
函子是类别之间的映射。给定两类别 C 和 D,一个函子 F 能将 C 中的对象映射到 D 中的对象——它是作用在对象上的函数。
这个定义看上去相当抽象,但幸运的是,我们还可以去看到一些简化版 。在《C++ 函数式编程》这本书的第 10 章中,作者 Ivan Cukic 将这个抽象的定义“翻译”成更适合 C++ 语言的版本:
拥有一个定义在其上的变换(或映射)函数的类模板 F 是一个函子。
此外,这样的变换函数必须遵守恒性等和可组合性这两条规则。“函子”一词在 C++ 规范中没有以任何形式出现(即使在 C++ 98/03 中也是如此),因此在本书的其余部分,我们将尽量避免使用它。
当然,您还可以通过以下资源的阅读来了解更多关于函子的内容:
- Functors, Applicatives, And Monads In Pictures - adit.io
- Functors | Bartosz Milewski’s Programming Cafe
- What are C++ functors and their uses? - Stack Overflow
- Functor - Wikipedia
函数对象类类型的问题
如你所见,创建一个重载了调用运算符的类类型非常强大。你可以有全流程的把控,你可以以任何喜欢的方式设计它们。
然而,在 C++98/03 中,问题在于当你要用一个算法调用一个函数对象时,你却不得不在不同的地方定义它。这可能意味着可调用对象可以在源文件的前面或后面几十或几百行,甚至位于不同的翻译单元中。
作为一种可能的解决方案,您可能尝试过编写局部类,因为 C++ 支持这样的语法。但这并不适用于模板。代码如下:
1 |
|
尝试在 GCC 上用 -std=c++98
参数来编译它将会得到如下错误提示:
error: template argument for
'template<class _IIter, class _Funct> _Funct
std::for_each(_IIter, _IIter, _Funct)'
uses local type 'main()::LocalPrinter'
看起来,在 C++ 98/03 中,无法用局部类型实例化模板。
C++ 程序员很快就理解了这些限制,并找到了在 C++98/03 中绕过这个问题的方法。一种解决方案就是准备一组辅助类。让我们看下一节。
使用辅助类
那么,究竟什么是辅助类和和预定义的函数对象呢?
如果你去查看标准库中的 <functional>
头文件,你将会一系列可以立即用于标准库算法的类型和函数。
例如:
std::plus<T>()
- 接受两个参数并返回它们的和。std::minus<T>()
- 接受两个参数并返回它们的差。std::less<T>()
- 接受两个参数返回是否第一个参数小于第二个参数。std::greater_equal<T>()
- 接受两个参数返回是否第一个参数大于等于第二个参数。std::bind1st
- 创建一个将第一个参数固定为所给值的可调用对象。std::bind2nd
- 创建一个将第二个参数固定为所给值的可调用对象。std::mem_fun
- 创建一个成员函数的包装对象。- 等等。
让我们编写一些得益于这些辅助类的代码:
1 |
|
该示例使用 std::less 并通过使用 std::bind2nd 固定其第二个参数(bind1st, bind2nd 和其他函数辅助器已在 C++11 中弃用,并在 C++ 17 中移除。本章中的代码仅用于说明 C++ 98/03 中的问题。请在您的项目中使用更加现代的替代方案。)。这整个组件被传递到 count_if 中。您可能已经猜到了,代码最终转换成了一个执行简单比较的函数:return x < 5;
如果您想要更多现成的帮助程序,那么您还可以查看 boost 库,例如 boost::bind
。
不幸的是,这种方法的主要问题是语法复杂且难以学习。
例如,编写包含两个或多个辅助函数的代码很不自然。如下例所示:
1 |
|
改代码使用了 std::bind(它来自 C++ 11,所以我们作弊了,它不是 C++98/03)
来完成 std::greater,std::less_equal 以及 std::logical_and 的连接。此外,代码使用 _1 作为第一个输入参数的占位符。
虽然上面的代码可以工作,并且你可以在局部定义它,但是你可能已经看出来它的复杂以及不自然的语法。且不说这个组合只代表了一个简单的条件:return x > 2 && x <= 6;
那么,是否还有更好用更直接的方法呢?
新特性引入的动机
如你所见,在 C++98/03,调用标准库的一些算法和工具总是需要定义并传入一个可调用对象。然而,所有的可选方案都或多或少有一些限制。例如,你不能定义一个局部函数对象类型,或是使用辅助函数对象的组合,但它很复杂。
幸运的是,在 C++11 中我们终于看到了许多改进!
首先,C++ 标准委员会取消了模板实例化对局部类类型的限制。从 C++11 开始,你可以在任何你需要的局部作用域编写重载了调用操作符的类类型。
更重要的是,C++11 还带来了另一个想法:如果我们有一个简短的语法,然后编译器可以将它“展开”为相应的局部函数对象的定义呢?
这就是“Lambda 表达式”的诞生!
如果我们看看 N3337—— C++11 的最终草案,我们可以看到一个关于 Lambdas 的单独部分:[expr.prim.lambda]。
让我们在下一章中看看这个新特性。
C++11 中的 Lambda
万岁!C++ 委员会听取了开发人员的意见,在 C++11 标准中加入了 lambda 表达式!
lambda 表达式很快就成为现代 C++ 中最具辨识度的一个特性。
你可以在 N3337(C++11 的最终草案)中阅读其完整规范,以及关于 lambda 的单独部分:[express .prim.lambda]。
我认为委员会以一种聪明的方式在语言中添加了 Lambda。他们设计了新的语法,但随后编译器将其“展开”为一个未命名的“隐藏的”函数对象类型。这样我们就拥有了真正强类型语言的所有优点(以及缺点),使代码理解起来更加容易。
在本章,你将会学习到:
- lambda 的基础语法。
- 如何捕获一个变量。
- 如何捕获一个类的非静态成员变量。
- lambda 的返回类型。
- 什么是闭包类型。
- 怎样将 lambda 表达式转换成一个函数指针从而能够去使用 C 风格的 API.
- 什么是 IIFE 以及为什么它是的有用的。
- 如何继承一个 lambda 表达式。
让我们出发吧!
Lambda 表达式的语法
下图说明了 C++11 中 lambda 的语法:
现在让我们通过几个例子来感受一下它。
Lambda 表达式的几个例子
1 |
|
在第一个示例中,你可以看到一个“最迷你”的 lambda 表达式。它只需要[]
部分(Lambda 引入器),然后用空的{}
部分作为函数体。形参列表()
是可选的,在本例中不需要。
1 |
|
在第二个例子中,可能是最常见的例子了,你可以看到参数都传递到()
部分,就像普通函数一样。返回类型不需要,因为编译器会自动推导它。
1 |
|
在上面的例子中,我们显式地设置了一个返回类型。后面的返回类型也可用在 C++11 以来的常规函数声明中。
1 |
|
最后一个示例显示,在 lambda 的主体之前,可以使用其他说明符。在代码中,我们使用了 mutable
(这样我们可以改变捕获的变量)和noexcept
。第三个 lambda 使用了mutable
和noexcept
,并且它们必须以该顺序出现(你不能写noexcept
mutable
,因为编译器会拒绝它)。
虽然()
部分是可选的,但如果你想应用 mutable
或 noexcept
,此时()
则需要在出现的表达中:
1 |
|
同样的模式也适用于其他可以应用于 Lambdas 的说明符,比如 C++17 中的 constexpr
和 C++20 中的 consteval
。
在熟悉了基本的例子之后,我们现在可以尝试去理解它是如何工作的,并学习 lambda 表达式的所有可能用法。
核心定义
在我们继续之前,从 C++ 标准中引入一些核心定义是很方便的:
来自 [expr.prim.lambda#2]
lambda 表达式的计算结果是一个临时的纯右值。这个临时值叫做闭包对象。
作为旁注,Lambda 表达式是一个 prvalue 即“纯右值” 。这种类型的表达式通常产生自初始化并出现在赋值的右侧(或在 return 语句中)。阅读 C++ Reference,[express.prim.lambda#3] 中给出的的另一个定义是:
lambda 表达式的类型(也就是闭包对象的类型)是一个唯一的,未命名的非联合类类型——称为闭包类型。
编译器展开
从以上定义中,我们可以了解到编译器从一个 lambda 表达式生成唯一的闭包类型。然后我们可以通过这个类型来实例化出闭包对象。
以下示例展示了如何写一个 lambda 表达式并将其传给std::for_each
。为了便于比较,代码还说明了编译器生成的相应的函数对象类型:
1 |
|
在本例中,编译器将[](int x) { std::cout << x << '\n'; }
翻译成一个匿名函数对象,简化形式如下:
1 |
|
“翻译”或“展开”的过程可以很容易地在 C++ Insights 在线网页工具上看到。该工具获取有效的 C++ 代码,然后产生编译器需要的源代码版本:像 lambda 的匿名函数对象,模板的实例化等其他 C++ 的特性。
在下一节中,我们将深入研究 lambda 表达式的各个部分。
Lambda 表达式的类型
由于编译器为每个 lambda (闭包类型)生成唯一的名称,所以我们就没法把它“拼写”在前面。
这就是为什么必须使用auto
(或 decltype
)来推断其类型。auto myLambda = [](int a) -> double { return 2.0 * a; };
而且,如果你有两个看起来一样的 Lambda:auto firstLam = [](int x) { return x * 2; };
auto secondLam = [](int x) { return x * 2; };
它们的类型也是不同的,即使“代码背后”是相同的!编译器需要为这两个 lambda 声明的每个都生成惟一的匿名类型。我们可以用下面的代码来证明这个属性:
1 |
|
上面的例子验证了 oneLam 和 twoLam 的闭包类型是否不相同。
在 C++17 中我们可以使用无需消息的static_assert
以及用于类型萃取的辅助变量模板is_same_v
:static_assert(std::is_same_v<double, decltype(baz(10))>);
然而,虽然你不知道确切的名称,但是你还是可以拼出 lambda 的签名,然后将其存储在std::function
中。一般来说,如果 lambda 是通过std::function<>
类型“表示”的,那么它可以完成定义为auto
的 lambda 无法完成的任务。例如,前面的 lambda 具有double(int)
的签名,因为它接受int
作为输入参数并返回double
。然后我们可以用以下方法创建std::function
对象:
std::function<double(int)> myFunc = [](int a) -> double { return 2.0 * a; };
std::function
是一个重量级的对象,因为它需要处理所有可调用对象。要做到这一点,它需要高级的内部机制,如类型双关语,甚至是动态内存分配。我们可以通过一个简单的实验来检验它的大小:
1 |
|
在 GCC 编译下代码输出如下:sizeof(myLambda) is 1
sizeof(myFunc) is 32
因为 myLambda 只是一个无状态的 Lambda,所以它也是一个空类,没有任何数据成员字段,所以它的最小大小只有一个字节。另一边的std::function
版本则要大得多——32 个字节。这就是为什么如果可以的话,应该依靠自动类型推导来获得尽可能小的闭包对象。
当我们讨论std::function
时,还需要注意的是,这种类型不是只移型闭包。你可以在 C++14 的可移动的类型章节中阅读关于这个问题的更多信息。
构造和复制
在特性规范 [expr.prim.lambda] 里我们 可以读到如下信息:
一个 lambda 表达式关联的闭包类型拥有一个删除的默认构造函数和一个删除的复制赋值运算符。
这就是为什么你无法写出:auto foo = [&x, &y]() { ++x; ++y; };
decltype(foo) fooCopy;
在 GCC 上这段代码将出现如下错误提示:
然而,你可以复制 Lambda:
1 |
|
如果你复制一个 Lambda,那么你也复制了它的状态。当我们讨论捕获变量时,这一点很重要。在该上下文中,闭包类型将捕获的变量存储为成员字段。执行 lambda 复制将复制这些数据成员字段。
展望未来
在 C++20 中,无状态 lambda 将是默认为可构造和可赋值的。
调用运算符
放入 lambda 体中的代码被“翻译”为对应闭包类型的operator()
中的代码。
在 C++11 中,默认情况下它是一个const inline
成员函数。例如:auto lam = [](double param) { /* do something*/ };
将会被展开成类似于:
1 |
|
接下来我们讨论这种方法的结果,以及如何修改生成的调用操作符声明。
重载
值得一提的是,在定义 lambda 时,无法创建接受不同参数的“重载” Lambda。如:// doesn't compile!
auto lam = [](double param) { /* do something*/ };
auto lam = [](int param) { /* do something*/ };
以上代码无法通过编译,由于编译器无法将这两个 lambda 翻译到一个函数对象。此外,你不能定义两个相同的变量。然而,创建同一个函数对象中的两个调用运算符的重载却是允许的:
1 |
|
MyFunctionObject
可以同时接收double
和int
这两种参数。如果你需要类似行为的 Lambda,你可以去看关于 lambda 继承的小节或是 C++17 中的重载模式小节。
属性
C++11 允许以[[attr_name]]
的语法去给 lambda 设置属性。但是,如果将一个属性应用到 Lambda,那么它将应用于调用的类型而不是运算符本身。这就是为什么现在(甚至在c++ 20中)有没有对 lambda 有意义的属性。大多数编译器甚至会报告错误。如果我们取一个 C++17 的属性,并尝试将它与表达式一起使用:auto myLambda = [](int a) [[nodiscard]] { return a * a; };
这会在 Clang 上生成以下错误:error: 'nodiscard' attribute cannot be applied to types
虽然理论上已经准备好了 lambda 语法,但目前还没有适用的语法属性。
其他变化
我们在语法部分简要介绍了这个主题,但是你并不局限于闭包类型的调用操作符的默认声明。在 C++11 中,您可以添加 mutable 或异常声明符。
如果可能的话,本书中较长的例子尝试用 const 标记闭包对象,并使 lambda noexcept。
你可以通过在参数声明子句后面指定 mutable 和 noexcept 来使用这些关键字:auto myLambda = [](int a) mutable noexcept { /* do something */ }
编译器将生成如下代码:
1 |
|
请注意,const 关键字现在没有了,调用操作符现在可以更改 lambda 的数据成员。
但是什么数据成员呢?如何声明 lambda 的数据成员?请参阅下一节关于变量的“捕获”:
捕获
方括号 [] 不仅引导了 Lambda而且还保存了捕获变量的列表。因此,它也被称作“捕获子句”。
通过从 lambda 外部作用域捕获一个变量, 你为闭包类型创建了一个非静态数据成员。进而在 lambda 主体内部,你可以访问到它。
在 C++98/03 章节,我们为 Printer 函数对象做了相似的事情。在那个类里,我添加了一个 std::string 类型的数据成员 strText 并在构造函
数中做了初始化。可调用对象拥有一个数据成员使我们能够保存它的状态。
C++11 中的捕获语法是这样的:
| 语法 | 描述 |
| | |
| [&] | 通过引用捕获在到达作用域中声明的所有自动存储持续时间变量 |
| [=] | 按值捕获(创建副本)在到达范围中声明的所有自动存储持续时间变量 |
| [x, &y] | 通过值显式捕获x,通过引用显式捕获y |
| [args…] | 按值捕获模板参数包 |
| [&args…] | 按引用捕获模板参数包 |
| this | 捕获成员函数内部的this指针 |
请注意,对于 [=] 和 [&] 情况,编译器会为 lambda 主体内所有使用的变量生成数据成员。这是一种方便的语法,您不需要显式提及捕获的变量。
下面是基本语法的总结和示例:
1 |
|
什么是“自动存储期”?
程序中的所有对象都有四种可能的“存储”方式:automatic(自动存储)、static(静态)、thread(线程)或 dynamic(动态)。自动意味着在作用域开始时分配存储,就像在函数中一样。大多数局部变量都有自动存储期(声明为 static、extern 或 thread_local 的除外)。详见 cppreference - storage duration。
为了理解捕获一个变量时到底发生了什么,让我们来考虑以下代码:
1 |
|
对于上面的 Lambda,str 是按值进行捕获的(也就是被复制)。编译器可能会为此生成如下的局部函数对象:
1 |
|
当你将一个变量传递给捕获子句时,它就被用来直接初始化数据成员 str。所以前面的例子可以“展开”为:
1 |
|
当计算 lambda 表达式时,使用值捕获的实体直接初始化结果闭包对象的每个相应的非静态数据成员。
再来看一个捕获了两个变量的例子:
1 |
|
对于以上 lambda,编译器可能生成如下局部函数对象:
1 |
|
由于我们按引用捕获了 x 和 y;闭包类型将会包含两个数据成员,而且都是引用。
值捕获变量的值是在定义 lambda 时的值,而不是在调用时的值!引用捕获的变量的值是使用 lambda 时的值,而不是定义它时的值。
C++ 闭包不会延长捕获的引用的生存期。确保在调用 lambda 时捕获变量仍然存在。
代码生成
在本书中,我展示了一个可能的编译器生成的代码,作为一个结构体来定义闭包类类型。然而,这只是一种简化——一种理想模型——在编译器内部,情况可能会有所不同。
例如,对于 Clang,它的抽象语法生成树(AST:Abstract Syntax Tree)就使用类来表示一个闭包。其调用运算符被定义为共有的,而数据成员则被定义为私有的。
这就是为什么我们无法写出这样的代码:
1 |
|
在 GCC (在 Clang 与之类似)将会得到如下报错信息:error: 'struct main()::<lambda()>' has no member named 'x'
另一方面,规范的一个重要部分提到,捕获的变量是直接初始化的,这对于私有成员(对于代码中的常规类)是不可能的。这意味着编译器可以在这里发挥一点“魔力”,创建更高效的代码(不需要复制变量,甚至不需要移动它们)。
如果你想了解更多关于 lambda 的内部实现细节,请移步至 Andreas Fertig(C++ Insights 的创办人) 的博客:Under the covers of C++ Lambdas - Part 2: Captures, captures, captures。
捕获所有或显式捕获
虽然指定 [=] 或 [&] 可能很方便,因为它捕获了所有自动存存储期的变量,然而显式捕获一个变量会更清晰。这样,编译器就可以警告你不想要的效果(例如,请参阅关于全局变量和静态变量的说明)。
你也可以在 Scott Meyer 著的 《Effective Modern C++》的条款 31:“避免默认捕获模式”了解更多相关信息。
关键字 mutable
闭包类型的 operator() 默认被标记为 const,因此你无法在 lambda 体内改变捕获到的变量值。
如果你想改变这个行为,你需要在形参列表后面添加 mutable 关键字。这种语法有效地从闭包类型的调用操作符声明中删除了const。如果你有定义了一个带有 mutable 关键字的 lambda 表达式:
1 |
|
它将会被“拓展”成如下函数对象:
1 |
|
如你所见,调用运算符重载可以更改成员字段的值了。
1 |
|
在上例中,我们可以改变 x 和 y 的值。因为它们只是封闭作用域中 x 和 y 的副本,所以在调用 foo 之后我们看不到它们的新值。
另一方面,如果通过引用捕获,则不需要对 lambda 应用mutable 修改该值。这是因为捕获的数据成员是引用,这意味着无论如何都不能将它们绑定到新对象,但可以更改引用的值。
1 |
|
在上面的例子中,Lambda 没有被指定为 mutable,但是它可以改变被引用的值。
需要注意的一件重要事情是,当应用 mutable 时,不能用const 标记生成的闭包对象,因为这会阻止对 lambda 的调用!
1 |
|
最后一行不能编译,因为不能在 const 对象上调用非 const成员函数。
调用计数器——捕获变量的一个例子
在我们开始讨论关于捕获的更复杂的主体之前,我们可以休息一下,来专注于一个更加实际的例子。
当你想要使用标准库中的某些现有算法并更改其默认行为时,Lambda 表达式非常方便。例如,对于 std::sort 你可以传入你自己的比较函数。
但是我们可以更进一步,传入一个有调用计数器的增强版比较函数。
1 |
|
示例中提供的比较器的工作方式与默认比较器相同,如果 a 小于 b,它将返回,因此我们使用从小到大的自然顺序。然而,传递给 std::sort 的 lambda 也捕获了一个局部变量 compCounter。然后使用该变量对排序算法中对该比较器的所有调用进行计数。
捕获全局变量
如果你试图在你的 lambda 中使用 [=] 来捕获一个全局变量 ,你可能认为这个全局对象也会以传值的方式被捕获……,但并非如此。看代码:
1 |
|
上例中定义了一个全局变量,然后在 main 函数中定义的几个 lambda 中使用它。如果你运行这段代码,那么无论你以何种方式捕获,它都将始终指
向全局对象,并不会创建本地副本。
这是因为只有具有自动存储期的变量才能被捕获。GCC 甚至可以报出以下警告:warning: capture of variable 'global' with non-automatic storage duration
只有在显式捕获全局变量时才会出现此警告,因此如果使用 [=],编译器也帮不了你。
Clang 编译器甚至更有帮助,因为它会生成一个错误:error: 'global' cannot be captured because it does not have automatic storage duration
捕获静态变量
与捕获全局变量类似,对于静态对象,你也会得到相似的错误:
1 |
|
这次,我们尝试捕获一个静态变量,然后更改它的值,但由于它不是自动存储期,编译器无法做到这一点。
当你通过名称 [static_int] 捕获变量时,GCC 报告一个警告,而 Clang 显示一个错误。
捕获类成员和 this 指针
当你在类成员函数中,并且希望捕获数据成员时,事情会变得稍微复杂一些。由于所有非静态数据成员都与 this 指针相关,因此它也必须存储在某个地方。
看代码:
1 |
|
这段代码尝试去捕获一个数据成员 s。然而编译器却发出了如下错误信息:
In member function ‘void Baz::foo()’:
error: capture of non-variable ‘Baz::s’
error: ‘this’ was not captured for this lambda function
为了解决这个错误,我们必须捕获 this 指针。之后我们就能访问到数据成员了。
我们将代码更新为:
1 |
|
现在就没有编译错误了。
你也可以使用 [=] 或 [&] 去捕获 this 指针(它们在 C++ 11/14 中具有相同的效果)。
请注意,我们通过指针的值捕获了 this。这就是为什么您可以访问初始数据成员,而不是它的副本。
在 C++11(甚至是 C++14)中你无法这样写:auto lam = [*this]() { std::cout << s; }
这段代码在 C++11/14 下无法编译,然而,在 C++17 下可以。
如果您在单个方法的上下文中使用 Lambda,那么捕获 this 将很好。但是更复杂的情况呢?
你知道如下代码将会发生什么吗?
1 |
|
这段代码定义了一个 Baz 对象然后去调用 foo()。请注意 foo() 返回的是一个 Lambda(存储在 std::function)而且它捕获了这个类的一个成员变量。
由于我们使用了临时对象,因而无法确定当我们调用 f1 和 f2 的时候会发生什么。这是一个悬垂引用问题,会导致未定义行为。类似的:
1 |
|
如果显式地声明捕获([s]),则会得到编译器错误。
1 |
|
总而言之,当 lambda 比对象本身活得更久时,捕获这一点可能会变得棘手。当您使用异步调用或多线程时,可能会发生这种情况。
我们将在 C++17 的章节中回到这个主题。参见“并发执行使用 Lambda”。
只移对象
如果你有一个只移对象(例如 unique_ptr),你无法将它作为一个捕获变量移动到 lambda 内。按值捕获不起作用,你只能按引用去捕获。
1 |
|
在上面的示例中,您可以看到捕获unique_ptr的唯一方法是通过引用。然而,这种方法可能不是最好的,因为它不转移指针的所有权。
在关于 C++14 的下一章中,您将看到由于使用初始化器捕获,这个问题得到了解决。转到 C++ 14章中的“移动”一节,继续了解这个主题。
常量性的保留
如果你捕获一个常量,那么常量性会被保留下来:
1 |
|
上面的代码不能编译,因为捕获的变量是常量。下面是本例可能生成的函数对象:
1 |
|
捕获参数包
为了结束对捕获子句的讨论,我们应该提到您还可以利用可变模板来捕获。编译器将包展开为一个非静态数据成员列表,如果您想在模板化代码中使用 Lambda,这可能很方便。例如,下面是一个测试捕获的代码示例:
1 |
|
这段实验性的代码表明,您可以按值捕获可变参数包(也可以通过引用),然后将该包“存储”到元组对象中。然后在元组上调用一些辅助函数来访问它的数据和属性。
你也可以使用 C++ Insights 来查看编译器是如何生成代码并将模板、参数包和 lambda 扩展为代码的。参见这里的示例 @C++Insight。
返回类型
在大多数情况下,即便是在 C++11 中,你也可以跳过 lambda 的返回类型,然后编译器可以为您推断出类型名。
附注:最初,返回类型推导仅限于函数体中包含单个返回语句的 Lambda。然而,这个限制很快就被取消了,因为实现一个更方便的版本没有问题。
总而言之,从 C++11 开始,只要所有的返回语句都是相同的类型,编译器就能够推断出返回类型。
从缺陷报告中我们可以读到以下内容:
如果Lambda表达式不包含尾随返回类型,则尾随返回类型表示以下类型:
- 如果复合语句中没有return语句,或者所有的return语句返回一个void类型的表达式,或者没有表达式或带括号的init-list,则类型为void;
- 否则,如果所有的return语句都返回一个表达式以及左值到右值转换(7.3.2 [conv.lval])、数组到顶层指针转换(7.3.3 [conv.array])和函数
- 到指针转换(7.3.4 .lval)后返回的表达式的类型[conv.func])是相同的,即共同类型
- 否则,程序为病式
1 |
|
在上面的 lambda 中,我们有两个返回语句,但它们都指向 double,因此编译器可以推断出类型。
在 C++ 14中,Lambda 的返回类型将被更新,以适应正则函数的自动类型推导规则。参见“返回类型推断”。这样就得到了一个更简单的定义。
尾随返回类型语法
如果你想明确返回类型,可以使用尾随返回类型说明。例如,当你返回一个字符串字面值时:
1 |
|
上面的代码无法编译,因为编译器将 const char* 推断为 lambda 的返回类型。而字符串字面量上没有 += 操作符,所以代码会中断。
可以通过显式地将返回类型设置为 std::string 来解决这个问题:
1 |
|
请注意,我们现在必须删除 noexcept,因为 std::string 创建可能会抛出错误。
另外,您还可以使用命名空间 std::string_literals; 然后你返回 “you’re a regular”s 表示 std::string 类型。
转换成一个函数指针
如果你的 lambda 并不捕获任何变量,那么编译器可以将其转换成一个常规函数指针。以下是标准对此的详细描述:
没有 lambda 捕获的 lambda 表达式的闭包类型具有一个公共非虚非显式 const 转换函数,该转换函数指向与闭包类型的函数调用操作符具有相同形参和返回类型的函数。此转换函数返回的值应为函数的地址,该函数在调用时与调用闭包类型的函数调用操作符具有相同的效果。
为了说明 lambda 如何支持这种转换,让我们考虑以下示例。它定义了一个函数对象 baz,该对象显式地定义了转换操作符:
1 |
|
在前面的程序中,有一个函数 callWith10,它接受一个函数指针。然后我们用两个参数调用它(第 18 行和第 19 行):第一个使用 baz,它是一个函数对象类型,包含必要的转换操作符—它转换为 f_ptr,这与 callWith10 的输入参数相同。稍后,我们将调用 lambda 函数。在这种情况下,编译器在下面执行所需的转换。
当需要调用需要回调的 C 风格函数时,这种转换可能很方便。例如,下面你可以找到从 C 库调用 qsort 并使用 lambda 以相反顺序对元素排序的代码:
1 |
|
如你所见,使用 std::qsort,它只接受函数指针作为比较器。编译器可以对我们传递的无状态 lambda 进行隐式转换。
棘手的情况
在我们进入另一个话题之前,还有一个案例可能会很有趣:
1 |
|
请注意 + 的奇怪语法。如果删除加号,则 static_assert 失败。为什么呢?
为了理解它是如何工作的,我们可以看看 C++ Insights 项目生成的输出。
1 |
|
代码使用 +,这是一个一元操作符。该操作符可以操作指针,因此编译器将无状态Lambda转换为函数指针,然后将其赋值给funcPtr。
另一方面,如果去掉加号,那么 funcPtr 就只是一个普通的闭包对象,这就是 static_assert 失败的原因。
虽然用“+”来编写这样的语法可能不是最好的主意,但是如果用 static_cast,效果是一样的。在不希望编译器创建太多函数实例化的情况下,可以应用此技术。例如:
1 |
|
在上面的例子中,编译器只需要创建一个 call_function 的实例,因为它只接受一个函数指针 int (*)(int)。但是如果你去掉 static_cast,那么你将得到两个版本的 call_function,因为编译器必须为 Lambdas 创建两个不同的类型。
IIFE —— 立即调用的函数表达式
到目前为止,在您看到的大多数示例中,您可以注意到我定义了一个 Lambda,然后在稍后调用它。
然而,你也可以立即调用 Lambda:
1 |
|
正如你在上面看到的,Lambda 被创建并且没有被赋值给任何闭包对象。然后用()调用它。如果您运行这个程序,你可以期望看到 2, 2 作为输出。当你对 const 对象进行复杂的初始化时,这种表达式可能很有用。
1 |
|
以上代码中,val 是一个由 lambda 表达式返回的某种类型的常量。例如:
1 |
|
下面你可以找到一个更长的例子,我们使用 IIFE 作为辅助 lambda 来在函数中创建一个常量值:
1 |
|
上面的例子包含一个函数 BuildAHref,它接受两个参数,然后构建一个 <a> </a>
HTML 标记。基于输入参数,我们构建 html 变量。如果文本不为空,则使用它作为内部 HTML 值。否则,我们使用链接。我们希望 html 变量为 const,但是很难编写具有输入参数所需条件的紧凑代码。多亏了 IIFE,我们可以编写一个单独的 Lambda,然后用 const 标记变量。稍后,可以将该变量传递给 ValidateHTML。
关于可读性的一个注意事项
有时,立即调用 lambda 可能会导致一些可读性问题。例如:
1 |
|
在上面的示例中,Lambda 代码非常复杂,阅读代码的开发人员不仅要破译 lambda 是立即调用的,还要推断 EnableErrorReporting 类型。他们可能会假设 EnableErrorReporting 是闭包对象,而不仅仅是一个 const 变量。对于这种情况,您可以考虑不使用 auto,以便我们可以很容易地看到类型。甚至可以在 }() 旁边添加注释,比如 // 立即调用。
关于升级版的 IIFE,你可以在 C++17 章节了解到更多。
从 Lambda 继承
这可能令人惊讶,但您确实可以从 lambda 派生!
由于编译器使用 operator() 将 lambda 表达式展开为函数对象,因此我们可以从这个类型继承。
来看一个基础的例子:
1 |
|
在这个例子中,ComplexFn 类是从 Callable 派生出来的,Callable 是一个模板参数。如果我们想从 lambda 中派生,我们需要一点小技巧,因为我们不能拼写出闭包类型的确切类型(除非我们将其包装到 std::function 中)。
这就是为什么我们需要 MakeComplexFunctionObject 函数来执行模板参数推导并获得 lambda 闭包的类型。
除了它的名字,ComplexFn 只是一个简单的包装器,没有太多的用途。这样的代码模式有什么用例吗?
例如,我们可以扩展上面的代码,从两个 Lambdas 继承并创建一个重载集合:
1 |
|
这次我们有更多的代码:我们从两个模板形参派生,但是我们还需要显式地公开它们的调用操作符。
为什么呢?这是因为在寻找正确的函数重载时,编译器要求候选函数在相同的作用域内。
为了理解这一点,让我们编写一个从两个基类派生的简单类型。该示例还注释掉了两个 using 语句:
1 |
|
我们有两个实现 Func 的基类。我们想从派生对象调用那个方法。
GCC 报告以下错误:error: request for member 'Func' is ambiguous
因为我们注释掉了 using 语句 ::Func() 可以来自 BaseInt 或 BaseDouble 的作用域。编译器有两个作用域来搜索最佳候选,根据标准,这是不允许的。
那么,让我们回到我们的主要用例:SimpleOverloaded 是一个基本类,它还不能用于生产环境。请参阅 C++17 章节,在那里我们将讨论该模式的高级版本。由于 C++17 的一些特性,我们将能够从多个 Lambdas 继承(多亏了可变模板)并语法更加紧凑。
存储 Lambda 到容器
作为本章的最后一项技术,让我们看一下在容器中存储闭包的问题。
但是我不是写过不能默认创建和赋值 lambda 吗?
是的,但是,我们可以在这里做一些戏法。
其中一种技术是利用无状态 lambda 转换为函数指针的属性。虽然不能直接存储闭包对象,但可以保存从 lambda 表达式转换而来的函数指针。
例如:
1 |
|
在上面的例子中,我们创建了用来存储变量的函数指针的向量。容器中有三个条目:
- 第一个打印输入变量的值。
- 第二个更改它的值。
- 第三个复制自第一个,因此它也打印值。
以上解决方案可以工作,但仅限于无状态 Lambda。如果我们想解除这个限制呢?
为了解决这个问题,我们可以使用求助于 std::function。为了使示例更有趣,它还以简单的整数转换为处理 std::string 对象的 lambda 为例:
1 |
|
这次我们将 std::function<std::string(const std::string&)> 存储在容器中。
这允许我们使用任何类型的函数对象,包括带有捕获变量的 lambda 表达式。其中一个 lambda removeSpacesCnt
捕获一个变量,该变量用于存储有关从输入字符串中删除的空格的信息。
总结
在本章中,您学习了如何创建和使用 lambda 表达式。我描述了语法、捕获子句、Lambda 的类型,并介绍了许多示例和用例。我们甚至更进一步,我给你们展示了一种派生自 lambda 的模式或者把它存储在一个容器里。
但这还不是全部!
lambda 表达式已经成为现代 C++ 的重要组成部分。有了更多的用例,开发人员也看到了改进这个特性的可能性。这就是为什么你现在可以转到下一章,看看 ISO 委员会在 C++14 中添加的重要更新。
C++14 中的 Lambda
C++14 对 lambda 表达式增加了两个重要的功能:
- 带初始化器的捕获
- 泛型 lambda
此外,该标准还更新了一些规则,例如:
- lambda 的默认参数
- 以 auto 作为返回类型
这些特性可以解决 C++11 中出现的几个问题。你可以在 N4140 和 lambdas 中查看相关细节。此外,在本章,你将会了解到:
- 捕获非静态数据成员
- 用现代的技术替代旧的函数风格工具,如 std::bind1st
- LIFTING 惯用法
- 递归 lambda
Lambda 的默认参数
让我们从一些较小的更新开始:
在 C++14 中,可以在函数调用中使用默认参数。这是一个小功能,但可以使 lambda 更像一个常规函数。
1 |
|
在上例中,我们调用了 lambda 两次。第一次没有传入任何参数,因此它使用了默认值 x = 10
。第二次我们传入了 100。
有趣的是 GCC 和 CLang 编译器早在 C++11 时就支持该特性了。