Introduction
Hello everyone. I would like to share with you a new method for creating compile-time key-value maps that I discovered while experimenting with the new features introduced in C++26. I will also show a new trick I call the compile-time mutable variable. I believe these methods will be very helpful in your stateful metaprogramming endeavors.
Before continuing, you should have an understanding of what a reflection is and its basics. You should also know about the reflection and splice operators. If you do not, I recommend reading §4.1 and §4.2 of the “Reflection for C++26” P2996R13[1] paper. You do not need to know the other sections, as I will explain and reference them as necessary.
To understand the new method, it’s best to start by dissecting the compile-time ticket counter example (P2996R13 §3.17). If you understand how it works, you can skip this section.
Compile-Time Ticket Counter (P2996R13 §3.17)
The examples used in this section can be found on godbolt[2].
The compile-time ticket counter is an example of how a compile-time counter could be made using the new features of reflection. The main goal of a compile-time counter is to be able to, during compilation, get the value of the counter and increment the value of the counter. This is useful for things such as automatically giving certain program elements unique numbers during compile-time, instead of manually setting the values and having to make sure that no duplicate numbers are given.
The class TU_Ticket has two static consteval functions: latest and increment.
The latest function returns the latest (current) value of the counter, and the increment function increments the counter's value by one.
To do this, TU_Ticket uses three new functions introduced with reflection in C++26: substitute, is_complete_type, and define_aggregate. These functions are part of the new namespace ‘meta’ and interact with reflections.
You may have also noticed the consteval block, with the syntax:
consteval {
...contents...
}The contents of a consteval block will be evaluated once during compilation. This is mainly used for the define_aggregate function and functions that will call define_aggregate. For details, see P2996R13 §4.4.1.
Substitute
“Given a reflection for a template and reflections for template arguments that match that template, substitute returns a reflection for the entity obtained by substituting the given arguments in the template. ”(p2996R13 §4.4.16). Here’s a quick example to illustrate what a substitute does:
template <typename T>
struct A;
constexpr auto refl_1 = ^^A<int>;
constexpr auto refl_2 = substitute(^^A, {
^^int});
static_assert(refl_1 == refl_2); // using substitute is the same as manually writing it
// example 1 is_complete_type
The function is_complete_type, as its name implies, checks if the given reflection represents a complete type.
Example:
struct A; // incomplete type
struct B {}; // complete type
static_assert(is_complete_type(^^A) == false);
static_assert(is_complete_type(^^B) == true);
// example 2
An important thing to know is that if a template is incomplete, then the templated types will be incomplete, unless the explicit template specialization is complete.
Example:
template <typename>
struct A; // incomplete class template
template <>
struct A<int> {}; // complete explicit template specialization
template <>
struct A<long>; // incomplete explicit template specialization
static_assert(is_complete_type(^^A<long>) == false);
static_assert(is_complete_type(^^A<char>) == false);
static_assert(is_complete_type(^^A<int>) == true);
// example 3define_aggregate
“define_aggregate takes the reflection of an incomplete class/struct/union type and a range of reflections of data member descriptions and completes the given class type with data members as described (in the given order).”(P2996R13 §4.4.19). In short, it takes an incomplete class and completes it. An important thing to note is that define_aggregate only completes the class if the function is called. This means that you can complete a class conditionally.
The following two code fragments are functionally the same:
With define_aggregate:
struct A;
static_assert(is_complete_type(^^A) == false);
consteval {
define_aggregate(^^A, {
}); // completes the type
}
static_assert(is_complete_type(^^A) == true);
// example 4aWithout define_aggregate:
struct A;
static_assert(is_complete_type(^^A) == false);
struct A {}; // completes the type
static_assert(is_complete_type(^^A) == true);
// example 4bIt is important to know that when you complete a templated type, you only complete that specific specialization of the template:
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);
// example 5aThis code is functionally the same as:
template <typename>
struct A;
template <>
struct A<int> {};
static_assert(is_complete_type(^^A<int>) == true);
static_assert(is_complete_type(^^A<char>) == false);
// example 5bThe define_aggregate function can complete the given class with data members. In our case, we will need to add a single data member, as such:
struct A1;
consteval {
define_aggregate(^^A1, {
data_member_spec(^^int, {
.name = "value"})});
}
struct A2 {
int value;
};
// example 6In example 6, the classes ‘A1’ and ‘A2’ are the same from the view of their data member.
The latest() function
The latest() function returns the current value of the counter. It does this by linearly searching for the first incomplete template specialization of the template ‘Helper’, where the template parameter is an integer starting from zero and incrementing by one. Once it finds an incomplete template specialization, it knows that the integer is the current value of the counter.
Example:
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);
//example 7a
This code is functionally the same as:
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);
// example 7bLet's go through a small example of what the latest function does. Let's say the counter has been incremented two times, so the latest function should return 2. The function starts by testing the value 0. It checks if the type ‘Helper<0>’ is complete. It is, as it was completed the first time we incremented the counter. So then it checks if the type ‘Helper<1>’ is complete, which was completed in the second increment. Now it checks the type ‘Helper<2>’. It is incomplete, which means that 2 is the current value of the counter. And so it returns the value 2, as it should.
Example code:
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(); // increment once
TU_Ticket::increment(); // increment twice
}
static_assert(is_complete_type(^^Helper<0>) ==
true); // class is complete, 0 is not the current value
static_assert(is_complete_type(^^Helper<1>) ==
true); // class is complete, 1 is not the current value
static_assert(is_complete_type(^^Helper<2>) ==
false); // class is incomplete! 2 is the current value, no need to search further
static_assert(TU_Ticket::latest() == 2);With this, you should understand that the counter stores information about its previous states by creating new complete template specializations of the incomplete ‘Helper’ class template. With this approach, we have some limitations. We cannot remove or alter any complete template specializations, because we cannot undefine or redefine a class definition. This means that the counter cannot be reset or have its count decremented.
The increment() function
The increment() function simply completes the template specialization with the current count, effectively incrementing the counter.
Compile-Time Map
The examples used in this section can be found on godbolt[3].
For this compile-time map, both the key and the value will be of type meta::info. This will allow the map to use pretty much anything for its key and value. Want an integer to point to a template? You can do that. Want to map a specific type to a member function? You can do that. The possibilities are endless.
You can probably guess that to store the key-value pairs of the map, we will be using class template specialization, where the template parameter will be the key, and the value will be stored in the complete class. But how will we store the value in the class? With define_aggregate, we can define the class to have named data members of a given type. Since we want to store a value of meta::info, we will have to wrap it in a type by having it as a static constexpr data member of type meta::info.
example of wrapping a value of type meta::info in a type:
template<auto>
struct storage;
template<meta::info v>
struct info_as_type{
static constexpr meta::info value = v;
};
//example 1aSo to store a key-value pair, you can do it as such:
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"})});
}
//example 1bHere, the ‘insert’ function takes two non-type template parameters: the key and the value. The function uses the incomplete class template ‘storage’ as the storage medium for the map, in a way similar to the ‘Helper’ template in the TU_Ticket example. First, the function substitutes the ‘storage’ template with the key as its parameter, then it completes the type and completes it, as well as giving it a data member ‘value’ of type ‘info_as_type<value>’. This way, the template specialization of ‘storage’ with the key as the parameter stores the value as a static constexpr data member of the type of the class's only data member.
To get the value, you could do it as such:
template<meta::info key>
consteval meta::info at(){
constexpr meta::info refl = substitute(^^storage,{reflect_constant(key)});
return decltype([:refl:]::value)::value;
}
//example 1cHere, the ‘at’ function takes one non-type template parameter, which is the key, and returns the corresponding value. The function gets the class template specialization of ‘storage<key>’, then it gets the type of its data member ‘value’, which is ‘info_as_type<value>’ . From that, it gets the key’s corresponding value and returns it. If for a given key, no key-value pair exists, then the template specialization would be incomplete, and therefore a compilation error would occur.
To wrap these functions together, we can put them in a class, and we’ll also put a ‘contains’ function to check if the map contains a specific key.
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);
}
//example 2aAnd so the compile-time map can be used as such:
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);
//example 2bAn important thing to know about this specific example of a compile-time map is that the map type itself is stateless. Only the class template saves states. This means that if two different map types use the same class template, the two maps will act as if they are the same type. Specifically, any pairs inserted into the first map will also be seen by the second map, as if they were inserted into it.
Example:
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>(); // inserts a pair into map1
}
static_assert(map1::contains(^^int) == true);
using map2 = CT_map<^^storage>;
static_assert(map2::at<^^int>() == ^^char); // map2 sees the same pair as its own
//example 3In this example, map1 and map2 are different types, but they use the same class template ‘storage’. Because of that, when we insert a pair into map1, map2 will have that pair aswell. map1 and map2 act as if they were the same type.
There are two ways to solve this problem. The first way is obvious: use different template classes for different maps. simple, but requires unique class templates for each map. The other way to solve this problem is to assign each map its own unique key, and when storing a pair, we will include this unique key with the regular key. Now all maps can use the same class template, and the unique key can be either manually set or automatically gotten from a compile-time counter.
Example:
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);
}
//example 4aThis example differs from the previous map in how it creates template specializations. Let's say you have a map with the unique key ‘^^1’ and we insert a pair with the key ‘^^int’. In the previous map, the template specialization would be ‘storage<^^int>’, but in this map it would be ‘storage<pair{^^1,^^int}>’. Now, if we have a second map with the unique key ‘^^2’ and the second map searches for a pair with the key ‘^^int’, it will search for the template specialization ‘storage<pair{^^2,^^int}>’, which is different from ‘storage<pair{^^1,^^int}>’. Now, as long as the unique keys are different between maps, the maps will not intersect with each other.
Example:
template<auto>
struct storage;
using map1 = CT_map<^^storage, reflect_constant(1)>;
static_assert(map1::contains(^^int) == false);
consteval{
map1::insert<^^int,^^char>(); // creates the specialization storage<{^^1,^^int}>
}
static_assert(map1::at<^^int>() == ^^char); // searches for the specialization storage<{^^1,^^int}>
static_assert(map1::contains(^^int) == true);
using map2 = CT_map<^^storage, reflect_constant(2)>;
static_assert(map2::contains(^^int) == false); // searches for the specialization storage<{^^2,^^int}>
consteval{
map2::insert<^^int,^^long>(); // creates the specialization storage<{^^2,^^int}>
}
static_assert(map2::at<^^int>() == ^^long); // searches for the specialization storage<{^^2,^^int}>
static_assert(map2::contains(^^int) == true);
//example 4bIn this example, map1 and map2 use different unique keys. Because of that, they will never interfere with each other and act as if the other doesn't exist. If they used the same unique key, then they would act as one map.
Just like with the compile-time ticket counter, once a pair has been inserted into the map, it cannot be removed or changed, not this one.
Compile-Time Mutable Variable
What is a compile-time mutable variable? This is a question you may be asking. A compile-time mutable variable, or CMV, is a program element that stores a value that is a constant expression. Throughout compilation, the value of the CMV can be changed. The CMV is very similar to a compile-time counter, except it can store any reflectable element instead of just an integer, and it can change its stored value to any other value, instead of just incrementing it.
Just like the counter, the CMV doesn’t actually change its value. The CMV works exactly like the ticket counter, except it also saves the value, like the map. In fact, we will build the CMV on top of the map.
The CMV will be defined as:
template<meta::info storage, meta::info unique_key = ^^void, int Hint = 100>
struct CMV{
using map = CT_map<storage,unique_key>;
...
};
Here, the CMV has three template parameters. The first two, storage and unique_key, are used by the internal compile-time map. This map will be used to set and get the value of the CMV. The third parameter is used by the ‘latest()’ function.
CMV will have a ‘latest()’ function, like the counter, but instead of searching for the greatest incomplete index, we search for the greatest complete index. We can also make a little optimization. Instead of searching for the latest index linearly, we will use binary search. We can do this because we can check if any given index gives us a complete type. If a tested index is complete, that means the latest index is greater than or equal to the tested index. If an index is incomplete, that means the latest index is less than the tested index. Combining these two facts together allows us to use binary search.
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;
}
Here, the starting search interval for the search is [0,Hint]. If the upper bound index is contained in the map, that means that there is a complete template specialization and that the latest index is not in the interval. While the upper bound index is contained in the map, it will be increased. In this example, the upper bound index is squared each time, but you can increase it in any way, depending on your specific needs. Mathematically, the Hint template parameter should be set to be greater than the expected number of times the CMV’s value will be set. Once it is guaranteed that the latest index is in the interval, a binary search is performed on the interval. The result is returned. If the latest index is -1, that means that the CMV is empty. Attempting to get the value from an empty CMV will result in a compilation error.
The ‘get()’ function will return the current value of the CMV. The function will get the latest index, and then it will use the index as a key in the map to get the corresponding value. This value is returned.
template<int index = latest()>
static consteval meta::info get(){
return map::template at<reflect_constant(index)>();
}
You may have noticed that the get function is a template function with the index being the parameter. By default, it will refer to the current value of the CMV, but as all previous values of the CMV are stored in the map, you can get any previous value of the CMV by using any index in the interval [0,latest()]. If get is called with an index outside of that interval, a compilation error will occur.
The ‘set()’ function will set the value of the CMV. When we want to store a new value in the CMV, the function will get the latest index and increment it by one, then we will insert a new pair into the underlying map with the new key and value. Now, the next time the latest function is called, it will point to the new value.
template<meta::info value, int index = latest() + 1>
static consteval void set(){
map::template insert<reflect_constant(index),value>();
}
Here’s an example of using a 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
The code shown can be found on godbolt[4].
A Mutable Compile-Time Map?
Now we have an immutable compile-time map and a compile-time mutable variable. What will happen if we put these two things together? That’s right, we can make a mutable compile-time map, eliminating one of the previous limitations of the map.
Code:
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<>();
}
};
And here’s an example of using the mutable compile-time map:
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)>(); // set initial value for map_1[1]
map_2::insert<refl(1), refl(2.34)>(); // set initial value for 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>(); // set new value for map_1[1]
map_2::insert<refl(1), ^^long>(); // set new value for map_1[1]
map_1::insert<refl(2), refl(123)>(); // set initial value for map_1[2]
map_2::insert<refl(2), refl(321)>(); // set initial value for 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));
The code shown can be found on godbolt[5].
What Reflection Adds
As you now understand, these methods rely on creating template specializations to store states. Most of what these examples do can be done with normal explicit template specializations and macros to hide the boilerplate. Reflection allows us to do two things that we could not do without it.
First, it allows us to use all templates, types, and values as keys and values with only one class. Without reflection, we would have to create a special map class every time we need a different type that will be the key and value. Specifically, we would need 4 differently coded classes to have all the possible combinations of maps that use types and values (non-type): type-to-type map, type-to-value map, value-to-type map, and value-to-value map. And a new map class would be needed for every different template parameter list, when you want to use a template-to-other or other-to-template map. Instead, we get to sidestep this problem by just saving the reflection, meaning we only ever have to have a value-to-value map, and when we want the original element, we simply splice the reflection.
Secondly, because we create a complete class template specialization using the function define_aggregate, we can conditionally choose whether we want to complete a specialization or not during compilation. This is impossible to do without reflection. The #if directive cannot accomplish this as it executes during the preprocessor phase, not during compilation. This means that #if cannot rely on any information gotten during compilation, such as type information or a constant’s value, it can only use other macros. The ability to easily and conditionally save states during compilation will be useful for stateful metaprogramming.
Conclusion
I hope you understand that these methods are understandable, informative, and interesting. I believe these methods will be useful for metaprogramming, and I hope you will be able to use reflection to improve your code's performance, readability, maintainability, and safety.
I’ll leave you with a link[6] to a repository that I have made, which has the previously described classes and their tests, as well as a universal enum and compile-time random number generator.
Bibliography
- “Reflection for C++26” P2996R13 (https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2025/p2996r13.html)
- Compile-Time Ticket Counter Examples (https://godbolt.org/z/on4xMe338)
- Compile-Time Map Examples (https://godbolt.org/z/ac4zbjzx8)
- Compile-Time Mutable variable Examples (https://godbolt.org/z/KhT155Psf)
- Mutable Compile-Time Map Examples (https://godbolt.org/z/ovqE3xsaq)
- Repository for universal enum and compile-time RNG (https://github.com/Alexey-Saldyrkine/compile_time_tools)
- Repository for all shown code examples (https://github.com/Alexey-Saldyrkine/CT-map-and-mutable-variable-example-code)