Document number: P4414R0
Audience: EWG, LEWG


Ville Voutilainen
2026-10-21

Structural classes: a bugfix for reflection

Abstract

This proposal allows encapsulated types, including string_view and span, to be used with define_static_array, and to be conveyed from compile-time code to run-time code.

The current compile-time/run-time bridge of reflection requires using raw pointers and separate lengths for object graphs, or any nested arrays. C++ programmers shouldn't have to use such low-level facilities for that purpose. This proposal aims to fix that problem, allowing the use of better abstractions for that purpose.

This paper proposes a new class-property-specifier, structural, used the same way as final:

class Widget structural {
    int id;
    std::string_view name;
public:
    constexpr Widget(int i, std::string_view n) : id(i), name(n) {}
    constexpr int get_id() const { return id; }
    constexpr std::string_view get_name() const { return name; }
};

A class marked structural is a structural type even if it has private or protected bases or non-static data members, provided that everything else the current rules require still holds. That is, the class is a literal type, no member is mutable, and the type of every base and non-static data member is itself structural. This paper also proposes marking std::span and std::basic_string_view structural, and changing std::define_static_string to return a std::basic_string_view instead of a pointer, the same way std::define_static_array already returns a std::span.

Together, these changes fix the most visible remaining problem with C++26 Reflection: you can't use std::define_static_array to convey a compile-time-computed array of encapsulated class objects to run time, nor an array of objects that contain string_views or spans. The proposal is deliberately small. It is not intended to solve every problem with constant template parameters of class type. P4340R0 provides more functionality at the cost of being less straightforward. It's not much more complicated to use its proposed default form, but that's still considered too elaborate for the simple cases, by the author of this paper. The two are compatible.

The problem

Since C++20, a class type can be the type of a constant template parameter if it is a structural type ([temp.param]/12). For a class, that means all of its bases and non-static data members must be public and non-mutable, and their types must be structural too, recursively.

C++26 Reflection builds on the same notion. std::meta::reflect_constant, std::meta::reflect_constant_array, std::define_static_object and std::define_static_array all require structural types. That is what allows a value computed during constant evaluation to be promoted to an object with static storage duration, which run-time code can then read.

The public-only requirement is a poor fit for arguably idiomatic C++ code, for one major reason, and two more detailed reasons. The major reason is that Reflection Should Not Require De-encapsulation. The two more detailed reasons are:

  1. Arguably idiomatic classes keep their data private and offer constructors and getters. Such a class can't be put into an array with define_static_array today, however simple its members are, unless every member is made public.
  2. Even a class with only public members is shut out if any of those members is a std::string_view or a std::span. Both are, conceptually, a pointer and a length, which are perfectly structural, but the members are private. Those are exactly the vocabulary types that reflection-generated data is made of. Names come back from std::define_static_string, and nested sequences come back from std::define_static_array, which already returns a span.

Consider a program that reflects over the classes in a namespace and builds, at compile time, a graph that it walks at run time. This is a real use case, from someone writing a reflection-based code generator intended to generate wrappers for types in whichever program. The full example is at https://godbolt.org/z/cfhsjhr71; here is its core:

class FunctionParameterData structural {
    std::string_view param_type;
public:
    constexpr FunctionParameterData(std::string_view pt) : param_type(pt) {}
    std::string_view get_param_type() const { return param_type; }
};

class MemberFunctionData structural {
    std::string_view name;
    std::string_view return_type;
    std::span<const FunctionParameterData> params;
public:
    constexpr MemberFunctionData(std::string_view n, std::string_view ret,
                                 std::span<const FunctionParameterData> parms)
      : name(n), return_type(ret), params(parms) {}
    // getters ...
};

class ClassData structural {
    std::string_view name;
    std::span<const MemberFunctionData> members;
public:
    // constructor and getters ...
};

consteval std::span<const FunctionParameterData> get_params(std::meta::info fun) {
    std::vector<FunctionParameterData> ret;
    for (auto x : std::meta::parameters_of(fun))
        ret.push_back(FunctionParameterData(
            std::define_static_string(std::meta::display_string_of(type_of(x)))));
    return std::define_static_array(ret);
}

