MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++ Lambda 表达式的捕获列表策略

2022-08-072.2k 阅读

C++ Lambda 表达式的捕获列表策略

捕获列表概述

在 C++ 中,Lambda 表达式为我们提供了一种简洁的定义匿名函数对象的方式。而捕获列表则是 Lambda 表达式中一个至关重要的部分,它决定了 Lambda 表达式如何访问其外部作用域中的变量。捕获列表可以让 Lambda 表达式在定义时捕获周围作用域的变量,以便在其内部使用。

捕获列表位于 Lambda 表达式的开头部分,语法形式为 [capture - list],其中 capture - list 是由逗号分隔的捕获项组成。捕获列表有多种不同的形式,每种形式都有其特定的用途和行为。

值捕获

值捕获是捕获列表中最常见的形式之一。在值捕获中,Lambda 表达式会复制外部作用域中被捕获变量的值,在 Lambda 表达式内部使用的是这些变量的副本。这意味着,即使外部作用域中的变量值发生变化,Lambda 表达式内部使用的副本值也不会受到影响。

单个变量值捕获

#include <iostream>

int main() {
    int num = 10;
    auto lambda = [num]() {
        std::cout << "The value of num in lambda is: " << num << std::endl;
    };
    num = 20;
    lambda();
    return 0;
}

在上述代码中,lambda 表达式通过 [num] 捕获了外部作用域中的 num 变量。之后,在 main 函数中,num 的值被修改为 20,但当调用 lambda 时,输出的仍然是最初捕获的 num 的值 10。这清楚地表明,lambda 内部使用的是 num 的副本。

多个变量值捕获

#include <iostream>

int main() {
    int a = 5;
    int b = 10;
    auto lambda = [a, b]() {
        std::cout << "a + b = " << a + b << std::endl;
    };
    a = 15;
    b = 20;
    lambda();
    return 0;
}

这里,lambda 表达式同时捕获了 ab 两个变量。同样,在修改 ab 的值后调用 lambda,输出的是捕获时 ab 的值相加的结果,即 15,而不是修改后的值相加的结果。

引用捕获

与值捕获不同,引用捕获使得 Lambda 表达式可以直接访问外部作用域中的变量,而不是使用变量的副本。这意味着,如果外部作用域中的变量值发生变化,Lambda 表达式内部访问到的值也会相应改变。

单个变量引用捕获

#include <iostream>

int main() {
    int num = 10;
    auto lambda = [&num]() {
        std::cout << "The value of num in lambda is: " << num << std::endl;
    };
    num = 20;
    lambda();
    return 0;
}

在这个例子中,lambda 通过 [&num] 以引用的方式捕获了 num 变量。当 num 在外部作用域中被修改为 20 后,调用 lambda 输出的值就是修改后的 20

多个变量引用捕获

#include <iostream>

int main() {
    int a = 5;
    int b = 10;
    auto lambda = [&a, &b]() {
        std::cout << "a + b = " << a + b << std::endl;
    };
    a = 15;
    b = 20;
    lambda();
    return 0;
}

此代码中,lambda 以引用方式捕获了 ab。当 ab 的值在外部作用域被修改后,调用 lambda 输出的是修改后 ab 相加的结果 35

隐式捕获

隐式捕获允许编译器根据 Lambda 表达式内部对外部变量的使用情况自动推断捕获列表。隐式捕获有两种方式:隐式值捕获和隐式引用捕获。

隐式值捕获

隐式值捕获使用 = 符号来指定。编译器会自动将 Lambda 表达式内部使用到的外部作用域变量以值捕获的方式添加到捕获列表中。

#include <iostream>

int main() {
    int num = 10;
    auto lambda = [=]() {
        std::cout << "The value of num in lambda is: " << num << std::endl;
    };
    num = 20;
    lambda();
    return 0;
}

在上述代码中,虽然捕获列表只写了 [=],但由于 lambda 内部使用了 num 变量,编译器会自动将 num 以值捕获的方式添加到捕获列表中,其效果与显式值捕获 [num] 类似。

隐式引用捕获

隐式引用捕获使用 & 符号来指定。编译器会自动将 Lambda 表达式内部使用到的外部作用域变量以引用捕获的方式添加到捕获列表中。

#include <iostream>

