Manually translated from: 扩展 C++26 std::define_static_array() 的编译期数据提升能力

Compile-time reflection has been part of C++26 standard, and C++ standard library provides std::define_static_array() which promotes a range to a constant array in static data storage, effectively helping us to cut off boilerplate code and compile-time overhead with template metaprogramming especially when the constexpr evaluation is complex. However, std::define_static_array() has its own limitations that require the range value type to be structural, the same constraint to non-type template parameters (NTTP) due to the fact that std::define_static_array() is technically based on template instantiation internally. That is, the range value type is limited to C++ fundamental types, or structs or unions whose data members are structural recursively, etc., which is not the common case in real-world codebase. Even the result type of std::define_static_array() itself, which is std::span<const T>, is not structural due to its private data members, making nested range promotion difficult with std::define_static_array() alone. This blog shows how we can extend the ability of compile-time data promotion by utilizing the great power of C++26 reflection plus a few helper types as replacements to standard library, enabling more non-structural types (including complicated, nested ones) to be promoted as compile-time constant data.

The contents shown by this blog is one of the core components in my rbox library. The source code of rbox::to_static_storage() is available in my GitHub repository.

How It Works Finally

As an extension to std::define_static_array() etc. in C++ standard library, rbox::to_static_storage() is capable of processing more complicated types like graph_t in the following example whose members are nested non-structural range types. rbox::meta_span and rbox::meta_string_view are used as structural replacements to std::span and std::string_view respectively. graph_t itself is promoted as struct rbox::structural_mirror_t<graph_t> which is defined via std::meta::define_aggregate() provided by C++26 standard library of reflection.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct adj_list_item_t {
size_t target_index;
double edge_weight;
};

struct graph_t {
std::vector<double> node_weights;
std::vector<std::string> node_labels;
std::vector<std::vector<adj_list_item_t>> adjacency_list;
};

// Type of promoted_graph is rbox::structural_mirror_t<graph_t>, which is defined as {
// rbox::meta_span<double> node_weights;
// rbox::meta_span<rbox::meta_string_view> node_labels;
// rbox::meta_span<rbox::meta_span<adj_list_item_t>> adjacency_list;
// };
constexpr auto promoted_graph = rbox::to_static_storage(graph_t{ /* ... */ });

// Type of promoted_graphs is rbox::meta_span<rbox::structural_mirror_t<graph_t>>
constexpr auto promoted_graphs = rbox::to_static_storage(std::vector<graph_t>{ /* ... */ });

What is Structural-ness and Why std::define_static_array() Requires It

C++ standard mandates that non-type template parameters (NTTP) must be of structural types. A type is structural only if it is one of the following (candidates since C++20 are marked blue and candidates since C++26 are marked green):

  • Arithmetic types: integral or floating-point;
  • Enumeration types;
  • Pointers (to data in static storage) and std::nullptr_t;
  • Left-value references (to data in static storage);
  • Pointer-to-members;
  • Lambda expressions without capture list (i.e. convertible to function pointers)
  • Literal class types: class or union types that satisfy all the requirements below:
    1. All the base classes are public and structural in a recursive manner;
    2. All the non-static data members are public, non-volatile and non-mutable;
    3. For class types, all the non-static data members are either structural or array of structural type (including multi-dimensional ones); For union types, at least one of non-static data members is either structural or array of structural type (including multi-dimensional ones);
    4. At least one constexpr constructor exists;
  • std::meta::info.

For example: std::pair<int, int> and user-defined struct pair_t {int x, y; }; are both structural and thus available as NTTPs. std::tuple<int, int> is non-structural since its data members are not public. std::pair<int, std::tuple<int, int>> is non-structural either since its data member second is of non-structural type.

As we have mentioned before, std::define_static_array() is technically based on template instantiation. It promotes all the range values as NTTPs of the internal __fixed_array, a template of constexpr C-style arrays. Data promotion is performed by std::meta::reflect_constant_array(), which instantiates the __fixed_array template with input values as NTTPs via std::meta::substitute(). std::define_static_array() works as encapsulation of std::meta::reflect_constant_array, extracting the internal array’s start pointer via std::meta::extract() and then wrapping the result as std::span. The rationale of std::define_static_array() explains why it requires the range value type to be structural, the same requirements to NTTPs.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// From: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2025/p3491r3
template <typename T, T... Vs>
inline constexpr T __fixed_array[sizeof...(Vs)]{Vs...}; // Internal array

template <std::ranges::input_range R>
consteval auto reflect_constant_array(const R& range) -> std::meta::info
{
using T = std::ranges::range_value_t<R>;
auto args = std::vector<std::meta::info>{^^T};
for (auto&& elem : range) {
args.push_back(std::meta::reflect_constant(elem));
}
return substitute(^^__fixed_array, args); // std::meta::substitute found via ADL
}