// get_members() and get_classes() follow the same pattern ...

int main() {
    constexpr std::span data = get_classes();
    for (auto&& x : data) {
        std::cout << "Found class with name " << x.get_name() << '\n';
        for (auto&& mem : x.get_members()) {
            std::cout << "member " << mem.get_name() << '\n';
            for (auto&& param : mem.get_params())
                std::cout << "param " << param.get_param_type() << '\n';
        }
    }
}

This is how a C++ programmer would naturally write it: three encapsulated classes, each holding string_views and a span of the next level down. With the proposed changes, it compiles and prints the expected graph. Without the structural marking, the first define_static_array is rejected:

error: 'reflect_constant_array' argument with 'std::ranges::range_value_t<std::vector<FunctionParameterData> >'
       {aka 'FunctionParameterData'} which is not a structural type
note: 'FunctionParameterData::param_type' is not public

Making the data public doesn't help, because the members are string_views and spans, which aren't structural either. With the C++26 rules, the user has to replace each string_view with a raw const char* and each span with a pointer and a separate length. They then have to make every member public, and rebuild the views by hand at run time. Every level of the data structure adds the same chore.

The two promotion functions are also inconsistent with each other. std::define_static_array returns a span, which knows its length. std::define_static_string returns a bare const CharT*. The length is known at the point of the call, but it is thrown away, and every caller has to recover it from the terminator. The example above does exactly that: each define_static_string result is immediately turned back into a string_view by a constructor that scans for the terminator. Once string_view is structural, it is the natural result type: it's what users store, it can be a constant template argument directly, and it matches define_static_array.

Before and after

The table below shows a three-level nested structure (a class, its member functions, and their parameters) built at compile time and walked at run time, for this class:

struct Widget { void resize(int w, int h); int area() const; };
constexpr auto ctx = std::meta::access_context::unprivileged();
using namespace std::meta;

Both versions print the same output. On the left is what C++26 requires today: every member public, each string_view replaced by a const char*, and each span replaced by a pointer and a separate count. Run-time code then rebuilds every span by hand. On the right is the same program with this proposal: encapsulated classes that hold string_views and spans directly. Without the structural marking, the right-hand version is rejected with the error shown above.

C++26 todayWith this proposal
struct ParamData {                  // every member public
  const char* type;                 // not std::string_view
};
struct FunctionData {
  const char* name;
  const ParamData* params;          // not std::span
  std::size_t param_count;
};
struct ClassData {
  const char* name;
  const FunctionData* functions;
  std::size_t function_count;
};

consteval ClassData make_class(std::meta::info cls) {
  std::vector<FunctionData> fns;
  for (auto f : members_of(cls, ctx))
    if (is_function(f) && has_identifier(f)) {
      std::vector<ParamData> ps;
      for (auto p : parameters_of(f))
        ps.push_back({std::define_static_string(
                        display_string_of(type_of(p)))});
      auto sp = std::define_static_array(ps);
      fns.push_back({std::define_static_string(identifier_of(f)),
                     sp.data(), sp.size()});
    }
  auto sf = std::define_static_array(fns);
  return {std::define_static_string(identifier_of(cls)),
          sf.data(), sf.size()};
}

int main() {
  constexpr ClassData c = make_class(^^Widget);
  std::cout << c.name << '\n';
  for (auto& f : std::span(c.functions, c.function_count)) {
    std::cout << "  " << f.name << '\n';
    for (auto& p : std::span(f.params, f.param_count))
      std::cout << "    " << p.type << '\n';
  }
}
class ParamData structural {
  std::string_view type;
public:
  constexpr ParamData(std::string_view t) : type(t) {}
  constexpr std::string_view get_type() const { return type; }
};
class FunctionData structural {
  std::string_view name;
  std::span<const ParamData> params;
public:
  constexpr FunctionData(std::string_view n, std::span<const ParamData> p)
    : name(n), params(p) {}
  constexpr std::string_view get_name() const { return name; }
  constexpr std::span<const ParamData> get_params() const { return params; }
};
class ClassData structural {
  std::string_view name;
  std::span<const FunctionData> functions;
public:
  constexpr ClassData(std::string_view n, std::span<const FunctionData> f)
    : name(n), functions(f) {}
  constexpr std::string_view get_name() const { return name; }
  constexpr std::span<const FunctionData> get_functions() const { return functions; }
};