int main() {
    int num = 10;
    auto lambda = [&]() {
        std::cout << "The value of num in lambda is: " << num << std::endl;
    };
    num = 20;
    lambda();
    return 0;
}

这里,[&] 告诉编译器,对于 lambda 内部使用的外部变量,以引用捕获的方式处理。所以当 num 在外部作用域被修改后,lambda 输出的是修改后的值。

混合捕获

在实际编程中,我们也可以在捕获列表中同时使用值捕获、引用捕获以及隐式捕获,形成混合捕获的方式。

显式值捕获与隐式引用捕获混合

#include <iostream>

int main() {
    int a = 5;
    int b = 10;
    int c = 15;
    auto lambda = [a, &]() {
        std::cout << "a + b + c = " << a + b + c << std::endl;
    };
    b = 20;
    c = 25;
    lambda();
    return 0;
}

在这个例子中,lambda 显式地以值捕获了 a,同时使用隐式引用捕获(&)让编译器自动以引用方式捕获 bc。所以当 bc 在外部作用域被修改后,lambda 输出的是修改后的值与 a 相加的结果。

显式引用捕获与隐式值捕获混合

#include <iostream>

int main() {
    int a = 5;
    int b = 10;
    int c = 15;
    auto lambda = [&a, =]() {
        std::cout << "a + b + c = " << a + b + c << std::endl;
    };
    b = 20;
    c = 25;
    lambda();
    return 0;
}

此代码中,lambda 显式地以引用捕获了 a,并使用隐式值捕获(=)让编译器自动以值捕获方式捕获 bc。所以 bc 在外部作用域的修改不会影响 lambda 内部的值,而 a 的修改会影响。

可变捕获

在默认情况下,以值捕获方式捕获的变量在 Lambda 表达式内部是只读的,不能被修改。但如果我们希望在 Lambda 表达式内部修改以值捕获的变量,可以使用可变捕获。可变捕获通过在参数列表后添加 mutable 关键字来实现。

#include <iostream>

int main() {
    int num = 10;
    auto lambda = [num]() mutable {
        num++;
        std::cout << "The modified value of num in lambda is: " << num << std::endl;
    };
    lambda();
    std::cout << "The value of num outside lambda is: " << num << std::endl;
    return 0;
}

在上述代码中,lambda 使用了可变捕获(mutable),因此可以在内部修改值捕获的 num 变量。需要注意的是,这种修改只影响 lambda 内部的 num 副本,外部作用域中的 num 值并不会改变。

捕获列表的生命周期问题

理解捕获列表中变量的生命周期对于正确使用 Lambda 表达式至关重要。

值捕获的生命周期

当以值捕获方式捕获变量时,Lambda 表达式会在创建时复制变量的值。这些副本的生命周期与 Lambda 表达式对象的生命周期相同。当 Lambda 表达式对象被销毁时,这些副本也会被销毁。例如:

#include <iostream>
#include <functional>

std::function<void()> createLambda() {
    int num = 10;
    auto lambda = [num]() {
        std::cout << "The value of num in lambda is: " << num << std::endl;
    };
    return lambda;
}

int main() {
    auto func = createLambda();
    func();
    return 0;
}

createLambda 函数中,num 以值捕获的方式被 lambda 捕获。当 createLambda 函数返回 lambda 时,num 的原始变量已经超出作用域被销毁,但 lambda 内部的 num 副本仍然存在并可以正常使用。

引用捕获的生命周期

以引用捕获方式捕获变量时,Lambda 表达式内部使用的是外部变量的引用。这就要求外部变量的生命周期必须长于 Lambda 表达式对象的生命周期。否则,当外部变量被销毁后,Lambda 表达式内部对该变量的引用将成为悬空引用,导致未定义行为。

#include <iostream>
#include <functional>

std::function<void()> createLambda() {
    int num = 10;
    auto lambda = [&num]() {
        std::cout << "The value of num in lambda is: " << num << std::endl;
    };
    return lambda;
}

int main() {
    auto func = createLambda();
    // num 已经在 createLambda 函数结束时被销毁
    func(); // 未定义行为
    return 0;
}

在这个例子中,lambda 以引用方式捕获了 num,但 numcreateLambda 函数结束时就被销毁了。当在 main 函数中调用 func 时,func 内部对 num 的引用已经是悬空引用,这会导致未定义行为。

捕获列表在函数对象中的实现

