15. Lambda 的捕获陷阱:悬空引用、生命周期问题、捕获 this 指针

Lambda 表达式用起来确实爽,但捕获这块儿,坑是真的多。

我见过不少项目,线上崩溃查了半天,最后定位到是 Lambda 捕获出了问题。说白了,Lambda 捕获的本质是把外部变量“带进”闭包。但怎么带、带的是什么、带进来之后还能不能用——这些细节一旦忽略,就是灾难。

15.1 捕获的本质:值还是引用?

先看个最简单的例子:

int x = 42;
auto lambda = [&]() { return x; };
x = 100;
std::cout << lambda(); // 输出 100

为什么是 100?因为 [&] 捕获的是引用。Lambda 内部存的是 x 的地址,不是副本。你想想看,如果外部 x 被销毁了,这个引用就悬空了。

反过来,值捕获就安全得多:

int x = 42;
auto lambda = [=]() { return x; };
x = 100;
std::cout << lambda(); // 输出 42

值捕获在 Lambda 创建时就把 x 的副本存进去了。之后外部怎么改,跟 Lambda 内部没关系。

我的习惯:能用值捕获就别用引用捕获。除非你明确知道变量生命周期比 Lambda 长,否则引用捕获就是定时炸弹。

15.2 悬空引用:最常见的坑

我在项目中遇到过最典型的场景——把 Lambda 存起来异步执行:

std::function<void()> task;

void createTask() {
    int localVar = 42;
    task = [&localVar]() {
        std::cout << localVar << std::endl;
    };
} // localVar 在这里销毁了

void runTask() {
    task(); // 崩溃!localVar 已经没了
}

为什么会这样?createTask 执行完,localVar 的栈帧就释放了。Lambda 里存的引用指向了一块已经被回收的内存。访问它,就是未定义行为。

警告:引用捕获的变量,生命周期必须覆盖 Lambda 的整个生命周期。这是铁律,没有例外。

我曾经在一个网络库中排查过一个诡异的内存错乱问题。查了两天才发现,是某个回调 Lambda 捕获了一个栈上的临时对象引用。那个对象早就析构了,但 Lambda 还在队列里等着执行。嗯,从那以后,我给自己定了个规矩:

  • Lambda 如果立即执行(比如传给 std::for_each),引用捕获没问题
  • Lambda 如果被存储或延迟执行,一律值捕获,或者用 std::shared_ptr 管理生命周期

15.3 生命周期问题:不仅仅是引用

值捕获就绝对安全吗?不一定。看这个:

std::unique_ptr<int> ptr = std::make_unique<int>(42);
auto lambda = [ptr = std::move(ptr)]() {
    std::cout << *ptr << std::endl;
};

这里用了移动捕获(C++14 特性),把 unique_ptr 的所有权转移到了 Lambda 内部。Lambda 销毁时,unique_ptr 自动释放内存。没问题。

但如果你不小心捕获了原始指针:

int* raw = new int(42);
auto lambda = [raw]() {
    std::cout << *raw << std::endl;
};
delete raw;
lambda(); // 悬空指针!

值捕获只复制了指针本身,没复制它指向的内存。你想想看,指针的值没变,但指向的内存已经被释放了。这跟引用捕获本质上是一个问题。

核心原则:捕获的是“值”还是“引用”不重要,重要的是捕获的东西在 Lambda 执行时是否仍然有效。

15.4 捕获 this 指针:隐蔽的陷阱

这是最容易被忽视的坑。看代码:

class Widget {
    int value = 42;
public:
    auto getLambda() {
        return [this]() {
            return value; // 等价于 this->value
        };
    }
};

void test() {
    auto lambda = Widget().getLambda();
    std::cout << lambda(); // 崩溃!Widget 对象已销毁
}

[this] 捕获的是当前对象的指针。如果对象被销毁了,这个 this 指针就悬空了。Lambda 里访问 value,实际上是在访问一块已经被释放的内存。

更隐蔽的是 [=] 默认捕获:

class Widget {
    int value = 42;
public:
    auto getLambda() {
        return [=]() {  // 你以为捕获了 value 的副本?
            return value; // 实际上捕获了 this,访问的是 this->value
        };
    }
};

很多人以为 [=] 会把所有成员变量都复制一份。错了![=] 在成员函数中只捕获 this 指针。访问成员变量时,走的还是 this->value

注意:C++17 引入了 [*this],可以按值捕获当前对象的副本。这样 Lambda 内部操作的是副本,跟原对象无关。
class Widget {
    int value = 42;
public:
    auto getLambda() {
        return [*this]() {  // 捕获 this 对象的副本
            return value;   // 操作的是副本,安全!
        };
    }
};

我个人建议:如果 Lambda 的生命周期可能超过当前对象,优先用 [*this] 或者显式捕获需要的成员变量副本。

15.5 知识体系图

Lambda 捕获陷阱知识体系 Lambda 捕获 值捕获 vs 引用捕获 生命周期问题 this 指针捕获 引用捕获悬空 值捕获副本安全 栈变量销毁 原始指针悬空 移动捕获所有权 [this] 捕获指针 [=] 隐式捕获 this [*this] 安全副本 解决方案:显式捕获 + 生命周期管理

15.6 避坑指南

总结几条实战经验,都是我踩过的坑:

  1. 延迟执行的 Lambda,不要用引用捕获。 我曾经把一个引用捕获的 Lambda 塞进线程池,结果线程执行时变量早就没了。排查了一下午。
  2. 成员函数里用 [=] 要小心。 它捕获的是 this,不是成员变量的副本。如果 Lambda 生命周期超过对象,用 [*this] 或者显式捕获成员变量。
  3. 捕获智能指针时注意循环引用。 如果 Lambda 被 shared_ptr 管理,Lambda 内部又捕获了同一个 shared_ptr,就会形成循环引用,内存泄漏。
  4. 能用值捕获就别用引用捕获。 值捕获的语义清晰,不容易出错。性能损失通常可以忽略。
  5. 移动捕获是 C++14 的好东西。 对于只能移动的类型(如 unique_ptrfstream),用移动捕获把所有权转给 Lambda。
我的建议:写 Lambda 之前,先问自己三个问题——这个 Lambda 什么时候执行?它捕获的变量那时候还在吗?如果不在,我该怎么办?想清楚再写,能省很多调试时间。

Lambda 捕获的陷阱,说白了就是生命周期管理的问题。你想想看,C++ 里所有悬空指针、悬空引用的问题,本质上都是“对象已经死了,但还有人拿着它的地址”。Lambda 捕获只是把这个老问题包装了一层新皮而已。

嗯,记住一点:捕获不是复制生命期。你捕获了一个变量,不代表这个变量会一直活着。生命期管理,永远是程序员自己的责任。


公众号:蓝海资料掘金营,微信deep3321