Skip to content
团子云技术 Lite 1.048596
Go back

C++ Lambda 拆开看:一个匿名 struct 和它 const 的 operator()

本文是我在复习 C++ lambda 底层机制时与 Gemini 的一次对话整理。从最基础的展开方式聊到 C++20 的模板 lambda,一层层递进,整理成文。

Lambda 就是编译器生成的匿名 struct

Lambda 表达式本质上是语法糖。你写下一个 lambda,编译器在背后生成一个匿名的、重载了 operator() 的类,标准里管它叫闭包类型(Closure Type)。

把下面的 lambda 拆开看:

int x = 10;
int y = 20;

auto myLambda = [x, &y](int z) {
    y = y + 1;
    return x + y + z;
};

编译器大致会把它翻译成这样一个带 operator() 的匿名结构体:

// 编译器自动生成的匿名类(名字是随机的)
struct __Lambda_Compiler_Generated_Name {
private:
    int x;       // 对应按值捕获的 [x]
    int& y;      // 对应按引用捕获的 [&y]

public:
    __Lambda_Compiler_Generated_Name(int _x, int& _y) : x(_x), y(_y) {}

    // lambda 的函数体变成了 const 的 operator()
    inline int operator()(int z) const {
        y = y + 1; // 合法:改的是引用指向的外部 y,不是引用本身
        // x = x + 1; // 报错:operator() 是 const 的,不能修改成员 x
        return x + y + z;
    }
};

int x = 10;
int y = 20;
auto myLambda = __Lambda_Compiler_Generated_Name(x, y);

捕获列表变成了成员变量,按值捕获是成员副本,按引用捕获是引用成员。函数体原封不动搬进 operator()。就这么简单。

operator() 为什么默认是 const

这是这次对话里我觉得最有意思的一问。C++ 标准把 lambda 的 operator() 默认标成 const,主要出于两个原因。

原因一:防止值捕获带来的”逻辑错觉”。

[x] 按值捕获时,闭包内部存的是 x 的副本。如果 operator() 不是 const 的,你可以在 lambda 里随意”改 x”:

int x = 10;
auto lambda = [x]() {
    x++; // 如果允许这样写……
    std::cout << x;
};
lambda();       // 打印 11
std::cout << x; // 外部的 x 仍然是 10!

改的是内部副本,外部变量纹丝不动。开发者很容易误以为修改的是外部环境的 x。为了堵住这种静默的、只改了副本的逻辑错误,标准直接把 operator() 标成 const,让你改不了按值捕获的变量。

原因二:函数式编程的”无状态”理念。

lambda 深受函数式编程影响。函数式风格里函数应当尽量”纯”,不改变自身状态。operator() 为 const 意味着调用闭包不会改变闭包本身的状态,多次调用的行为是一致的。

那为什么上例中按引用捕获的 &y 可以改?const 成员函数保证的是”不修改成员变量本身”。对引用成员 int& y 来说,“不修改它”意味着不能让它重新绑定到别的变量——而 C++ 的引用本来就不允许重新绑定。通过引用修改它所指向的外部对象,不违反 const 规则。

mutable:打破 const 限制

如果你明确知道自己在做什么,确实想在 lambda 内部修改那个值捕获的副本——让它成为一个带内部状态的函数对象——用 mutable:

int count = 0;
auto counter = [count]() mutable {
    count++; // 现在合法了,修改的是闭包内部的副本
    return count;
};

std::cout << counter(); // 输出 1
std::cout << counter(); // 输出 2
std::cout << count;     // 输出 0(外部的 count 没变)

加上 mutable 之后,编译器生成的 operator() 就去掉了 const:

struct __Mutable_Lambda {
    int count;
    __Mutable_Lambda(int c) : count(c) {}

    // 注意:这里没有 const 了!
    inline int operator()() {
        count++;
        return count;
    }
};

mutable 的语义在这一层看得清清楚楚:它不是”让外部变量可变”,只是把闭包自己的 operator() 从 const 版本换成非 const 版本。

C++14 之一:带初始化的捕获

C++11 的捕获列表只能简单按值 [x] 或按引用 [&x]。这带来一个巨大的痛点:无法把 std::unique_ptr 这类只可移动、不可拷贝的对象捕获进 lambda——按引用捕获又有悬垂引用的风险。

C++14 引入 [x = expr],允许在捕获列表里声明新变量,用任意表达式初始化它:

auto ptr = std::make_unique<int>(10);

// 将外部的 ptr 移动进 lambda,并重命名为 p
auto myLambda = [p = std::move(ptr)]() {
    return *p + 5;
};

底层展开:编译器生成一个带成员 p 的类,直接用等号右边的表达式初始化这个成员:

struct __Lambda_Init_Capture {
private:
    std::unique_ptr<int> p; // 变量名由等号左边决定

public:
    // 构造函数用等号右边的表达式(std::move(ptr))初始化
    __Lambda_Init_Capture(std::unique_ptr<int>&& _ptr)
        : p(std::move(_ptr)) {}

    inline int operator()() const {
        return *p + 5;
    }
};

