IV · C++ 与 Linux

lambda 和 std::function:闭包对象、捕获和类型擦除

lambda 是编译器替你写的一个类的对象:捕获的变量是它的成员,函数体是它的 operator()。按值捕获在创建时拷一份,按引用捕获只存一个地址,[=] 不会拷全局变量,在成员函数里拷的是 this 指针;初始化捕获能把 unique_ptr 搬进去。每个 lambda 都是一个新类型,要统一存放就得用 std::function:它用类型擦除把任何可调用的东西装进一个 32 字节的对象,代价是捕获大了要分配堆内存,调用多一次间接跳转、没法内联,而且只能装可拷贝的东西。

本页目录

排序时传一个比较规则,注册一个“数据到了就调用”的回调,开线程时告诉它要跑什么:这些地方都要把“一段代码”当成值传来传去。C++ 里最常用的写法是 lambda,存起来的时候常用 std::function。两样东西用起来很像,编译出来却差很多。

1. lambda 是一个类的对象

1.1 编译器替你写了一个类

写下一个 lambda,编译器就生成一个没有名字的类:捕获的变量是它的成员,函数体是它的 operator()。lambda 表达式本身得到的,是这个类的一个对象,叫闭包(closure)。下面两种写法编译出来一样:

int k = 3;
auto f = [k](int y) { return k * y; };          // lambda

struct Closure {                                 // 编译器替你写的,大致就是这个
    int k;
    int operator()(int y) const { return k * y; }
};
Closure g{k};

所以闭包对象有多大,就看捕获了什么。用 GCC 16.2 打出来:不捕获的 lambda sizeof 是 1(空类也要占 1 字节),按值捕获一个 int 是 4,按引用捕获一个 int 是 8,存的是一个地址。

不捕获按值捕获 x按引用捕获 x[](int y) { … }[x](int y) { … }[&x](int y) { … }没有成员sizeof = 1int x = 10(创建时的副本)sizeof = 4外面的 x 再改,副本不变int& x:存的是地址sizeof = 8栈上的 x捕获的变量就是这个类的成员:按值存一份,按引用存一个地址。函数体是它的 operator(),默认是 const 成员函数。

闭包就是一个普通的类,所以它的调用和手写的函数对象一样快。 把 lambda 传给一个模板函数,编译器知道调的是哪个 operator(),能整个内联进来:

template <class F> int apply3(F f) { return f(3); }
int by_template(int k) { return apply3([k](int y) { return k * y; }); }
_Z11by_templatei:                 # g++ -O2,x86-64
        leal    (%rdi,%rdi,2), %eax   # k * 3,lambda 已经内联没了
        ret

1.2 每个 lambda 都是一个新类型

两个一模一样的 lambda 也是两个不同的类:std::is_same_v<decltype(l1), decltype(l2)> 是 false。这个类型没有名字,写不出来,所以存 lambda 只能用 auto,或者让模板去推导。这也是 std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; }) 快的原因:sort 为这个比较规则单独实例化一份,比较直接内联进排序循环。

参数写成 auto 的 lambda(C++14 起)叫泛型 lambda,它的 operator() 是一个模板,每种实参类型实例化一份:

auto print = [](const auto& v) { std::cout << v << '\n'; };
print(1);        // operator()<int>
print("hi");     // operator()<char[3]>

不捕获任何东西的 lambda 没有成员,不依赖哪个对象,所以能隐式转成普通函数指针。这正好用在只收函数指针的 C 接口上,比如 qsort 的比较函数、pthread_create 的线程入口:

int (*fp)(int) = [](int v) { return v + 1; };   // 只有不捕获的 lambda 才能这样转

2. 捕获

2.1 按值捕获:创建时拷一份

[x] 在 lambda 被创建的那一刻把 x 拷进闭包对象,之后外面的 x 怎么变,闭包里的都不变:

int x = 10;
auto by_value = [x](int y) { return x * y; };
x = 20;
by_value(4);      // 40:用的还是创建时的 10

operator() 默认是 const 成员函数,所以函数体里不能改按值捕获的成员。要改,加 mutable。改的是闭包自己的那份,每个闭包对象各有一份状态:

auto counter = [n = 0]() mutable { return ++n; };
auto copy = counter;          // 拷贝的是整个闭包对象,n 也拷了一份
counter(); counter();
counter();                    // 3
copy();                       // 1:copy 有自己的 n

2.2 按引用捕获:存的是地址,活多久要你管

[&z] 只存 z 的地址,每次调用都去读 z 现在的值,所以看得到外面的修改。代价是生命周期:闭包只拿着一个地址,被捕获的变量没了,地址就悬空了。 最典型的是把它带出了作用域:

std::function<int()> make_counter() {
    int n = 0;
    return [&n] { return ++n; };   // 返回以后 n 已经没了,调用就是未定义行为
}

异步回调是同一个问题换了个样子:登记一个按引用捕获局部变量(或者捕获了 this)的回调,函数返回了,回调要过一会儿才执行,那时它引用的东西早已销毁。解决办法是让闭包自己持有它要的东西:按值捕获;对象由 shared_ptr 管的,捕获一份 shared_ptr,成员函数里用 shared_from_this() 拿(见 智能指针那页)。

2.3 [=] 和 [&]:捕获了什么,没捕获什么

