std::function、std::invoke 与可调用对象
引言
笔者在写事件系统的时候遇到一个很实际的问题:需要存储各种类型的回调——普通函数、成员函数、lambda、仿函数,五花八门。函数指针只能指向静态函数和全局函数,没法携带上下文;直接用 auto 存 lambda 的话,每个 lambda 类型都不一样,没法放进同一个容器里。std::function 解决了这个问题——它用类型擦除把各种可调用对象统一成同一种类型。但类型擦除是有代价的,于是问题就变成了:这个代价到底有多大?有没有办法两全其美?
这一章我们从 std::function 的内部机制讲起,再到 std::invoke 这个"万能调用器",最后聊聊零开销回调的设计模式——在类型安全和性能之间找到平衡点。
学习目标
- 理解
std::function的类型擦除机制和 SBO- 掌握
std::invoke统一调用可调用对象的方式- 学会用模板 + lambda 设计零开销的回调系统
C++ 的可调用对象全家福
在讲具体机制之前,我们先梳理一下 C++ 里的"可调用对象"到底有哪些形态。所谓可调用对象,就是能用 () 语法(或者 std::invoke)来调用的东西。普通函数和函数指针是最基础的——直接调用或者通过指针间接调用。仿函数(functor)是重载了 operator() 的类对象。Lambda 是编译器生成的匿名仿函数。成员函数指针指向类的成员函数,调用时需要配合对象实例。此外还有 std::function 包装后的对象和 std::bind 的结果。
问题在于,这些可调用对象的调用语法各不相同——普通函数直接调,成员函数要用 obj.*ptr 或 obj->*ptr,仿函数和 lambda 像 f(args) 一样调。如果你要写一个泛型函数来"统一调用"这些东西,在 C++17 之前你得写一堆模板特化;有了 std::invoke,一个函数搞定。
std::function——类型擦除的函数容器
std::function 是 C++11 引入的通用函数包装器,定义在 <functional> 头文件中。它可以存储、复制和调用任何匹配签名要求的可调用对象。核心能力就一个:把不同类型的可调用对象统一成同一种类型。
#include <functional>
#include <iostream>
int add(int a, int b) { return a + b; }
struct Multiplier {
int factor;
int operator()(int x) const { return x * factor; }
};
void demo_std_function() {
std::function<int(int, int)> func;
// 存储普通函数
func = add;
std::cout << func(3, 4) << "\n"; // 7
// 存储 lambda
func = [](int a, int b) { return a * b; };
std::cout << func(3, 4) << "\n"; // 12
// 存储仿函数
func = Multiplier{5};
std::cout << func(10) << "\n"; // 编译错误:签名不匹配
// Multiplier 的 operator() 只接受一个参数,但 func 签名是 int(int,int)
}类型擦除机制
std::function 是怎么做到把不同类型的东西放进同一个壳子里的?答案是类型擦除(type erasure)。简化的原理是这样的:std::function 内部定义了一个抽象基类(Concept),持有纯虚函数 invoke;然后为每种具体的可调用类型生成一个派生类(Model),在派生类中实现 invoke。std::function 持有一个指向 Concept 的指针,调用时通过虚函数分派到具体实现。
我们可以用代码来模拟这个过程:
#include <memory>
#include <utility>
// 简化版 std::function 原理示意
template<typename Signature>
class SimpleFunction;
template<typename R, typename... Args>
class SimpleFunction<R(Args...)> {
// 抽象接口
struct ICallable {
virtual ~ICallable() = default;
virtual R invoke(Args... args) = 0;
virtual ICallable* clone() const = 0;
};
// 具体实现:模板化的派生类存储真正的可调用对象
template<typename T>
struct CallableImpl : ICallable {
T callable;
explicit CallableImpl(T c) : callable(std::move(c)) {}
R invoke(Args... args) override {
return callable(std::forward<Args>(args)...);
}
ICallable* clone() const override {
return new CallableImpl(callable);
}
};
ICallable* ptr_ = nullptr;
public:
SimpleFunction() = default;
template<typename T>
SimpleFunction(T callable)
: ptr_(new CallableImpl<std::decay_t<T>>(std::move(callable))) {}
SimpleFunction(const SimpleFunction& other)
: ptr_(other.ptr_ ? other.ptr_->clone() : nullptr) {}
~SimpleFunction() { delete ptr_; }
R operator()(Args... args) {
return ptr_->invoke(std::forward<Args>(args)...);
}
};从这段伪代码可以看到类型擦除的三个要素:一个统一的抽象接口(ICallable)、一个模板化的具体实现(CallableImpl<T>)、一个指向接口的指针(ptr_)。存储的时候类型信息被"擦除"了——外部看到的只是 ICallable*;调用的时候通过虚函数表恢复回来。
小对象优化(SBO)
上面的简化版有个明显的问题:每次构造都用 new 在堆上分配。对于捕获一两个 int 的小 lambda 来说,这个堆分配的代价可能比 lambda 本身还高。所以实际的 std::function 实现都使用了小对象优化(Small Buffer Optimization,也叫 SBO 或 SOO)——在 std::function 对象内部预留一块固定大小的缓冲区(通常是 16-32 字节),如果被包装的可调用对象足够小,就直接存放在这个缓冲区里,不需要堆分配。
#include <functional>
#include <iostream>
#include <array>
void demo_sbo_size() {
// 小 lambda:通常能放进 SBO 缓冲区
auto small = [x = 42]() { return x * 2; };
std::function<int()> f1 = small;
std::cout << "sizeof(std::function<int()>): "
<< sizeof(f1) << " bytes\n";
// 通常 32-64 字节(取决于实现)
// 大 lambda:超出 SBO 缓冲区,触发堆分配
auto large = [data = std::array<int, 100>{}]() {
return data.size();
};
std::function<std::size_t()> f2 = large;
std::cout << "sizeof(std::function<size_t()>): "
<< sizeof(f2) << " bytes\n";
// 同样大小,但内部有堆分配
// 对比:函数指针的大小
std::cout << "sizeof(void(*)()): "
<< sizeof(void(*)()) << " bytes\n";
// 通常 8 字节(64 位系统)
}我们实际测一下 libstdc++ 的 SBO 行为。在 GCC 15.2.1 上,std::function<int()> 的大小是 32 字节。但测试结果显示,即使是捕获单个 int 的 lambda(闭包对象仅 4 字节)不会触发堆分配,而捕获 5 个 int 或一个指针的 lambda 就会分配——说明 GCC 15.2 的 SBO 实现比较保守,可能需要额外的空间存储虚函数表指针和管理元数据。libc++(Clang)的实现可能不同,具体行为因版本而异。
验证代码:
code/volumn_codes/vol2/ch03-lambda/test_sbo_size.cpp(GCC 15.2.1,-O2)重要:不同编译器和版本的 SBO 行为差异很大。如果你的代码对性能敏感,建议用模板参数或手写类型擦除来获得可预测的行为。
函数指针——零开销但功能受限
在讲零开销替代方案之前,我们先回顾一下函数指针。函数指针是 C 时代传承下来的机制,直接指向代码地址,简单而高效。它的大小就是一个指针(64 位系统上 8 字节),调用就是一条 call 指令(call *%rax),没有额外的间接层。
性能实测:在我们的测试中(GCC 15.2.1,
-O2),函数指针调用比直接调用慢约 30%(1.29x)。这是因为直接调用可以被完全内联成计算指令,而函数指针仍然需要间接call。但在无优化的代码中,两者都需要call指令,差异会更小。验证代码:
code/volumn_codes/vol2/ch03-lambda/test_function_performance.cpp
// 函数指针的声明和赋值
int (*func_ptr)(int, int) = [](int a, int b) { return a + b; };
// 用 using 简化类型名
using BinaryOp = int(*)(int, int);
BinaryOp op = [](int a, int b) { return a + b; };
int result = op(3, 4); // 7函数指针最大的局限是无法携带上下文——它只能指向无捕获的 lambda(或者普通函数、静态成员函数)。任何有捕获的 lambda 都不能转换为函数指针。当你需要把 this 指针或者某些状态传给回调的时候,函数指针就无能为力了。
// 无捕获 lambda 可以转换为函数指针
int (*fp1)(int, int) = [](int a, int b) { return a + b; }; // OK
// 有捕获 lambda 不能转换
int x = 42;
int (*fp2)(int, int) = [x](int a, int b) { return a + b + x; }; // 编译错误| 特性 | 函数指针 | std::function |
|---|---|---|
| 大小 | 8 字节(64 位) | 32-64 字节 |
| 堆分配 | 无 | SBO 范围外触发 |
| 间接调用层数 | 1 层(直接 call) | 1 层(虚函数表间接) |
| 携带上下文 | 否 | 是 |
| 内联友好 | 是 | 较差(类型擦除阻碍) |
| 性能(相对直接调用) | ~1.3x | ~7-9x |
std::invoke——统一调用接口
C++17 引入的 std::invoke(定义在 <functional> 中)是一个"万能调用器"。不管你的可调用对象是什么类型——普通函数、成员函数指针、lambda、仿函数——std::invoke 都能用同一个语法调用。它实现了标准中定义的 INVOKE 表达式的语义:
#include <functional>
#include <iostream>
struct Widget {
void greet(const std::string& msg) {
std::cout << "Widget says: " << msg << "\n";
}
int data = 42;
};
void free_func(int x) {
std::cout << "free_func: " << x << "\n";
}
void demo_invoke() {
Widget w;
// 普通函数
std::invoke(free_func, 42);
// 仿函数 / lambda
std::invoke([](int x) { std::cout << "lambda: " << x << "\n"; }, 99);
// 成员函数指针 + 对象
std::invoke(&Widget::greet, w, "hello");
// 成员变量指针 + 对象(可以读取和修改)
int val = std::invoke(&Widget::data, w);
std::invoke(&Widget::data, w) = 100;
}看那个成员函数调用——传统写法是 (w.*(&Widget::greet))("hello") 或者 (wg.*mem_func)("hello"),这个语法笔者每次写都得查一下。用 std::invoke 只需要 std::invoke(mem_func, obj, args...),简洁得多。
invoke 的底层原理
std::invoke 的实现原理不复杂,核心就是编译期的类型判断和分派。对于普通可调用对象(函数指针、lambda、仿函数),它直接用 f(args...) 调用;对于成员函数指针,它根据对象的类别(指针、引用、reference_wrapper)选择合适的调用语法;对于成员变量指针,它返回对应的成员引用。所有这些判断都在编译期完成,运行时零开销。
std::invoke_result_t
C++17 还配套提供了 std::invoke_result_t,可以在编译期获取 std::invoke 调用的返回类型。在写泛型代码的时候,这个工具非常实用:
#include <type_traits>
#include <functional>
template<typename Func, typename... Args>
auto safe_call(Func&& func, Args&&... args)
-> std::invoke_result_t<Func, Args...>
{
using Ret = std::invoke_result_t<Func, Args...>;
if constexpr (std::is_void_v<Ret>) {
std::invoke(std::forward<Func>(func), std::forward<Args>(args)...);
std::cout << "(void return)\n";
} else {
Ret result = std::invoke(std::forward<Func>(func),
std::forward<Args>(args)...);
std::cout << "result: " << result << "\n";
return result;
}
}invoke 的性能
在模板代码中使用 std::invoke,编译器能看到完整的调用链,会把它内联到和直接调用一样的程度。我们实测了一下:在 -O2 优化下,std::invoke 调用与直接调用的性能完全相同(误差范围内,甚至可能稍快因为测试误差)。这是因为 std::invoke 本身只是一层薄薄的编译期分派包装,在优化后被完全内联消除了。
验证代码:
code/volumn_codes/vol2/ch03-lambda/test_invoke_performance.cpp汇编验证:生成汇编(
g++ -O2 -S)可以看到,直接调用、std::invoke、函数指针、lambda 都被编译成完全相同的代码——直接计算结果并返回,没有call指令。
当然,如果你通过 std::function 存储的可调用对象来调用,间接开销来自 std::function 的类型擦除,不是 std::invoke 造成的。
零开销回调设计——模板 + lambda
理解了 std::function 的开销来源(类型擦除、可能的堆分配、间接调用)之后,问题就变成了:在很多场景下,回调的类型在注册时就已经确定了,我们是不是可以不用类型擦除?
答案是肯定的。最简单的零开销方案就是直接用模板参数传递 lambda——编译器知道完整的闭包类型,调用完全内联:
#include <algorithm>
#include <vector>
#include <iostream>
// 模板参数接收任意可调用对象,零开销
template<typename Callback>
void for_each_if(std::vector<int>& data, Callback pred, Callback action) {
for (auto& elem : data) {
if (pred(elem)) {
action(elem);
}
}
}
void demo_template_callback() {
std::vector<int> data = {1, 2, 3, 4, 5, 6, 7, 8};
int threshold = 5;
int sum = 0;
// lambda 直接传给模板参数,完全内联
for_each_if(data,
[threshold](int x) { return x > threshold; }, // 谓词
[&sum](int& x) { sum += x; } // 操作
);
std::cout << "Sum of elements > " << threshold << ": " << sum << "\n";
// 输出: Sum of elements > 5: 21 (6+7+8)
}这个方案的问题是:每个不同的 lambda 类型会实例化不同的模板函数,你不能把不同类型的回调放进同一个容器。如果你的设计确实需要运行时多态(比如事件队列里存各种类型的回调),那就必须引入某种形式的类型擦除。
手动类型擦除:函数指针表替代虚函数
如果你需要类型擦除但又想避免 std::function 的全部开销,可以手写一个轻量级的类型擦除容器。核心思路是用函数指针表替代虚函数表,用固定大小的栈上缓冲区替代堆分配:
#include <cstddef>
#include <utility>
#include <iostream>
#include <new>
template<typename Signature, std::size_t BufSize = 32>
class LightCallback;
template<typename R, typename... Args, std::size_t BufSize>
class LightCallback<R(Args...), BufSize> {
// 操作表:用函数指针代替虚函数
struct VTable {
void (*move)(void* dst, void* src);
void (*destroy)(void* obj);
R (*invoke)(void* obj, Args... args);
};
// 为每种可调用类型生成专属的 VTable
template<typename T>
struct VTableFor {
static void do_move(void* dst, void* src) {
new(dst) T(std::move(*static_cast<T*>(src)));
}
static void do_destroy(void* obj) {
static_cast<T*>(obj)->~T();
}
static R do_invoke(void* obj, Args... args) {
return (*static_cast<T*>(obj))(std::forward<Args>(args)...);
}
static constexpr VTable value{do_move, do_destroy, do_invoke};
};
alignas(std::max_align_t) unsigned char storage_[BufSize];
const VTable* vtable_ = nullptr;
public:
LightCallback() = default;
template<typename T>
LightCallback(T&& callable) {
using Decay = std::decay_t<T>;
static_assert(sizeof(Decay) <= BufSize, "Callable too large for buffer");
static_assert(alignof(Decay) <= alignof(std::max_align_t),
"Callable alignment too high");
new(storage_) Decay(std::forward<T>(callable));
vtable_ = &VTableFor<Decay>::value;
}
LightCallback(LightCallback&& other) noexcept : vtable_(other.vtable_) {
if (vtable_) {
vtable_->move(storage_, other.storage_);
other.vtable_ = nullptr;
}
}
~LightCallback() {
if (vtable_) vtable_->destroy(storage_);
}
LightCallback(const LightCallback&) = delete;
LightCallback& operator=(const LightCallback&) = delete;
R operator()(Args... args) {
return vtable_->invoke(storage_, std::forward<Args>(args)...);
}
explicit operator bool() const { return vtable_ != nullptr; }
};
void demo_light_callback() {
int multiplier = 3;
LightCallback<int(int), 32> cb = [multiplier](int x) {
return x * multiplier;
};
std::cout << cb(14) << "\n"; // 42
}这个 LightCallback 没有 std::function 那么通用(不支持拷贝、不支持分配器),但它满足了最常见的使用场景:存储带捕获的 lambda,无堆分配,单层间接调用。在嵌入式或高性能场景中,这种"够用就好"的设计通常是最务实的选择。
选择指南
总结一下回调存储方案的权衡。函数指针适合不需要上下文的场景,零开销但只能指向无捕获的 lambda 或普通函数。std::function 适合需要运行时多态的场景,通用但有显著的性能开销——即使对象在 SBO 范围内,虚函数表间接调用也会阻碍内联,实测比直接调用慢 7-9 倍。模板参数适合编译期类型已知的场景,完全零开销但不能存入容器。手写类型擦除适合需要运行时多态且对性能有要求的场景,代码量稍多但可控。
性能数据来源:
code/volumn_codes/vol2/ch03-lambda/test_function_performance.cpp(GCC 15.2.1,-O2,1 亿次调用)
// 1. 无上下文、热路径:函数指针
void fast_path(int (*cb)(int)) { cb(42); }
// 2. 有上下文、通用场景:std::function
void generic_path(std::function<void(int)> cb) { cb(42); }
// 3. 编译期类型已知:模板参数
template<typename CB>
void zero_cost_path(CB&& cb) { cb(42); }
// 4. 有上下文、高性能:手动类型擦除
void optimized_path(LightCallback<void(int), 24> cb) { cb(42); }小结
这一章我们把 C++ 中可调用对象的存储和调用机制串了起来:
std::function用类型擦除统一了各种可调用对象的类型,SBO 避免了小对象的堆分配- 函数指针零开销但无法携带上下文,适合无状态回调
std::invoke是可调用对象的统一调用接口,在模板代码中零开销- 零开销回调的核心思路是"能用模板就不用类型擦除,必须类型擦除时用函数指针表替代虚函数"
- 根据场景在通用性和性能之间选择合适的方案