consteval ClassData make_class(std::meta::info cls) {
  std::vector<FunctionData> fns;
  for (auto f : members_of(cls, ctx))
    if (is_function(f) && has_identifier(f)) {
      std::vector<ParamData> ps;
      for (auto p : parameters_of(f))
        ps.push_back({std::define_static_string(
                        display_string_of(type_of(p)))});
      fns.push_back({std::define_static_string(identifier_of(f)),
                     std::define_static_array(ps)});
    }
  return {std::define_static_string(identifier_of(cls)),
          std::define_static_array(fns)};
}

int main() {
  constexpr ClassData c = make_class(^^Widget);
  std::cout << c.get_name() << '\n';
  for (auto& f : c.get_functions()) {
    std::cout << "  " << f.get_name() << '\n';
    for (auto& p : f.get_params())
      std::cout << "    " << p.get_type() << '\n';
  }
}

Output of both:

Widget
  resize
    int
    int
  area

The proposal

The structural class-property-specifier

structural becomes a second class-property-specifier, next to final. It is a context-sensitive identifier, not a keyword, and is recognized in exactly the same position and by the same disambiguation rule as final. The two can be combined, in either order.

Its only effect is on the definition of structural type. A class marked structural is exempt from the requirement that its own bases and non-static data members be public. Nothing else changes:

Template-argument-equivalence ([temp.type]) is already defined subobject by subobject, without regard to access, so it needs no change. Two values of a structural class type are template-argument-equivalent exactly when their corresponding subobjects are, just as for a struct with public members. Likewise, the Reflection facilities that require structural types work with such classes unchanged.

A union can be marked structural too. A union can have private members, although an anonymous union can't, and a union whose members are all public and of structural type is already a structural type today. Marking a union structural works exactly as it does for a class: the public-access requirement is lifted, and everything else still applies. Template-argument-equivalence for a union is already defined by its active member ([temp.type]), and that doesn't change.

Marking a class structural when it doesn't meet the remaining requirements makes the class definition ill-formed. The author has stated an intent, and a class that silently isn't structural after all would only be found out later, at some distant use, with a less helpful diagnostic. For a class template, each specialization is checked when it is instantiated. So Box<int> is fine, while Box<T> for a non-structural T is diagnosed at the point of instantiation.

This paper deliberately doesn't propose a conditional form, along the lines of structural(constant-expression), for wrapper templates that want to be structural exactly when their contents are. That is left intentionally as a possible future extension, if it turns out to be needed. Adding it later would be a pure extension: plain structural would simply mean structural(true), so no code written against this proposal would change meaning.

What structural means for the class author

By writing structural, the class author is promising that the values of the class's subobjects are its value, as far as template argument identity is concerned. Two objects whose subobjects are template-argument-equivalent are the same template argument. The template parameter object is copy-initialized from the template argument ([temp.arg.nontype]), so the class's copy constructor maintains its invariants, as it does for any other copy. That is the same promise a struct with public members makes today, just without having to expose the members to everyone. For classes like span and string_view, which are a pointer and a length, that is exactly right.

The promise is not right for every class. It doesn't hold for a class in which equal values can have different representations, such as a fraction that is reduced to lowest terms only lazily, or for one whose storage within its lifetime holds data that isn't part of the value, such as a cached hash. Those classes need more than member-wise identity, and simply shouldn't be marked structural; see "What this proposal doesn't do" below.

Many classes that look like they need more are fine, though. A class that normalizes its value on construction, such as a fraction kept in lowest terms by its constructor, has equal subobjects for equal values, so member-wise identity is exactly value identity. Storage that isn't part of the value is also fine if it consists of inactive union elemental subobjects ([intro.object]), which template-argument-equivalence ignores ([temp.type]): for example, a small-string buffer held in a union, whose elements past the string's length are never brought within their lifetime.