template <std::ranges::input_range R>
consteval auto define_static_array(const R& range) -> std::span<std::ranges::range_value_t<R> const>
{
using T = std::ranges::range_value_t<R>;
// Produce the array.
auto array = reflect_constant_array(range);
// Turn the array into a span. std::meta::{extract, extent, type_of} are found via ADL.
return std::span<T const>(extract<T const*>(array), extent(type_of(array)));
}

First Step to Nested Range: Structural Replacement to std::span

std::span is not structural due to its private data members, making std::define_static_array() face difficulty with nested ranges. After all the inner ranges promoted to std::span, the outer range can not be further promoted from a range of std::spans. The solution is straightforward. We can implement a structural replacement type to std::span, and convert the std::span returned by std::define_static_array() to our own replacement type. In my rbox library the replacement type is rbox::meta_span which is simply struct { const T* head; size_t n; } plus member functions aligned to std::span. Similarly, rbox::meta_string_view is provided as structural replacement of std::string_view, enabling std::vector<std::string> to be promoted recursively to rbox::meta_span<rbox::meta_string_view>.

p.s. Although std::define_static_string() returns const CharT* which is already structural, a string-view is helpful to improve runtime performance (with slight memory cost) by recording the string length. That is why rbox::meta_string_view is preferred to const CharT* in rbox API design.

Now we can implement an initial draft of to_static_storage(input). If input is a range whose value type is character, then the promotion result is rbox::meta_string_view. If input is a range of other value types, rbox::to_static_storage() is invoked recursively to each of its values. Otherwise, input is returned unchanged (more cases will be shown in the following contents).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template <class T>
consteval auto to_static_storage(const T& input)
{
if constexpr (std::ranges::input_range<T>) {
// (1) Range
using V = std::ranges::range_value_t<T>;
if constexpr (char_type<V>) {
const auto* c_str = std::define_static_string(input);
return meta_basic_string_view{c_str};
} else {
auto elementwise = input | std::views::transform([](const auto& value) {
return to_static_storage(value);
});
auto span = std::define_static_array(elementwise);
return meta_span{span.data(), span.size()};
}
} else {
// Otherwise: identity
return input;
}
}

Handling Complex structs: Generating its Structural Mirror Type

Real-world codebase often goes beyond the complexity of nested ranges. Now we revisit graph_t shown in the example before. Till now, each of its data members can be promoted conveniently, but promoting graph_t as a whole (i.e. keeping data organized as data members of some struct) still requires boilerplate code and lots of manual work: defining a new struct named static_graph_t, copy-and-pasting all the data members and then modifying the types one-by-one, then repeating to_static_storage() calls for every data member. At least two times of repetition are required for each data member of graph_t. But fortunately, C++26 reflection is still powerful enough to eradicate such boilerplates and free us from the code maintenance burden.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct graph_t {
std::vector<double> node_weights;
std::vector<std::string> node_labels;
std::vector<std::vector<adj_list_item_t>> adjacency_list;
};

struct static_graph_t {
rbox::meta_span<double> node_weights;
rbox::meta_span<rbox::meta_string_view> node_labels;
rbox::meta_span<rbox::meta_span<adj_list_item_t>> adjacency_list;
};

consteval auto promote_graph(const graph_t& G) -> static_graph_t
{
return {
.node_weights = rbox::to_static_storage(G.node_weights),
.node_labels = rbox::to_static_storage(G.node_labels),
.adjacency_list = rbox::to_static_storage(G.adjacency_list),
};
}

The essence of our goal is to transform graph_t to its corresponding structural alternative where each data member is converted to its mirror type which is the promotion result of this data member with to_static_storage(). C++26 provides std::meta::define_aggregate(), a helpful tool to define a struct or union type within constexpr expressions. Typical usage of std::meta::define_aggregate() is to convert an existing struct to a new type, like the “Struct to Struct of Arrays” example in the draft of C++ reflection standard (a.k.a. the p2996 paper). std::meta::define_aggregate() takes two arguments type_class and specs. type_class reflects a struct or union type which is currently an incomplete type, and specs is a list representing all the non-static data members we would like to add to type_class. Each element of specs is a return value of std::meta::data_member_spec(type, options) where type specifies the type of this data member and options specifies the identifier, alignment and bit-width (for bit-fields) of this data member, and whether it has [[no_unique_address]] attribute. The following constraints are imposed to options:

  1. name should be a valid C++ identifier, and must be provided unless this data member is an anonymous bit-field;
  2. If alignment is not provided, the default alignment (same as that of type) is applied to this data member; Otherwise, alignment should be some 2^i where i is non-negative integer and must be no less than the alignment of type;
  3. If bit_width is provided, then this data member is a bit-field. In this case bit_width must be a non-negative integer, type must be either integral type or enumeration type. and no_unique_address must be false. Specially, if bit_width is 0, then name must not be provided.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
namespace std::meta {
struct data_member_options {
optional<name_type> name; // name_type is constructible from string or u8string
optional<int> alignment;
optional<int> bit_width;
bool no_unique_address = false;
};

consteval auto data_member_spec(info type, data_member_options options) -> info;

template <reflection_range R = initializer_list<info>>
consteval auto define_aggregate(info type_class, R&& specs) -> info;
}