从底层实现角度来看,Lambda 表达式实际上是一个匿名的函数对象(闭包)。捕获列表中的变量会被存储在这个函数对象的成员变量中。

值捕获的实现

对于值捕获,函数对象会有相应类型的成员变量来存储被捕获变量的副本。例如,对于 [num] 的值捕获,函数对象内部会有一个 int 类型的成员变量来存储 num 的值。在 Lambda 表达式调用时,使用的就是这个成员变量的值。

引用捕获的实现

引用捕获则是在函数对象内部存储外部变量的引用。例如,对于 [&num] 的引用捕获,函数对象内部会有一个 int& 类型的成员变量来引用外部的 num 变量。在 Lambda 表达式调用时,直接通过这个引用访问外部变量。

捕获列表在 STL 算法中的应用

捕获列表在 STL 算法中有着广泛的应用,可以使代码更加简洁和灵活。

std::for_each 与捕获列表

#include <iostream>
#include <algorithm>
#include <vector>

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5};
    int sum = 0;
    std::for_each(numbers.begin(), numbers.end(), [&sum](int num) {
        sum += num;
    });
    std::cout << "The sum of numbers is: " << sum << std::endl;
    return 0;
}

在上述代码中,使用 std::for_each 算法遍历 numbers 向量,并通过引用捕获 sum 变量,在遍历过程中累加向量中的值。

std::find_if 与捕获列表

#include <iostream>
#include <algorithm>
#include <vector>

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5};
    int target = 3;
    auto it = std::find_if(numbers.begin(), numbers.end(), [target](int num) {
        return num == target;
    });
    if (it != numbers.end()) {
        std::cout << "Target " << target << " found." << std::endl;
    } else {
        std::cout << "Target " << target << " not found." << std::endl;
    }
    return 0;
}

这里,std::find_if 算法使用 Lambda 表达式来查找 numbers 向量中是否存在值为 target 的元素。通过值捕获 target,使得 Lambda 表达式可以在查找条件中使用该变量。

捕获列表的性能考虑

在使用捕获列表时,性能也是一个需要考虑的因素。

值捕获的性能

值捕获由于需要复制变量的值,对于大型对象可能会带来一定的性能开销。特别是当捕获的对象很大时,复制操作可能会比较耗时。例如,捕获一个大型的自定义结构体或类对象,值捕获可能会导致额外的内存分配和复制操作。

引用捕获的性能

引用捕获避免了复制操作,对于大型对象来说,性能上通常比值捕获更优。但引用捕获需要注意变量的生命周期问题,确保在 Lambda 表达式使用期间,被引用的变量不会被销毁。

捕获列表的最佳实践

  1. 明确捕获需求:在编写 Lambda 表达式时,首先要明确是需要值捕获还是引用捕获。如果需要在 Lambda 表达式内部修改捕获的变量,并且希望这种修改不影响外部变量,使用值捕获并结合 mutable 关键字;如果需要反映外部变量的变化,使用引用捕获。
  2. 注意生命周期:对于引用捕获,要确保被引用变量的生命周期足够长,避免悬空引用。如果不确定变量的生命周期,可以考虑使用值捕获。
  3. 避免过度捕获:不要捕获不必要的变量,只捕获 Lambda 表达式内部实际使用到的变量,以减少内存占用和潜在的性能开销。
  4. 文档化捕获列表:在复杂的代码中,对捕获列表进行适当的注释,说明每个捕获项的用途和作用,有助于提高代码的可读性和可维护性。

通过合理运用捕获列表的各种策略,我们可以充分发挥 C++ Lambda 表达式的强大功能,编写出更加简洁、高效且易于维护的代码。在实际编程中,根据具体的需求和场景,选择最合适的捕获方式是非常关键的。无论是在 STL 算法中,还是在自定义的函数和类中,正确使用捕获列表都能为我们的代码带来很大的便利。同时,深入理解捕获列表的底层实现和生命周期问题,有助于我们避免潜在的错误,提升程序的稳定性和可靠性。在日常开发中,不断积累使用捕获列表的经验,将能更好地驾驭 C++ 的 Lambda 表达式,提升编程效率和代码质量。

以上就是关于 C++ Lambda 表达式捕获列表策略的详细介绍,希望通过这些内容,读者能够对捕获列表有更深入的理解,并在实际编程中灵活运用。