Why an opt-in, and not automatic

An alternative would be to treat a class with private data as structural automatically, whenever its members happen to meet the other requirements. The author of this paper firmly thinks that heuristics are just bad for determining intent; just because private data looks structural doesn't mean that it is structural semantically. Such heuristics would be nothing more than guesswork, and incorrect guesses could have unpredictable results. The opt-in context-sensitive keyword is considered a very clear expression of intent, with no such guesswork problems.

Some might think it's not good language design to have this shortcut and the more elaborate solution proposed in P4340, and have users decide which to use, and some might not like yet-another context-sensitive keyword. There's no objective fact that this paper can state either way; the author prefers having the context-sensitive keyword forever over having to worry about C++26 programs having to use de-encapsulated types for reflection, and staying that way forever.

Another alternative would be to use an attribute. That has some attractive properties; older language versions would just not make the type structural, and there are no class-type constant template parameters in languages older than C++20 anyway. Here the author prefers a strong statement of semantic intent, and if that requires macroing away a context-sensitive keyword not recognized by an older language, that seems better than having an attribute with all of its maybe-semantic-maybe-not properties. The preference isn't strong, and doesn't have an objective fact rationale. If the committee says with strong preference that an attribute is how we should do this, the author will accept that and regret it later, but will live with it. The author does see this as a semantic property stronger than what attributes seem to be expressing, though. Furthermore, ignoring the attribute in the current language version would make otherwise-valid uses of values of such types as constant template arguments ill-formed, and that's not what ignoring an attribute should result in.

Library: std::span and std::basic_string_view

This paper proposes marking std::span and std::basic_string_view structural. Both are already, conceptually, a pointer and a size. With this change they can be used as constant template parameters and, more importantly, as members of the encapsulated classes that Reflection code produces. Template argument identity for them is pointer identity plus size, not content equality, which is the same as what a defaulted customization would give under P4340R0.

Implementations need some latitude here. A library may store the extent of a span in a private helper type, which then also needs to be structural. libstdc++ does this; the implementation marks its helper structural as well. The library wording should say that these types are structural, rather than exactly how the implementation achieves that.

Library: std::define_static_string returns a basic_string_view

This paper proposes changing the return type of std::define_static_string from const ranges::range_value_t<R>* to basic_string_view<ranges::range_value_t<R>>. The promoted storage is unchanged: it is still the null-terminated array that std::meta::reflect_constant_string produces, so .data() is still a pointer to a null-terminated string with static storage duration. The view's length is that of the null-terminated string, so it stops at the first null character. Embedded null characters don't need any special treatment: the current const CharT* result can't represent them either, because callers can only ever use it as a null-terminated string. This also matches the usual convention for string literals.

With string_view structural, the result can be used directly as a constant template argument:

template<std::string_view S> struct X { /* ... */ };
X<std::define_static_string(name)> x;            // OK

template<const char* P> struct Y { /* ... */ };
Y<std::define_static_string(name).data()> y;     // the pointer, if needed

This is a breaking change against C++26 as specified: code that stores the result in a const CharT*, or passes it to a pointer template parameter, needs .data(). Code that initializes a string_view from the result, as the example above does, is unaffected. In the implementation, four existing call sites needed .data() added: three tests in GCC's reflection test suite and one use inside libstdc++. Because define_static_string is new in C++26, there is little existing code to break, and the window for making the change cheaply is now.

This paper proposes making this change for C++26, together with the rest of the proposal. Failing that, this paper proposes it for C++29, as a defect report against C++26. Note that this is strictly against the author's own policy of "Never Backport Features; that dilutes what a particular standard of the language means and makes it harder to cope with DR variance". The author grudgingly considers this an improvement important enough to violate that policy with a feature-backporting DR. The author considers not doing that feature-backporting worse.

Comparison with P4340R0

P4340R0, "Extending constant template parameter support by customizing std::meta::reflect_constant", proposes that a class can opt in to being a constant template parameter by declaring a consteval member function reflect_constant. The function returns a std::meta::info that reflects the template parameter object for that value. The function can be user-written or = default.

