C++26反射特性下的编译时常量映射与可变变量
TL;DR · AI 摘要
C++26反射特性引入了编译时常量映射和可变变量的新方法,显著提升状态化模板编程能力。
核心要点
- C++26新增substitute、is_complete_type和define_aggregate函数支持编译时反射。
- 通过define_aggregate可动态完成类定义,实现条件性类型完善。
- 示例展示了如何利用这些新特性创建编译时常量计数器。
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- C++26反射特性
- 编译时常量映射
- substitute函数
- is_complete_type函数
- 编译时可变变量
- define_aggregate函数
金句 / Highlights
值得收藏与分享的关键句。
substitute函数允许在模板中替换参数并生成新的反射。
is_complete_type函数用于判断类型是否完全定义。
define_aggregate函数能够根据数据成员描述动态完成类定义。
标题:使用 C++26 反射的编译时常量映射与可变变量
来源链接:https://stackoverflow.blog/2026/05/11/compile-time-map-and-compile-time-mutable-variable-with-c-26-reflection/
Markdown 内容: 大家好,我想与大家分享一种我在试验 C++26 引入的新特性时发现的创建编译时常量键值对映射的新方法。我还将展示一个我称之为“编译时常量可变变量”的新技巧。我相信这些方法将在您的状态化元编程工作中非常有帮助。
在继续之前,您应该了解什么是反射及其基础知识。您还应该知道关于反射和拼接操作符的内容。如果您不了解,请阅读《C++26 反射》P2996R13 第 4.1 和 4.2 节([1])。您不需要了解其他部分,因为我会根据需要解释并引用相关内容。
为了理解新方法,最好从剖析编译时常量票号计数器示例(P2996R13 §3.17)开始。如果您已经理解了它的运作方式,可以跳过这一部分。
本节中使用的示例可以在 godbolt[2] 上找到。
编译时常量票号计数器是一个示例,展示了如何使用 C++26 的新特性来实现编译时常量计数器。编译时常量计数器的主要目标是在编译期间获取计数器的值并递增计数器的值。这对于诸如在编译期间自动为某些程序元素分配唯一编号非常有用,而不是手动设置值并确保不会分配重复的编号。
类 TU_Ticket 包含两个静态的 consteval 函数:latest 和 increment。
latest 函数返回计数器的最新(当前)值,而 increment 函数将计数器的值递增 1。
为此,TU_Ticket 使用了 C++26 中引入的反射功能中的三个新函数:substitute、is_complete_type 和 define_aggregate。这些函数属于新的命名空间‘meta’,并与反射交互。
您可能还注意到 consteval 块,其语法如下:
consteval {
...contents...
}consteval 块的内容将在编译期间仅评估一次。这主要用于 define_aggregate 函数以及调用 define_aggregate 的函数。有关详细信息,请参见 P2996R13 §4.4.1。
“给定模板的反射以及与该模板匹配的模板参数的反射,substitute 返回通过在模板中替换给定参数所获得的实体的反射。”(P2996R13 §4.4.16)。这里有一个快速示例来说明 substitute 的作用:
template <typename T>
struct A;
constexpr auto refl_1 = ^^A<int>;
constexpr auto refl_2 = substitute(^^A, {
^^int});
static_assert(refl_1 == refl_2); // 使用 substitute 与手动编写相同
// 示例 1is_complete_type 函数顾名思义,检查给定的反射是否表示完整的类型。
示例:
struct A; // 不完整类型
struct B {}; // 完整类型
static_assert(is_complete_type(^^A) == false);
static_assert(is_complete_type(^^B) == true);
// 示例 2重要的是要知道,如果一个模板是不完整的,则其模板化的类型也将是不完整的,除非显式的模板特化是完整的。
示例:
template <typename>
struct A; // 不完整的类模板
template <>
struct A<int> {}; // 完整的显式模板特化
template <>
struct A<long>; // 不完整的显式模板特化
static_assert(is_complete_type(^^A<long>) == false);
static_assert(is_complete_type(^^A<char>) == false);
static_assert(is_complete_type(^^A<int>) == true);
// 示例 3“define_aggregate 接受一个不完整类/结构体/联合类型的反射以及数据成员描述的反射范围,并按照给定顺序完成指定的类类型及其数据成员。”(P2996R13 §4.4.19)。简而言之,它接受一个不完整的类并完成它。需要注意的是,只有在调用 define_aggregate 函数时才会完成类。这意味着您可以有条件地完成一个类。
以下两个代码片段在功能上是相同的:
使用 define_aggregate:
struct A;
static_assert(is_complete_type(^^A) == false);
consteval {
define_aggregate(^^A, {
}); // 完成类型
}
static_assert(is_complete_type(^^A) == true);
// 示例 4a不使用 define_aggregate:
struct A;
static_assert(is_complete_type(^^A) == false);
struct A {}; // 完成类型
static_assert(is_complete_type(^^A) == true);
// 示例 4b重要的是要知道,当您完成一个模板化的类型时,您只完成了该模板的特定特化:
template <typename>
struct A;
consteval {
define_aggregate(^^A<int>, {
});
}
static_assert(is_complete_type(^^A<int>) == true);
static_assert(is_complete_type(^^A<char>) == false);
// 示例 5a这段代码的功能与以下代码相同:
template <typename>
struct A;
template <>
struct A<int> {};
static_assert(is_complete_type(^^A<int>) == true);
static_assert(is_complete_type(^^A<char>) == false);
// 示例 5bdefine_aggregate 函数可以用数据成员完成指定的类。在我们的例子中,我们需要添加一个单一的数据成员,如下所示:
struct A1;
consteval {
define_aggregate(^^A1, {
data_member_spec(^^int, {
.name = "value"})});
}
struct A2 {
int value;
};
// 示例 6在示例 6 中,类 'A1' 和 'A2' 从其数据成员的角度来看是相同的。
latest() 函数返回计数器的当前值。它通过线性搜索找到第一个未完成的模板特化版本的模板 'Helper' 来实现这一点,其中模板参数是从零开始并逐步递增的整数。一旦找到一个未完成的模板特化版本,它就知道该整数是计数器的当前值。
示例:
consteval{
TU_Ticket::increment();
}
constexpr int a = TU_Ticket::latest();
static_assert(a == 1);
consteval{
TU_Ticket::increment();
}
constexpr int b = TU_Ticket::latest();
static_assert(b==2);
// 示例 7a这段代码的功能与以下代码相同:
template <>
struct Helper<TU_Ticket::latest()> {};
constexpr int a = TU_Ticket::latest();
static_assert(a == 1);
template <>
struct Helper<TU_Ticket::latest()> {};
constexpr int b = TU_Ticket::latest();
static_assert(b == 2);
// 示例 7b让我们通过一个小例子来理解 latest 函数的作用。假设计数器已经增加了两次,因此 latest 函数应该返回 2。函数首先测试值 0。它检查类型 'Helper<0>' 是否完整。它是完整的,因为在我们第一次增加计数器时完成了定义。然后它检查类型 'Helper<1>' 是否完整,这是在第二次增加时完成的。现在它检查类型 'Helper<2>'。它是不完整的,这意味着 2 是计数器的当前值。因此它返回值 2,正如它应该做的那样。
示例代码:
template <int N>
struct Helper;
struct TU_Ticket {
static consteval int latest() {
int k = 0;
while (is_complete_type(substitute(^^Helper, {
std::meta::reflect_constant(k)})))
++k;
return k;
}
static consteval void increment() {
define_aggregate(substitute(^^Helper,
{
std::meta::reflect_constant(latest())}),
{});
}
};
consteval {
TU_Ticket::increment(); // 增加一次
TU_Ticket::increment(); // 增加两次
}
static_assert(is_complete_type(^^Helper<0>) ==
true); // 类已完整,0 不是当前值
static_assert(is_complete_type(^^Helper<1>) ==
true); // 类已完整,1 不是当前值
static_assert(is_complete_type(^^Helper<2>) ==
false); // 类不完整!2 是当前值,无需进一步搜索
static_assert(TU_Ticket::latest() == 2);通过这种方式,你应该明白计数器通过创建新的完整模板特化版本的不完整 'Helper' 类模板来存储其先前状态的信息。采用这种方法,我们有一些限制。我们不能删除或修改任何完整的模板特化版本,因为我们无法取消定义或重新定义类定义。这意味着计数器无法重置或减少其计数值。
increment() 函数简单地用当前计数值完成模板特化,从而有效地增加计数器。
本节中使用的示例可以在 godbolt[3] 上找到。
对于这个编译时常量映射,键和值都将是类型 meta::info。这将允许映射使用几乎任何东西作为其键和值。想让一个整数指向一个模板?你可以做到。想将特定类型映射到成员函数?你可以做到。可能性是无穷无尽的。
你可以猜到,为了存储映射的键值对,我们将使用类模板特化,其中模板参数将是键,而值将存储在完整的类中。但我们将如何在类中存储值呢?通过 define_aggregate,我们可以定义类具有指定类型的命名数据成员。由于我们想要存储一个 meta::info 类型的值,我们必须将其包装在一个类型中,方法是将其作为类型 meta::info 的静态 constexpr 数据成员。
包裹类型 meta::info 值的例子:
template<auto>
struct storage;
template<meta::info v>
struct info_as_type{
static constexpr meta::info value = v;
};
// 示例 1a因此,为了存储键值对,你可以这样做:
template<meta::info key, meta::info value>
consteval void insert(){
meta::info refl = substitute(^^storage,{reflect_constant(key)});
define_aggregate(refl,{data_member_spec(^^info_as_type<value>,{.name = "value"})});
}
// 示例 1b在这里,'insert' 函数接受两个非类型模板参数:键和值。该函数使用不完整的类模板 'storage' 作为映射的存储介质,类似于 TU_Ticket 示例中的 'Helper' 模板。首先,函数用键作为参数替换 'storage' 模板,然后它完成该类型并定义它,同时还为其提供一个类型为 'info_as_type<value>' 的数据成员 'value'。这样,以键为参数的 'storage' 模板特化将值存储为类唯一数据成员类型的静态 constexpr 数据成员。
要获取值,你可以这样做:
template<meta::info key>
consteval meta::info at(){
constexpr meta::info refl = substitute(^^storage,{reflect_constant(key)});
return decltype([:refl:]::value)::value;
}
// 示例 1c在这里,函数 'at' 接受一个非类型模板参数作为键,并返回对应的值。该函数获取类模板特化版本 'storage<key>',然后获取其数据成员 'value' 的类型,即 'info_as_type<value>'。通过这一点,它获取与该键对应的值并返回。如果对于给定的键,不存在键值对,则模板特化将是不完整的,因此会导致编译错误。
为了将这些函数组合在一起,我们可以将它们放入一个类中,并且我们还会添加一个 'contains' 函数来检查映射是否包含特定的键。
template<meta::info storage>
struct CT_map{
template<meta::info v>
struct info_as_type{
static constexpr meta::info value = v;
};
template<meta::info key, meta::info value>
static consteval void insert(){
meta::info refl = substitute(storage,{reflect_constant(key)});
define_aggregate(refl,{data_member_spec(^^info_as_type<value>,{.name = "value"})});
}
template<meta::info key>
static consteval meta::info at(){
constexpr meta::info refl = substitute(storage,{reflect_constant(key)});
return decltype([:refl:]::value)::value;
}
static consteval bool contains(meta::info key){
meta::info refl = substitute(storage,{reflect_constant(key)});
return is_complete_type(refl);
}
// 示例 2a因此,编译时映射可以这样使用:
template<auto>
struct storage;
using map = CT_map<^^storage>;
static_assert(map::contains(^^int) == false);
consteval{
map::insert<^^int,^^char>();
}
static_assert(map::at<^^int>() == ^^char);
static_assert(map::contains(^^int) == true);
// 示例 2b关于这个特定的编译时映射示例,重要的一点是映射类型本身是没有状态的。只有类模板保存状态。这意味着,如果两个不同的映射类型使用相同的类模板,这两个映射将表现得像同一种类型。具体来说,插入到第一个映射中的任何键值对也会被第二个映射视为自己的键值对。
示例:
using ::example_2::CT_map;
template<auto>
struct storage;
using map1 = CT_map<^^storage>;
static_assert(map1::contains(^^int) == false);
consteval{
map1::insert<^^int,^^char>(); // 向 map1 插入一对键值
}
static_assert(map1::contains(^^int) == true);
using map2 = CT_map<^^storage>;
static_assert(map2::at<^^int>() == ^^char); // map2 将其视为自己的键值对
// 示例 3在这个例子中,map1 和 map2 是不同类型的,但它们使用相同的类模板 'storage'。因此,当我们向 map1 插入一对键值时,map2 也会拥有这对键值。map1 和 map2 表现得好像它们是同一种类型一样。
解决这个问题有两种方法。第一种方法显而易见:为不同的映射使用不同的模板类。简单,但需要为每个映射提供唯一的类模板。另一种解决方法是为每个映射分配唯一的键,并在存储键值对时包含这个唯一键和常规键。现在所有映射都可以使用相同的类模板,唯一键可以手动设置或从编译时计数器自动获取。
示例:
template<meta::info storage, meta::info unique_key = ^^void>
struct CT_map{
template<meta::info v>
struct info_as_type{
static constexpr meta::info value = v;
};
template<meta::info key, meta::info value>
static consteval void insert(){
meta::info refl = substitute(storage,{reflect_constant(pair<meta::info,meta::info>{unique_key,key})});
define_aggregate(refl,{data_member_spec(^^info_as_type<value>,{.name = "value"})});
}
template<meta::info key>
static consteval meta::info at(){
constexpr meta::info refl = substitute(storage,{reflect_constant(pair<meta::info,meta::info>{unique_key,key})});
return decltype([:refl:]::value)::value;
}
static consteval bool contains(meta::info key){
meta::info refl = substitute(storage,{reflect_constant(pair<meta::info,meta::info>{unique_key,key})});
return is_complete_type(refl);
}
// 示例 4a这个示例与之前的映射不同之处在于它如何创建模板特化。假设你有一个具有唯一键 '^^1' 的映射,并插入了一个键为 '^^int' 的键值对。在之前的映射中,模板特化将是 'storage<^^int>',但在这个映射中,它将是 'storage<pair{^^1,^^int}>'。现在,如果我们有一个具有唯一键 '^^2' 的第二个映射,并且第二个映射搜索键为 '^^int' 的键值对,它将搜索模板特化 'storage<pair{^^2,^^int}>',这与 'storage<pair{^^1,^^int}>' 不同。现在,只要映射之间的唯一键不同,映射就不会相互交叉。
示例:
template<auto>
struct storage;
using map1 = CT_map<^^storage, reflect_constant(1)>;
static_assert(map1::contains(^^int) == false);
consteval{
map1::insert<^^int,^^char>(); // 创建特化 storage<{^^1,^^int}>
}
static_assert(map1::at<^^int>() == ^^char); // 搜索特化 storage<{^^1,^^int}>
static_assert(map1::contains(^^int) == true);
using map2 = CT_map<^^storage, reflect_constant(2)>;
static_assert(map2::contains(^^int) == false); // 搜索特化 storage<{^^2,^^int}>
consteval{
map2::insert<^^int,^^long>(); // 创建特化 storage<{^^2,^^int}>
}
static_assert(map2::at<^^int>() == ^^long); // 搜索特化 storage<{^^2,^^int}>
static_assert(map2::contains(^^int) == true);
// 示例 4b在这个例子中,map1 和 map2 使用不同的唯一键。由于这个原因,它们永远不会相互干扰,并且表现得好像彼此不存在一样。如果它们使用相同的唯一键,那么它们就会表现为一个单一的映射。
就像运行时票证计数器一样,一旦一对元素被插入到映射中,它就不能被移除或更改,这个特性同样适用于这一情况。
什么是编译期可变变量?这可能是你正在问的问题。编译期可变变量,或者称为 CMV,是一种存储常量表达式值的程序元素。在整个编译过程中,CMV 的值可以被改变。CMV 非常类似于编译期计数器,除了它可以存储任何可反射的元素而不是仅仅是一个整数,并且它可以将其存储的值更改为任何其他值,而不仅仅是递增。
就像计数器一样,CMV 实际上并不会改变其值。CMV 的工作方式与票证计数器完全相同,除了它还会保存值,就像映射一样。事实上,我们将基于映射来构建 CMV。
CMV 将被定义为:
template<meta::info storage, meta::info unique_key = ^^void, int Hint = 100>
struct CMV{
using map = CT_map<storage,unique_key>;
...
};在这里,CMV 有三个模板参数。前两个,storage 和 unique_key,由内部的编译期映射使用。这个映射将用于设置和获取 CMV 的值。第三个参数由 'latest()' 函数使用。
CMV 将有一个 'latest()' 函数,就像计数器一样,但不是寻找最大的未完成索引,而是寻找最大的已完成索引。我们还可以进行一个小优化。与其线性地寻找最新索引,我们可以使用二分查找。这是因为我们可以检查任何一个给定的索引是否给我们提供了一个完整的类型。如果测试的索引是完整的,这意味着最新的索引大于或等于测试的索引。如果索引是不完整的,这意味着最新的索引小于测试的索引。结合这两个事实,我们可以使用二分查找。
static consteval int latest(){
int l=0, r = Hint;
while(map::contains(reflect_constant(r))) r*=r;
while(l<r){
int mid = (l+r)/2;
if(map::contains(reflect_constant(mid))){
l=mid+1;
}else{
r = mid;
}
}
return l-1;
}在这里,搜索的初始区间是 [0,Hint]。如果上界索引包含在映射中,这意味着存在一个完整的模板特化,并且最新的索引不在该区间内。只要上界索引包含在映射中,它就会被增加。在这个例子中,每次上界索引都会平方,但你可以根据具体需求以任何方式增加它。从数学上讲,Hint 模板参数应该设置为大于预期的 CMV 值设置次数。一旦保证最新的索引在区间内,就在区间上执行二分查找。结果会被返回。如果最新的索引是 -1,这意味着 CMV 是空的。尝试从空的 CMV 中获取值会导致编译错误。
‘get()’ 函数将返回 CMV 的当前值。该函数会获取最新的索引,然后将其作为映射中的键来获取相应的值。这个值会被返回。
template<int index = latest()>
static consteval meta::info get(){
return map::template at<reflect_constant(index)>();
}你可能已经注意到,get 函数是一个带有索引作为参数的模板函数。默认情况下,它将引用 CMV 的当前值,但由于 CMV 的所有先前值都存储在映射中,你可以通过使用区间 [0,latest()] 内的任何索引来获取 CMV 的任何先前值。如果 get 被调用时传入的索引超出该区间,则会发生编译错误。
‘set()’ 函数将设置 CMV 的值。当我们想要在 CMV 中存储新值时,该函数会获取最新的索引并将其加一,然后我们将新的键值对插入到底层映射中。现在,下次调用 latest 函数时,它将指向新值。
template<meta::info value, int index = latest() + 1>
static consteval void set(){
map::template insert<reflect_constant(index),value>();
}这是一个使用 CMV 的示例:
template<auto>
struct storage;
using var = CMV<^^storage>;
static_assert(var::latest() == -1); // CMV is empty
consteval{
var::set<^^int>(); // set var to ^^int
}
static_assert(var::latest() == 0);
static_assert(var::get() == ^^int); // get var's value
consteval{
var::set<^^char>();
}
static_assert(var::latest() == 1);
static_assert(var::get() == ^^char); // get var's value
consteval{
var::set<^^long>();
}
static_assert(var::latest() == 2);
static_assert(var::get() == ^^long); // get var's value
static_assert(var::get<0>() == ^^int); // get a previous value of var上面显示的代码可以在 godbolt[4] 上找到。
现在我们有了一个不可变的编译期映射和一个编译期可变变量。如果我们把这两者放在一起会发生什么?没错,我们可以创建一个可变的编译期映射,消除之前映射的一个限制。
代码:
template <meta::info storage, meta::info unique_key = ^^void, int Hint = 100>
struct MCT_map {
template <meta::info key>
using CMV_T =
CMV<storage, reflect_constant(std::pair<meta::info, meta::info>{unique_key, key}), Hint>;
template <meta::info key, meta::info value, int index = CMV_T<key>::latest()>
static consteval void insert() {
CMV_T<key>::template set<value>();
}
template <meta::info key, int index = CMV_T<key>::latest()>
static consteval meta::info at() {
return CMV_T<key>::template get<>();
}
};以下是翻译后的 Markdown:
这里有一个使用可变编译时映射的示例:
template <typename T> consteval meta::info refl(T a) { return reflect_constant(a); } template <auto> struct storage; using map_1 = MCT_map<^^storage, ^^int>; using map_2 = MCT_map<^^storage, ^^char>; consteval { map_1::insert<refl(1), refl(2)>(); // 为 map_1[1] 设置初始值 map_2::insert<refl(1), refl(2.34)>(); // 为 map_2[1] 设置初始值 } static_assert(map_1::at<refl(1)>() == refl(2)); static_assert(map_2::at<refl(1)>() == refl(2.34)); consteval { map_1::insert<refl(1), ^^long long>(); // 为 map_1[1] 设置新值 map_2::insert<refl(1), ^^long>(); // 为 map_2[1] 设置新值 map_1::insert<refl(2), refl(123)>(); // 为 map_1[2] 设置初始值 map_2::insert<refl(2), refl(321)>(); // 为 map_2[2] 设置初始值 } static_assert(map_1::at<refl(1)>() == ^^long long); static_assert(map_2::at<refl(1)>() == ^^long); static_assert(map_1::at<refl(2)>() == refl(123)); static_assert(map_2::at<refl(2)>() == refl(321));
上面的代码可以在 [godbolt](https://godbolt.org/z/ovqE3xsaq)[5] 上找到。
正如你现在所理解的,这些方法依赖于创建模板特化来存储状态。大多数示例所做的工作都可以通过普通的显式模板特化和宏来完成,以隐藏样板代码。反射使我们能够做到没有它无法实现的两件事。
首先,它允许我们将所有模板、类型和值作为键和值存储在一个类中。如果没有反射,每次我们需要不同的类型作为键和值时,就必须创建一个特殊的映射类。具体来说,为了拥有所有可能的类型和值(非类型)映射组合(类型到类型映射、类型到值映射、值到类型映射和值到值映射),我们需要 4 种不同编码的类。而且,每当我们想使用模板到其他或反之亦然的映射时,就需要一个新的映射类。相反,通过保存反射,我们只需拥有一个值到值的映射,当我们需要原始元素时,只需解析反射即可。
其次,由于我们使用 `define_aggregate` 函数创建了一个完整的类模板特化,因此可以在编译期间有条件地选择是否完成特化。没有反射这是不可能实现的。`#if` 指令无法实现这一点,因为它在预处理阶段执行,而不是在编译期间执行。这意味着 `#if` 不能依赖编译期间获取的任何信息,例如类型信息或常量的值,它只能使用其他宏。在编译期间轻松且有条件地保存状态对于有状态的元编程非常有用。
我希望你能理解这些方法是易于理解、信息丰富且有趣的。我相信这些方法对元编程很有用,希望你能利用反射来提高代码的性能、可读性、可维护性和安全性。
最后,我为你提供了一个 [链接](https://github.com/Alexey-Saldyrkine/compile_time_tools)[6],这是一个我创建的存储库,其中包含之前描述的类及其测试,以及通用枚举和编译时随机数生成器。
1. “C++26 的反射” P2996R13 ([https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2025/p2996r13.html](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2025/p2996r13.html))
2. 编译时票证计数器示例 ([https://godbolt.org/z/on4xMe338](https://godbolt.org/z/on4xMe338))
3. 编译时映射示例 ([https://godbolt.org/z/ac4zbjzx8](https://godbolt.org/z/ac4zbjzx8))
4. 编译时可变变量示例 ([https://godbolt.org/z/KhT155Psf](https://godbolt.org/z/KhT155Psf))
5. 可变编译时映射示例 ([https://godbolt.org/z/ovqE3xsaq](https://godbolt.org/z/ovqE3xsaq))
6. 通用枚举和编译时随机数生成器存储库 ([https://github.com/Alexey-Saldyrkine/compile_time_tools](https://github.com/Alexey-Saldyrkine/compile_time_tools))
7. 所有示例代码的存储库 ([https://github.com/Alexey-Saldyrkine/CT-map-and-mutable-variable-example-code](https://github.com/Alexey-Saldyrkine/CT-map-and-mutable-variable-example-code))