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 | struct adj_list_item_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:
classoruniontypes that satisfy all the requirements below:- All the base classes are
publicand structural in a recursive manner; - All the non-static data members are
public, non-volatileand non-mutable; - For
classtypes, all the non-static data members are either structural or array of structural type (including multi-dimensional ones); Foruniontypes, at least one of non-static data members is either structural or array of structural type (including multi-dimensional ones); - At least one
constexprconstructor exists;
- All the base classes are
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 | // From: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2025/p3491r3 |
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()returnsconst 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 whyrbox::meta_string_viewis preferred toconst CharT*inrboxAPI 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 | template <class T> |
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 | struct graph_t { |
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:
nameshould be a valid C++ identifier, and must be provided unless this data member is an anonymous bit-field;- If
alignmentis not provided, the default alignment (same as that oftype) is applied to this data member; Otherwise,alignmentshould be some 2^i where i is non-negative integer and must be no less than the alignment oftype; - If
bit_widthis provided, then this data member is a bit-field. In this casebit_widthmust be a non-negative integer,typemust be either integral type or enumeration type. andno_unique_addressmust be false. Specially, ifbit_widthis 0, thennamemust not be provided.
1 | namespace std::meta { |
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:
std::meta::nonstatic_data_members_of()returns only the direct members ofT, i.e. those defined inTdirectly rather than inherited from some base class ofT. In practice, we need to further query all the inherited data members fromT‘s base classes recursively.rboxlibrary 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.std::meta::nonstatic_data_members_of()requires two arguments: one isTand the other isctxof typestd::meta::access_contextwhich is related to access control of data members. Onlypublicdata members are accessible ifctxisunprivileged(). All the data members includingprotectedandprivateones are accessible ifctxisunchecked(). Access control is consistent with currentconstexprevaluation point ifctxiscurrent().rboxlibrary requiresTnot to contain anyprotectedorprivatedata member (including those from base classes), otherwiseTwill be rejected and a compilation error will occur. These details are omitted here as well.
1 | namespace impl { |
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 traditionalstd::make_index_sequence<n>()idiom. After adding support to mirroringstructs with reference data members (which are mirrored as-is),Rcan 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 ofR{...}in one go.
1 | template <class T> |