20-7 Lambda 捕获(todo)

捕获子句与按值捕获

在上节课(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;
}

现在,让我们修改nut示例,让用户选择要搜索的子字符串。这并不像你想象的那样直观。

#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
#include <string>

int main()
{
  std::array<std::string_view, 4> arr{ "apple", "banana", "walnut", "lemon" };

  // Ask the user what to search for.
  std::cout << "search for: ";

  std::string search{};
  std::cin >> search;

  auto found{ std::find_if(arr.begin(), arr.end(), [](std::string_view str) {
    // Search for @search rather than "nut".
    return str.find(search) != std::string_view::npos; // Error: search not accessible in this scope
  }) };

  if (found == arr.end())
  {
    std::cout << "Not found\n";
  }
  else
  {
    std::cout << "Found " << *found << '\n';
  }

  return 0;
}

image

此代码无法编译。与嵌套代码块不同——外层代码块中可访问的任何标识符在嵌套代码块中均可访问——lambda表达式仅能访问在lambda外部定义的特定类型对象,包括:

  • 具有静态(或线程局部)存储周期的对象(涵盖全局变量和静态局部变量)
  • constexpr对象(显式或隐式声明)

由于 search 不满足上述任何条件,lambda 无法访问它。

提示:
lambda 只能访问在 lambda 外部定义的特定类型对象,包括具有静态存储期的对象(如全局变量和静态局部变量)以及 constexpr 对象。

要在 lambda 内部访问 search,我们需要使用捕获子句。


捕获子句

捕获子句capture clause用于(间接地)使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;

  // Capture @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;
}

用户现在可以搜索我们数组中的某个元素。

输出

image


那么捕获机制究竟如何运作?

虽然上例中的lambda表达式看似直接访问了main函数中search变量的值,但实际并非如此。lambda表达式看似嵌套代码块,但其工作原理略有不同(且这种区别至关重要)。

当lambda定义被执行时,对于每个被捕获的变量,都会在lambda内部创建一个同名变量的克隆副本。这些克隆变量此时会从外部作用域中同名变量初始化。

因此在上例中,当lambda对象创建时,它会获得名为search的独立克隆变量。该克隆变量与main中的search值相同,故表现上看似访问了main的search,实则不然。

虽然这些克隆变量名称相同,但类型未必与原始变量一致。本节后续内容将深入探讨此特性。

关键要点:
lambda捕获的变量是外部作用域变量的副本,而非原始变量本身。

进阶知识:
尽管lambda看似函数,其实是可像函数调用的对象(称为函子functors——后续课程将讲解如何从零创建函子)。

编译器遇到lambda定义时,会为其创建专属对象定义。每个捕获变量都成为该对象的数据成员。

运行时遇到lambda定义时,会实例化lambda对象,并在此阶段初始化其成员。


默认情况下,捕获变量被视为 const

当调用 lambda 表达式时,会调用 operator()。默认情况下,此 operator() 将捕获变量视为 const,这意味着 lambda 表达式不允许修改这些捕获变量。

在下面的示例中,我们捕获变量 ammo 并尝试对其进行递减操作。

#include <iostream>

int main()
{
  int ammo{ 10 };

  // Define a lambda and store it in a variable called "shoot".
  auto shoot{
    [ammo]() {
      // Illegal, ammo cannot be modified.
      --ammo;

      std::cout << "Pew! " << ammo << " shot(s) left.\n";
    }
  };

  // Call the lambda
  shoot();

  std::cout << ammo << " shot(s) left\n";

  return 0;
}

image

上述代码无法编译,因为在lambda表达式内部,ammo被视为const类型。


可变捕获

为允许修改捕获的变量,我们可以将lambda标记为可变:

#include <iostream>

int main()
{
  int ammo{ 10 };

  auto shoot{
    [ammo]() mutable { // now mutable
      // We're allowed to modify ammo now
      --ammo;

      std::cout << "Pew! " << ammo << " shot(s) left.\n";
    }
  };

  shoot();
  shoot();

  std::cout << ammo << " shot(s) left\n";

  return 0;
}

输出:

image

虽然现在能编译通过,但逻辑错误依然存在。问题出在哪里?当lambda被调用时,它捕获了ammo的副本。当lambda将ammo从10递减到9再到8时,它递减的是自己的副本,而非main()中的原始ammo值。

请注意:ammo的值在多次调用lambda时始终保持不变!

警告:
由于捕获的变量是lambda对象的成员,其值会在多次调用lambda时持续存在!


按引用捕获

正如函数可以修改按引用传递的参数值,我们也可以通过按引用捕获变量,使lambda表达式能够影响参数的值。

