std::function 与 copyable_function 的互转换

Lobsters Hottest 工具

摘要

对 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:更多函数包装器

Lobsters Hottest

C++26 引入了两个新的函数包装器:std::copyable_function(提供了可复制且 const 正确的 std::function 替代品)和 std::function_ref(一个非拥有、可调用的引用,具有引用语义)。

使用SIMD加速std::copy_if

Lobsters Hottest

一篇博文,分析和实现了在AMD Zen 4上使用AVX-512指令的SIMD加速版本的std::copy_if,并进行了性能分析和与编译器自动向量化的对比。

C++ 编译器何时可以反虚拟化调用?

Hacker News Top

探讨 C++ 编译器何时可以对虚函数调用进行去虚拟化,涵盖已知动态类型和 final 关键字等情况,并在 GCC、Clang、MSVC 和 ICC 之间进行比较。

const_cast:必要之恶

Lobsters Hottest

本文解释了为什么在C++中有时必须使用const_cast,特别是从std::priority_queue中移动对象时,以及如何安全地使用它。

Fil-C 优化调用约定

Hacker News Top

Fil-C 优化调用约定确保 C 程序即使在恶意滥用情况下也能保持内存安全性,同时通过在常见情况下省略安全检查来保持效率。它解释了通过 panic 或定义明确的行为来处理类型违规的通用优化和寄存器传递优化。