P4340R0 solves a larger problem than this paper: normalization that isn't done on construction, values whose representation contains non-value data within its lifetime, string literals as template arguments, and a path towards std::vector and std::string once non-transient allocation exists. This paper considers that work valuable. This paper's claim is narrower: the problem that actually blocks Reflection users today (encapsulated data, and string_view/span members) doesn't need any of that machinery. A one-word opt-in solves it, and that gives far more bang for the buck:

The two proposals compose. If P4340R0, or something like it, is adopted later for the harder cases, structural remains the concise spelling for the common case. It could even be specified as being equivalent to a defaulted customization. Nothing in this proposal closes the door on normalization or custom serialization.

ABI: adding a P4340R0-style mechanism later

Adopting a P4340R0-style customization later, on top of this proposal, is not by itself an ABI break. A constant template argument of class type is mangled from the values of its subobjects, whether they are public or not. A structural class, and a class with a defaulted customization under P4340R0, both get member-wise template parameter objects and member-wise template-argument-equivalence. Their specializations therefore keep the same mangled names. P4340R0 adds rules only for types that customize, so classes that stay structural are unaffected.

Changing the template-argument identity of an existing type is an ABI break, by whichever mechanism and on top of whichever proposal. For example, suppose std::string_view later gained a customization that compares contents instead of pointer and length. Specializations for views with equal contents but different pointers, currently distinct, would merge. The mangled names of every specialization with a string_view argument would change, and so would the .data() of the template parameter object. The same break would follow from starting with P4340R0's defaulted customization for string_view and changing it later.

The identity of std::string_view and std::span as template arguments is therefore decided when they first become usable as constant template arguments, by this paper or any other. Marking them structural fixes their identity as pointer plus length. P4340R0 makes the same commitment: it proposes defaulted customizations for std::string_view and std::span, which give them exactly this member-wise, pointer-plus-length identity, and it argues for keeping string_view distinct from std::string, which compares contents. On this point the two papers don't differ. For the strings that Reflection code produces, the two notions of identity coincide: the result of define_static_string refers to a template parameter object, and no two template parameter objects have template-argument-equivalent values ([temp.param]). So equal strings promoted this way share storage and are the same template argument. Pointer identity and content identity disagree only for views into other storage, such as a substring of a larger static array.

Regardless of practical uses, real or imagined, a string_view doesn't own the string, so the contents are not its value. Furthermore, due to the existing reality of extern buffers, the contents may not even be visible in the translation unit that uses the view as a template argument:

extern const char key[16];   // defined, and possibly changed, in another TU
template<std::string_view K> struct Cipher { /* ... */ };
using C = Cipher<std::string_view(key, sizeof key)>;   // OK

This is a valid constant template argument, and the specialization is identified by the buffer's address and the length alone. Under content identity, the identity of C would depend on characters that this translation unit can't see, and that could change by recompiling a different translation unit.

What this proposal doesn't do

structural isn't intended as a complete solution for every use of constant template parameters of class type. In particular, it doesn't provide:

Types that need these shouldn't be marked structural, and remain candidates for a P4340R0-style mechanism.

The library part of this proposal is also intentionally limited to the bare minimum needed to fix Reflection: marking std::span and std::basic_string_view structural, and changing the return type of std::define_static_string. This paper doesn't propose marking any other library types, such as std::optional, std::expected, std::tuple, std::reference_wrapper or the <chrono> types. Whether and how to mark them can be decided separately, type by type, once the language feature is in place. Keeping the library scope minimal keeps this a small fix that can be applied to C++26.

Implementation experience

The proposal is implemented in a GCC branch, available on Compiler Explorer as the "x86-64 gcc (P4324 contracts, and profiles)" compiler, under -std=c++26 -freflection. The example above compiles and runs there. The implementation also marks std::span (and its internal extent storage) and std::basic_string_view structural in libstdc++. The tests cover:

The implementation also changes std::define_static_string to return a std::basic_string_view. Its tests cover the result's length and contents, identity of the underlying storage via .data(), use of the result as a string_view constant template argument, and the null-character truncation rule.