要按引用捕获变量,需在捕获语句中于变量名前添加一个&符号。与按值捕获的变量不同,按引用捕获的变量默认是非const的,除非被捕获的变量本身是const。当你通常更倾向于按引用传递函数参数时(例如对非基本类型),应优先选择按引用捕获而非按值捕获。

以下是将ammo按引用捕获的示例代码:

#include <iostream>

int main()
{
  int ammo{ 10 };

  auto shoot{
    // We don't need mutable anymore
    [&ammo]() { // &ammo means ammo is captured by reference
      // Changes to ammo will affect main's ammo
      --ammo;

      std::cout << "Pew! " << ammo << " shot(s) left.\n";
    }
  };

  shoot();

  std::cout << ammo << " shot(s) left\n";

  return 0;
}

这产生了预期的答案:

image

现在,让我们使用引用捕获来统计 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(),
    // Capture @comparisons by reference.
    [&comparisons](const auto& a, const auto& b) {
      // We captured comparisons by reference. We can modify it without "mutable".
      ++comparisons;

      // Sort the cars by their 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;
}

可能的输出

image


捕获多个变量

通过用逗号分隔多个变量即可捕获它们。这可以包含按值捕获和按引用捕获的混合变量:

int health{ 33 };
int armor{ 100 };
std::vector<CEnemy> enemies{};

// Capture health and armor by value, and enemies by reference.
[health, armor, &enemies](){};

默认捕获

显式列出需要捕获的变量可能很繁琐。修改lambda表达式时,可能会忘记添加或移除捕获的变量。幸运的是,我们可以借助编译器自动生成需要捕获的变量列表。

默认捕获default capture(也称为捕获默认值capture-default)会捕获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) { // will default capture width and height by value
                             return width * height == knownArea; // because they're mentioned here
                           }) };

  if (found == areas.end())
  {
    std::cout << "I don't know this area :(\n";
  }
  else
  {
    std::cout << "Area found :)\n";
  }

  return 0;
}

image

默认捕获可以与常规捕获混合使用。我们可以按值捕获某些变量,按引用捕获其他变量,但每个变量只能被捕获一次。

int health{ 33 };
int armor{ 100 };
std::vector<CEnemy> enemies{};

// Capture health and armor by value, and enemies by reference.
[health, armor, &enemies](){};

// Capture enemies by reference and everything else by value.
[=, &enemies](){};

// Capture armor by value and everything else by reference.
[&, armor](){};

// Illegal, we already said we want to capture everything by reference.
[&, &armor](){};

// Illegal, we already said we want to capture everything by value.
[=, armor](){};

// Illegal, armor appears twice.
[armor, &health, &armor](){};

// Illegal, the default capture has to be the first element in the capture group.
[armor, &](){};

在lambda捕获中定义新变量

有时我们希望捕获一个略有修改的变量,或声明一个仅在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;

  // We store areas, but the user entered width and height.
  // We need to calculate the area before we can search for it.
  auto found{ std::find_if(areas.begin(), areas.end(),
                           // Declare a new variable that's visible only to the lambda.
                           // The type of userArea is automatically deduced to 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;
}

image

userArea 仅在 lambda 定义时计算一次。计算结果存储在 lambda 对象中,每次调用时保持不变。若 lambda 为可变类型且修改了捕获范围内的变量,则原始值将被覆盖。

最佳实践:
仅当变量值简短且类型明确时,才应在捕获范围内初始化变量。否则,建议在 lambda 外部定义变量并进行捕获。


悬空捕获变量

变量在lambda表达式定义处被捕获。若通过引用捕获的变量在lambda表达式终止前消失,该lambda将持有悬空引用。

例如:

#include <iostream>
#include <string>

// returns a lambda
auto makeWalrus(const std::string& name)
{
  // Capture name by reference and return the lambda.
  return [&]() {
    std::cout << "I am a walrus, my name is " << name << '\n'; // Undefined behavior
  };
}

int main()
{
  // Create a new walrus whose name is Roofus.
  // sayName is the lambda returned by makeWalrus.
  auto sayName{ makeWalrus("Roofus") };

  // Call the lambda function that makeWalrus returned.
  sayName();

  return 0;
}

image

对 makeWalrus() 的调用会从字符串字面量 “Roofus” 创建一个临时 std::string。makeWalrus() 中的 lambda 表达式通过引用捕获该临时字符串。当包含 makeWalrus() 调用的完整表达式结束时,临时字符串会销毁,但 lambda 表达式 sayName 仍会在此之后继续引用它。因此,当调用 sayName 时,会访问悬空引用,导致未定义行为。

需注意,即使将“Roofus”按值传递给makeWalrus()也会发生此问题。参数name在makeWalrus()结束时销毁,而lambda仍持有悬空引用。

警告:
通过引用捕获变量时需格外谨慎,尤其采用默认引用捕获时。被捕获的变量必须比lambda表达式存活更久。

若需确保捕获的名称在使用lambda时有效,必须改用值捕获(通过显式捕获或默认值捕获)。


可变lambda表达式的意外复制

由于lambda表达式是对象,它们可以被复制。在某些情况下,这可能会引发问题。请考虑以下代码:

#include <iostream>

int main()
{
  int i{ 0 };

  // Create a new lambda named count
  auto count{ [i]() mutable {
    std::cout << ++i << '\n';
  } };

  count(); // invoke count

  auto otherCount{ count }; // create a copy of count

  // invoke both count and the copy
  count();
  otherCount();

  return 0;
}

输出

image

代码并未打印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 };

    // Increments and prints its local copy of @i.
    auto count{ [i]() mutable {
      std::cout << ++i << '\n';
    } };

    myInvoke(count);
    myInvoke(count);
    myInvoke(count);

    return 0;
}

