随着静态反射(Reflection)如愿成为 C++26 标准的一部分,C++ 标准库也提供了 std::define_static_array() 帮助我们在编译期将一个 range 提升为程序静态数据段的数组常量,尤其是在 constexpr 运算逻辑较为复杂的时候可以有效避免样板代码量以及元编程带来的编译期时空开销。然而,由于 std::define_static_array() 的内部实现原理是将被提升的元素全部作为 constexpr 模板变量的非类型模板参数(NTTP),因此可以被 std::define_static_array() 直接提升的类型也必须符合 NTTP 的全部限制,即元素类型必须为 structural:要么是 C++ 基本类型,要么只能是所有成员均为 public 且递归满足 structural 性质的结构体或联合体。但在实际开发中,我们更多是与非 structural 类型打交道,比如 std::define_static_array() 自己的返回类型 std::span<const T> 就不是 structural(其成员变量均为私有),这就导致 std::define_static_array() 自身难以胜任处理嵌套 range 的进阶任务。本文将介绍如何通过 C++26 强大的反射能力,并借助一些简易的标准库替代品,有效地扩展 std::define_static_array() 的数据提升能力,让更多原本非 structural 的类型(甚至是复杂的嵌套类型)也能顺利提升为编译期常量。
本文所述内容作为笔者实现的 rbox 库的核心组件之一。rbox::to_static_storage() 的实现源代码已在 GitHub 开源。
成果展示
扩展后的 rbox::to_static_storage() 能够处理结构体内复杂的嵌套 range。提升后的数据类型中,rbox::meta_span 和 rbox::meta_string_view 分别为标准库 std::span 和 std::string_view 的 structural 替代品。自身不满足 structural 性质的结构体类型(例如代码示例中的 graph_t)会被提升为 struct rbox::structural_mirror_t<graph_t>,该类型通过 C++26 标准库提供的 std::meta::define_aggregate() 定义。
1 | struct adj_list_item_t { |
预备知识:什么是 Structural,为什么 std::define_static_array() 需要它
根据 C++ 标准,非类型模板参数(NTTP)必须为如下类型之一,满足该约束的类型被 C++ 标准称为 structural(仅在 C++20 及后续标准支持的用蓝色标识,仅在 C++26 及后续标准支持的用深绿色标识):
- 算术类型:整数、浮点数;
- 枚举类型;
- 指针(指向静态数据段的变量)以及
std::nullptr_t; - 左值引用(指向静态数据段的变量);
- 成员指针;
- 不含捕获变量(即:可以转换为函数指针类型)的 Lambda 表达式;
- Literal class type,即符合以下全部约束的
class或union:- 所有基类均为
public并且递归地满足 structural 约束; - 所有非静态成员变量均为
public、non-volatile、non-mutable; - 对于
class,所有非静态成员变量均为 structural 类型或者 structural 类型的数组(含高维数组);对于union,至少一个非静态成员变量为 structural 类型或者 structural 类型的数组(含高维数组); - 存在至少一个
constexpr构造函数;
- 所有基类均为
std::meta::info。
举例说明:std::pair<int, int> 或者自己定义的 struct pair_t { int x, y; }; 均符合 structural 约束,因此其实例可以作为 NTTP;但 std::tuple<int, int> 就不行,因为 std::tuple 的成员变量不是 public;std::pair<int, std::tuple<int, int>> 也不行,因为其成员变量 second 不属于 structural 类型。
我们在简介部分提到过,std::define_static_array() 的内部实现原理是将被提升的元素全部作为 constexpr 模板变量的非类型模板参数(NTTP)。C++ 标准库内部定义了一个数组模板 __fixed_array,将所有模板参数 Vs... 写到一个 C-style 数组内。C++26 标准库提供了另一个提升 range 数据的组件 std::meta::reflect_constant_array(),它通过 std::meta::substitute() 生成 __fixed_array 的实例,将 range 内的全部数据作为 NTTP 传进去。而 std::define_static_array() 其实就是对 std::meta::reflect_constant_array() 的简单封装,通过 std::meta::extract() 提取出该数组的头指针,然后将其打包为 std::span。既然 range 内的数据最终被反射为 __fixed_array 的 NTTP,那么元素类型必须为 structural 的限制也在情理之中了。
1 | // From: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2025/p3491r3 |
嵌套 range 的第一步:std::span 的 Structural 替代品
由于 std::span 的成员变量均为私有,因此其不满足 structural 约束,这就导致 std::define_static_array() 处理嵌套 range 时,内层提升后得到的 std::span 数组不能通过 std::define_static_array() 继续提升。其解决方案非常直截了当,我们手动实现一个 std::span 的替代品并使其满足 structural 约束,然后对 std::define_static_array() 作二次封装,将其返回的 std::span 转换为我们自己的 structural 版本。笔者的 rbox 库提供了 rbox::meta_span,它就是一个简单的结构体 { const T* head; size_t n; } 外加一些与标准库对应的成员函数。类似地,笔者也提供了 rbox::meta_string_view 作为 std::string_view 的 structural 替代品,于是 std::vector<std::string> 可以被递归地提升为 rbox::meta_span<rbox::meta_string_view>。
p.s. 虽然
std::define_static_string()返回的const CharT*已经满足了 structural 约束,但在rbox库的 API 设计中,笔者还是倾向于用 string-view 将字符串的长度记录下来,以少量的空间开销换取运行时性能提升。
到这里我们可以实现一个 to_static_storage(input) 的初版:如果 input 是一个元素类型为字符的 range,就将 input 提升为 rbox::meta_string_view;如果 input 是其他 range,则对其每个元素递归地调用 to_static_storage();否则,直接返回 input(其余复杂情形此处暂时略过,我们将在下文介绍)。
1 | template <class T> |
处理复杂的结构体:生成其 Structural 镜像类型
真实的案例往往比嵌套 range 更加复杂。回到“成果展示”一节中的 graph_t 案例,我们现在具备了逐一提升其成员变量的能力,但对 graph_t 本身还需要一层手动包装,才能让其中的数据保持结构体的组织形式。我们仍然需要手动实现一个 static_graph_t ,把 graph_t 的所有成员变量复制粘贴过去,逐一修改其类型;最后提升 graph_t 整体时对每个成员逐一调用 to_static_storage()。也就是说,graph_t 中的每个成员变量还要被手工重复至少两次,对 graph_t 成员变量的増删也要被重复至少两次,这些冗余代码和多出来的维护成本正是我们希望通过 C++26 反射消除的。
1 | struct graph_t { |
从根本上说,我们的目标是将 graph_t 自动变换为与之对应的 structural 版本,变换后每个成员变量都是变换前同名成员的“镜像类型”,用于装载该成员使用 to_static_storage() 提升的结果。C++26 提供了一个强大的工具 std::meta::define_aggregate() ,用于在编译期使用 constexpr 表达式定义一个结构体类型。典型的使用场景就是对已有结构体进行变换得到一个新类型,C++26 反射草案(也就是著名的 P2996)中也包含了诸如“Struct to Struct of Arrays”(见草案原文第 3.10 节)等经典案例。std::meta::define_aggregate() 接收两个参数 type_class 和 specs,前者表示一个此时未定义的 class 或 union 类型,后者表示我们要在 type_class 中添加哪些成员变量。specs 列表中每个元素都是 std::meta::data_member_spec(type, options) 的返回值,type 为该成员变量的类型,options 则用于指定该成员变量的名称、对齐、位域的比特长度,以及是否被属性 [[no_unique_address]] 修饰,且必须满足下列全部约束:
name必须为合法的 C++ 标识符;除非该成员变量是匿名位域,否则name必须提供;- 若
alignment未提供,则该成员的对齐量取默认值,即与type保持一致;否则alignment必须为 2 的非负整数次幂,且不得低于type自身的对齐量; - 若
bit_width有值,则表示该成员为位域,此时bit_width必须为非负整数,同时type必须为整型或枚举类型,alignment不得有值,no_unique_address必须为 false;若bit_width == 0,则name也不得有值。
1 | namespace std::meta { |
给定给构体 T,我们利用 std::meta::define_aggregate() 生成 T 的镜像类型。通过 std::meta::nonstatic_data_members_of() 获取 T 的非静态成员列表,使用 std::meta::type_of() 逐个获取每个成员变量的类型 U,并将 U 变换为 to_static_storage() 的结果类型;成员变量的命名保持不变。成员变量的类型变换通过别名模板 to_static_storage_result_t 完成,在 constexpr 表达式内我们借助 std::meta::substitute() 实例化该模板。生成的镜像类型定义到 structural_mirror<T>::type 内。
需要注意:
std::meta::nonstatic_data_members_of()只能获取由类型T自身直接定义的成员变量。在实际应用中,还需要递归地获取来自基类的成员。rbox库的内部实现包含了将所有类成员进行“平铺”(Flatten)的预处理机制(实现细节见rbox源代码),为行文方便此处略去对类继承的处理;std::meta::nonstatic_data_members_of()除了结构体类型T以外,还需要一个std::meta::access_context类型的参数ctx,用于控制每个成员变量的访问权限。unprivileged()只能访问到public成员,unchecked()可以无限制地访问全部成员,current()对应的权限则与其调用位置保持一致。rbox库的 API 设计会直接拒绝掉包含protected或private成员变量(包括来自基类的成员)的类型,为行文方便此处略去相关细节。
1 | namespace impl { |
得到结构体的 structural 镜像之后,to_static_storage() 的实现也就水到渠成。令 n 表示输入结构体的成员变量个数,只需要依次将输入的第 i 个成员提升之后赋值给镜像类型的第 i 个成员。C++26 的 template for 语法可以让我们干净利落地实现该过程,配合 C++ Ranges 提供的 iota_view( 以及 C++26 进一步引入的 std::views::indices ),它能代替 std::make_index_sequence<N>() 生成 0 ~ N-1 的常量列表,无需模板元编程和辅助函数的介入。
不过很不幸,以下基于 template for 的实现虽然简洁但无法支持
T的成员变量包含引用的情况:T包含的引用会在镜像后的R中原样保留,此时无法将result先初始化为R{}然后对其成员逐一赋值,只能一次性R{...}初始化完毕。所以rbox库实际的实现还是退回了传统的std::make_index_sequence<N>()。
1 | template <class T> |