auto ptr = std::make_unique<int>(10);
auto myLambda = __Lambda_Init_Capture(std::move(ptr)); // 此处发生移动

除了解决 move-only 类型的捕获问题,初始化捕获还允许在捕获时执行计算(比如 [cost = price * num]),或者给捕获的变量重命名,避免名字冲突。

C++14 之二:泛型 lambda

C++11 的 lambda 必须明确指定参数类型,想写一个通用的加法函数只能求助于繁琐的重载或模板类。C++14 允许参数列表用 auto,lambda 由此变成真正的泛型工具:

auto add = [](auto a, auto b) {
    return a + b;
};

int sum1 = add(2, 3);          // 传入 int
double sum2 = add(2.5, 3.5);   // 传入 double
std::string sum3 = add(std::string("Hello "), "World"); // 传入 string

编译器看到参数里的 auto 时,不会生成普通的 operator(),而是生成一个成员函数模板:

struct __Lambda_Generic {
    // 没有捕获变量,所以没有状态成员

    // auto 被转换成了模板参数
    template <typename T1, typename T2>
    inline auto operator()(T1 a, T2 b) const {
        return a + b;
    }
};

auto add = __Lambda_Generic{};

// 调用时发生正常的模板实参推导
int sum1 = add.operator()<int, int>(2, 3);
double sum2 = add.operator()<double, double>(2.5, 3.5);

每个 auto 对应一个独立的模板参数——记住这一点,C++20 部分会回来找它算账。泛型 lambda 极大简化了和 STL 算法的配合:在 std::sort 或 std::for_each 里直接写 [](const auto& item),不用死记容器内部元素的类型。

C++20 模板 lambda:auto 做不好的三件事

C++20 允许在 lambda 形参列表前直接手写模板参数列表:[]<typename T>(...) { ... }。

你可能会问:C++14 已经有 [](auto a) 了,为什么 C++20 还要”退回去”引入传统的模板语法?因为 auto 的推导机制在处理类型约束和类型提取时过于粗暴和隐晦。C++20 模板 lambda 专门解决这三件事。

其一:强制要求多个参数类型一致。

C++14 里每个 auto 都是独立的模板参数,想写一个只接受两个相同类型参数的函数很头疼:

auto add = [](auto a, auto b) {
    return a + b;
};
add(1, 2.5); // 编译通过:a 是 int,b 是 double,但你可能本意是禁止类型混用

要在 C++14 里强制类型相同,只能求助于晦涩的 decltype 和 static_assert:

auto add_strict = [](auto a, auto b) {
    static_assert(std::is_same_v<decltype(a), decltype(b)>, "Must be same type!");
    return a + b;
};

C++20 直接用模板参数,像写普通模板函数一样自然:

auto add_strict = []<typename T>(T a, T b) {
    return a + b;
};
add_strict(1, 2);    // OK,T 推导为 int
// add_strict(1, 2.5);  // 编译报错!T 无法同时推导为 int 和 double

其二:提取复杂的内部类型。

想获取传入参数的底层类型(比如提取 std::vector<T> 里的 T),C++14 的 auto 让代码变得冗长难读:

auto process_vector = [](const auto& vec) {
    // 为了拿到 vector 内部的类型,得写出这么一长串毫无美感的代码
    using T = typename std::decay_t<decltype(vec)>::value_type;
    T sum = 0;
    // ...
};

C++20 用模板的模式匹配(Pattern Matching)直接把 T 扣出来:

auto process_vector = []<typename T>(const std::vector<T>& vec) {
    // 直接用 T,清清爽爽
    T sum = 0;
    // ...
};

其三:完美转发太繁琐。

写通用包装器时,C++14 的语法又长又容易写错:

auto forwarder = [](auto&& arg) {
    // 必须用 decltype(arg) 才能正确推导引用类型
    return some_function(std::forward<decltype(arg)>(arg));
};

C++20 有了明确的模板参数 T,完美转发回归它最经典的样子:

auto forwarder = []<typename T>(T&& arg) {
    return some_function(std::forward<T>(arg));
};

底层展开呢? 和 C++14 的泛型 lambda 几乎一样,都是生成一个带模板 operator() 的匿名类。区别只在于:编译器不再把 auto 盲目翻译成独立的 T1、T2,而是完全按照你手写的模板签名来生成函数。

// 你的 C++20 代码:
auto func = []<typename T>(const std::vector<T>& vec) { return vec.size(); };

// 编译器的底层展开:
struct __Lambda_Cpp20_Template {
    // 完全照搬你写的模板参数和签名
    template <typename T>
    inline auto operator()(const std::vector<T>& vec) const {
        return vec.size();
    }
};

说白了:C++14 的 auto 胜在快速开发和极简;C++20 的模板 lambda 胜在精确控制。隐式推导和显式约束之间,现在可以自由切换。

几点心得

有理解不对的地方,欢迎指正。


Share this post on:

Previous Post
金渐成的投资体系:从公众号存档还原,附一份冷静拆解
Next Post
迭代器失效规则不用背:从绑定关系一句话推完五种容器