输出:

image

这与前例存在相同问题,只是表现形式更为隐晦。

当调用 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 };

    // Increments and prints its local copy of @i.
    std::function count{ [i]() mutable { // lambda object stored in a std::function
      std::cout << ++i << '\n';
    } };

    myInvoke(count); // doesn't create copy when called
    myInvoke(count); // doesn't create copy when called
    myInvoke(count); // doesn't create copy when called

    return 0;
}

我们的输出结果现在符合预期:

image

另一种解决方案是使用引用包装器。C++提供了一个便捷的类型(作为头文件的一部分),名为std::reference_wrapper,它允许我们将普通类型像引用一样传递。为进一步提升便利性,可通过 std::ref() 函数创建 std::reference_wrapper。将 lambda 表达式封装在 std::reference_wrapper 中后,当有人尝试复制该 lambda 时,系统将自动复制 reference_wrapper(从而避免复制 lambda 本身)。

以下是使用 std::ref 后的更新代码:

#include <iostream>
#include <functional> // includes std::reference_wrapper and std::ref

void myInvoke(const std::function<void()>& fn)
{
    fn();
}

int main()
{
    int i{ 0 };

    // Increments and prints its local copy of @i.
    auto count{ [i]() mutable {
      std::cout << ++i << '\n';
    } };

    // std::ref(count) ensures count is treated like a reference
    // thus, anything that tries to copy count will actually copy the reference
    // ensuring that only one count exists
    myInvoke(std::ref(count));
    myInvoke(std::ref(count));
    myInvoke(std::ref(count));

    return 0;
}

我们的输出结果现在符合预期:

image

此方法的有趣之处在于,即使 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() };

  [](){
    // Try to use the variables without explicitly capturing them.
    a;
    b;
    c;
    d;
    e;
    f;
    g;
    h;
    i;
    j;
  }();

  return 0;
}

显示解决方案:

Variable Usable without explicit capture
a No. a has automatic storage duration.
b Yes. b is usable in a constant expression.
c Yes. c has static storage duration.
d Yes.
e Yes. e is usable in a constant expression.
f No. f‘s value depends on getValue, which might require the program to run.
g Yes.
h Yes. h has static storage duration.
i Yes. i is a global variable.
j Yes. j is accessible in the entire file.

问题 #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;
}

显示解决方案

I like grapes

printFavoriteFruit 通过值捕获 favoriteFruit。修改 main 的 favoriteFruit 不会影响 lambda 的 favoriteFruit。


问题 #3

我们将编写一个基于平方数的简单游戏(平方数指通过整数自乘得到的数,如 1, 4, 9, 16, 25, …)。

游戏设置步骤:

  1. 询问用户输入起始数(例如3)。
  2. 询问用户需要生成多少个数值。
  3. 随机选取2至4之间的整数作为乘数。
  4. 根据用户指定的数量生成数值序列。从起始数开始,每个数值应为下一个平方数乘以乘数。

游戏流程:

  • 用户输入猜测值。
  • 若猜测值与生成的数值匹配,则该数值从列表中移除,用户可继续猜测。
  • 若用户猜中所有生成的数值,则获胜。
  • 若猜测值与生成的数值不匹配,用户失败,程序将告知最接近的未猜中数值。

以下示例会话可帮助您更好地理解游戏机制:

