万岁!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 表达式。它只需要[]
部分
(lambda 引入器),然后用空的{}
部分作为函数体。形参列表()
是可选的,在本例中不需要。
// 2. 拥有两个参数的 lambda:
[] (float f, int a) { return a * f; };
[] (int a, int b) { return a < b; };
在第二个例子中,可能是最常见的例子了,你可以看到参数都传递到()
部分,就像普通函数一样。返回类型不需要,因为编译器会自动推导它。
// 3. 尾置返回类型:
[] (MyClass t) -> int { auto a = t.compute(); print(a); return a; };
在上面的例子中,我们显式地设置了一个返回类型。后面的返回类型也可用在 C++11 以来的常规函数声明中。
// 4. 额外的说明符:
[x] (int a, int b) mutable { ++x; return a < b; };
[] (float param) noexcept { return param * param; };
[x] (int a, int b) mutable noexcept { ++x; return a < b; };
最后一个示例显示,在 lambda 的主体之前,可以使用其他说明符。在代码中,我们使用了 mutable
(这样我们可以改变捕获的变量)和noexcept
。第三个 lambda 使用了mutable
和noexcept
,并且它们必须以该顺序出现(你不能写noexcept
mutable
,因为编译器会拒绝它)。
虽然()
部分是可选的,但如果你想应用 mutable
或 noexcept
,此时()
则需要在出现的表达中:
// 5. 可选项
[x] { std::cout << x; }; // 不需要 ()
[x] mutable { ++x; }; // 无法通过编译!
[x] () mutable { ++x; }; // 可以,mutable 前面的 () 是必要的
[] noexcept { }; // 无法通过编译!
[] () noexcept { }; // 可以
同样的模式也适用于其他可以应用于 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
。为了便于比较,代码还说明了编译器生成的相应的函数对象类型:
// Ex2_1: Lambda 和 相应的函数对象。
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
struct {
void operator()(int x) const {
std::cout << x << '\n';
}
} someInstance;
const std::vector<int> v { 1, 2, 3 };
std::for_each(v.cbegin(), v.cend(), someInstance);
std::for_each(v.cbegin(), v.cend(), [] (int x) {
std::cout << x << '\n';
}
);
}
在本例中,编译器将
[](int x) { std::cout << x << '\n'; }
翻译成一个匿名函数对象,简化形式如下:
struct {
void operator()(int x) const {
std::cout << x << '\n';
}
} someInstance;
“翻译”或“展开”的过程可以很容易地在 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 声明的每个都生成惟一的匿名类型。我们可以用下面的代码来证明这个属性:
// Ex2_1: 相同的代码,不同的类型。
#include <type_traits>
int main() {
const auto oneLam = [](int x) noexcept { return x * 2; };
const auto twoLam = [](int x) noexcept { return x * 2; };
static_assert(!std::is_same<decltype(oneLam), decltype(twoLam)>::value,
"must be different!");
}
上面的例子验证了 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
是一个重量级的对象,因为它需要处理所有可调用对象。要做到这一点,它需要高级的内部机制,如类型双关语,甚至是动态内存分配。我们可以通过一个简单的实验来检验它的大小:
// Ex2_3: std::function 和 auto 类型推导。
#include <functional>
#include <iostream>
int main() {
const auto myLambda = [](int a) noexcept -> double {
return 2.0 * a;
};
const std::function<double(int)> myFunc =
[](int a) noexcept -> double {
return 2.0 * a;
};
std::cout << "sizeof(myLambda) is " << sizeof(myLambda) << '\n';
std::cout << "sizeof(myFunc) is " << sizeof(myFunc) << '\n';
return myLambda(10) == myFunc(10);
}
在 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:
// Ex2_4: Copying lambdas. Live code
#include <type_traits>
int main() {
const auto firstLam = [](int x) noexcept { return x * 2; };
const auto secondLam = firstLam;
static_assert(std::is_same<decltype(firstLam), decltype(secondLam)>::value,
"must be the same");
}
如果你复制一个 lambda,那么你也复制了它的状态。当我们讨论捕获变量时,这一点很重要。在该上下文中,闭包类型将捕获的变量存储为成员字段。执行 lambda 复制将复制这些数据成员字段。
展望未来
在 C++20 中,无状态 lambda 将是默认为可构造和可赋值的。
调用运算符
放入 lambda 体中的代码被“翻译”为对应闭包类型的operator()
中的代码。
在 C++11 中,默认情况下它是一个const inline
成员函数。例如:
auto lam = [](double param) { /* do something*/ };
将会被展开成类似于:
struct __anonymousLambda {
inline void operator()(double param) const { /* do something */ }
};
接下来我们讨论这种方法的结果,以及如何修改生成的调用操作符声明。
重载
值得一提的是,在定义 lambda 时,无法创建接受不同参数的“重载” lambda。如:
// doesn't compile!
auto lam = [](double param) { /* do something*/ };
auto lam = [](int param) { /* do something*/ };
以上代码无法通过编译,由于编译器无法将这两个 lambda 翻译到一个函数对象。此外,你不能定义两个相同的变量。然而,创建同一个函数对象中的两个调用运算符的重载却是允许的:
struct MyFunctionObject {
inline void operator()(double param) const { /* do something */ }
inline void operator()(int param) const { /* do something */ }
};
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 */ }
编译器将生成如下代码:
struct __anonymousLambda {
inline void operator()(double param) noexcept { /* do something */ }
};
请注意,const 关键字现在没有了,调用操作符现在可以更改 lambda 的数据成员。
但是什么数据成员呢?如何声明 lambda 的数据成员?请参阅下一节关于变量的“捕获”:
捕获
方括号 [] 不仅引导了 lambda而且还保存了捕获变量的列表。因此,它也被称作“捕获子句”。
通过从 lambda 外部作用域捕获一个变量, 你为闭包类型创建了一个非静态数据成员。进而在 lambda 主体内部,你可以访问到它。
在 C++98/03 章节,我们为 Printer 函数对象做了相似的事情。在那个类里,我添加了一个 std::string 类型的数据成员 strText 并在构造函数中做了初始化。可调用对象拥有一个数据成员使我们能够保存它的状态。
C++11 中的捕获语法是这样的:
语法 | 描述 |
---|---|
[&] | 通过引用捕获在到达作用域中声明的所有自动存储持续时间变量 |
[=] | 按值捕获(创建副本)在到达范围中声明的所有自动存储持续时间变量 |
[x, &y] | 通过值显式捕获x,通过引用显式捕获y |
[args...] | 按值捕获模板参数包 |
[&args...] | 按引用捕获模板参数包 |
this | 捕获成员函数内部的this指针 |
请注意,对于 [=] 和 [&] 情况,编译器会为 lambda 主体内所有使用的变量生成数据成员。这是一种方便的语法,您不需要显式提及捕获的变量。
下面是基本语法的总结和示例:
int x = 2, y = 3;
const auto l1 = []() { return 1; }; // 无捕获
const auto l2 = [=]() { return x; }; // lambda 中使用的所有变量都会被复制
const auto l3 = [&]() { return y; }; // lambda 中使用的所有变量都会被引用
const auto l4 = [x]() { return x; }; // 仅通过值捕获 x (复制)
// const auto lx = [=x]() { return x; }; // 语法错误,显示复制 x 不需要 =
const auto l5 = [&y]() { return y; }; // 仅通过引用捕获 y
const auto l6 = [x, &y]() { return x * y; }; // x 值捕获 y 引用捕获
const auto l7 = [=, &x]() { return x + y; }; // x 以引用捕获,除此之外都以值捕获
const auto l8 = [&, y]() { return x - y; }; // y 以值捕获,除此之外都以引用捕获
什么是“自动存储期”?
程序中的所有对象都有四种可能的“存储”方式:automatic(自动存储)、static(静态)、thread(线程)或 dynamic(动态)。自动意味着在作用域开始时分配存储,就像在函数中一样。大多数局部变量都有自动存储期(声明为 static、extern 或 thread_local 的除外)。详见 cppreference - storage duration。
为了理解捕获一个变量时到底发生了什么,让我们来考虑以下代码:
std::string str {"Hello World"};
auto foo = [str]() { std::cout << str << '\n'; };
foo();
对于上面的 lambda,str 是按值进行捕获的(也就是被复制)。编译器可能会为此生成如下的局部函数对象:
struct _unnamedLambda {
inline void operator()() const {
std::cout << str << '\n';
}
std::string str;
};
当你将一个变量传递给捕获子句时,它就被用来直接初始化数据成员 str。所以前面的例子可以“展开”为:
std::string str {"Hello World"};
_unnamedLambda foo { str };
foo();
当计算lambda表达式时,使用值捕获的实体直接初始化结果闭包对象的每个相应的非静态数据成员。
再来看一个捕获了两个变量的例子:
int x = 1, y = 1;
std::cout << x << " " << y << '\n';
const auto foo = [&x, &y] noexcept { ++x; ++y; };
foo();
std::cout << x << " " << y << '\n';
对于以上 lambda,编译器可能生成如下局部函数对象:
struct _unnameLambda {
void operator()() const noexcept {
++x; ++y;
};
int& x;
int& y;
};
由于我们按引用捕获了 x 和 y;闭包类型将会包含两个数据成员,而且都是引用。
值捕获变量的值是在定义 lambda 时的值,而不是在调用时的值!引用捕获的变量的值是使用 lambda 时的值,而不是定义它时的值。
C++ 闭包不会延长捕获的引用的生存期。确保在调用 lambda 时捕获变量仍然存在。
代码生成
在本书中,我展示了一个可能的编译器生成的代码,作为一个结构体来定义闭包类类型。然而,这只是一种简化——一种理想模型——在编译器内部,情况可能会有所不同。
例如,对于 Clang,它的抽象语法生成树(AST:Abstract Syntax Tree)就使用类来表示一个闭包。其调用运算符被定义为共有的,而数据成员则被定义为私有的。
这就是为什么我们无法写出这样的代码:
int x = 0;
auto lam = [=]() { std::cout << x; };
lam.x = 10; // ??
在 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 表达式:
int x = 1;
auto foo = [x]() mutable { ++x; };
它将会被“拓展”成如下函数对象:
struct __lambda_x1 {
void operator()() { ++x; }
int x;
};
如你所见,调用运算符重载可以更改成员字段的值了。
// Ex2_5: Capturing Two Variables by Copy and Mutable.
#include <iostream>
int main() {
const auto print = [](const char* str, int x, int y) {
std::cout << str << ": " << x << " " << y << '\n';
};
int x = 1, y = 1;
print("in main()", x, y);
auto foo = [x, y, &print]() mutable {
++x;
++y;
print("in foo()", x, y);
};
foo();
print("in main()", x, y);
}
// 输出:
// in main(): 1 1
// in foo(): 2 2
// in main(): 1 1
在上例中,我们可以改变 x 和 y 的值。因为它们只是封闭作用域中 x 和 y 的副本,所以在调用 foo 之后我们看不到它们的新值。
另一方面,如果通过引用捕获,则不需要对 lambda 应用mutable 修改该值。这是因为捕获的数据成员是引用,这意味着无论如何都不能将它们绑定到新对象,但可以更改引用的值。
int x = 1;
std::cout << x << '\n';
const auto foo = [&x]() noexcept { ++x; };
foo();
std::cout << x << '\n';
在上面的例子中,lambda 没有被指定为 mutable,但是它可以改变被引用的值。
需要注意的一件重要事情是,当应用 mutable 时,不能用const 标记生成的闭包对象,因为这会阻止对 lambda 的调用!
int x = 10;
const auto lam = [x]() mutable { ++x; }
lam(); // 无法编译!
最后一行不能编译,因为不能在 const 对象上调用非 const成员函数。
调用计数器——捕获变量的一个例子
在我们开始讨论关于捕获的更复杂的主体之前,我们可以休息一下,来专注于一个更加实际的例子。
当你想要使用标准库中的某些现有算法并更改其默认行为时,Lambda表达式非常方便。例如,对于 std::sort 你可以传入你自己的比较函数。
但是我们可以更进一步,传入一个有调用计数器的增强版比较函数。
// Ex2_6: Invocation Counter.
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> vec { 0, 5, 2, 9, 7, 6, 1, 3, 4, 8 };
size_t compCounter = 0;
std::sort(vec.begin(), vec.end(),
[&compCounter](int a, int b) noexcept {
++compCounter;
return a < b;
}
);
std::cout << "number of comparisons: " << compCounter << '\n';
for (const auto& v : vec)
std::cout << v << ", ";
}
示例中提供的比较器的工作方式与默认比较器相同,如果 a 小于 b,它将返回,因此我们使用从小到大的自然顺序。然而,传递给 std::sort 的 lambda 也捕获了一个局部变量 compCounter。然后使用该变量对排序算法中对该比较器的所有调用进行计数。
捕获全局变量
如果你试图在你的 lambda 中使用 [=] 来捕获一个全局变量 ,你可能认为这个全局对象也会以传值的方式被捕获......,但并非如此。看代码:
// Ex2_7: Capturing Globals.
#include <iostream>
int global = 10;
int main() {
std::cout << global << '\n';
auto foo = [=]() mutable noexcept { ++global; };
foo();
std::cout << global << '\n';
const auto increaseGlobal = []() noexcept { ++global; };
increaseGlobal();
std::cout << global << '\n';
const auto moreIncreaseGlobal = [global]() noexcept { ++global; };
moreIncreaseGlobal();
std::cout << global << '\n';
}
上例中定义了一个全局变量,然后在 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
捕获静态变量
与捕获全局变量类似,对于静态对象,你也会得到相似的错误:
// Ex2_8: Capturing Static Variables.
#include <iostream>
void bar() {
static int static_int = 10;
std::cout << static_int << '\n';
auto foo = [=]() mutable noexcept{ ++static_int; };
foo();
std::cout << static_int << '\n';
const auto increase = []() noexcept { ++static_int; };
increase();
std::cout << static_int << '\n';
const auto moreIncrease = [static_int]() noexcept { ++static_int; };
moreIncrease();
std::cout << static_int << '\n';
}
int main() {
bar();
}
// 输出
// 10
// 11
// 12
// 13
这次,我们尝试捕获一个静态变量,然后更改它的值,但由于它不是自动存储期,编译器无法做到这一点。
当你通过名称 [static_int] 捕获变量时,GCC 报告一个警告,而 Clang 显示一个错误。
捕获类成员和 this 指针
当你在类成员函数中,并且希望捕获数据成员时,事情会变得稍微复杂一些。由于所有非静态数据成员都与 this 指针相关,因此它也必须存储在某个地方。
看代码:
// Ex2_9: Error when capturing a data member.
#include <iostream>
struct Baz {
void foo() {
const auto lam = [s]() { std::cout << s; };
lam();
}
std::string s;
};
int main() {
Baz b;
b.foo();
}
这段代码尝试去捕获一个数据成员 s。然而编译器却发出了如下错误信息:
In member function 'void Baz::foo()':
error: capture of non-variable 'Baz::s'
error: 'this' was not captured for this lambda function
为了解决这个错误,我们必须捕获 this 指针。之后我们就能访问到数据成员了。
我们将代码更新为:
struct Baz {
void foo() {
const auto lam = [this]() { std::cout << s; };
lam();
}
std::string s;
};
现在就没有编译错误了。
你也可以使用 [=] 或 [&] 去捕获 this 指针(它们在 C++ 11/14 中具有相同的效果)。
请注意,我们通过指针的值捕获了 this。这就是为什么您可以访问初始数据成员,而不是它的副本。
在 C++11(甚至是 C++14)中你无法这样写:
auto lam = [*this]() { std::cout << s; }
这段代码在 C++11/14 下无法编译,然而,在 C++17 下可以。
如果您在单个方法的上下文中使用 lambda,那么捕获 this 将很好。但是更复杂的情况呢?
你知道如下代码将会发生什么吗?
// Ex2_10: Returning a Lambda From a Method
#include <functional>
#include <iostream>
struct Baz {
std::function<void()> foo() {
return [=] { std::cout << s << '\n'; };
}
std::string s;
};
int main() {
auto f1 = Baz{"abc"}.foo();
auto f2 = Baz{"xyz"}.foo();
f1();
f2();
}
这段代码定义了一个 Baz 对象然后去调用 foo()。请注意 foo() 返回的是一个 lambda(存储在 std::function)而且它捕获了这个类的一个成员变量。
由于我们使用了临时对象,因而无法确定当我们调用 f1 和 f2 的时候会发生什么。这是一个悬垂引用问题,会导致未定义行为。
类似的:
struct Bar {
std::string const& foo() const { return s; };
std::string s;
};
auto&& f1 = Bar{"abc"}.foo(); // a dangling reference
如果显式地声明捕获([s]),则会得到编译器错误。
std::function<void()> foo() {
return [s] { std::cout << s << '\n'; };
} // error: 'this' was not captured!
总而言之,当 lambda 比对象本身活得更久时,捕获这一点可能会变得棘手。当您使用异步调用或多线程时,可能会发生这种情况。
我们将在 C++17 的章节中回到这个主题。参见“并发执行使用 lambda”。
只移对象
如果你有一个只移对象(例如 unique_ptr),你无法将它作为一个捕获变量移动到 lambda 内。按值捕获不起作用,你只能按引用去捕获。
std::unique_ptr<int> p(new int{10});
auto foo = [p]() {}; // 无法通过编译
auto foo_ref = [&p]() { }; // 通过编译,然而,所有权未能传递
在上面的示例中,您可以看到捕获unique_ptr的唯一方法是通过引用。然而,这种方法可能不是最好的,因为它不转移指针的所有权。
在关于 C++14 的下一章中,您将看到由于使用初始化器捕获,这个问题得到了解决。转到 C++ 14章中的“移动”一节,继续了解这个主题。
常量性的保留
如果你捕获一个常量,那么常量性会被保留下来:
#include <iostream>
#include <type_traits>
int main() {
const int x = 10;
auto foo = [x] () mutable {
std::cout << std::is_const<decltype(x)>::value << '\n';
x = 11;
};
foo();
}
上面的代码不能编译,因为捕获的变量是常量。下面是本例可能生成的函数对象:
struct __lambda_x {
void operator()() { x = 11; /*error!*/ }
const int x;
};
捕获参数包
为了结束对捕获子句的讨论,我们应该提到您还可以利用可变模板来捕获。编译器将包展开为一个非静态数据成员列表,如果您想在模板化代码中使用 lambda,这可能很方便。例如,下面是一个测试捕获的代码示例:
// Ex2_12: Capturing a Variadic Pack.
#include <iostream>
#include <tuple>
template<class... Args>
void captureTest(Args... args) {
const auto lambda = [args...] {
const auto tup = std::make_tuple(args...);
std::cout << "tuple size: " <<
std::tuple_size<decltype(tup)>::value << '\n';
std::cout << "tuple 1st: " << std::get<0>(tup) << '\n';
};
lambda(); // call it
}
int main() {
captureTest(1, 2, 3, 4);
captureTest("Hello world", 10.0f);
}
// 输出:
// tuple size: 4
// tuple 1st: 1
// tuple size: 2
// tuple 1st: Hello world
这段实验性的代码表明,您可以按值捕获可变参数包(也可以通过引用),然后将该包“存储”到元组对象中。然后在元组上调用一些辅助函数来访问它的数据和属性。
你也可以使用 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])是相同的,即共同类型
- 否则,程序为病式
// Ex2_13: Return Type Deduction.
#include <type_traits>
int main() {
const auto baz = [](int x) noexcept {
if (x < 20)
return x * 1.1;
else
return x * 2.1;
};
static_assert(std::is_same<double, decltype(baz(10))>::value, "has to be the same!");
}
在上面的 lambda 中,我们有两个返回语句,但它们都指向 double,因此编译器可以推断出类型。
在 C++ 14中,lambda 的返回类型将被更新,以适应正则函数的自动类型推导规则。参见“返回类型推断”。这样就得到了一个更简单的定义。
尾置返回类型语法
如果你想明确返回类型,可以使用尾随返回类型说明。例如,当你返回一个字符串字面值时:
#include <iostream>
#include <string>
int main() {
const auto testSpeedString = [](int speed) noexcept {
if (speed > 100)
return "you're a super fast";
return "you're a regular";
};
auto str = testSpeedString(100);
str += " driver"; // oops! no += on const char*!
std::cout << str;
return 0;
}
上面的代码无法编译,因为编译器将 const char* 推断为 lambda 的返回类型。而字符串字面量上没有 += 操作符,所以代码会中断。
可以通过显式地将返回类型设置为 std::string 来解决这个问题:
auto testSpeedString = [](int speed) -> std::string {
if (speed > 100)
return "you're a super fast";
return "you're a regular";
};
auto str = testSpeedString(100);
str += " driver"; // works fine
请注意,我们现在必须删除 noexcept,因为 std::string 创建可能会抛出错误。
另外,您还可以使用命名空间 std::string_literals; 然后你返回 "you're a regular"s 表示 std::string 类型。
转换成一个函数指针
如果你的 lambda 并不捕获任何变量,那么编译器可以将其转换成一个常规函数指针。以下是标准对此的详细描述:
没有 lambda 捕获的 lambda 表达式的闭包类型具有一个公共非虚非显式 const 转换函数,该转换函数指向与闭包类型的函数调用操作符具有相同形参和返回类型的函数。此转换函数返回的值应为函数的地址,该函数在调用时与调用闭包类型的函数调用操作符具有相同的效果。
为了说明 lambda 如何支持这种转换,让我们考虑以下示例。它定义了一个函数对象 baz,该对象显式地定义了转换操作符:
// Ex2_15: Conversion to a Function Pointer.
#include <iostream>
void callWith10(void(* bar)(int)) { bar(10); }
int main() {
struct {
using f_ptr = void(*)(int);
void operator()(int s) const { return call(s); }
operator f_ptr() const { return &call; }
private:
static void call(int s) { std::cout << s << '\n'; };
} baz;
callWith10(baz);
callWith10([](int x) { std::cout << x << '\n'; });
}
在前面的程序中,有一个函数 callWith10,它接受一个函数指针。然后我们用两个参数调用它(第 18 行和第 19 行):第一个使用 baz,它是一个函数对象类型,包含必要的转换操作符—它转换为 f_ptr,这与 callWith10 的输入参数相同。稍后,我们将调用 lambda 函数。在这种情况下,编译器在下面执行所需的转换。
当需要调用需要回调的 C 风格函数时,这种转换可能很方便。例如,下面你可以找到从 C 库调用 qsort 并使用 lambda 以相反顺序对元素排序的代码:
// Ex2_16: Calling a C-style function.
#include <cstdlib>
#include <iostream>
int main () {
int values[] = { 8, 9, 2, 5, 1, 4, 7, 3, 6 };
constexpr size_t numElements = sizeof(values)/sizeof(values[0]);
std::qsort(values, numElements, sizeof(int),
[](const void* a, const void* b) noexcept {
return ( *(int*)b - *(int*)a );
}
);
for (const auto& val : values)
std::cout << val << ", ";
}
如你所见,使用 std::qsort,它只接受函数指针作为比较器。编译器可以对我们传递的无状态lambda 进行隐式转换。
棘手的情况
在我们进入另一个话题之前,还有一个案例可能会很有趣:
// Ex2_17: Plus and a Lambda.
#include <type_traits>
int main() {
auto funcPtr = +[]{};
static_assert(std::is_same<decltype(funcPtr), void (*)()>::value);
}
请注意 + 的奇怪语法。如果删除加号,则 static_assert 失败。为什么呢?
为了理解它是如何工作的,我们可以看看 C++ Insights 项目生成的输出。
using FuncPtr_4 = void (*)();
FuncPtr_4 funcPtr = +static_cast<void (*)()>(__la.operator __la::retType_4_18());
/* PASSED: static_assert(std::integral_constant<bool, 1>::value); */
// __la is __lambda_4_18 in cppinsights
代码使用 +,这是一个一元操作符。该操作符可以操作指针,因此编译器将无状态lambda转换为函数指针,然后将其赋值给funcPtr。
另一方面,如果去掉加号,那么 funcPtr 就只是一个普通的闭包对象,这就是 static_assert 失败的原因。
虽然用“+”来编写这样的语法可能不是最好的主意,但是如果用 static_cast,效果是一样的。在不希望编译器创建太多函数实例化的情况下,可以应用此技术。例如:
// Ex2_18: Casting to a Function Pointer.
template<typename F>
void call_function(F f) { f(10); }
int main() {
call_function(static_cast<int (*)(int)>([](int x){return x + 2;}));
call_function(static_cast<int (*)(int)>([](int x){return x * 2;}));
}
在上面的例子中,编译器只需要创建一个 call_function 的实例,因为它只接受一个函数指针 int (*)(int)。但是如果你去掉 static_cast,那么你将得到两个版本的 call_function,因为编译器必须为 lambdas 创建两个不同的类型。
IIFE —— 立即调用的函数表达式
到目前为止,在您看到的大多数示例中,您可以注意到我定义了一个 lambda,然后在稍后调用它。
然而,你也可以立即调用 lambda:
// Ex2_19: Calling Lambda Immediately.
#include <iostream>
int main() {
int x = 1, y = 1;
[&]() noexcept { ++x; ++y; }(); // <-- call ()
std::cout << x << ", " << y;
}
正如你在上面看到的,lambda 被创建并且没有被赋值给任何闭包对象。然后用()调用它。如果您运行这个程序,你可以期望看到 2, 2 作为输出。当你对 const 对象进行复杂的初始化时,这种表达式可能很有用。
const auto val = []() {
/* several lines of code... */
}(); // call it!
以上代码中,val 是一个由 lambda 表达式返回的某种类型的常量。例如:
// val1 is int
const auto val1 = []() { return 10; }();
// val2 is std::string
const auto val2 = []() -> std::string { return "ABC"; }();
下面你可以找到一个更长的例子,我们使用 IIFE 作为辅助 lambda 来在函数中创建一个常量值:
// Ex2_20: IIFE and HTML Generation.
#include <iostream>
#include <string>
void ValidateHTML(const std::string&) { }
std::string BuildAHref(const std::string& link, const std::string& text) {
const std::string html = [&link, &text] {
const auto& inText = text.empty() ? link : text;
return "<a href=\"" + link + "\">" + inText + "</a>";
}(); // call!
ValidateHTML(html);
return html;
}
int main() {
try {
const auto ahref = BuildAHref("www.leanpub.com", "Leanpub Store");
std::cout << ahref;
} catch (...) {
std::cout << "bad format...";
}
}
上面的例子包含一个函数 BuildAHref,它接受两个参数,然后构建一个 <a> </a> HTML 标记。基于输入参数,我们构建html变量。如果文本不为空,则使用它作为内部 HTML 值。否则,我们使用链接。我们希望 html 变量为 const,但是很难编写具有输入参数所需条件的紧凑代码。多亏了 IIFE,我们可以编写一个单独的 lambda,然后用 const 标记变量。稍后,可以将该变量传递给ValidateHTML。
关于可读性的一个注意事项
有时,立即调用 lambda 可能会导致一些可读性问题。例如:
const auto EnableErrorReporting = [&]() {
if (HighLevelWarningEnabled())
return true;
if (MidLevelWarningEnabled())
return UsersWantReporting(); // depends on user settings...
return false;
}();
if (EnableErrorReporting) {
// ...
}
在上面的示例中,lambda 代码非常复杂,阅读代码的开发人员不仅要破译 lambda 是立即调用的,还要推断EnableErrorReporting 类型。他们可能会假设 EnableErrorReporting 是闭包对象,而不仅仅是一个 const 变量。对于这种情况,您可以考虑不使用 auto,以便我们可以很容易地看到类型。甚至可以在 }() 旁边添加注释,比如 // 立即调用。
关于升级版的 IIFE,你可以在 C++17 章节了解到更多。
从 lambda 继承
这可能令人惊讶,但您确实可以从 lambda 派生!
由于编译器使用 operator() 将 lambda 表达式展开为函数对象,因此我们可以从这个类型继承。
来看一个基础的例子:
// Ex2_21: Inheriting from a single Lambda.
#include <iostream>
template<typename Callable>
class ComplexFn : public Callable {
public:
explicit ComplexFn(Callable f) : Callable(f) {}
};
template<typename Callable>
ComplexFn<Callable> MakeComplexFunctionObject(Callable&& cal) {
return ComplexFn<Callable>(std::forward<Callable>(cal));
}
int main() {
const auto func = MakeComplexFunctionObject([]() {
std::cout << "Hello Complex Function Object!";
});
func();
}
在这个例子中,ComplexFn 类是从 Callable 派生出来的,Callable 是一个模板参数。如果我们想从 lambda 中派生,我们需要一点小技巧,因为我们不能拼写出闭包类型的确切类型(除非我们将其包装到 std::function 中)。
这就是为什么我们需要 MakeComplexFunctionObject 函数来执行模板参数推导并获得 lambda 闭包的类型。
除了它的名字,ComplexFn 只是一个简单的包装器,没有太多的用途。这样的代码模式有什么用例吗?
例如,我们可以扩展上面的代码,从两个 lambdas 继承并创建一个重载集合:
// Ex2_22: Inheriting from two Lambdas.
#include <iostream>
template<typename TCall, typename UCall>
class SimpleOverloaded : public TCall, UCall {
public:
SimpleOverloaded(TCall tf, UCall uf) : TCall(tf), UCall(uf) {}
using TCall::operator();
using UCall::operator();
};
template<typename TCall, typename UCall>
SimpleOverloaded<TCall, UCall> MakeOverloaded(TCall&& tf, UCall&& uf) {
return SimpleOverloaded<TCall, UCall>(std::forward<TCall> tf, std::forward<UCall> uf);
}
int main() {
const auto func = MakeOverloaded(
[](int) { std::cout << "Int!\n"; },
[](float) { std::cout << "Float!\n"; }
);
func(10);
func(10.0f);
}
这次我们有更多的代码:我们从两个模板形参派生,但是我们还需要显式地公开它们的调用操作符。
为什么呢?这是因为在寻找正确的函数重载时,编译器要求候选函数在相同的作用域内。
为了理解这一点,让我们编写一个从两个基类派生的简单类型。该示例还注释掉了两个 using 语句:
// Ex2_23: Deriving from two classes, error.
#include <iostream>
struct BaseInt {
void Func(int) { std::cout << "BaseInt...\n"; }
};
struct BaseDouble {
void Func(double) { std::cout << "BaseDouble...\n"; }
};
struct Derived : public BaseInt, BaseDouble {
//using BaseInt::Func;
//using BaseDouble::Func;
};
int main() {
Derived d;
d.Func(10.0);
}
我们有两个实现 Func 的基类。我们想从派生对象调用那个方法。
GCC 报告以下错误:
error: request for member 'Func' is ambiguous
因为我们注释掉了 using 语句 ::Func() 可以来自 BaseInt 或 BaseDouble 的作用域。编译器有两个作用域来搜索最佳候选,根据标准,这是不允许的。
那么,让我们回到我们的主要用例:SimpleOverloaded 是一个基本类,它还不能用于生产环境。请参阅 C++17 章节,在那里我们将讨论该模式的高级版本。由于C++17 的一些特性,我们将能够从多个 lambdas 继承(多亏了可变模板)并语法更加紧凑。
存储 lambda 到容器
作为本章的最后一项技术,让我们看一下在容器中存储闭包的问题。
但是我不是写过不能默认创建和赋值 lambda 吗?
是的,但是,我们可以在这里做一些戏法。
其中一种技术是利用无状态 lambda 转换为函数指针的属性。虽然不能直接存储闭包对象,但可以保存从 lambda 表达式转换而来的函数指针。
例如:
// Ex2_24: Storing Lambdas As Function Pointers.
#include <iostream>
#include <vector>
int main() {
using TFunc = void (*)(int&);
std::vector<TFunc> ptrFuncVec;
ptrFuncVec.push_back([](int& x) { std::cout << x << '\n'; });
ptrFuncVec.push_back([](int& x) { x *= 2; });
ptrFuncVec.push_back(ptrFuncVec[0]); // print it again;
int x = 10;
for (const auto &entry : ptrFuncVec)
entry(x);
}
在上面的例子中,我们创建了用来存储变量的函数指针的向量。容器中有三个条目:
- 第一个打印输入变量的值。
- 第二个更改它的值。
- 第三个复制自第一个,因此它也打印值。
以上解决方案可以工作,但仅限于无状态 lambda。如果我们想解除这个限制呢?
为了解决这个问题,我们可以使用求助于 std::function。为了使示例更有趣,它还以简单的整数转换为处理 std::string 对象的lambda 为例:
// Ex2_25: Storing Lambdas As std::function.
#include <algorithm>
#include <functional>
#include <iostream>
#include <vector>
int main() {
std::vector<std::function<std::string(const std::string&)>> vecFilters;
size_t removedSpaceCounter = 0;
const auto removeSpaces = [&removedSpaceCounter](const std::string& str) {
std::string tmp;
std::copy_if(str.begin(), str.end(), std::back_inserter(tmp),
[](char ch) {return !isspace(ch); });
removedSpaceCounter += str.length() - tmp.length();
return tmp;
};
const auto makeUpperCase = [](const std::string& str) {
std::string tmp = str;
std::transform(tmp.begin(), tmp.end(), tmp.begin(),
[](unsigned char c){ return std::toupper(c); });
return tmp;
};
vecFilters.emplace_back(removeSpaces);
vecFilters.emplace_back([](const std::string& x) {return x + " Amazing"; });
vecFilters.emplace_back([](const std::string& x) {return x + " Modern"; });
vecFilters.emplace_back([](const std::string& x) {return x + " C++"; });
vecFilters.emplace_back([](const std::string& x) {return x + " World!"; });
vecFilters.emplace_back(makeUpperCase);
const std::string str = " H e l l o ";
auto temp = str;
for (const auto &entryFunc : vecFilters)
temp = entryFunc(temp);
std::cout << temp;
std::cout <<"\nremoved spaces: " << removedSpaceCounter << '\n';
}
// 输出:
// HELLO AMAZING MODERN C++ WORLD!
// removed spaces: 12
这次我们将 std::function<std::string(const std::string&)> 存储在容器中。
这允许我们使用任何类型的函数对象,包括带有捕获变量的 lambda 表达式。其中一个 lambda removeSpacesCnt
捕获一个变量,该变量用于存储有关从输入字符串中删除的空格的信息。
总结
在本章中,您学习了如何创建和使用 lambda 表达式。我描述了语法、捕获子句、lambda 的类型,并介绍了许多示例和用例。我们甚至更进一步,我给你们展示了一种派生自 lambda 的模式或者把它存储在一个容器里。
但这还不是全部!
Lambda 表达式已经成为现代 C++ 的重要组成部分。有了更多的用例,开发人员也看到了改进这个特性的可能性。这就是为什么你现在可以转到下一章,看看 ISO 委员会在 C++14 中添加的重要更新。