捕获子句与按值捕获
在前一课程(20.6——Lambda(匿名函数)简介)中,我们给出了以下示例:
#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
int main()
{
std::array<std::string_view, 4> arr{ "apple", "banana", "walnut", "lemon" };
auto found{ std::find_if(arr.begin(), arr.end(),
[](std::string_view str)
{
return str.find("nut") != std::string_view::npos;
}) };
if (found == arr.end())
{
std::cout << "No nuts\n";
}
else
{
std::cout << "Found " << *found << '\n';
}
return 0;
}
现在,我们修改该“坚果”示例,让用户自行选择要搜索的子串,但这并不像看上去那么直观。
#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
#include <string>
int main()
{
std::array<std::string_view, 4> arr{ "apple", "banana", "walnut", "lemon" };
// 询问用户要搜索的内容
std::cout << "search for: ";
std::string search{};
std::cin >> search;
auto found{ std::find_if(arr.begin(), arr.end(), [](std::string_view str) {
// 搜索 @search 而非 "nut"
return str.find(search) != std::string_view::npos; // 错误:search 在此作用域不可见
}) };
if (found == arr.end())
{
std::cout << "Not found\n";
}
else
{
std::cout << "Found " << *found << '\n';
}
return 0;
}
该代码无法通过编译。与嵌套块不同——在嵌套块中,外层块可访问的任何标识符在内层块亦可访问——Lambda 只能访问某些在 Lambda 之外定义的对象,包括:
- 具有静态(或线程局部)存储期的对象(包括全局变量与静态局部变量)
constexpr
对象(显式或隐式)
由于 search
不满足上述任一条件,Lambda 无法看到它。
提示 Lambda 仅能访问某些在 Lambda 之外定义的对象,包括具有静态存储期的对象(如全局变量与静态局部变量)以及
constexpr
对象。
为了让 Lambda 能够访问 search
,我们必须使用捕获子句。
捕获子句
捕获子句用于(间接地)让 Lambda 访问其外围作用域中本不可见的变量。我们只需在捕获子句中列出希望从 Lambda 内部访问的实体即可。在此例中,我们想让 Lambda 访问变量 search
,故将其加入捕获子句:
#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
#include <string>
int main()
{
std::array<std::string_view, 4> arr{ "apple", "banana", "walnut", "lemon" };
std::cout << "search for: ";
std::string search{};
std::cin >> search;
// 捕获 @search vvvvvv
auto found{ std::find_if(arr.begin(), arr.end(), [search](std::string_view str) {
return str.find(search) != std::string_view::npos;
}) };
if (found == arr.end())
{
std::cout << "Not found\n";
}
else
{
std::cout << "Found " << *found << '\n';
}
return 0;
}
现在用户可以搜索数组中的元素。
search for: nana
Found banana
捕获究竟如何工作?
尽管上例中的 Lambda 看似直接访问了 main
中的 search
变量,事实并非如此。Lambda 看似嵌套块,实则工作方式略有不同(区别至关重要)。
当 Lambda 定义被执行时,对于每个被捕获的变量,都会在 Lambda 内部生成一个同名克隆。这些克隆变量在 Lambda 创建时由外层作用域的同名变量初始化。
因此,在上例中,当 Lambda 对象被创建时,Lambda 会获得一个名为 search
的克隆变量。该克隆 search
与 main
的 search
值相同,故行为看似访问 main
的 search
,实际却非如此。
尽管这些克隆变量同名,其类型却未必与原始变量一致,我们将在本节后续部分探讨这一点。
关键洞察 Lambda 的捕获变量是外层作用域变量的副本,而非变量本身。
进阶阅读 尽管 Lambda 看似函数,它们实则为可像函数般调用的对象(称为函数对象——我们将在后继课程中讨论如何从零创建自己的函数对象)。 当编译器遇到 Lambda 定义时,会为该 Lambda 创建自定义对象定义。每个被捕获的变量成为该对象的数据成员。 运行时,当 Lambda 定义被遇到时,Lambda 对象被实例化,其成员在此时初始化。
捕获变量默认被视为 const
Lambda 被调用时,operator()
被触发。默认情况下,该 operator()
将捕获变量视为 const
,即 Lambda 不允许修改这些捕获。
下例中,我们捕获变量 ammo
并试图递减:
#include <iostream>
int main()
{
int ammo{ 10 };
// 定义 Lambda 并将其存入变量 "shoot"
auto shoot{
[ammo]() {
// 非法,ammo 不可修改
--ammo;
std::cout << "Pew! " << ammo << " shot(s) left.\n";
}
};
// 调用 Lambda
shoot();
std::cout << ammo << " shot(s) left\n";
return 0;
}
上述代码无法编译,因为 Lambda 内 ammo
被视为 const
。
可变捕获
若要允许修改按值捕获的变量,可将 Lambda 标记为 mutable
:
#include <iostream>
int main()
{
int ammo{ 10 };
auto shoot{
[ammo]() mutable { // 现在为 mutable
// 现在允许修改 ammo
--ammo;
std::cout << "Pew! " << ammo << " shot(s) left.\n";
}
};
shoot();
shoot();
std::cout << ammo << " shot(s) left\n";
return 0;
}
输出:
Pew! 9 shot(s) left.
Pew! 8 shot(s) left.
10 shot(s) left
尽管代码可通过编译,仍存在逻辑错误。发生了什么?Lambda 被调用时,按值捕获了 ammo
的一份副本。Lambda 将 ammo
从 10 递减到 9 再到 8,但修改的是自身副本,而非 main()
中的原始 ammo
。
注意,Lambda 多次调用之间,捕获变量的值得以保持!
警告 由于捕获变量是 Lambda 对象的成员,其值在 Lambda 的多次调用间持久存在!
按引用捕获
与函数可通过引用形参修改变量类似,我们亦可通过引用捕获,使 Lambda 能够影响实参值。
要按引用捕获变量,只需在捕获列表中变量名前加 &
。与按值捕获不同,按引用捕获的变量非常量,除非被捕获变量本身为 const
。当通常更倾向于将函数形参按引用传递时(例如针对非基本类型),应优先使用按引用捕获。
以下是按引用捕获 ammo
的代码:
#include <iostream>
int main()
{
int ammo{ 10 };
auto shoot{
// 不再需要 mutable
[&ammo]() { // &ammo 表示按引用捕获 ammo
// 对 ammo 的修改将影响到 main 中的 ammo
--ammo;
std::cout << "Pew! " << ammo << " shot(s) left.\n";
}
};
shoot();
std::cout << ammo << " shot(s) left\n";
return 0;
}
输出:
Pew! 9 shot(s) left.
9 shot(s) left
现在,我们使用引用捕获来统计 std::sort
对数组排序时进行了多少次比较:
#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
struct Car
{
std::string_view make{};
std::string_view model{};
};
int main()
{
std::array<Car, 3> cars{ { { "Volkswagen", "Golf" },
{ "Toyota", "Corolla" },
{ "Honda", "Civic" } } };
int comparisons{ 0 };
std::sort(cars.begin(), cars.end(),
// 按引用捕获 @comparisons
[&comparisons](const auto& a, const auto& b) {
// 我们按引用捕获了 comparisons,无需 mutable 即可修改
++comparisons;
// 按 make 排序
return a.make < b.make;
});
std::cout << "Comparisons: " << comparisons << '\n';
for (const auto& car : cars)
{
std::cout << car.make << ' ' << car.model << '\n';
}
return 0;
}
可能的输出:
Comparisons: 2
Honda Civic
Toyota Corolla
Volkswagen Golf
同时捕获多个变量
多个变量可通过逗号分隔进行捕获,可按值或按引用混合捕获:
int health{ 33 };
int armor{ 100 };
std::vector<CEnemy> enemies{};
// 按值捕获 health 与 armor,按引用捕获 enemies
[health, armor, &enemies](){};
默认捕获
显式列出要捕获的变量可能很繁琐。若修改 Lambda,可能忘记增删捕获变量。所幸,我们可以借助编译器自动生成所需捕获列表。
默认捕获(又称“捕获默认值”)会捕获 Lambda 中提及的所有变量;若使用默认捕获,未提及的变量不会被捕获。
- 若要以按值方式捕获所有使用到的变量,使用
=
作为捕获默认值。 - 若要以按引用方式捕获所有使用到的变量,使用
&
作为捕获默认值。
以下示例使用按值默认捕获:
#include <algorithm>
#include <array>
#include <iostream>
int main()
{
std::array areas{ 100, 25, 121, 40, 56 };
int width{};
int height{};
std::cout << "Enter width and height: ";
std::cin >> width >> height;
auto found{ std::find_if(areas.begin(), areas.end(),
[=](int knownArea) { // 将按值默认捕获 width 与 height
return width * height == knownArea; // 因为它们在此被使用
}) };
if (found == areas.end())
{
std::cout << "I don't know this area :(\n";
}
else
{
std::cout << "Area found :)\n";
}
return 0;
}
默认捕获可与普通捕获混合使用。我们可以按值捕获某些变量,按引用捕获另一些变量,但每个变量只能捕获一次。
int health{ 33 };
int armor{ 100 };
std::vector<CEnemy> enemies{};
// 按值捕获 health 与 armor,按引用捕获 enemies
[health, armor, &enemies](){};
// 按引用捕获 enemies,其余按值捕获
[=, &enemies](){};
// 按值捕获 armor,其余按引用捕获
[&, armor](){};
// 非法:已声明按引用捕获全部,不能再次单独按引用捕获 armor
[&, &armor](){};
// 非法:已声明按值捕获全部,不能再次单独按值捕获 armor
[=, armor](){};
// 非法:armor 出现两次
[armor, &health, &armor](){};
// 非法:默认捕获必须是捕获列表的首个元素
[armor, &](){};
在 Lambda 捕获中定义新变量
有时我们希望以稍作修改的方式捕获变量,或声明仅在 Lambda 作用域内可见的新变量。可在捕获列表中定义变量而不指定其类型。
#include <array>
#include <iostream>
#include <algorithm>
int main()
{
std::array areas{ 100, 25, 121, 40, 56 };
int width{};
int height{};
std::cout << "Enter width and height: ";
std::cin >> width >> height;
// 我们存储的是面积,但用户输入的是宽和高,因此需要先计算面积再搜索
auto found{ std::find_if(areas.begin(), areas.end(),
// 声明仅在 Lambda 内可见的新变量
// userArea 的类型自动推导为 int
[userArea{ width * height }](int knownArea) {
return userArea == knownArea;
}) };
if (found == areas.end())
{
std::cout << "I don't know this area :(\n";
}
else
{
std::cout << "Area found :)\n";
}
return 0;
}
userArea
仅在 Lambda 定义时计算一次,所得面积存储于 Lambda 对象内,每次调用均相同。若 Lambda 为 mutable
并修改了捕获时定义的变量,原值将被覆盖。
最佳实践 仅在变量值简短且类型明显时,才在捕获列表中初始化变量;否则,最好在 Lambda 外定义变量并捕获它。
悬垂捕获变量
变量在 Lambda 定义处被捕获。若按引用捕获的变量先于 Lambda 消亡,Lambda 将持有悬垂引用。
示例:
#include <iostream>
#include <string>
// 返回一个 Lambda
auto makeWalrus(const std::string& name)
{
// 按引用捕获 name 并返回 Lambda
return [&]() {
std::cout << "I am a walrus, my name is " << name << '\n'; // 未定义行为
};
}
int main()
{
// 创建一只名为 Roofus 的海象
// sayName 是 makeWalrus 返回的 Lambda
auto sayName{ makeWalrus("Roofus") };
// 调用 makeWalrus 返回的 Lambda 函数
auto sayName{ makeWalrus("Roofus") };
// 调用 makeWalrus 返回的 Lambda 函数
sayName();
return 0;
}
调用 makeWalrus()
时,由字符串字面量 "Roofus"
创建临时 std::string
。makeWalrus()
中的 Lambda 按引用捕获该临时字符串。包含 makeWalrus()
调用的完整表达式结束后,临时字符串消亡,但 Lambda sayName
仍引用之。因此,调用 sayName
时访问悬垂引用,导致未定义行为。
注意,即使将 "Roofus"
按值传递给 makeWalrus()
,形参 name
也会在 makeWalrus()
结束时消亡,Lambda 仍会持有悬垂引用。
警告 按引用捕获变量时需格外小心,尤其是使用默认引用捕获时。被捕获的变量必须比 Lambda 生命周期更长。 若希望被捕获的
name
在 Lambda 使用时仍然有效,应改为按值捕获(显式或使用按值默认捕获)。
可变 Lambda 的意外复制
由于 Lambda 是对象,它们可以被复制。某些情况下,这可能带来问题。考虑以下代码:
#include <iostream>
int main()
{
int i{ 0 };
// 创建名为 count 的新 Lambda
auto count{ [i]() mutable {
std::cout << ++i << '\n';
} };
count(); // 调用 count
auto otherCount{ count }; // 复制 count
// 分别调用 count 及其副本
count();
otherCount();
return 0;
}
输出:
1
2
2
代码未打印 1、2、3,却打印了两次 2。创建 otherCount
作为 count
的副本时,我们复制了 count
的当前状态。count
的 i
为 1,因此 otherCount
的 i
也为 1。由于 otherCount
是 count
的副本,它们拥有各自的 i
。
再看一个稍微不那么明显的示例:
#include <iostream>
#include <functional>
void myInvoke(const std::function<void()>& fn)
{
fn();
}
int main()
{
int i{ 0 };
// 递增并打印 @i 的本地副本
auto count{ [i]() mutable {
std::cout << ++i << '\n';
} };
myInvoke(count);
myInvoke(count);
myInvoke(count);
return 0;
}
输出:
1
1
1
该例以更隐蔽的形式展示了与前例相同的问题。
当我们调用 myInvoke(count)
时,编译器发现 count
(具有 Lambda 类型)与引用形参类型 std::function<void()>
不匹配。它会将 Lambda 转换为临时 std::function
,以便引用形参绑定,这会对 Lambda 进行复制。因此,对 fn()
的调用实际上是在临时 std::function
包含的 Lambda 副本上执行,而非真正的 Lambda。
若我们需要传递可变 Lambda,并希望避免意外复制的可能,有两种选择。一种方案是使用非捕获 Lambda——在上例中,可移除捕获,改用静态局部变量跟踪状态。但静态局部变量难以追踪,并使代码可读性降低。更好的方案是防止 Lambda 被复制。既然我们无法影响 std::function
(或其他标准库函数或对象)的实现,如何实现这一点?
一种方案(感谢读者 Dck)是立即将 Lambda 放入 std::function
。这样,调用 myInvoke()
时,引用形参 fn
可直接绑定到我们的 std::function
,不会产生临时副本:
#include <iostream>
#include <functional>
void myInvoke(const std::function<void()>& fn)
{
fn();
}
int main()
{
int i{ 0 };
// 递增并打印 @i 的本地副本
std::function count{ [i]() mutable { // Lambda 对象存入 std::function
std::cout << ++i << '\n';
} };
myInvoke(count); // 调用时不再创建副本
myInvoke(count); // 调用时不再创建副本
myInvoke(count); // 调用时不再创建副本
return 0;
}
现在输出符合预期:
1
2
3
另一种解决方案是使用引用包装器。C++ 在 <functional>
头文件中提供了便捷类型 std::reference_wrapper
,允许我们以引用方式传递普通类型。为方便起见,可使用 std::ref()
函数创建 std::reference_wrapper
。将 Lambda 包装进 std::reference_wrapper
后,任何试图复制 Lambda 的代码都会复制 reference_wrapper
的引用(避免复制 Lambda)。
以下代码使用 std::ref
:
#include <iostream>
#include <functional> // 包含 std::reference_wrapper 与 std::ref
void myInvoke(const std::function<void()>& fn)
{
fn();
}
int main()
{
int i{ 0 };
// 递增并打印 @i 的本地副本
auto count{ [i]() mutable {
std::cout << ++i << '\n';
} };
// std::ref(count) 确保 count 被视为引用
// 因此,任何试图复制 count 的代码实际上复制的是引用
// 确保仅存在一个 count
myInvoke(std::ref(count));
myInvoke(std::ref(count));
myInvoke(std::ref(count));
return 0;
}
输出符合预期:
1
2
3
有趣之处在于,即使 myInvoke
按值接收 fn
,该方法依然有效!
规则 标准库函数可能复制函数对象(提醒:Lambda 是函数对象)。若希望提供可变捕获变量的 Lambda,请使用
std::ref
按引用传递它们。
最佳实践 尽量避免可变 Lambda。不可变 Lambda 更易于理解,且不会出现上述问题,也不会出现并行执行时更危险的问题。
小测验
问题 1
以下变量中,哪些可在 main
中的 Lambda 内不经显式捕获而使用?
int i{};
static int j{};
int getValue()
{
return 0;
}
int main()
{
int a{};
constexpr int b{};
static int c{};
static constexpr int d{};
const int e{};
const int f{ getValue() };
static const int g{};
static const int h{ getValue() };
[](){
// 尝试不经显式捕获使用变量
a;
b;
c;
d;
e;
f;
g;
h;
i;
j;
}();
return 0;
}
问题 2 以下代码输出什么?请勿运行,用心算。
#include <iostream>
#include <string>
int main()
{
std::string favoriteFruit{ "grapes" };
auto printFavoriteFruit{
[=]() {
std::cout << "I like " << favoriteFruit << '\n';
}
};
favoriteFruit = "bananas with chocolate";
printFavoriteFruit();
return 0;
}
问题 3 我们将编写一个关于平方数的小游戏(平方数即整数的平方:1, 4, 9, 16, 25, …)。
游戏设置:
- 请用户输入起始数字(如 3)。
- 请用户输入要生成的数值个数。
- 随机选取 2 到 4 之间的整数作为乘数。
- 生成用户指定个数的数值。从起始数字开始,每个数值为下一个平方数乘以乘数。
游戏规则:
- 用户输入一个猜测值。
- 若猜测值匹配任一已生成值,该值从列表中移除,用户可再次猜测。
- 若用户猜中所有已生成值,则获胜。
- 若猜测值不匹配,用户失败,程序提示最近的未猜中值。
样例会话见原文。
提示:
- 使用
Random.h
(全局随机数(Random.h))生成随机数。 - 使用
std::find()
(标准库算法简介)在列表中查找数值。 - 使用
std::vector::erase
移除元素,例如:auto found{ std::find(/* ... */) }; // 确保找到元素 myVector.erase(found);
- 使用
std::min_element
与 Lambda 查找最接近用户猜测的数值。std::min_element
的用法与上一测验中的std::max_element
类似。