Wording

The wording is relative to the current working draft. It is an initial attempt.

[class.pre]

Modify the grammar in [class.pre]/1 as follows:

class-property-specifier:
    final
    structural

Modify [class.pre]/5 as follows:

Each class-property-specifier shall appear at most once within a single class-property-specifier-seq. Whenever a class-key is followed by a class-head-name, the identifier finala sequence of one or more identifiers that could be class-property-specifiers, and a colon or left brace, the identifier iseach identifier in that sequence is interpreted as a class-property-specifier.

[Example 2:

struct A;
struct A final {};      // OK, definition of struct A,
                        // not value-initialization of variable final

struct X {
 struct C { constexpr operator int() { return 5; } };
 struct B final : C{};  // OK, definition of nested class B,
                        // not declaration of a bit-field member final
};

class S structural {    // OK, definition of class S
  int i;
};
struct T final structural : X {};  // OK, both identifiers are class-property-specifiers

— end example]

Add a new paragraph after [class.pre]/6:

If a class is marked with the class-property-specifier structural and that class is not a structural type ([temp.param]), the program is ill-formed. [Note: A class marked structural can be a structural type even if it has non-public base classes or non-public non-static data members ([temp.param]). — end note]

[temp.param]

Modify [temp.param]/12 as follows:

A structural type is one of the following:

[Example:


class P structural {
  int v;
public:
  constexpr P(int i) : v(i) {}
};
class Q {
  int v;
public:
  constexpr Q(int i) : v(i) {}
};
template<P> struct A {};   // OK, P is a structural type
template<Q> struct B {};   // error: Q is not a structural type
class R structural {      // error: R is marked structural but is not a structural type,
  Q q;                     // because Q is not a structural type
};

— end example]

Library

Modify the class template definition of basic_string_view in [string.view.template.general]:

template<class charT, class traits = char_traits<charT>>
class basic_string_view structural {

Modify the class template definition of span in [views.span.overview]:

template<class ElementType, size_t Extent = dynamic_extent>
class span structural {

Add to [views.span.overview] and [string.view.template.general], respectively:

span<ElementType, Extent> is a structural type ([temp.param]).

basic_string_view<charT, traits> is a structural type ([temp.param]).

[Drafting note: The exposition-only data members of span and basic_string_view are of pointer and size_t type, which are structural, so the class-head marking alone makes the specification consistent. The added sentences are there to make it clear to implementations that any internal helper types must also be structural. — end drafting note]

Modify the declaration of define_static_string in the header <meta> synopsis [meta.syn] as follows:

template<ranges::input_range R>
  consteval const ranges::range_value_t<R>*basic_string_view<ranges::range_value_t<R>> define_static_string(R&& r);

Modify [meta.define.static]/15 and the declaration preceding it as follows:

template<ranges::input_range R>
  consteval const ranges::range_value_t<R>*basic_string_view<ranges::range_value_t<R>> define_static_string(R&& r);

Effects: Equivalent to:

return basic_string_view<ranges::range_value_t<R>>(meta::extract<const ranges::range_value_t<R>*>(meta::reflect_constant_string(r)));

Feature-test macros

Modify the entry for __cpp_nontype_template_args in [cpp.predefined] as follows, where 20XXXXL is replaced by the year and month in which this proposal is adopted:

__cpp_nontype_template_args 201911L20XXXXL

Modify the following entries in [version.syn], with 20XXXXL as above:

#define __cpp_lib_define_static 202506L20XXXXL // freestanding, also in <meta>
#define __cpp_lib_span          202311L20XXXXL // freestanding, also in <span>
#define __cpp_lib_string_view   202403L20XXXXL // also in <string>, <string_view>

[Drafting note: __cpp_nontype_template_args covers the language change, since a class marked structural can be the type of a constant template parameter. __cpp_lib_define_static covers the new return type of define_static_string, and __cpp_lib_span and __cpp_lib_string_view cover those types being structural. __cpp_lib_is_structural is unchanged: the trait's specification doesn't change, only the set of types it is true for. — end drafting note]