Aggregate Initialisation C++ — Member Reorder Silent Bugs
A struct reorder silently swapped width/height at 200 call sites.
20+ years shipping performance-critical C and C++ systems. Lessons pulled from things that broke in production.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Aggregate initialisation fills all fields of a struct or array in one braced statement — no constructor needed
- An aggregate has no user-provided constructors, no private/protected non-static members, no virtual functions
- Partial braced-init is safe: trailing members use default member init first, then zero-init — never garbage
- C++20 designated initialisers (.field = value) make call sites self-documenting and protect against reorder bugs
- Aggregate initialisation is trivially constexpr-compatible — constructors require explicit constexpr marking
- Biggest mistake: adding = default to a constructor and expecting aggregate status to be preserved in C++17
Imagine you're filling out a form at the doctor's office — name, age, blood type, allergies — all in one go before you even hand it in. Aggregate initialisation is exactly that: you fill in all the fields of a struct or array in one clean statement, right where you create it. No constructor needed, no setter calls, just a list of values in curly braces matched up to the fields in order. It's the C++ equivalent of 'fill in the whole form at once'.
Every C++ codebase has them — structs that represent a config object, a 2D point, a network packet header, or an RGB colour. And yet, a surprising number of intermediate developers write five lines of assignment code after declaring the struct, when a single brace-initialised line would do the job better, more safely, and with zero runtime overhead.
The problem it solves is subtle but important: before brace initialisation became powerful in C++11 (and significantly more expressive in C++20), initialising a plain data structure required either a custom constructor — which is boilerplate you shouldn't have to write — or a chain of manual assignments that left a window for uninitialised members to sneak through. An uninitialised member in a struct is a silent bug. It doesn't crash loudly; it corrupts quietly, and the values it produces are whatever happened to be sitting in that memory location from a previous stack frame or allocation. Aggregate initialisation closes that window by letting you express the entire initial state of an object in one declaration.
I've seen this go wrong in two directions. The first is the developer who writes a six-member struct and initialises it with six separate assignment statements in the constructor body — technically correct, but it defeats the purpose and introduces a maintenance burden. The second is the developer who discovers brace-init, uses it enthusiastically, then reorders struct members for cache alignment and silently ships swapped field values to production because 200 positional init sites compiled without a single warning.
Both problems have clean solutions, and understanding them requires knowing the exact rules that govern what an aggregate is, how initialisation precedence works, and what C++20 designated initialisers actually protect you against.
By the end of this article you'll know exactly what makes a type an aggregate and why that distinction matters, how to use brace initialisation confidently including the C++20 designated initialisers syntax, where aggregate initialisation saves you from writing unnecessary constructors, and which edge cases will bite you if you're not paying attention.
What Exactly Is an Aggregate? (The Rules You Must Know)
Before you can use aggregate initialisation confidently, you need to know what qualifies as an aggregate — because the rules are stricter than most people assume, and they changed meaningfully across C++ standards in ways that have caused real production bugs.
In C++20, an aggregate is a type that satisfies ALL of these conditions: it is an array, or it is a class/struct/union with no user-provided constructors (a defaulted constructor declared directly in the class body is now allowed in C++20), no private or protected non-static data members, no virtual functions, and no virtual, private, or protected base classes.
The distinction that trips people up most is 'user-declared' versus 'user-provided'. A user-declared constructor is any constructor that appears in the class definition — including = default and = delete. A user-provided constructor is a user-declared constructor that is not explicitly defaulted or deleted. In C++17, user-declared was enough to disqualify — so MyStruct() = default; killed aggregate status. In C++20, only user-provided constructors disqualify the type.
Why does this matter in practice? Because if your type stops being an aggregate, brace initialisation falls back to calling a constructor through the normal overload resolution path — a completely different code path with different rules around narrowing conversions, implicit conversions, and zero-initialisation. You might not notice the change immediately because the code still compiles. But the semantics are different, and the difference will surface at the worst possible time.
The tool you want is std::is_aggregate_v, available from C++17. Add a static_assert(std::is_aggregate_v to the header where you define any struct you depend on being an aggregate. This turns an accidental disqualification into a compile error at the definition site rather than a confusing failure at a call site 10 files away.
- No user-provided constructors — = default in C++20 is acceptable, but a constructor with a body is not
- No private or protected non-static data members — every data field must be public
- No virtual functions — no vtable, no polymorphic dispatch
- No virtual, private, or protected base classes — public non-virtual inheritance is allowed from C++17 onwards
- Arrays are always aggregates regardless of element type
- Verify with static_assert(std::is_aggregate_v<T>) in the header — catch disqualification at the definition, not at a call site
Brace Initialisation in Depth — Positional, Nested, and Zero-Initialisation
The syntax for aggregate initialisation is a braced-init-list: TypeName variable { val1, val2, val3 }. Values are assigned to members in declaration order, left to right, matching the order in which members are declared in the struct definition. If you provide fewer values than there are members, the remaining members are value-initialised — which means zero-initialisation for scalar types (0 for integers, 0.0 for floats, nullptr for pointers) and default construction for class-type members. This is one of the most practically useful safety properties aggregate initialisation gives you for free, and it means partial initialisation is a deliberate design choice, not a bug waiting to happen.
Nested aggregates initialise with their own inner braced-init-list. A struct containing another struct uses nested braces for the inner struct's members. Arrays of aggregates work identically. This nesting is how you express complex data structures — game entity components, network message frames, hardware register maps, shader parameter blocks — cleanly in a single declaration without any constructor boilerplate.
The precedence rule that catches developers most often: if a member has a default member initialiser and you omit it from the braced list, the default value is used — not zero-init. The precedence chain is strict and unambiguous: an explicit value in the braced list wins; if absent, the default member initialiser wins; if absent, zero-init applies. Knowing this chain precisely lets you design struct defaults intentionally rather than hoping the compiler does what you assume.
int zLayer = 0 keeps its default value of 0 when omitted from the braced list — the mechanism is default member initialisation, not zero-init. The result happens to be the same here, but if the default were int timeout = 30 and you expected 0 after omitting it, you'd get 30 instead.int timeoutMs = 5000 as a default member initialiser from an earlier refactor. The partial braced list omitted the timeout field, which used the 5000 default instead of zero. The connection hung for 5 seconds on every failure path instead of failing immediately as intended. The root cause was the precedence chain: the developer knew about zero-init but didn't realise the default member initialiser took priority over it. The fix was adding the timeout explicitly: { .host = "...", .timeoutMs = 0 }.C++20 Designated Initialisers — Self-Documenting Struct Construction
Designated initialisers let you name the fields you're initialising using a .fieldName = syntax in the braced list. If you've used C99's designated initialisers or Python's keyword arguments, this will feel immediately familiar. They landed in C++20 and they fundamentally change the maintainability of aggregate initialisation for structs with more than two or three fields.
The single most important practical benefit: the call site becomes self-documenting and reorder-safe. Compare Config { true, 8080, 30, false } against Config { .enableTLS = true, .port = 8080, .timeoutSeconds = 30, .verbose = false }. Six months later, to a developer who doesn't have the struct definition open in another tab, the second version is unambiguous. If you reorder members in the struct definition, any designated initialiser that references a field that has been renamed or removed fails to compile — instead of silently producing wrong values.
There are constraints you need to know. First, designated initialisers must appear in the same order as the member declarations in the struct — you cannot reference them in arbitrary order the way C99 allows. Second, you cannot name the same member twice. Third, any member you do not name gets zero-initialised or uses its default member initialiser, following the same precedence chain as positional init. You can mix designated and non-designated initialisers technically, but in practice it creates confusion — once you start naming fields, name all of them.
The constraint about declaration order feels limiting at first but is actually a feature: it forces you to think about whether your struct's member order makes logical sense at the call site. If you find yourself wanting to specify .port before .host but the struct declares host first, that's a signal the struct's member order should be reconsidered.
Real-World Pattern: Aggregate Initialisation as a Constructor Replacement
Here is an opinion that divides C++ developers, but experience on large codebases makes it hard to argue against: for plain data types that need no invariant enforcement, writing a constructor is almost always the wrong choice. A constructor implies business logic — validation, resource acquisition, non-trivial setup. When your type is just a bag of related data, a constructor is ceremony that costs you aggregate initialisation, constexpr construction, C interoperability, and in C++20 the ability to use the type as a non-type template parameter.
The pattern that works in practice is this: use aggregates for value types and data transfer objects, use constructors for types with invariants. A Vec3 with no range constraints is an aggregate. A BoundedFloat that must always stay between 0 and 1 needs a constructor because the invariant check has to happen somewhere, and the constructor is the right place. Draw that line deliberately, not by habit.
This pattern also plays exceptionally well with constexpr. Aggregate initialisation is the primary mechanism for creating constexpr objects of user-defined types without writing constexpr constructors. Game engines use this for shader constant tables. Embedded systems use it for hardware register maps and lookup tables baked into flash memory. Networking code uses it for protocol constant definitions. In all of these cases, the compiler evaluates the entire initialisation at compile time and the result is a zero-cost read-only data structure — no runtime allocation, no constructor call, no startup cost.
There is one more benefit that becomes important at the senior level: aggregates are structural types and can be used as non-type template parameters in C++20. If you want a compile-time configuration struct as a template parameter — a pattern that appears in policy-based design and high-performance template metaprogramming — the type must be structural. Aggregates qualify trivially. Types with constructors must meet additional structural type requirements.
- Aggregate: Vec3, Point2D, Colour, Config, PacketHeader, ShaderConstant — pure data, no construction constraints
- Constructor: BoundedFloat (0-1 range), NonEmptyString (must not be empty), UniquePtr (ownership invariant), Connection (must acquire a socket)
- Aggregates get constexpr compatibility, C interop, and brace-init without any explicit marking
- A constructor 'for consistency' or 'just in case' costs all three of those benefits and adds boilerplate with no return
- Non-type template parameters in C++20 require structural types — aggregates qualify trivially, constructors may not
Aggregate Initialisation with Inheritance and C++17 Base Class Support
Before C++17, having any base class — even a simple public non-virtual one — disqualified a type from being an aggregate. This was a significant limitation for codebases that used inheritance for code reuse on plain data structures. C++17 removed this restriction: an aggregate can now have public, non-virtual base classes, as long as the base class itself is also an aggregate.
This opens up a practical pattern: you can use inheritance to add methods to a base struct — common utility functions, operator overloads, serialisation helpers — while keeping the derived type fully aggregate-compatible with brace initialisation support intact.
The initialisation order for a derived aggregate is well-defined: the base class sub-object is initialised first, using the initial values from the braced-init-list in declaration order, followed by the derived class's own members. If the base class has 4 members and the derived class adds 3, your braced list has 7 values with the first 4 going to the base and the last 3 to the derived fields.
The constraints are the same as always: virtual base classes disqualify the derived type. Private or protected base classes disqualify it. And designated initialisers typically cannot name base class members in most compiler implementations — you must use positional initialisation for the base class portion of the braced list, even if you use designated initialisers for the derived class's own members. This mixing is clumsy enough in practice that it's usually cleaner to either use fully positional init for the whole braced list, or restructure so the base class members are not needed separately.
tick() method to the base Entity struct to allow polymorphic update dispatch. The change was reasonable for gameplay objects, but the Entity struct was also used as a plain data aggregate throughout the rendering and serialisation layers. Every derived struct — Player, Enemy, Pickup, Trigger — immediately lost aggregate status. Every brace-init call site in the rendering and serialisation code either failed to compile or required a new constructor to be added. The fix was separating the concerns: a plain EntityData aggregate for the data layer, and a separate EntityBehaviour base class with the virtual method for the gameplay layer. Adding virtual to a shared base is not a local change.The Hidden Cost: Aggregate Initialisation and Lifetime Surprises
You'd think slapping braces around a struct is harmless. It's not. Aggregate initialisation bypasses constructors entirely — that means no member-initialiser lists, no constructor bodies, no safety nets. When you write Config cfg{42, 3.14f}, the compiler just memcpy's the values into place. If those members are pointers, handles, or reference-counted types, you've just copied raw bits without incrementing refcounts or validating state. I've debugged use-after-free nightmares where a junior thought aggregate init was "cleaner" than a proper constructor. It wasn't cleaner — it was a time bomb. The C++ standard is explicit: aggregates don't get constructor calls. If your struct manages resources, don't use aggregate init. Write a constructor. Pay the tax upfront or pay it in incident reports at 3 AM.
Why Most C++ Devs Get Brace Initialisation Wrong (And How C++20 Fixed It)
The problem isn't braces — it's ambiguity. Before C++20, vector<int> v{1, 2, 3} calls the initializer_list constructor, not the fill constructor. That's fine until you write vector<int> v{5} expecting five zeros but getting one five. Aggregate types with std::initializer_list constructors break the rule entirely: the initializer_list always wins. C++20's designated initialisers don't suffer this. They're unambiguous: Config{.timeout = 30, .retries = 3}. You can't accidentally hit an initializer_list overload because designated init only works on aggregates, and aggregates can't have user-defined constructors. It's a clean escape hatch from the worst overload resolution pit in C++. Use designated initialisers when the struct is defined in someone else's library, when you want self-documenting code, or when you're tired of counting commas.
Why Aggregate Initialization Beats Constructor Overloads
Aggregate initialization eliminates boilerplate by initializing public members directly, bypassing custom constructors. The core advantage is simplicity: aggregates require no user-declared constructors, no virtual functions, and no private or protected non-static data members — the compiler handles member-wise initialization. This means zero runtime overhead and guaranteed trivial destructibility. For structs that are pure data, aggregate init replaces tedium with clarity: positional braces match member order, zero-initialization is automatic for omitted trailing members, and C++20 designated initializers let you skip unrelated fields. The hidden win is composability — aggregates work naturally with templates, variadic arguments, and constexpr contexts. You get value semantics, stack allocation by default, and no hidden allocation costs. In practice, teams using aggregate init report fewer bugs from constructor ordering errors and faster compilation because there’s no constructor call chain to resolve.
Real-World Examples of Aggregate Initialization in C++17/20
Aggregate initialization shines in data-heavy contexts. A common example is configuration structs: define a flat struct of public fields and initialize only the values you need using C++20 designated initializers. This avoids constructor overloads and documents intent at the call site. Another pattern is nested aggregates — initialize a tree of POD-like types in one brace-enclosed list. C++17 base class support extends this to inheritance: initialize derived members then base subobjects in declaration order. For static data, aggregate init combines with constexpr to produce compile-time tables. Embedded systems rely on it to map structs directly to memory-mapped registers — no padding, no constructor overhead. The rule of thumb: if every member is public and the object is a bundle of values, aggregate init is more readable, faster to compile, and safer than a constructor with member initializer lists.
C++20: Designated Initializers
Designated initializers, introduced in C++20, allow you to initialize specific members of an aggregate by name, improving code readability and reducing errors from member reordering. Unlike C99, C++20 designated initializers require that members be initialized in declaration order and do not allow out-of-order initialization. This feature is particularly useful for structs with many members, as it makes the initialization self-documenting.
Example: Consider a struct Point with members x, y, and z. Using designated initializers, you can write Point p = {.x = 1, .y = 2, .z = 3}; instead of relying on positional arguments. This prevents bugs when members are reordered in the struct definition.
However, note that designated initializers cannot be mixed with positional initializers in the same initializer list. Also, all non-designated members must be initialized after the last designated one, which is implicitly zero-initialized if omitted.
Designated initializers work with aggregates, including arrays and structs with inheritance (C++17 onward). They are a powerful tool for writing safer and more maintainable code.
C++20: Parenthesized Aggregate Initialization
C++20 introduced parenthesized aggregate initialization, allowing aggregates to be initialized using parentheses instead of braces. This is particularly useful for avoiding the most vexing parse and for consistency with non-aggregate types. For example, Point p(1, 2, 3); now works for aggregates, whereas before C++20 it would have required a constructor.
Parenthesized initialization follows the same rules as brace initialization: it performs aggregate initialization, including member-wise copy initialization. However, there are subtle differences: parenthesized initialization does not allow narrowing conversions (like brace initialization) and does not support designated initializers.
This feature simplifies generic code where the type might be an aggregate or a class with a constructor. For instance, std::make_unique<Point>(1, 2, 3) now works without requiring a user-defined constructor.
Example: std::pair<int, double> p(1, 2.5); initializes the pair as an aggregate. Before C++20, this required a constructor; now it's direct aggregate initialization.
Note that parenthesized initialization cannot be used with arrays (e.g., int arr[3](1,2,3); is invalid). Also, it does not support brace elision for nested aggregates.
Aggregates vs User-Defined Constructors
Choosing between aggregate initialization and user-defined constructors is a key design decision in C++. Aggregates are simple, efficient, and support brace initialization, designated initializers, and structural binding. User-defined constructors provide encapsulation, validation, and complex initialization logic.
Aggregates are types with no user-declared constructors, no private/protected non-static data members, no base classes (until C++17), and no virtual functions. They are initialized member-wise. Advantages: performance (no constructor call), readability (designated initializers), and compatibility with aggregate algorithms (e.g., std::apply).
User-defined constructors allow validation, default values, and invariant enforcement. However, they disable aggregate initialization, meaning you lose designated initializers and brace elision. They also introduce overhead (constructor call) and can complicate generic code.
When to use aggregates: For simple data containers (e.g., Point, Vec3) where member-wise initialization is sufficient. Use designated initializers for clarity.
When to use constructors: When you need to enforce invariants (e.g., non-negative radius), compute derived values, or provide multiple initialization paths.
Hybrid approach: Provide a constructor but also keep the type as an aggregate? Not possible in C++17/20. Instead, consider using a factory function or a builder pattern.
Example: A Circle struct with radius validation cannot be an aggregate; it needs a constructor. A Point struct with no validation can be an aggregate.
Silent member-reorder bug shipped to production — width and height swapped in 200 call sites
struct Rect { int width; int height; }. After reordering for alignment: struct Rect { int height; int width; }. Every call site using positional init like Rect r { 800, 600 } silently swapped — r.height became 800 and r.width became 600. The compiler had no reason to warn: the types matched, the member count matched, and positional initialisation is defined by the C++ standard to follow declaration order. There was no UB, no type error, no runtime exception — just wrong values flowing through 200 construction sites into every layout calculation in the application.Rect r { .width = 800, .height = 600 }. Added a clang-tidy check (cppcoreguidelines-pro-type-member-init combined with a custom rule) that flags positional aggregate initialisation for structs with 3 or more members. Added static_assert(std::is_aggregate_v<Rect>) to the header and a compile-time check that verifies member count has not changed unexpectedly. Future member reorders now produce a compile error at every call site where a designated initialiser references a field that no longer exists by that name.- Positional aggregate initialisation is order-dependent and name-blind — reordering struct members silently swaps the values at every call site
- C++20 designated initialisers (.field = value) are the only compile-time-safe way to initialise structs where member order might change during the codebase's lifetime
- A clang-tidy rule rejecting positional init for 3+ member structs prevents this entire class of bug — add it to your CI pipeline, not just your local config
- Unit tests that use named field assignment (s.width = 800) can mask positional-init bugs entirely — the tests pass because they bypass the broken construction site
MyStruct() = default — disqualifies the type as an aggregate in C++17 and earlier. In C++17, remove the defaulted constructor entirely; the compiler generates one implicitly for aggregates. In C++20, a defaulted constructor declared directly in the class body no longer disqualifies the type, so upgrading the standard version is an option. Verify aggregate status with static_assert(std::is_aggregate_v<T>) — add this to your header so the failure is immediate and descriptive rather than a confusing compile error at the call site.| File | Command / Code | Purpose |
|---|---|---|
| AggregateCheck.cpp | struct Colour { | What Exactly Is an Aggregate? (The Rules You Must Know) |
| NestedAggregateInit.cpp | struct Vec2 { | Brace Initialisation in Depth |
| DesignatedInitialisers.cpp | struct ServerConfig { | C++20 Designated Initialisers |
| AggregateVsConstructor.cpp | struct Vec3 { | Real-World Pattern |
| AggregateInheritance.cpp | struct EntityBase { | Aggregate Initialisation with Inheritance and C++17 Base Cla |
| ResourceLeak.cpp | struct StringView { | The Hidden Cost |
| OverloadAmbiguity.cpp | struct TimerConfig { | Why Most C++ Devs Get Brace Initialisation Wrong (And How C+ |
| AggregateAdvantage.cpp | struct Vec3 { | Why Aggregate Initialization Beats Constructor Overloads |
| AggregateExamples.cpp | struct Config { | Real-World Examples of Aggregate Initialization in C++17/20 |
| designated_initializers.cpp | struct Point { | C++20 |
| parenthesized_init.cpp | struct Point { | C++20 |
| aggregate_vs_constructor.cpp | struct Point { | Aggregates vs User-Defined Constructors |
Key takeaways
Interview Questions on This Topic
What are the exact rules that make a class an aggregate in C++20, and how do those rules differ from C++17? Can you give an example of a type that's an aggregate in C++20 but not in C++17?
struct S { int x; S() = default; }; — this is NOT an aggregate in C++17 because S() = default is user-declared. In C++20, it IS an aggregate because the defaulted constructor is not user-provided. Use std::is_aggregate_v<S> to verify at compile time which standard version gives you.Frequently Asked Questions
20+ years shipping performance-critical C and C++ systems. Lessons pulled from things that broke in production.
That's C++ Basics. Mark it forged?
11 min read · try the examples if you haven't