14. Lambda 表达式:随手可写的函数对象

文章目录

  • 引言
  • [一、Lambda 的基本语法](#一、Lambda 的基本语法)
    • [1.1 语法拆解](#1.1 语法拆解)
    • [1.2 Lambda 的类型是什么](#1.2 Lambda 的类型是什么)
  • [二、捕获列表:Lambda 的核心能力](#二、捕获列表:Lambda 的核心能力)
    • [2.1 `\[\]` ------ 不捕获任何外部变量](#2.1 [] —— 不捕获任何外部变量)
    • [2.2 `=` ------ 按值捕获所有外部变量](#2.2 [=] —— 按值捕获所有外部变量)
    • [2.3 `\&` ------ 按引用捕获所有外部变量](#2.3 [&] —— 按引用捕获所有外部变量)
    • [2.4 显式捕获------推荐的方式](#2.4 显式捕获——推荐的方式)
  • [三、Lambda 与 C 回调函数的对比](#三、Lambda 与 C 回调函数的对比)
    • [3.1 函数指针做不到的事](#3.1 函数指针做不到的事)
    • [3.2 函数指针能做的,Lambda 都能做](#3.2 函数指针能做的,Lambda 都能做)
  • 四、捕获的四个陷阱
    • [4.1 按引用捕获导致的悬挂引用](#4.1 按引用捕获导致的悬挂引用)
    • [4.2 `=` 不会真的"拷贝所有东西"](#4.2 [=] 不会真的"拷贝所有东西")
    • [4.3 按值捕获的变量在定义点是 const](#4.3 按值捕获的变量在定义点是 const)
    • [4.4 隐式捕获成员的静默开销](#4.4 隐式捕获成员的静默开销)
  • [五、Lambda 的进阶特性(C++14/17/20)](#五、Lambda 的进阶特性(C++14/17/20))
    • [5.1 泛型 Lambda(C++14)](#5.1 泛型 Lambda(C++14))
    • [5.2 `constexpr` Lambda(C++17)](#5.2 constexpr Lambda(C++17))
    • [5.3 模板 Lambda(C++20)](#5.3 模板 Lambda(C++20))
  • [六、编译器视角:Lambda 到底是什么](#六、编译器视角:Lambda 到底是什么)
  • [七、Lambda 在 STL 中的典型用法](#七、Lambda 在 STL 中的典型用法)
    • [7.1 自定义排序](#7.1 自定义排序)
    • [7.2 条件查找和过滤](#7.2 条件查找和过滤)
    • [7.3 用 Lambda 做 RAII 式的清理](#7.3 用 Lambda 做 RAII 式的清理)
  • [八、Lambda vs 函数对象 vs 函数指针------什么时候用什么](#八、Lambda vs 函数对象 vs 函数指针——什么时候用什么)
  • 总结

本系列为《C++深度修炼:基础、STL源码与多线程实战》第14篇

前置条件:理解函数指针(C 语言),了解 auto(第13篇),了解引用(第9篇)

引言

C 语言里,你想传递"一段逻辑"给别的函数,基本只有一个办法------函数指针:

c 复制代码
#include <stdlib.h>

int cmp_asc(const void *a, const void *b) {
    return *(int*)a - *(int*)b;
}

int main() {
    int arr[] = {5, 2, 8, 1, 9};
    qsort(arr, 5, sizeof(int), cmp_asc);
    // cmp_asc 的定义和调用点可能相距 50 行------阅读代码时要反复跳转
}

问题在哪里?数据和逻辑是分离的。 cmp_asc 函数看不到调用处的上下文,不能直接访问 main 中的局部变量。如果你想让比较逻辑依赖某个外部阈值,只能走全局变量或者 qsort_r 这种平台特定的扩展。

C++ 的 lambda 表达式 一次性解决了这个问题:你可以在需要的地方直接写一个函数 ,而且这个函数能捕获调用处的局部变量

cpp 复制代码
#include <algorithm>
#include <vector>

int main() {
    std::vector<int> v = {5, 2, 8, 1, 9};
    std::sort(v.begin(), v.end(),
              [](int a, int b) { return a < b; });  // 就地定义比较逻辑
}

本文将 lambda 从头到尾讲清楚------从语法到捕获列表的机制,再到 C++14/17/20 的增强。


一、Lambda 的基本语法

1.1 语法拆解

cpp 复制代码
//  [捕获列表] (参数列表) -> 返回类型 { 函数体 }
auto add = [](int a, int b) -> int { return a + b; };

大部分情况下可以省略返回类型(编译器自动推导):

cpp 复制代码
auto add = [](int a, int b) { return a + b; };  // 自动推导返回 int

// 完整语法:
// [capture] (params) mutable exception -> return_type { body }
// 只有 [capture] 和 { body } 是必须的,其他都可以省略

最简形式:

cpp 复制代码
auto hello = [] { std::cout << "hello\n"; };  // 无参数、无返回值
hello();  // 调用方式和普通函数一样

1.2 Lambda 的类型是什么

每个 lambda 表达式都有一个独一无二的、不可书写的类型------由编译器为它单独生成:

cpp 复制代码
auto f1 = [](int x) { return x * 2; };
auto f2 = [](int x) { return x * 2; };
// f1 和 f2 的类型完全不同------即使函数体一模一样

// sizeof(lambda) 通常很紧凑
std::cout << sizeof(f1) << '\n';  // 通常是 1(无捕获时)

因为类型没有名字,所以必须用 auto 来接收 lambda。如果你想把它存到容器里或者传给需要具体类型的地方,可以用 std::function(第53篇会深入其实现):

cpp 复制代码
#include <functional>
std::function<int(int)> f = [](int x) { return x * 2; };

编译器实际上把 lambda 转换为一个匿名的函数对象(functor) ------一个重载了 operator() 的类。这是理解 lambda 工作原理的关键,下文会展开。


二、捕获列表:Lambda 的核心能力

2.1 [] ------ 不捕获任何外部变量

cpp 复制代码
int x = 10;
// auto f = [] { return x; };  // ❌ 编译错误:x 不在 lambda 的作用域内
auto f = [] { return 42; };    // ✅ 只能访问自己的参数和全局变量

2.2 [=] ------ 按值捕获所有外部变量

cpp 复制代码
int threshold = 10;
int offset = 5;

auto pred = [=](int x) {
    return x > threshold + offset;  // threshold 和 offset 被拷贝进了 lambda
};

threshold = 100;  // 修改外部变量不影响 lambda 内部的拷贝
std::cout << std::boolalpha << pred(20) << '\n';  // true(20 > 10 + 5)

2.3 [&] ------ 按引用捕获所有外部变量

cpp 复制代码
int counter = 0;

auto inc = [&] { ++counter; };  // counter 是引用------直接操作外部变量
inc();
inc();
std::cout << counter << '\n';  // 2

2.4 显式捕获------推荐的方式

按值/按引用全捕获虽然方便,但会掩盖"lambda 到底依赖什么"的信息。更好的做法是显式列出捕获的变量:

cpp 复制代码
int x = 10;
double y = 3.14;
std::string name = "alice";

// 按值捕获 x 和 y,按引用捕获 name
auto f = [x, y, &name] {
    // x, y 是副本,name 是引用
    std::cout << x << ' ' << y << ' ' << name << '\n';
};

为什么显式捕获更好:

  1. 读者一眼就知道 lambda 依赖什么外部状态------不用脑补
  2. 避免意外的拷贝 ------[=] 对大型对象会产生性能问题
  3. 避免悬挂引用 ------[&] 捕获的引用在 lambda 比变量活得久时是致命的(见第四节)

C++14 起,还支持带初始化器的捕获

cpp 复制代码
// 移动捕获------把 unique_ptr 移进 lambda
auto up = std::make_unique<int>(42);
auto f = [p = std::move(up)] { return *p; };
// up 现在是 nullptr,p 在 lambda 内部是 unique_ptr

三、Lambda 与 C 回调函数的对比

3.1 函数指针做不到的事

C 语言的函数指针无法携带上下文数据。当你调用 qsort 时,比较函数只能拿到两个元素------看不到任何外部信息。绕过这个限制只有两种办法:

c 复制代码
// 方法 1:全局变量(丑陋、线程不安全)
static int g_threshold;
int cmp_with_threshold(const void *a, const void *b) {
    return (*(int*)a - g_threshold) - (*(int*)b - g_threshold);
}

// 方法 2:平台特定的 qsort_r(不跨平台、参数顺序不一致)
// glibc: qsort_r(base, nmemb, size, thunk, cmp)
// BSD:   qsort_r(base, nmemb, size, cmp, thunk)  // 参数顺序都不一样!

Lambda 的答案------上下文直接跟在代码旁边:

cpp 复制代码
int threshold = 10;
std::vector<int> v = {5, 15, 8, 12, 3};
std::sort(v.begin(), v.end(),
          [threshold](int a, int b) {
              // 可以用 threshold!不需要全局变量
              return std::abs(a - threshold) < std::abs(b - threshold);
          });
// 按与 threshold 的距离排序

3.2 函数指针能做的,Lambda 都能做

无捕获的 lambda 可以隐式转换为函数指针:

cpp 复制代码
// ✅ 无捕获 → 函数指针
int (*fp)(int, int) = [](int a, int b) { return a + b; };

// ❌ 有捕获 → 不能转换为函数指针
int x = 10;
// int (*fp2)(int) = [x](int a) { return a + x; };  // 编译错误

如果你需要把有捕获的 lambda 传给 C API 的回调参数(就是 void* userdata 那个),可以这样做:

cpp 复制代码
// C API
typedef void (*callback_t)(void *userdata, int value);
void register_callback(callback_t cb, void *userdata);

// C++ 包装
auto lambda = [&](int value) { std::cout << value << '\n'; };

// 传 lambda 的地址 + 捕获数据的地址------但需要用无捕获 lambda 做桥接
using cb_adapt = void(*)(void*, int);
register_callback(
    [](void *data, int value) {
        auto &fn = *static_cast<decltype(lambda)*>(data);
        fn(value);
    },
    &lambda
);

四、捕获的四个陷阱

4.1 按引用捕获导致的悬挂引用

cpp 复制代码
#include <iostream>
#include <functional>

std::function<int()> create_dangling() {
    int local = 42;
    return [&] { return local; };  // ❌ local 的引用在函数返回后就失效了!
}

int main() {
    auto f = create_dangling();
    std::cout << f() << '\n';  // 未定义行为------读取已销毁的栈变量
}
cpp 复制代码
// ✅ 按值捕获------安全
std::function<int()> create_safe() {
    int local = 42;
    return [=] { return local; };  // local 被拷贝进 lambda
}

4.2 [=] 不会真的"拷贝所有东西"

cpp 复制代码
class Widget {
public:
    void schedule() {
        int local = 1;
        // [=] 看起来应该拷贝 this 和 local......
        auto f = [=] { return value_ + local; };
        // 实际上 [=] 只拷贝了 local,value_ 是通过 this 指针访问的!
        // 等价于 [this, local] 而不是 [*this, local]

        task_ = f;
    }  // Widget 对象析构后,task_ 还持有 this 指针的拷贝------悬挂!
private:
    int value_ = 10;
    std::function<int()> task_;
};

C++17 起,可以用 [*this] 显式捕获对象副本:

cpp 复制代码
// C++17:捕获 *this 的副本------安全
auto f = [*this, local] { return value_ + local; };

4.3 按值捕获的变量在定义点是 const

cpp 复制代码
int x = 0;
auto f = [x] { /* x = 5; */ };  // ❌ 编译错误:x 是只读的
// 按值捕获的变量默认带 const------lambda 的 operator() 是 const 成员函数

如果你需要在 lambda 内部修改按值捕获的副本,用 mutable

cpp 复制代码
int x = 0;
auto counter = [x]() mutable {
    ++x;  // ✅ 现在可以修改 x 的副本了
    return x;
};
std::cout << counter() << '\n';  // 1
std::cout << counter() << '\n';  // 2
std::cout << x << '\n';          // 0 ------ 外部变量不受影响

4.4 隐式捕获成员的静默开销

cpp 复制代码
struct LargeObject {
    char data[1000000];
    int id;
};

LargeObject obj;

// ❌ [=] 看起来像拷贝------但你用到了 obj,它整个被拷贝了!
auto f = [=] { return obj.id; };
std::cout << "sizeof(f) = " << sizeof(f) << '\n';  // 约 1MB!
// 更好的做法:
auto f2 = [id = obj.id] { return id; };  // 只拷贝需要的 int

五、Lambda 的进阶特性(C++14/17/20)

5.1 泛型 Lambda(C++14)

Lambda 的参数可以用 auto------这会把它变成隐式模板:

cpp 复制代码
// C++14:泛型 lambda
auto twice = [](auto x) { return x * 2; };

std::cout << twice(10) << '\n';      // 20
std::cout << twice(3.14) << '\n';    // 6.28
std::cout << twice(std::string("hello")) << '\n';  // 编译错误------string 不能 *2

编译器为 auto 参数生成的等价代码:

cpp 复制代码
// 等价于:
struct AnonymousLambda {
    template <typename T>
    auto operator()(T x) const { return x * 2; }
};

泛型 lambda 配合 STL 算法非常强大:

cpp 复制代码
std::vector<int> vi = {1, 2, 3, 4, 5};
std::vector<double> vd = {1.1, 2.2, 3.3};

auto print_all = [](const auto &container) {
    for (const auto &x : container) {
        std::cout << x << ' ';
    }
    std::cout << '\n';
};

print_all(vi);  // 自动推导为 vector<int>
print_all(vd);  // 自动推导为 vector<double>

5.2 constexpr Lambda(C++17)

C++17 起,lambda 隐式为 constexpr(如果函数体满足 constexpr 的要求):

cpp 复制代码
// C++17:lambda 可以用于编译期计算
constexpr auto square = [](int n) { return n * n; };
static_assert(square(5) == 25);  // ✅ 编译期求值

// 用 lambda 在编译期初始化数组
constexpr int squares[] = {
    [](int i) constexpr { return i * i; }(0),
    [](int i) constexpr { return i * i; }(1),
    [](int i) constexpr { return i * i; }(2),
};

5.3 模板 Lambda(C++20)

C++20 允许 lambda 参数使用显式模板语法(不用 auto 绕弯):

cpp 复制代码
// C++20:显式模板 lambda
auto make_vector = []<typename T>(std::initializer_list<T> il) {
    return std::vector<T>(il);
};

auto v = make_vector({1, 2, 3, 4, 5});  // vector<int>
// 和 auto 参数版本的区别:可以拿到"T"这个名字------可以做类型相关的操作

六、编译器视角:Lambda 到底是什么

当你写下一个 lambda 表达式时,编译器悄悄生成了一个类:

cpp 复制代码
// 你写的:
int threshold = 10;
auto pred = [threshold](int x) { return x > threshold; };

// 编译器生成的(语义等价):
class __lambda_14_3 {
    int threshold_;  // 按值捕获的变量变成了成员变量
public:
    explicit __lambda_14_3(int t) : threshold_(t) {}
    auto operator()(int x) const { return x > threshold_; }
};

__lambda_14_3 pred(threshold);

理解这个转换后,很多行为就清楚了:

  • 捕获 = 将外部变量存入成员变量------在 lambda 构造时完成
  • 调用 lambda = 调用 operator()------访问的是成员变量,不是原始外部变量
  • mutable = 去掉 operator()const 限定------允许修改按值捕获的成员
  • sizeof lambda = sizeof 所有捕获变量之和(可能加上对齐填充)

验证:

cpp 复制代码
int a = 0, b = 0, c = 0;
auto f1 = []{};                         // 无捕获 → sizeof = 1
auto f2 = [a]{};                        // 捕获 1 个 int → sizeof = 4
auto f3 = [a, b]{};                     // 捕获 2 个 int → sizeof = 8
auto f4 = [&a]{};                       // 捕获 1 个引用 → sizeof = 8(64 位平台上指针大小)
auto f5 = [=]{};                        // 捕获 3 个 int → sizeof = 12
// 打印 sizeof 验证即可

七、Lambda 在 STL 中的典型用法

7.1 自定义排序

cpp 复制代码
#include <algorithm>
#include <vector>
#include <string>
#include <iostream>

struct Person {
    std::string name;
    int age;
};

int main() {
    std::vector<Person> people = {
        {"alice", 30}, {"bob", 25}, {"charlie", 35}
    };

    // 按年龄排序
    std::sort(people.begin(), people.end(),
              [](const Person &a, const Person &b) { return a.age < b.age; });

    // 按名字长度排序
    std::sort(people.begin(), people.end(),
              [](const Person &a, const Person &b) {
                  return a.name.size() < b.name.size();
              });

    for (const auto &p : people) {
        std::cout << p.name << " (" << p.age << ")\n";
    }
}

7.2 条件查找和过滤

cpp 复制代码
#include <algorithm>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {1, 5, 10, 15, 20, 25};

    // 查找第一个大于阈值的元素
    int threshold = 12;
    auto it = std::find_if(v.begin(), v.end(),
                           [threshold](int x) { return x > threshold; });
    if (it != v.end()) {
        std::cout << "第一个大于 " << threshold << " 的元素: " << *it << '\n';  // 15
    }

    // 计数
    auto n = std::count_if(v.begin(), v.end(),
                           [](int x) { return x % 2 == 0; });
    std::cout << "偶数个数: " << n << '\n';  // 3(10, 20, 空头25)
}

7.3 用 Lambda 做 RAII 式的清理

cpp 复制代码
#include <cstdio>
#include <iostream>
#include <memory>

int main() {
    FILE *fp = std::fopen("/tmp/test.txt", "w");
    if (!fp) return -1;

    // 保证函数退出时 fclose 一定被调用
    auto cleanup = [](FILE *f) { if (f) std::fclose(f); };
    std::unique_ptr<FILE, decltype(cleanup)> guard(fp, cleanup);

    std::fputs("hello\n", fp);
    // guard 析构时自动调用 fclose
}

八、Lambda vs 函数对象 vs 函数指针------什么时候用什么

维度 函数指针 函数对象(Functor) Lambda
携带上下文 ❌ 不能 ✅ 成员变量 ✅ 捕获列表
可内联 ⚠️ 困难 ✅ 容易 ✅ 最容易
书写位置 必须定义在外部 必须定义在外部 可以就地定义
类型名 无(匿名)
与 C API 交互 ✅ 天然 ❌ 需要函数指针桥接 无捕获的才可转
模板参数 ✅(C++14 起支持 auto 参数)
适合场景 简单的无状态回调 需要复用或有状态的复杂逻辑 一次性、就地定义的回调

经验法则:

  • 传给 STL 算法的比较/判断逻辑 → Lambda(就地定义,看一眼就懂)
  • 需要复用的有状态逻辑 → 函数对象(有名字,可测试,可文档化)
  • C API 回调 → 函数指针(或带 void* userdata 参数的 C API + lambda 桥接)
  • 5 行以上的逻辑 → 提取为命名函数或函数对象------不要用 lambda 吞掉大段代码

总结

Lambda 表达式让 C++ 从"函数只能定义在远处"升级到"逻辑可以跟在调用点身边":

  1. 语法核心:[捕获列表](参数){函数体}------捕获列表是 lambda 区别于函数指针的唯一特征
  2. [=] 按值捕获,[&] 按引用捕获 ------默认用显式捕获([x, &y]),避免意外拷贝和悬挂引用
  3. 编译器把 lambda 变成一个匿名的函数对象 ------捕获的变量成为成员变量,operator() 就是函数体
  4. 有捕获的 lambda 不能转成函数指针------这是和 C API 交互时的关键限制
  5. C++14 泛型 lambda(auto 参数)让 lambda 能处理任意类型------实际上是隐式模板
  6. 悬挂引用是 lambda 的第一大陷阱 ------尤其是 [&] + 返回 lambda、[=] + this 指针

lambda 是现代 C++ 算法式编程的基石。下一篇------C++工程基础:从 gcc 到 CMake 的过渡------我们将把视野从语言特性扩展到工程构建,理解多文件编译、链接和头文件组织的关键规则。


📝 动手练习

  1. 写一个 lambda,按字符串长度对一个 vector<string> 排序。对比用函数指针的写法(需要额外定义函数)
  2. 写一个 create_counter(int start) 函数,返回一个 lambda,每次调用时返回 start + 调用次数(需要用 mutable
  3. std::find_if + lambda 在一个 vector<int> 中查找第一个能被 3 整除且大于 10 的数
  4. 构造一个返回 lambda 的函数,lambda 按引用捕获函数的局部变量------在 main 中调用这个 lambda,观察未定义行为(打印随机值或崩溃)
  5. 写一个泛型 lambda(C++14),打印任意类型容器中每个元素的平方(假设元素支持 * 运算符)
相关推荐
-To be number.wan1 小时前
算法日记 | 暴力枚举
学习·算法
s_w.h2 小时前
【 linux 】动静态库的制作
linux·运维·服务器·算法·bash
百珏2 小时前
个人理解的AI Code Review 架构的三代演进
架构·aigc·ai编程
不想写代码的星星2 小时前
从分支预测角度看 C++:为什么你的热循环慢得离谱?
c++
人月神话Lee2 小时前
【图像处理】Core Image 与 GPU 渲染管线——让滤镜飞起来
ios·ai编程·图像识别
过期动态2 小时前
【LeetCode 热题 100】接雨水
java·数据结构·算法·leetcode·职场和发展
春日见2 小时前
5分钟入门强化学习之动态规划算法与实现
大数据·人工智能·python·算法·机器学习·计算机视觉
DO_Community2 小时前
为AI编程降本!OpenCode 原生支持 DigitalOcean 推理路由器
智能路由器·ai编程·claude
郝学胜-神的一滴2 小时前
Qt 高级开发 018:复刻经典登录界面布局与窗口美化全解析
开发语言·c++·qt·程序人生·用户界面