枚举转字符串的开销:C++26 反射与旧方法对比

Hacker News Top 论文

摘要

本文使用 GCC 16 基准测试了 C++26 反射在枚举转字符串转换中的编译时开销,并将其与 C++17 库和 X 宏预处理器技术进行了对比。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/05/13 12:16

# 枚举转字符串的代价:C++26 反射 vs 传统方式 来源:https://vittorioromeo.com/index/blog/refl_enum_to_string.html 两个月前,我发布了**“C++26 反射隐藏的编译时成本”**(https://vittorioromeo.com/index/blog/refl_compiletime.html),其中我测量了在每个翻译单元中包含 `<std.reflect>` 并进行一些基本反射操作的实际**成本**。如果你还没读过那篇文章,请从那里开始——这篇文章直接建立在它的基础之上。 那篇文章使用的是 GCC 16 的**预发布**快照。此后,**GCC 16 已正式发布** [1](#fn1),并且现在广泛可用,这似乎是一个重新探讨该主题的好借口,这次使用一个更现实的例子:**枚举到字符串的转换**。 枚举到字符串是反射的*“hello world”*——但在实际项目中它确实很有用,例如用于日志记录、序列化、调试等。如果你在实际代码库中采用反射,这可能是你写的第一件事。 那么:**与替代方案相比,基于反射的枚举到字符串转换在编译时间上的实际成本是多少?** ### 我基准测试的三种方法 我基准测试了同一操作的三种实现:给定一个枚举值,返回一个包含其枚举器名称的 `std::string_view`。 #### 1. 反射 (C++26) 无需宏,无需样板代码,适用于任何枚举: ```cpp #include <std.reflect> #include <string_view> #include <string> template <std::enum T> requires std::is_enum_v<T> constexpr std::string_view to_enum_string(T val) { for (constexpr auto e : std::define_static_array(std::meta::enumerators_of(^^T))) { if (val == [:e:]) return std::meta::identifier_of(e); } return ""; } ``` 这段代码取自 Murat Hepeyiler (https://www.murathepeyiler.com/) 撰写的“What the heck is Reflection?” (https://www.murathepeyiler.com/what-the-heck-is-reflection/)。我认为这是一个相当典型的例子。 #### 2. `enchantum` (https://github.com/ZXShady/enchantum) (C++17) 由 ZXShady (https://github.com/ZXShady) 开发的 C++17 仅头文件库,通过 `__PRETTY_FUNCTION__` 解析技巧实现枚举反射。调用站点无需宏,也无需反射标志: ```cpp #include <enchantum/enchantum.hpp> enum class E { V0, V1, V2, V3 }; std::string_view s = enchantum::to_string(E::V0); ``` #### 3. x-macro (预处理器) C 风格的解决方案。你只需列出一次枚举器,单个宏展开为**两者**:`enum class` 定义和一个使用 `switch` 的 `to_string` 函数: ```cpp #include "xmacro_enum.hpp" #define E_LIST(X) \ X(V0) \ X(V1) \ X(V2) \ X(V3) DEFINE_ENUM(E, E_LIST) // Generates: // enum class E { V0, V1, V2, V3 }; // constexpr std::string_view to_string(E e) { ... } ``` `DEFINE_ENUM` 的实现只有几行预处理器胶水代码: ```cpp #include <string_view> #define XMACRO_VALUE_(name) name, #define XMACRO_CASE_(name) \ case EnumType_::name: return #name; #define DEFINE_ENUM(x_enum_name, x_list) \ enum class x_enum_name { x_list(XMACRO_VALUE_) }; \ constexpr std::string_view to_string(x_enum_name e) \ { \ using EnumType_ = x_enum_name; \ switch (e) { x_list(XMACRO_CASE_) } \ return ""; \ } ``` 它引用的唯一头文件是 `<string_view>`。我们还将测试这种方法的变体,它返回 `const char*` 而不是 `std::string_view`,且**零**标准库包含。 ### 基准测试 对于每种方法,我创建了几个翻译单元,它们: 1. 定义一个带有 **N** 个枚举器(`V0`, `V1`, ..., `V(N-1)`)的单个 `enum class E`; 2. 包含枚举到字符串的头文件; 3. 使用运行时值调用转换函数一次以强制实例化。 我将 **N** 变化为 `4`、`16`、`64`、`256` 和 `1024`,以查看成本如何随枚举大小缩放。 例如,`N=4` 的反射变体如下所示: ```cpp #include "enum_to_string.hpp" enum class E { V0, V1, V2, V3 }; volatile int runtime_idx = 0; int main() { const E val = static_cast<E>(runtime_idx); const auto sv = to_enum_string(val); return static_cast<int>(sv.size()); } ``` `enchantum` 和 X-macro 变体在结构上是相同的——只有头文件和函数调用不同。 我还单独测量了仅**包含**每个头文件的成本,以将头文件税与反射工作本身隔离开来。 > ⚠️ **关于 `enchantum` 默认范围的注意事项:** `enchantum` 在可配置的范围(默认 `[-256, 256]`)内扫描枚举值。对于 `N=1024`,我必须将 `ENCHANTUM_MAX_RANGE` 提升到 `1024`,这也会减慢同一翻译单元中所有其他枚举的速度。阅读 `N=1024` 行时请记住这一点。 所有基准测试文件均可在 GitHub 上找到 (https://github.com/vittorioromeo/vittorioromeo.com/tree/master/extra/reflection_enum_to_string_benchmarks)。 ### 基准测试设置 与上一篇文章 (https://vittorioromeo.com/index/blog/refl_compiletime.html) 相同——Fedora 44 Docker 容器中的 `hyperfine`,运行在 13th Gen i9-13900K 上。这次有两个不同之处: - **编译器:** `gcc 16.1.1 20260501` (Red Hat 16.1.1-1, release build)——官方发布的 GCC 16,不是预发布快照。 - **噪音控制:** 容器使用 `--cpuset-cpus=0-7` 运行,主机设置为 `performance` CPU 调节器,编译器进程通过 `taskset -c 0` 绑定到 P-core。`hyperfine` 使用 `--warmup 5 --min-runs 20` 运行。 通常的免责声明:测量并不严格严谨,我的硬件很强壮(YMMV),且单翻译单元的数字低估了项目级成本。此外,这些测量特定于 GCC 16 当前的反射和模块实现;其他编译器可能表现出非常不同的行为。 ### 基准测试结果 #### 每个翻译单元的总编译时间 | N | X-macro (`const char*`) | X-macro (`string_view`) | `enchantum` | 反射 | 基线 (`int main()`) | | :--- | :--- | :--- | :--- | :--- | :--- | | | 25.8 ms | 25.7 ms | 25.8 ms | 25.7 ms | | | **仅头文件包含** | **25.7 ms** | 136.0 ms | 147.1 ms | 180.8 ms | | | **4** | **26.6 ms** | 137.6 ms | 170.6 ms | 186.7 ms | | | **16** | **26.9 ms** | 138.1 ms | 170.9 ms | 187.7 ms | | | **64** | **28.0 ms** | 141.2 ms | 172.8 ms | 191.1 ms | | | **256** | **32.5 ms** | 153.0 ms | 184.1 ms | 215.0 ms | | | **1024** | **54.7 ms** | 204.5 ms | 272.0 ms [2](#fn2) | 255.0 ms | | #### 仅算法成本(翻译单元时间减去仅包含时间) 这近似于头文件包含之外的**额外**反射工作: | N | X-macro (`const char*`) | X-macro (`string_view`) | `enchantum` | 反射 | | :--- | :--- | :--- | :--- | :--- | | **4** | **0.9 ms** | 1.6 ms | 23.5 ms | 5.9 ms | | **16** | **1.2 ms** | 2.1 ms | 23.8 ms | 6.9 ms | | **64** | **2.3 ms** | 5.2 ms | 25.7 ms | 10.3 ms | | **256** | **6.8 ms** | 17.0 ms | 37.0 ms | 34.2 ms | | **1024** | **29.0 ms** | 68.5 ms | 124.9 ms | 74.2 ms | #### 每个枚举器的缩放 | 方法 | 每枚举器 ms | | :--- | :--- | | **X-macro (`const char*`)** | **~0.027** | | X-macro (`string_view`) | ~0.06 | | 反射 | ~0.07 | | `enchantum` | O(扫描范围),非 O(N) | `enchantum` 不随实际枚举大小缩放——它随**配置的扫描范围**缩放,因为它必须探测该范围内的每个可能值。这就是为什么 `N=4` 枚举的成本几乎与 `N=64` 相同。 #### 带有 PCH 和模块的反射 由于反射变体需要为 `<std.reflect>` 支付约 155 ms 的头文件税,显而易见的问题是:预编译该头文件或切换到 C++20 模块能否消除它? 我用另外两个配置重新运行了反射基准测试: - **PCH**:预编译一次 `<std.reflect>`、`<string_view>`、`<string>`,然后使用 `-include pch.hpp` 编译翻译单元。 - **模块**:通过 GCC 16 的新 `--compile-std-module` 标志一次性预构建 `std` 和 `std.compat` 模块以及 `<std.reflect>` 头单元(此成本**未**包含在测量中),然后使用 `-fmodules` 编译翻译单元,使得 `#include <std.reflect>` 透明地翻译 (https://gcc.gnu.org/gcc-16/changes.html#cxx) 为 `import <std.reflect>`。 | N | 反射 (普通 `#include`) | **反射 + PCH** | 反射 + 模块 | | :--- | :--- | :--- | :--- | | **仅头文件包含** | 180.8 ms | **73.8 ms** | 397.4 ms | | **4** | 186.7 ms | **80.6 ms** | 403.4 ms | | **16** | 187.7 ms | **81.0 ms** | 403.1 ms | | **64** | 191.1 ms | **84.4 ms** | 409.4 ms | | **256** | 215.0 ms | **97.5 ms** | 423.2 ms | | **1024** | 255.0 ms | **147.9 ms** | 482.5 ms | PCH 是明显的赢家——在每个枚举大小上都有约 **2.3 倍的速度提升**,将 `N=4` 从 187 ms 降至 81 ms。有了 PCH,反射在速度上击败了 `enchantum` 和 `string_view` X-macro 变体。 模块则相反:约 **2.2 倍的减慢**。我验证了 std 模块工件确实被缓存了*(多次运行间 `mtime` 不变)*,并且 GCC 正在加载它们*(通过 `-flang-info-module-cmi` 和 `-ftime-report` 确认,其中约 190 ms 归因于 `module import`,另外约 190 ms 归因于模块触发的模板实例化工作)*。 [3](#fn3) 显式 `import std;` 的表现与透明翻译基本相同,因为 GCC 的 `std` 模块目前实现为 `<std.compat>` 头单元的薄包装器——两条路线最终加载相同的约 34 MB 工件。 ### 见解 1. **成本在于头文件,而不在于反射。** 反射*算法*很快——渐近线为**每枚举器 ~0.07 ms**,基本上与 X-macro 版本中手工编写的 `switch`(~0.06 ms)相同。使反射看起来昂贵的是 `<std.reflect>`:仅包含它就比基线贵**~155 ms/翻译单元**。 2. **带有 `const char*` 的 X-macro 是测试过的最快方法。** 没有标准库头文件,`N=4` 枚举在 **26.6 ms** 内编译——在基线的噪音范围内。甚至 `N=1024`(54.7 ms)也*比没有反射工作的纯 `#include <string_view>` 更快*。我们称为“慢速 C++ 编译”的大部分内容实际上是*标准库*编译慢。 3. **`enchantum` 在非平凡方法中具有最小的包含成本** [4](#fn4)(~147 ms vs 反射的 ~181 ms),但每次调用的工作最重(即使对于小型枚举也要 ~24 ms,因为它总是扫描完整的配置范围,无论你实际有多少个枚举器)。这就是为什么它在小型枚举中获胜而在大型枚举中失败。 4. **反射具有最佳的易用性,但头文件税最高。** 它适用于*任何*枚举——稀疏、作用域、非作用域——在声明站点无需特殊设置。但是,每个触及 `<std.reflect>` 的翻译单元在任何反射发生之前都要支付 ~155 ms。 5. **PCH 缩小了差距,模块扩大了它。** 预编译 `<std.reflect>` 将反射编译时间缩短了约 2.3 倍,使其成为三种方法中最快的。GCC 16 中的 C++20 模块,令人惊讶地,走向了另一个方向——比普通包含路径*慢*约 2.2 倍。 ### 这意味着什么 单翻译单元的数字看起来很小。但在大规模下,它们并不小。大型 C++ 代码库很容易有几百个拉取枚举到字符串头文件的翻译单元,可能是间接的。 选取**500 个翻译单元**作为整数,并选取 `N=16` 枚举作为典型大小: | 方法 | 每翻译单元成本 | 项目级成本 (500 TUs) | | :--- | :--- | :--- | | X-macro (`const char*`) | 26.9 ms | **~13 秒** | | X-macro (`string_view`) | 138.1 ms | **~69 秒** | | `enchantum` | 170.9 ms | **~85 秒** | | 反射 | 187.7 ms | **~94 秒** | 每个翻译单元几百毫秒在项目层面上变成了**超过一分钟**的编译时间。这是小于 15 秒的干净构建与一分半钟之间的区别。增量构建并不总是能拯救你,因为每个包含受影响头文件的翻译单元都要支付全额价格。 这乘以你项目中具有类似开销的每个头文件。真实的代码库没有*一个*重型头文件——它们有几十个。你在微基准测试中看到的几百毫秒一旦乘起来就变成了*几分钟*。 另一方面,这里显示的数字没有考虑并行性。例如,~94 CPU 秒在假设完美并行性的 16 核机器上约为 ~6 秒。 ### 该怎么办 如果你正在大型代码库中采用基于反射的枚举到字符串: 1. **对 `<std.reflect>` 使用 PCH——而不是模块。** 如上所示,PCH 将头文件成本降低了约 2.3 倍,并使反射成为三种方法中最快的。GCC 16 中的 C++20 模块目前做*相反*的事情(它们使事情变慢约 2.2 倍),所以这是少数旧机制是正确答案的情况之一。 2. **不要从其他头文件中包含枚举到字符串头文件。** 尽可能将其推入包含图的深处——理想情况下,仅进入实际需要它的 `.cpp` 文件。每个传递包含都会乘以成本。 3. **对于编译时间敏感代码,X-macros 仍然是正确的答案。** 它们看起来丑陋,它们不组合,它们强制你在宏中列出枚举器。但是**每翻译单元 27 ms** 是难以击败的。对于像我分叉的 SFML (https://vittorioromeo.com/index/blog/vrsfml.html) 这样的项目,其中整个代码库在 ~4 秒内重新编译,任何包含 `<std.reflect>` 的东西都是不可接受的。 4. **对于库作者来说,在通过公共头文件暴露反射之前要三思。** 一个将 `<std.reflect>` 拖入每个消费者翻译单元的库头文件是我个人不希望依赖的东西。在 `<std.reflect>` 变轻或模块变得普及之前,`enchantum` 或 X-macro 是更友好的选择。 ### 结论 此基准测试证实了原始文章所主张的内容,现在有了人们实际会编写的操作的具体数字: > C++26 反射的成本不在于反射本身。它在于 `<std.reflect>`。 > > 反射算法本身似乎相当快。基于反射的 `to_string` 最终比裸机 `const char*` X-macro 变体编译慢约 7 倍的原因几乎完全在于 `<std.reflect>`(及其传递包含的头文件)。 我仍然对反射感到兴奋。它将取代许多丑陋的宏样板代码,并解锁以前真正不可能的库。但是在 `<std.reflect>` 变轻*(或者直到模块变得快得多)*之前,每个采用它的项目都应该知道账单的样子。 ### 无耻的自我推广 - 我提供培训、导师和咨询服务。如果你感兴趣,请查看 **romeo.training** (https://romeo.training/),或者你可以通过 `mail (at) vittorioromeo (dot) com` 或 Twitter (https://twitter.com/supahvee1234) 联系我。 - 查看我在 Steam 上由 VRSFML 支持的游戏! - **BubbleByte** (https://store.steampowered.com/app/3499760/BubbleByte/):一款点击器-放置游戏,你在其中招募猫来戳破气泡。就是这样。或者不...? - **Open Hexagon** (https://store.steampowered.com/app/1358090/Open_Hexagon/):一款快节奏的开源街机游戏,具有用户创建的内容——Terry Cavanagh 备受好评的 Super Hexagon 的事实上的社区驱动精神续作。 - 我的书**“Embracing Modern C++ Safely”**可从所有主要零售商处获得 (http://emcpps.com/)。 - 欲了解更多信息,请阅读此采访:“Why 4 Bloomberg engineers wrote another C++ book” (https://www.techatbloomberg.com/blog/why-4-bloomberg-engineers-wrote-another-cplusplus-book/) --- 1. 截至撰写时,GCC 16 已登陆 Arch Linux 的官方存储库、Fedora 44 以及大多数滚动发布发行版。此基准测试中使用的 Fedora 镜像附带 `gcc 16.1.1 20260501`,使用 `--enable-checking=release --with-build-config=bootstrap-lto` 构建。↩︎ 2. 使用 `-DENCHANTUM_MAX_RANGE=1024`。默认值将无法编译。↩︎ 3. 这大概是一个临时问题。模块加载尚未优化到 PCH 级别。我预计这一差距最终会缩小,但截至今天,模块对于 `<std.reflect>` 密集的翻译单元来说不是可行的优化。↩︎ 4. 我怀疑它可以通过避免标准库而采用专用组件和编译器内建函数来进一步优化。↩︎

相似文章

信任你的编译器:现代C++

Hacker News Top

本文对比了旧的C++性能技巧与现代编译器的能力,表明编译器现在能够将朴素代码优化得比手工调整的技巧更好。包含在AMD Zen 5上使用Clang 21的基准测试。

当编译器让你惊喜

Lobsters Hottest

Matt Godbolt 探讨了编译器优化如何将 O(n) 求和循环转换为 O(1) 的闭式解,突出了 Clang 和 GCC 如何采用循环展开和数学简化等复杂技术来大幅提升代码性能。