std::function 与 copyable_function 的互转换
摘要
对 C++26 中 std::function 和 std::copyable_function 之间互转换的技术分析,讨论了实现方法和性能权衡,并特别指出 libstdc++ 目前采用了一种较次优的方法。
<p><a href="https://lobste.rs/s/lhnggy/interconverting_std_function_with">评论</a></p>
查看缓存全文
缓存时间: 2026/07/27 07:44
# 在 std::function 与 copyable_function 之间相互转换
> 来源:https://quuxplusone.github.io/blog/2026/07/26/function-explosion/
C++11 引入了 `std::function`,作为一个类型擦除(https://quuxplusone.github.io/blog/2019/03/18/what-is-type-erasure/)的可调用对象持有器。但 `function` 的设计存在几个问题:它很少使用的“go fish”API(https://quuxplusone.github.io/blog/2019/03/27/design-space-for-std-function/#type-un-erasure)让每个用户都变得臃肿;它宣称 `operator() const` 可用,即使所控对象的 `operator()` 是可变的;它通过抛异常来处理本应是前置条件违反的情况,这同样让每个用户变得臃肿,并且意味着它的 `operator()` 永远不能是 noexcept。因此 C++26 通过添加 `std::copyable_function`(https://en.cppreference.com/cpp/utility/functional/copyable_function)修复了这些问题。(不过它保留了 `function` 部分次优的设计决策:`copyable_function` 仍然可以隐式转换为 `copyable_function`,可以上下文转换为 `bool`,并且可以从 `nullptr` 赋值。)
P2548 “`copyable_function`”(https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/p2548r6.pdf)(Hava 2023)为实现者提供了如下指导:
> 建议实现者在从 `copyable_function` 实例转换到兼容的 `move_only_function` 实例时,不要执行额外的内存分配,但这属于实现质量的问题。请注意,反向转换——从 `move_only_function` 到 `copyable_function`——是不允许的:`copyable_function` 只能持有可拷贝的可调用对象,而 `move_only_function` 是不可拷贝的。
但是 `copyable_function` 与 `std::function` 是可以相互转换的——双向都可以!这两种类型擦除的包装器都可以从任何可拷贝且可调用的对象构造,并且它们 *本身* 是可拷贝且可调用的。所以我们可以这样做:
```cpp
std::function f = []{ return 5; };
std::copyable_function cf = std::move(f);
std::function g = std::move(cf);
```
你希望从 `f` 移动到 `cf` 的实现大致如下:
(示意图)
反向移动回 `g` 也是如此:
(示意图)
但如果 `copyable_function` 不了解 `function`,它可能只会把 `f` 当作任意一个可拷贝、可调用类型的右值:它可能会将 `f` 的一份拷贝移动到堆上并自己持有。
(示意图)
反向移动回 `g` 也是如此:`function` 可能只会把 `cf` 当作任意一个可拷贝、可调用类型的右值,并将 `cf` 的一份拷贝移动到堆上并自己持有。
(示意图)
这种移动操作仍然相对“高性能”——每次构造只做 O(1) 的工作——但在经过 \(n\) 次这样的构造后,会产生一个长度为 \(n\) 的“链表”式可调用对象链。每次我们调用 `g` 时,`g` 的 `operator()` 会调用其控制的 `copyable_function` 的 `operator()`,后者又调用其控制的 `function` 的 `operator()`,再调用其控制的 lambda(图中绿色对象)的 `operator()`。
---
截至本文写作时(2026 年 7 月),libstdc++ 是三大 STL 厂商中唯一实现了 C++26 `copyable_function` 的;而截至目前,他们实现了上面图示的“糟糕”行为。考虑以下虚构场景:我们在 `std::function` 中存储某种“处理回调”。我们会周期性地考虑是否根据用户输入修改该处理函数,例如:
```cpp
using Handler = std::function<bool(char)>;
Handler update(Handler h, bool negate) {
if (negate) {
h = [f = std::move(h)](char c) { return !f(c); };
}
return h;
}
~~~~
Handler h = original_handler;
while (~~~~) {
~~~~
h = update(std::move(h), (input == '!'));
}
```
注意,`h` 被移入 `update`,然后又移出(得益于隐式移动(https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p2266r3.html#wording))。这使用了 `std::function` 的移动构造函数,和大多数移动构造函数一样,快速且不分配内存。
但假设我们的一位同事将部分代码更新到 C++26,并且我们可能更合理地倾向于使用 `std::copyable_function` 代替 `std::function`。[\[1\]](https://quuxplusone.github.io/blog/2026/07/26/function-explosion/#footnote-should-you-abandon-func) 如果同事将整个代码库一致地改为使用 `copyable_function`,那就没问题。但如果他们只更新了自己的部分,导致他们基于 `copyable_function` 的新代码必须与其他人基于旧 `function` 的代码互操作呢?那么就会出现类似这样的情况:
```cpp
using OldHandler = std::function<bool(char)>;
using NewHandler = std::copyable_function<bool(char)>;
OldHandler update(OldHandler h, bool negate); // 未改动,为了兼容 C++26 之前的调用者
~~~~
NewHandler h = original_handler;
while (~~~~) {
~~~~
h = update(std::move(h), (input == '!'));
}
```
这会触发那个“链表”场景,`h` 的调用运算符随着每次通过 `update` 的往返而变得越来越慢——即使 `negate` 为 `false` 也是如此!
我编写了这个小型基准测试(Godbolt(https://godbolt.org/z/zG9WW6bdK)):
```cpp
#include <chrono>
#include <functional>
#include <cstdio>
size_t now() {
static const auto epoch = std::chrono::high_resolution_clock::now();
auto tp = std::chrono::high_resolution_clock::now();
return std::chrono::duration_cast<std::chrono::microseconds>(tp - epoch).count();
}
void print_elapsed_time(int i, size_t call_started) {
printf("Invocation on the %dth iteration took %zu us\n", i, now() - call_started);
}
using OldHandler = std::function<void(int, size_t)>;
using NewHandler = std::copyable_function<void(int, size_t)>;
OldHandler update(OldHandler h, bool negate) {
if (negate) { /* 这里做一些操作,但本示例中我们不关心具体内容 */ }
return h;
}
int main() {
NewHandler handler = print_elapsed_time;
for (int i = 0; i < 160'000; ++i) {
handler = update(std::move(handler), false);
if (i % 10'000 == 0) {
handler(i, now());
}
}
}
```
在 libstdc++(截至 GCC 16)上运行此程序,你会看到类似如下的输出:
```
Invocation on the 0th iteration took 0 us
Invocation on the 10000th iteration took 180 us
Invocation on the 20000th iteration took 296 us
Invocation on the 30000th iteration took 365 us
[...]
Invocation on the 130000th iteration took 1057 us
Invocation on the 140000th iteration took 1108 us
Invocation on the 150000th iteration took 1202 us
```
每次我们调用 `handler(i, now())` 时,挂在 `handler` 上的“可调用对象链表”都比之前长了 10000 个元素,追踪这些指针所需的时间也多了大约 100 微秒。当然,它还持有了比实际需要多出许多千字节的内存。这是一种“逻辑内存泄漏”:虽然这些内存在技术上仍然可访问,并会在 `h` 的析构函数中被释放,但只要 `h` 存在,我们就会看到 RAM 使用量随时间不断攀升,而程序的“逻辑”状态却没有任何变化。
---
截至 2026 年 7 月,Microsoft STL 使得 `move_only_function` 和 `function` 能够互操作而不会出现这种“逻辑内存泄漏”,我敢打赌他们的 `copyable_function`(一旦实现)也会避免这个问题。libstdc++ 的实现未来可能会改进。与此同时,libc++ 尚未实现 `move_only_function` 或 `copyable_function`,所以他们仍有充足的时间思考这个问题。
---
顺便注意,在编写你自己的类型擦除类型时,要警惕这个陷阱的一个更简单的版本。确保当你复制一个持有 `int` 的 `Printable` 时,得到的是另一个持有 `int` 的 `Printable`,而不是一个持有 `Printable`(而该 `Printable` 又持有 `int`)的 `Printable`。编写单元测试以确保从 `Printable&`、从 `const Printable&`、从 `Printable&&` 以及从 `const Printable&&` 构造时仍然成立。如果像 STL 一样,你有多个需要良好协作的类型擦除类型,解决这个问题会变得更加痛苦。如果可以,尽量避免陷入那种情况;但如果不得不如此,要警惕这个陷阱。
---
脚注:*是否应该*在 C++26 中用 `copyable_function` 取代 `function`?有些人会说是。我个人认为你应该同时抛弃两者,而自己编写类型擦除的可调用对象。用不了 100 行代码!在完全全新的 C++26 代码中,当然,我会推荐使用 `copyable_function` 而不是 `function`(一旦你的供应商实现了它)。我 *不* 推荐在现有代码库中混合使用这两种类型;我会选择一种并坚持使用,同时努力推进,直到能够一次性更新整个代码库。
相似文章
C++26:更多函数包装器
C++26 引入了两个新的函数包装器:std::copyable_function(提供了可复制且 const 正确的 std::function 替代品)和 std::function_ref(一个非拥有、可调用的引用,具有引用语义)。
使用SIMD加速std::copy_if
一篇博文,分析和实现了在AMD Zen 4上使用AVX-512指令的SIMD加速版本的std::copy_if,并进行了性能分析和与编译器自动向量化的对比。
C++ 编译器何时可以反虚拟化调用?
探讨 C++ 编译器何时可以对虚函数调用进行去虚拟化,涵盖已知动态类型和 final 关键字等情况,并在 GCC、Clang、MSVC 和 ICC 之间进行比较。
const_cast:必要之恶
本文解释了为什么在C++中有时必须使用const_cast,特别是从std::priority_queue中移动对象时,以及如何安全地使用它。
Fil-C 优化调用约定
Fil-C 优化调用约定确保 C 程序即使在恶意滥用情况下也能保持内存安全性,同时通过在常见情况下省略安全检查来保持效率。它解释了通过 panic 或定义明确的行为来处理类型违规的通用优化和寄存器传递优化。