C++26 引入的静态反射机制赋予了开发者在编译期获取 C++ 程序自身元信息的强大能力。利用 C++26 标准库的反射 API,我们可以查询任意一个 C++ 实体(变量、函数、类、模板、命名空间……)的元信息,其中最常用的就是名称、类型、所有下属成员以及所有基类。善用这些元信息可以帮助我们实现更加优雅且高效的 C++ 代码设计。本文从分派表(Dispatch table)这一高频使用场景入手,展示 C++26 反射的表达能力如何消除传统方式实现分派表所需的大量样板代码和难以避免的运行时开销,并循序渐进地拆解如何基于 C++26 反射不断迭代分派表的 API 设计。
本文所述内容为笔者实现的 rbox 库的一部分,实现源码已在 GitHub 开源。
成果展示
该案例直观地展现了 C++26 反射赋予强大的表达能力。编译期生成派发表的全部逻辑封装在 RBOX_FUNCTION_FIXED_MAP 宏内部,第一个参数 ops 表示我们要遍历 namespace ops 内的全部函数成员,第二个参数 "do_*" 为函数名的匹配模式,该命名空间内所有命名以 "do_" 开头的函数都会被加入到该派发表内。生成的派发表是一个关联数组,值的类型 int (*)(int, int) 自动推导得出,内部数据结构(通常为哈希表,数据较少时也会回退到线性查找)根据输入数据自适应选取,均无需开发者手动介入。
更多案例可以参考 rbox 库的 examples 目录。
1 | namespace ops { |
问题背景:传统方式实现派发表的缺陷
在 C++ 引入静态反射机制之前,派发表的实现方式主要为以下三种:
- if-else 链:最简单粗暴的方式,条目较少时勉强可以接受,但条目一多起来就是代码维护的灾难,一不小心增删错误或者字符串内拼写错哪个单词就是极其隐蔽的 bug;
- X-macro:如下列代码所示,用宏实现 for-each 的代码预处理逻辑。这种方法可维护性比 if-else 链强很多,但仍然需要一对
#define和#undef的样板代码。并且 X-macro 对于复杂分类情形的表达能力并不是很强,像下边这种只有一个子集的倒还好,但一旦随着代码不断演进条目的分类也多起来,X-macro 要么切分地特别细,要么 X-macro 之间存在大量的重复条目,可维护性依然不容乐观; - 静态/全局容器:如下列代码所示,用
std::unordered_map等容器类型承载所有条目,然后创建一个静态或者全局的实例。借助数据结构可以有效降低查表的时间复杂度,但初始化的开销被延迟到了运行时。工程实践中,静态变量或全局变量也可能引入初始化顺序、初始化错误处理之类的麻烦。
C++26 反射为我们提供了新的解决方案:条目的列表通过 std::meta::members_of() 等反射 API 直接提取,使用 X-macro 等方式手工列举条目的工作量可以被消除;数据结构的选择与构建可以借助 C++ 灵活的 constexpr 表达式,配合反射机制在编译期自动完成。
1 |
|
预备知识:C++26 反射与数据提升
受篇幅以及本人精力限制,C++26 反射相关的基础知识不在本文详细阐述。如果您有学习的需要,请阅读 C++26 反射标准草案(也就是著名的 P2996 paper)以及其他介绍 C++26 反射的博客。
C++26 提供了 std::define_static_array() 等 API,可以让我们在 constexpr 表达式内部定义数组常量,将输入 range 的值提升到程序的静态数据段。在此基础上,rbox 库提供了一个增强版组件 rbox::to_static_storage() 可以突破前者对输入类型的严格限制。数据提升的原理和技术细节可参考笔者的前一篇博客:扩展 C++26 std::define_static_array() 的编译期数据提升能力。
着手实现
我们将派发表的实现过程拆解为三个循序渐进的步骤:
- 实现派发表的基本功能;
- 实现数据结构的自适应选取;
- 实现目标类型的自动推导。
第一步:实现派发表的基本功能
我们先考虑这个简化版的派发表 API:ns 表示被遍历的命名空间,prefix 指定被筛选成员的名称前缀,被筛选的成员类型 T(可以是变量或者函数)通过模板参数由开发者手动指定。namespace ns 内所有名称与类型均符合的成员会被加入到返回的派发表内。该 API 生成一个线性数据结构,其中的键值对按照字典序排序,以支持二分查找。返回值类型 Span<Pair<StringView, const T*>> 为使用 rbox::to_static_storage() 将该数据结构提升到静态数据段的结果。
注意:这里的 Span 和 StringView 使用的不是标准库的 std::span 和 std::string_view,而是我们自己实现的替代品。这些替代类型将全部成员变量暴露为 public 从而使自身符合 structural 性质,也就是与 C++ 非类型模板参数(NTTP)相同的约束。数据提升依赖 structural 性质的原因分析已在前一篇博客中详细阐释。
1 | template <class T> |
借助 C++26 标准库的反射 API,其实现原理并不复杂:std::meta::members_of() 可以获取一个类或者命名空间内部的成员列表。之后对每个成员 member:
std::meta::type_of(member)获取该成员的类型;std::meta::is_same_type(A, B)检测A和B是否为同一个类型(对应 C++11 的std::is_same);std::meta::has_identifier(member)检测member是否为匿名成员;std::meta::identifier_of(member)获取该成员的标识符,若member为匿名则该函数会抛出std::meta::exception类型的编译期异常。- 若
member的类型T已知,则std::meta::extract<T*>(member)可以提取指向member对应的变量/函数的指针。若类型不匹配,则该函数会抛出std::meta::exception类型的编译期异常。
需要注意的是:
- 由于
member的类型是std::meta::info,因此所有定义在namespace std::meta内的标准库 API 都可以通过实参依赖查找(Argument-Dependent Lookup,简写作 ADL)访问到,因此std::meta::可以省略以提升代码简洁程度; std::meta::members_of()以及类似的反射 API 只能查询类或命名空间直接定义的成员,来自基类、嵌套类、上层命名空间、嵌套命名空间的成员需要额外地递归处理,受篇幅限制本文略过这些细节;std::meta::members_of()以及类似的反射 API 接收两个参数,除了被查询的类/命名空间以外,还需要传入一个std::meta::access_context类型的参数ctx用于控制成员的访问权限。对于类成员,ctx直接关乎protected和private成员能否被访问到;对于命名空间成员则几乎无影响。
1 | template <class T> |
第二步:自适应选取数据结构
我们初步完成了派发表的实现,但它的数据结构是固定的线性列表 + 二分查找,与我们最终期望的自适应能力还存在距离。但在实现自适应数据结构之前,我们首先要处理一个棘手的问题,那就是 make_dispatch_table() 的返回值要如何设定。下列伪代码中我们提供了三种数据结构,每一种都不能单独作为派发表 API 的返回值,如果用 union 或者 std::variant 进行整合,那么不仅运行时开销陡增,而且查询派发表的代码也会变得臃肿不堪,远远背离了我们实现自动派发表的初衷。由于 ns 和 prefix 都不是常量表达式,我们也无法利用 if constexpr 决定返回值类型。
C++ 标准对何为“常量表达式”(Constant expression)有严格且复杂的定义,不过大致可以归纳为“能够在编译期求值,并且其值可以用于任何需要编译时常量的地方(如模板参数、数组大小、case 标签等)的表达式”,或者进一步简化为“能够放进
constexpr变量的表达式”。对于下列代码:
- 函数形参
ns和prefix都不是常量表达式(即便是consteval函数的形参);- 模板参数
T可以用在常量表达式内(但在第三步实现自动类型推导时,T会被下放到make_dispatch_table()内部的某个std::meta::info变量,此时它不再是常量表达式)。C++ 标准对常量表达式的完整定义可以参考 cppreference 相关条目 。
1 | template <class T> |
其解决方案是 std::meta::reflect_constant():它可以接收任意类型的值(只要其类型符合 structural 性质),并返回一个包含该值的反射,其意义在于不同类型的值统一都可以借此归一为 std::meta::info 类型。对于本文所述的派发表,我们将每种数据结构的实例分别用 std::meta::reflect_constant() 包装,然后让派发表 API 统一返回 std::meta::info。
C++26 反射提供了两种方式将包含一个值的 std::meta::info 还原成其反射的值:
- 拼接修饰符(Splice specifiers)
[: R :],其中R是一个std::meta::info类型的常量表达式; std::meta::extract<T>(r),其中r是一个std::meta::info类型的表达式。与拼接修饰符相比,r不要求是常量表达式,但它的精确类型T必须事先已知。
这里我们采用第一种方案,用拼接修饰符提取 make_dispatch_table() 的结果。在实际的 API 设计中,为了照顾不熟悉 C++26 新语法的开发者(也是出于个人的审美倾向),我们可以用宏 MAKE_DISPATCH_TABLE() 将整个包含反射运算符 ^^ 和拼接修饰符 [: :] 的表达式封装起来。
1 | template <class T> |
第三步:自动推导目标类型 T
我们依然没有挖掘 C++26 反射的全部潜力:注意到目标类型 T 还需要让开发者在 MAKE_DISPATCH_TABLE() 中手打一次,尤其是考虑到实际代码中函数签名的长度往往不低,派发表 API 的简洁性还需要进一步优化。既然 ns 的成员列表可以获取,数据结构也可以自适应,那么目标类型 T 也可以从过滤后的成员列表自动推导出来吗?下列代码中我们尝试去掉 template <class T>,按照 prefix 过滤 ns 中的成员之后,利用 std::meta::type_of() 获取成员类型的反射 rT 并进行类型一致性校验。新的实现瓶颈随之而来:rT 不再是常量表达式,既无法用在拼接修饰符内,也不能用作 std::meta::extract() 的模板参数。
1 | // No more explicitly specified T |
这里我们引入一个 C++26 反射编程的常用技巧:使用 std::meta::substitute() 配合 std::meta::extract() 将类型或者值提升到模板参数中,从而将其“常量化”。为不失一般性,我们以下列代码片段为例:我们希望通过 make_something(entity) 生成某个 SomethingWith<T> 类型的实例,其中模板参数 T 由形参 entity 推导而来,通过 constexpr 运算可以获取其反射 rT。尽管 rT 自身不是常量表达式,但我们可以实现一个将 T 作为模板参数的辅助函数 make_something_impl() ,再通过 std::meta::substitute() 以 rT 为模板参数将该函数模板实例化,最后用 std::meta::extract() 提取其实例化得到的函数指针。与之前类似,我们借助 std::meta::reflect_constant() 将 make_something_impl() 的返回值归一化为 std::meta::info,于是该辅助函数的签名固定为 call_signature = std::meta::info(std::meta::info),故可用 std::meta::extract<call_signature*>() 提取其函数指针。
substitute + extract 的组合可以形象地理解成一道桥梁,桥的一头是 consteval 函数体内的非常量表达式,另一头是作为模板参数的常量表达式。活用这对组合可以跨过两者之间的鸿沟,在桥梁两头灵活地穿梭;既保留了常量表达式的语法特性,又能充分利用 consteval 函数的表达能力和编译期性能。
1 | template <class T> |
利用上述 substitute + extract 的模式完成派发表 API 的目标类型自动推导:我们为每一种数据结构实现一个模板函数用于构建其实例。以二分查找的线性表为例,下文中 make_binary_search_dispatch_table() 函数的模板参数 template <class T> 用于承载推导得出的目标类型。当我们推导出目标类型的反射 rT 之后,使用 std::meta::substitute() 将其提升为模板参数 T,然后用 std::meta::extract() 提取模板实例化得到的构建函数。最终我们可以裁撤掉 MAKE_DISPATCH_TABLE 中的类型参数 T,只保留必要的 ns 和 prefix,至此 API 的设计顺利最小化。
1 | template <class T> |
结语:用反射改变对 C++ 代码的思考方式
在逐步迭代之后,我们利用 C++26 反射的力量完成了零样板代码、零运行时开销的派发表 API。受篇幅限制,更多的功能以及使用场景(类成员查找、基类递归遍历、多重目标类型等)不在本文赘述,相关细节可以参考笔者在 rbox 库中的实现。
这个案例的启发意义也不止步于表面上的“一键字符串 switch-case”。既然我们可以在编译期把一个变量或函数的名称通过 std::meta::identifier_of() 取出来然后做 constexpr 运算,那么标识符本身就成为了承载代码设计模式的一个维度;既然可以用 std::meta::members_of() 遍历类或命名空间的成员,那么类和命名空间就具备了划定 members_of() 遍历范围的新语义。不难想象出未来的 C++ 代码可能会充斥着这样的设计:“新加的事件处理请放到 namespace events 里边,函数名遵循 on_{key}_{variant} 的格式,事件 key 在 on_event() 里边自动解析并注册”,如下所示。这一套设计简洁优雅、所见即所得,帮助开发者彻底摆脱维护样板代码的困扰。唯一的风险就是编译期开销的控制:设计代码结构时需要避免 constexpr 运算量的膨胀,尤其是避免大量 constexpr 运算随着头文件 #include 扩散到多个不相关的翻译单元。
1 | /* |