Start where? 4
How many? 5
I generated 5 square numbers. Do you know what each number is after multiplying it by 2?
> 32
Nice! 4 number(s) left.
> 72
Nice! 3 number(s) left.
> 50
Nice! 2 number(s) left.
> 126
126 is wrong! Try 128 next time.
  • 程序从4开始生成接下来的5个平方数:16, 25, 36, 49, 64
  • 程序随机选取2作为乘数,因此每个平方数乘以2:32, 50, 72, 98, 128
  • 现在轮到用户猜测。
  • 32在列表中。
  • 72在列表中。
  • 126不在列表中,因此用户猜错。最接近的未猜中数字是128。
Start where? 1
How many? 3
I generated 3 square numbers. Do you know what each number is after multiplying it by 4?
> 4
Nice! 2 number(s) left.
> 16
Nice! 1 number(s) left.
> 36
Nice! You found all numbers, good job!

程序从1开始生成接下来的3个平方数:1, 4, 9
程序随机选取4作为乘数,因此每个平方数乘以4:4, 16, 36
用户正确猜出所有数字即获胜。

提示:

  • 使用Random.h(8.15节——全局随机数生成(Random.h))生成随机数。
  • 使用std::find()(18.3节——标准库算法介绍)在列表中搜索数字。
  • 使用std::vector::erase()删除元素,例如:
auto found{ std::find(/* ... */) };

// Make sure the element was found

myVector.erase(found);
  • 使用 std::min_element 和 lambda 表达式找出最接近用户猜测的数字。std::min_element 的工作原理与前次测验中的 std::max_element 类似。

显示提示

提示:使用<cmath>中的std::abs函数计算两个数之间的正差值。
int distance{ std::abs(3 - 5) }; // 2

显示解决方案

#include <algorithm> // std::find, std::min_element
#include <cmath> // std::abs
#include <cstddef> // std::size_t
#include <iostream>
#include <vector>
#include "Random.h"

using Numbers = std::vector<int>;

namespace config
{
    constexpr int multiplierMin{ 2 };
    constexpr int multiplierMax{ 6 };
}

// Generates @count numbers starting at @start*@start and multiplies
// every square number by @multiplier.
Numbers generateNumbers(int start, int count, int multiplier)
{
    Numbers numbers(static_cast<std::size_t>(count));

    for (int index = 0; index < count; ++index)
    {
        std::size_t uindex{ static_cast<std::size_t>(index) };
        numbers[uindex] = (start + index) * (start + index) * multiplier;
    }

    return numbers;
}

// Asks the user to input starting number, then generates array of numbers
Numbers setupGame()
{
    int start{};
    std::cout << "Start where? ";
    std::cin >> start;

    int count{};
    std::cout << "How many? ";
    std::cin >> count;

    int multiplier{ Random::get(config::multiplierMin, config::multiplierMax) };

    std::cout << "I generated " << count
        << " square numbers. Do you know what each number is after multiplying it by "
        << multiplier << "?\n";

    return generateNumbers(start, count, multiplier);
}

// Returns the user's guess
int getUserGuess()
{
    int guess{};

    std::cout << "> ";
    std::cin >> guess;

    return guess;
}

// Searches for the value @guess in @numbers and removes it.
// Returns true if the value was found. False otherwise.
bool findAndRemove(Numbers& numbers, int guess)
{
    auto found{ std::find(numbers.begin(), numbers.end(), guess) };

    if (found == numbers.end())
        return false;

    numbers.erase(found);
    return true;
}

// Finds the value in @numbers that is closest to @guess.
int findClosestNumber(const Numbers& numbers, int guess)
{
    return *std::min_element(numbers.begin(), numbers.end(),
        [=](int a, int b)
        {
            return std::abs(a - guess) < std::abs(b - guess);
        });
}


// Called when the user guesses a number correctly.
void printSuccess(const Numbers& numbers)
{
    std::cout << "Nice! ";

    if (numbers.size() == 0)
    {
        std::cout << "You found all numbers, good job!\n";
    }
    else
    {
        std::cout << numbers.size() << " number(s) left.\n";
    }
}

// Called when the user guesses a number that is not in the numbers.
void printFailure(const Numbers& numbers, int guess)
{
    int closest{ findClosestNumber(numbers, guess) };

    std::cout << guess << " is wrong!\n";

    std::cout << "Try " << closest << " next time.\n";
}

int main()
{
    Numbers numbers{ setupGame() };

    while (true)
    {
        int guess{ getUserGuess() };

        if (!findAndRemove(numbers, guess))
        {
            printFailure(numbers, guess);
            break;
        }

        printSuccess(numbers);
        if (numbers.size() == 0)
            break;
    }

    return 0;
}
posted @ 2026-01-21 10:46  游翔  阅读(7)  评论(0)    收藏  举报