Lambda 捕获

捕获子句与按值捕获

在前一课程(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 的克隆变量。该克隆 searchmainsearch 值相同,故行为看似访问 mainsearch,实际却非如此。

尽管这些克隆变量同名,其类型却未必与原始变量一致,我们将在本节后续部分探讨这一点。

关键洞察 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::stringmakeWalrus() 中的 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 的当前状态。counti 为 1,因此 otherCounti 也为 1。由于 otherCountcount 的副本,它们拥有各自的 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, …)。

游戏设置:

  1. 请用户输入起始数字(如 3)。
  2. 请用户输入要生成的数值个数。
  3. 随机选取 2 到 4 之间的整数作为乘数。
  4. 生成用户指定个数的数值。从起始数字开始,每个数值为下一个平方数乘以乘数。

游戏规则:

  1. 用户输入一个猜测值。
  2. 若猜测值匹配任一已生成值,该值从列表中移除,用户可再次猜测。
  3. 若用户猜中所有已生成值,则获胜。
  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 类似。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"