捕获子句与按值捕获
在前一课程(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类似。