// Example of usage
struct T; // Currently an incomplete type

consteval {
// std::meta::{define_aggregate, data_member_spec} found via ADL
define_aggregate(^^T, {
data_member_spec(^^int, {.name = "x"}),
data_member_spec(^^int, {.name = "y", .alignment = 8}),
data_member_spec(^^unsigned, {.name = "flags", .bit_width = 10}),
});
// Now T is a complete type which is defined as struct {
// int x;
// alignas(8) int y;
// unsigned flags : 10;
// };
}

Given struct T, we can generate its mirror type with std::meta::define_aggregate(). The list of T‘s non-static data members can be obtained via std::meta::nonstatic_data_members_of(). For each of T‘s data member, std::meta::type_of() can be used to obtain its type U, then we can transform U to its result type of to_static_storage(). Names of each data member are kept as-is in the mirror type. Type transformation is performed with the help of a type alias template to_static_storage_result_t which is instantiated in the constexpr expressions via std::meta::substitute(). The mirror type of T is defined in structural_mirror<T>::type.

Important notes:

  1. std::meta::nonstatic_data_members_of() returns only the direct members of T, i.e. those defined in T directly rather than inherited from some base class of T. In practice, we need to further query all the inherited data members from T‘s base classes recursively. rbox library contains preprocessing mechanism to “flatten” all the data members (see source code for details of flattening). These details are omitted here to keep this blog clean.
  2. std::meta::nonstatic_data_members_of() requires two arguments: one is T and the other is ctx of type std::meta::access_context which is related to access control of data members. Only public data members are accessible if ctx is unprivileged(). All the data members including protected and private ones are accessible if ctx is unchecked(). Access control is consistent with current constexpr evaluation point if ctx is current(). rbox library requires T not to contain any protected or private data member (including those from base classes), otherwise T will be rejected and a compilation error will occur. These details are omitted here as well.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
namespace impl {
consteval auto make_structural_mirror_members(std::meta::info T) -> std::vector<std::meta::info>
{
auto ctx = std::meta::access_context::unprivileged();
// std::meta::{nonstatic_data_members_of, type_of, identifier_of} and
// std::meta::{substitute, data_member_spec} found via ADL.
auto members = nonstatic_data_members_of(T, ctx);
auto n = members.size();

auto res = std::vector<std::meta::info>(n);
for (auto i = 0zU; i < n; i++) {
auto U = type_of(members[i]);
auto name = identifier_of(members[i]);

// S reflects to_static_storage_result_t<U>
auto S = substitute(^^to_static_storage_result_t, {U});
res[i] = data_member_spec(S, {.name = name});
}
return res;
}
} // namespace impl

template <class T>
using to_static_storage_result_t = decltype(to_static_storage(std::declval<T>()));

template <class T>
struct structural_mirror {
class type;

consteval {
// std::meta::define_aggregate found via ADL
define_aggregate(^^type, impl::make_structural_mirror_members(^^T));
}
};

template <class T>
using structural_mirror_t = typename structural_mirror<T>::type;

Let T be the input type of to_static_storage() which is a struct. Rest of work is natural after the mirror type of T can be obtained. Let n be the number of data members of T and R be the mirror type of T, all we need is to assign the promotion result of the i-th data member of T to the i-th data member of R for each i = 0, 1 … n-1. C++26 supports “template-for” syntax (expansion statements) which is helpful to implement this process with clean code. The index sequence from 0 to n-1 can be generated conveniently with std::views::indices(n) (since C++26, equivalent to std::views::indices(n)), then the old-fashioned std::make_index_sequence<n>() can be replaced by template for of std::views::indices(n) in a more readable and expressive style.

Unfortunately, template-for is not the choice in the actual implementation of rbox, which falls back to traditional std::make_index_sequence<n>() idiom. After adding support to mirroring structs with reference data members (which are mirrored as-is), R can no more be value-initialized due to existence of reference data members, causing the following code pattern fail to work. The only choice in this case is to initialize all the data members of R{...} in one go.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <class T>
consteval auto to_static_storage(const T& input)
{
if constexpr (std::ranges::input_range<T>) {
// (1) Range. Omitted here ...
} else if constexpr (/* T is non-structural class type which is mirrorable */) {
// (2) Mirrorable struct
using R = structural_mirror_t<T>;
constexpr size_t N = nonstatic_data_members_of(^^T).size();

auto result = R{};
// for I = 0 to N-1, with each I being constexpr
template for (constexpr auto I : std::views::indices(N)) {
constexpr std::meta::info from = nonstatic_data_members_of(^^T)[I];
constexpr std::meta::info to = nonstatic_data_members_of(^^R)[I];
result.[:to:] = to_static_storage(input.[:from:]);
}
return result;
} else {
// Otherwise: identity
return input;
}
}