[=]、[&] 不是把所有变量都拷一遍,而是函数体里用到哪个局部变量,就按值或按引用捕获哪个。有两类东西不在其中:

  • 全局变量和 static 变量不捕获。 它们一直在,不需要捕获,函数体里直接用。所以 [=] 里读全局变量 g,读到的是调用时的值:先创建闭包,再把 g 从 1 改成 7,闭包返回的是 7,不是创建时的 1。
  • 成员变量不单独捕获,捕获的是 this 指针。 成员函数里写 [=] { return count_; },count_ 其实是 this->count_,闭包里存的只是 this。看着像按值捕获,其实是通过指针读;对象销毁以后再调用,照样悬空。

所以 C++20 起,[=] 隐式捕获 this 被标为弃用,要捕获就明着写 [this];想要一份对象的副本,C++17 起写 [*this]。

2.4 初始化捕获:把对象搬进去

C++14 起可以在捕获列表里定义一个新成员并给它初值,最常用来把只能移动的对象搬进闭包:

auto up = std::make_unique<int>(42);
auto owner = [p = std::move(up)] { return *p; };   // 现在 p 归闭包所有,up 是空的

闭包有一个 unique_ptr 成员,它自己也就只能移动、不能拷贝(std::is_copy_constructible_v 是 false)。这一点在下一节会碰到。

3. std::function:能装下任何可调用的东西

3.1 类型擦除:把不同的类型装进同一个类型

每个 lambda 一个类型,可很多地方要的是一个统一的类型:一个回调表 std::vector<回调>,一个类的成员 回调 on_message_,它们不能是模板。std::function<int(int)> 就是这个统一的类型:任何能用 int 调用、返回 int 的东西(lambda、函数指针、函数对象、绑定了对象的成员函数)都能放进去。

它的做法叫类型擦除(type erasure):对象里不记“装的是什么类型”,只记两个函数指针,一个负责调用,一个负责拷贝和销毁。这两个函数是在赋值那一刻、按真实类型实例化出来的。libstdc++ 里一个 std::function 是 32 字节:

std::function<int(int)>:32 字节0162432小缓冲(16 字节)manager 指针invoker 指针小闭包:直接放在小缓冲里大闭包,或拷贝不平凡的闭包:放在堆上,小缓冲里只存它的指针拷贝、销毁闭包转去调闭包的 operator()调用 f(3):manager 为空就抛 bad_function_call,否则 call *24(%rdi) 跳到 invoker。

3.2 代价:可能分配,调用不能内联

第一笔是内存。闭包放不进 16 字节的小缓冲,赋值时就要 new 一块堆内存。libstdc++ 还多一个条件:闭包得能平凡拷贝(按字节拷就行),才放进小缓冲。把不同的 lambda 赋给 std::function,数一次 operator new:

捕获了什么闭包大小赋值时分配几次
一个 int4 B0
一个指针加一个 int16 B0
三个 long long24 B1:超过 16 字节
一个 shared_ptr16 B1:放得下,但拷贝它要改引用计数,不平凡

(GCC 16.2,libstdc++。别的标准库的缓冲大小和规则不一样,MSVC 和 libc++ 都更大一些。)

第二笔是调用。同一个 f(3),通过 std::function 调:

int by_function(const std::function<int(int)>& f) { return f(3); }
_Z11by_functionRKSt8functionIFiiEE:   # g++ -O2,x86-64
        subq    $24, %rsp
        movl    $3, 12(%rsp)          # 参数 3 放到栈上,按地址传给 invoker
        cmpq    $0, 16(%rdi)          # manager 为空 = 没装东西
        je      .L5                   # → 抛 bad_function_call
        leaq    12(%rsp), %rsi
        call    *24(%rdi)             # 间接调用 invoker
        addq    $24, %rsp
        ret

和 1.1 节模板版本的那一条 leal 比,多了判空、一次间接调用,参数还得经过内存。更重要的是编译器不知道 24(%rdi) 里是哪个函数,没法内联,也就没法跨这次调用做优化,和虚函数挡住内联是同一件事。函数体本身要跑很久时,这点差别看不出来;函数体只有一两条指令、又要调很多次时,差别就是好几倍。

3.3 只能装能拷贝的:move_only_function

std::function 自己能拷贝,拷贝时要把装着的闭包也拷一份,所以它要求闭包可拷贝。把 2.4 节那个持有 unique_ptr 的闭包塞进去,编译报错:

error: static assertion failed: std::function target must be copy-constructible

C++23 加了 std::move_only_function,它自己只能移动,也就不要求闭包可拷贝,装这种闭包正好。用 C++23 之前的标准,常见的绕法是改捕获 shared_ptr,代价是多一次引用计数。

3.4 怎么选

  • 调用的地方在编译期就知道是哪个函数(传给算法、传给一个立刻就用的函数):参数写成模板或 auto,lambda 照原样传进去,能内联。
  • 要存起来,或者要把不同的 lambda 放在一起(回调表、类成员、跨编译单元的接口):std::function;装的东西只能移动时用 std::move_only_function。
  • 对接只收函数指针的 C 接口:不捕获的 lambda 直接转成函数指针;要带状态,就靠接口另给的 void* 参数把状态传进去。

上面的数字和汇编,都来自本机 GCC 16.2(-std=c++23 -O2)编译、运行的结果;汇编是加 -mabi=sysv 生成的 Linux 调用约定版本。