Senior 8 min · March 06, 2026

Aggregate Initialisation C++ — Member Reorder Silent Bugs

A struct reorder silently swapped width/height at 200 call sites.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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
Plain-English First

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<T>, available from C++17. Add a static_assert(std::is_aggregate_v<YourType>, "YourType must remain an aggregate") 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.

AggregateCheck.cppCPP
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include <iostream>
#include <type_traits>
#include <cstdint>

// ============================================================
// AGGREGATE: all public members, no constructors, no virtuals
// Default member initialisers are fine in both C++17 and C++20
// ============================================================
struct Colour {
    uint8_t red;
    uint8_t green;
    uint8_t blue;
    uint8_t alpha = 255;  // default member initialiser — still an aggregate
};

// ============================================================
// NOT AN AGGREGATE: has a user-provided constructor
// The constructor body makes this user-provided, not just user-declared
// ============================================================
struct ColourWithCtor {
    uint8_t red;
    uint8_t green;
    uint8_t blue;
    ColourWithCtor(uint8_t r, uint8_t g, uint8_t b)
        : red(r), green(g), blue(b) {}  // user-provided — disqualifies in all standards
};

// ============================================================
// NOT AN AGGREGATE: has a private non-static data member
// Any private or protected data member disqualifies the type
// ============================================================
struct SecretColour {
    uint8_t red;
private:
    uint8_t green;  // private — disqualifies regardless of standard version
public:
    uint8_t blue;
};

// ============================================================
// C++17: NOT an aggregate — = default is user-declared
// C++20: IS an aggregate — defaulted ctors no longer disqualify
// Behaviour depends on which standard you compile with
// ============================================================
struct ColourDefaulted {
    uint8_t red;
    uint8_t green;
    uint8_t blue;
    ColourDefaulted() = default;  // user-declared, not user-provided
};

// Guard aggregate status so a future change fails loudly at the definition
static_assert(std::is_aggregate_v<Colour>,
    "Colour must remain an aggregate — do not add constructors or private members");

int main() {
    std::cout << std::boolalpha;
    std::cout << "Colour is aggregate:          " << std::is_aggregate_v<Colour>          << "\n";
    std::cout << "ColourWithCtor is aggregate:  " << std::is_aggregate_v<ColourWithCtor>  << "\n";
    std::cout << "SecretColour is aggregate:    " << std::is_aggregate_v<SecretColour>    << "\n";
    // ColourDefaulted: true in C++20, false in C++17
    std::cout << "ColourDefaulted is aggregate: " << std::is_aggregate_v<ColourDefaulted> << "\n";

    // Aggregate initialisation — one line, all fields, no constructor
    Colour skyBlue { 135, 206, 235, 255 };  // positional: red, green, blue, alpha
    std::cout << "\nSky blue RGBA: "
              << static_cast<int>(skyBlue.red)   << ", "
              << static_cast<int>(skyBlue.green) << ", "
              << static_cast<int>(skyBlue.blue)  << ", "
              << static_cast<int>(skyBlue.alpha) << "\n";

    // Partial init — alpha omitted, uses default member initialiser (255)
    Colour opaqueRed { 220, 30, 30 };
    std::cout << "Opaque red alpha: " << static_cast<int>(opaqueRed.alpha) << " (from default init)\n";

    return 0;
}
Output
Colour is aggregate: true
ColourWithCtor is aggregate: false
SecretColour is aggregate: false
ColourDefaulted is aggregate: true // C++20 output; false in C++17
Sky blue RGBA: 135, 206, 235, 255
Opaque red alpha: 255 (from default init)
The Aggregate Checklist
  • 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
Production Insight
A teammate added a private uint64_t cacheKey member to a frequently used data struct for a performance optimisation. The struct lost aggregate status immediately. All brace-init call sites across 15 files either stopped compiling or silently fell back to default construction — depending on whether a default constructor was implicitly available. The review caught the compile errors; the silent fallbacks needed a grep and manual audit to find. The fix took three hours. A static_assert on aggregate status in the header would have made the review catch it in 30 seconds.
Key Takeaway
An aggregate has no user-provided constructors, no private or protected data members, no virtual functions, and no virtual or private base classes. C++20 relaxed exactly one rule: a defaulted constructor declared in the class body no longer disqualifies the type. Everything else is the same. Use static_assert(std::is_aggregate_v<T>) in your headers to guard aggregate status — it is the only reliable way to catch accidental disqualification before it becomes a runtime problem.
Is My Type an Aggregate?
IfStruct with all public members, no constructors, no virtual functions, no virtual/private/protected bases
UseYes — it is an aggregate. Brace initialisation, constexpr construction, and C interop all work.
IfStruct with a = default constructor in C++20
UseYes — defaulted constructors declared in the class body no longer disqualify in C++20.
IfStruct with a = default constructor compiled under C++17
UseNo — user-declared constructors of any kind disqualify in C++17. Remove it; the compiler generates it implicitly for aggregates.
IfStruct with any user-provided constructor (constructor with a body)
UseNo — brace-init falls back to constructor overload resolution, not aggregate initialisation.
IfStruct with a private or protected non-static data member
UseNo — make it public or commit to a constructor-based design with accessors.

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.

NestedAggregateInit.cppCPP
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#include <iostream>
#include <string_view>

// ============================================================
// Nested aggregate: Vec2 is initialised with its own inner braces
// ============================================================
struct Vec2 {
    float x;
    float y;
};

struct Sprite {
    std::string_view  name;
    Vec2              position;    // nested aggregate
    Vec2              scale;       // nested aggregate
    float             rotation;    // degrees
    bool              visible;
    int               zLayer = 0;  // default member initialiser — not zero-init
};

void printSprite(const Sprite& s) {
    std::cout << "[" << s.name << "]\n"
              << "  position : (" << s.position.x << ", " << s.position.y << ")\n"
              << "  scale    : (" << s.scale.x    << ", " << s.scale.y    << ")\n"
              << "  rotation : " << s.rotation << " deg\n"
              << "  visible  : " << std::boolalpha << s.visible << "\n"
              << "  zLayer   : " << s.zLayer << "\n";
}

int main() {
    // ============================================================
    // Full initialisation: every field explicitly provided
    // Inner braces for Vec2 members make the nesting explicit
    // ============================================================
    Sprite player {
        "player_hero",       // name
        { 100.0f, 200.0f }, // position — inner braces for Vec2
        { 1.0f,   1.0f   }, // scale
        0.0f,               // rotation
        true,               // visible
        5                   // zLayer — overrides the default of 0
    };

    // ============================================================
    // Partial initialisation:
    // rotation  -> zero-init (0.0f) — no default member initialiser
    // visible   -> zero-init (false)
    // zLayer    -> default member initialiser (0) — NOT zero-init
    // Result is identical here, but the mechanism differs and matters
    // when the default is non-zero
    // ============================================================
    Sprite backgroundCloud {
        "cloud_01",
        { 300.0f, 50.0f }, // position
        { 2.0f, 1.5f }     // scale — everything after is zero/default
    };

    // ============================================================
    // Array of aggregates: all four waypoints initialised inline
    // No constructor, no loop, no push_back — compile-time layout
    // ============================================================
    Vec2 patrolPath[] = {
        {   0.0f,   0.0f },
        { 150.0f,   0.0f },
        { 150.0f, 150.0f },
        {   0.0f, 150.0f }
    };

    std::cout << "=== Player ===\n";
    printSprite(player);

    std::cout << "\n=== Background Cloud ===\n";
    printSprite(backgroundCloud);

    std::cout << "\nPatrol path waypoints: "
              << (sizeof(patrolPath) / sizeof(Vec2)) << "\n";

    for (std::size_t i = 0; i < sizeof(patrolPath) / sizeof(Vec2); ++i) {
        std::cout << "  [" << i << "] ("
                  << patrolPath[i].x << ", "
                  << patrolPath[i].y << ")\n";
    }

    return 0;
}
Output
=== Player ===
[player_hero]
position : (100, 200)
scale : (1, 1)
rotation : 0 deg
visible : true
zLayer : 5
=== Background Cloud ===
[cloud_01]
position : (300, 50)
scale : (2, 1.5)
rotation : 0 deg
visible : false
zLayer : 0
Patrol path waypoints: 4
[0] (0, 0)
[1] (150, 0)
[2] (150, 150)
[3] (0, 150)
Watch Out: Partial Initialisation Precedence
When you partially initialise an aggregate, all members before the last explicitly provided value must also be provided — you cannot skip the third field and specify the fourth with positional syntax. That requires C++20 designated initialisers. The other thing that catches people: a member with 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.
Production Insight
A developer initialised a network config struct partially, expecting the timeout member to be zero-initialised to mean 'disabled'. The struct had 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 }.
Key Takeaway
Partial initialisation is safe and deliberate — trailing members use their default member initialiser first, then fall back to zero-initialisation. The precedence chain is: explicit brace value > default member initialiser > zero-init. If you want a specific value, always provide it explicitly. Relying on zero-init when a default member initialiser exists will produce the wrong value and a confusing bug.
Partial Initialisation Behaviour
IfMember has no default member initialiser and is omitted from the braced list
UseZero-initialised: 0 for ints, 0.0f for floats, nullptr for pointers, value-initialised for class types
IfMember has a default member initialiser (e.g., int x = 42) and is omitted from the braced list
UseDefault value used (42) — not zero. Explicit brace values override this; omission does not.
IfMember is explicitly provided in the braced list
UseExplicit value always wins — overrides both default member initialiser and zero-init
IfWant to skip a middle member and specify the last one with positional syntax
UseNot possible — positional init must be contiguous from the beginning. Use C++20 designated initialisers instead.
IfNested aggregate is omitted from the outer braced list entirely
UseThe nested aggregate is value-initialised: all its scalar members become 0/nullptr, all its class-type members are default-constructed

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.

DesignatedInitialisers.cppCPP
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include <iostream>
#include <string_view>
#include <type_traits>

// ============================================================
// Config struct with default member initialisers
// Designated initialisers let callers override only what differs
// from the defaults — the rest uses default member init
// ============================================================
struct ServerConfig {
    std::string_view host           = "localhost";
    uint16_t         port           = 8080;
    int              maxConnections = 100;
    int              timeoutSeconds = 30;
    bool             enableTLS      = false;
    bool             logRequests    = true;
};

static_assert(std::is_aggregate_v<ServerConfig>,
    "ServerConfig must remain an aggregate");

void printConfig(const ServerConfig& cfg) {
    std::cout << "Host            : " << cfg.host            << "\n"
              << "Port            : " << cfg.port            << "\n"
              << "Max connections : " << cfg.maxConnections  << "\n"
              << "Timeout (sec)   : " << cfg.timeoutSeconds  << "\n"
              << "TLS enabled     : " << std::boolalpha << cfg.enableTLS    << "\n"
              << "Log requests    : " << cfg.logRequests     << "\n";
}

int main() {
    // ============================================================
    // Production server: override only the fields that differ from
    // defaults. maxConnections, timeoutSeconds, logRequests all
    // come from their default member initialisers.
    // ============================================================
    ServerConfig productionServer {
        .host      = "api.example.com",
        .port      = 443,
        .enableTLS = true
    };

    // ============================================================
    // Dev server: uses localhost from default, overrides port only
    // ============================================================
    ServerConfig devServer {
        .port        = 3000,
        .logRequests = true
    };

    // ============================================================
    // Compile error — designators must follow declaration order:
    // ServerConfig broken { .port = 9000, .host = "test.com" };
    //   error: designator 'host' does not refer to any field in
    //   type 'ServerConfig' after field 'port'
    // This is the compiler protecting you from the reorder bug
    // ============================================================

    std::cout << "=== Production Server ===\n";
    printConfig(productionServer);

    std::cout << "\n=== Dev Server ===\n";
    printConfig(devServer);

    // ============================================================
    // Designated initialisers with zero-overhead struct creation:
    // No constructor call, no heap allocation, constexpr-compatible
    // ============================================================
    constexpr ServerConfig loopback {
        .host = "127.0.0.1",
        .port = 5432
    };
    std::cout << "\n=== Loopback (constexpr) ===\n";
    printConfig(loopback);

    return 0;
}
Output
=== Production Server ===
Host : api.example.com
Port : 443
Max connections : 100
Timeout (sec) : 30
TLS enabled : true
Log requests : true
=== Dev Server ===
Host : localhost
Port : 3000
Max connections : 100
Timeout (sec) : 30
TLS enabled : false
Log requests : true
=== Loopback (constexpr) ===
Host : 127.0.0.1
Port : 5432
Max connections : 100
Timeout (sec) : 30
TLS enabled : false
Log requests : true
Interview Gold: C++20 vs C99 Designated Initialisers
C++20 designated initialisers are deliberately stricter than their C99 counterparts, and knowing why is what separates a good answer from a great one. Three key differences: (1) Order — C++20 requires designated initialisers to follow member declaration order; C99 allows arbitrary order. (2) Repetition — C++20 prohibits naming the same member more than once; C99 allows it with the last value winning. (3) Mixing — C++20 restricts mixing designated and positional initialisers more strictly than C99. These constraints exist because C++ has overload resolution, template argument deduction, and other machinery that would become ambiguous with arbitrary-order designated initialisers. C has none of that. If an interviewer asks this question, all three constraints with the 'why' behind them is the complete answer.
Production Insight
A codebase had 200 positional init sites for a 7-member Config struct. A new feature required adding a bool verboseLogging member in position 3 — between two existing members — to group related fields logically. Adding it silently shifted every value after position 3 at all 200 call sites. The types all still matched. Not a single compile error. The bug manifested as wrong timeout values, wrong port numbers, and wrong TLS settings in production after deployment. Migrating all 200 sites to designated initialisers took four hours; the incident caused by not having them took eight. The lesson wasn't 'don't add members' — it was 'designated initialisers for any struct with 4+ members, no exceptions'.
Key Takeaway
Designated initialisers (.field = value) make call sites self-documenting and protect against the silent member-reorder bug that positional init cannot detect. C++20 requires declaration order — stricter than C99 which allows arbitrary order, but this strictness is a feature, not a limitation. Use designated initialisers for any struct with 4 or more members. Positional init beyond 3 fields is a maintenance liability.

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.

AggregateVsConstructor.cppCPP
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
#include <iostream>
#include <array>
#include <cassert>
#include <type_traits>

// ============================================================
// AGGREGATE: pure data, no constraints to enforce
// Gets constexpr, C interop, and brace-init for free
// ============================================================
struct Vec3 {
    float x, y, z;

    // Member functions are fine — they don't disqualify aggregate status
    constexpr float lengthSquared() const { return x*x + y*y + z*z; }
    constexpr Vec3 operator+(const Vec3& rhs) const {
        return { x + rhs.x, y + rhs.y, z + rhs.z };
    }
};

static_assert(std::is_aggregate_v<Vec3>);

// ============================================================
// NON-AGGREGATE: enforces a real invariant (must be 0-255)
// Constructor is justified here — without it nothing prevents
// SafeColour(300, -1, 999) from producing garbage
// ============================================================
class SafeColour {
    uint8_t red_, green_, blue_;
public:
    SafeColour(int r, int g, int b) {
        assert(r >= 0 && r <= 255 && "Red out of range");
        assert(g >= 0 && g <= 255 && "Green out of range");
        assert(b >= 0 && b <= 255 && "Blue out of range");
        red_   = static_cast<uint8_t>(r);
        green_ = static_cast<uint8_t>(g);
        blue_  = static_cast<uint8_t>(b);
    }
    void print() const {
        std::cout << "SafeColour("
                  << static_cast<int>(red_)   << ", "
                  << static_cast<int>(green_) << ", "
                  << static_cast<int>(blue_)  << ")\n";
    }
};

// ============================================================
// COMPILE-TIME LOOKUP TABLE via aggregate initialisation
// ShaderConstant is an aggregate — the table lives in rodata,
// no constructor calls, no startup overhead
// ============================================================
struct ShaderConstant {
    const char* name;
    float       value;
    int         bindingSlot;
};

constexpr std::array<ShaderConstant, 3> kDefaultLightingUniforms = {{
    { "u_ambientStrength",  0.1f,  0 },
    { "u_specularPower",   32.0f,  1 },
    { "u_lightIntensity",   1.0f,  2 }
}};

// ============================================================
// C++20 non-type template parameter using an aggregate
// This only works because Vec3 is a structural type (an aggregate)
// ============================================================
template <Vec3 Gravity>
struct PhysicsWorld {
    void printGravity() const {
        std::cout << "Gravity: ("
                  << Gravity.x << ", "
                  << Gravity.y << ", "
                  << Gravity.z << ")\n";
    }
};

int main() {
    // constexpr — fully compile-time, zero runtime cost
    constexpr Vec3 gravity   { 0.0f, -9.81f, 0.0f };
    constexpr Vec3 unitRight { 1.0f,  0.0f,  0.0f };
    constexpr Vec3 sum = gravity + unitRight;

    std::cout << "Gravity Y         : " << gravity.y << "\n";
    std::cout << "Unit right length^2: " << unitRight.lengthSquared() << "\n";
    std::cout << "Sum: (" << sum.x << ", " << sum.y << ", " << sum.z << ")\n";

    SafeColour fireRed(220, 30, 30);
    fireRed.print();

    std::cout << "\nShader uniforms:\n";
    for (const auto& u : kDefaultLightingUniforms) {
        std::cout << "  slot " << u.bindingSlot
                  << " | " << u.name
                  << " = " << u.value << "\n";
    }

    // Aggregate as a non-type template parameter (C++20)
    PhysicsWorld<Vec3{ 0.0f, -9.81f, 0.0f }> earthPhysics;
    earthPhysics.printGravity();

    return 0;
}
Output
Gravity Y : -9.81
Unit right length^2: 1
Sum: (1, -9.81, 0)
SafeColour(220, 30, 30)
Shader uniforms:
slot 0 | u_ambientStrength = 0.1
slot 1 | u_specularPower = 32
slot 2 | u_lightIntensity = 1
Gravity: (0, -9.81, 0)
Aggregate vs Constructor Decision
  • 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
Production Insight
A team added a trivial defaulting constructor to every struct in their codebase as part of a 'consistency' initiative — every type would have an explicit default constructor. They lost constexpr compatibility across their entire shader constant table, broke their C FFI layer for a networking library, and required a two-week refactor to restore the functionality. The constructors provided exactly zero value because none of the types had any invariants to enforce. The lesson: a constructor is not a style choice, it is a statement about the type's behaviour. If the type has no behaviour to assert at construction, the constructor should not exist.
Key Takeaway
Use aggregates for value types and DTOs; use constructors only when the type enforces a specific, nameable invariant. A constructor 'just in case' costs you constexpr, C interop, structural type qualification, and the clarity of brace-init — for zero benefit. The default should be aggregate. Add a constructor only when you can state in one sentence what invariant it protects.
Aggregate vs Constructor
IfType is pure data with no validation requirements at construction
UseStay an aggregate — brace-init, constexpr, C interop, and structural type qualification all work for free
IfType must enforce a range, non-null, non-empty, or ownership constraint at construction
UseUse a constructor — the invariant check is the specific, nameable justification for this choice
IfType needs to be constexpr-compatible with user-defined member functions
UseStay an aggregate with constexpr member functions — or mark a constructor constexpr explicitly if an invariant is genuinely required
IfType is used as a non-type template parameter in C++20
UseMust be a structural type — aggregates qualify trivially; constructors require the type to meet additional structural requirements
IfType must interoperate with C code via FFI or be passed through a C API
UseStay an aggregate — C has no concept of constructors and cannot initialise or copy a type that requires one

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.

AggregateInheritance.cppCPP
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#include <iostream>
#include <type_traits>
#include <cstdint>

// ============================================================
// Base aggregate: pure data, no constructors, no virtuals
// Member functions are fine — they don't disqualify aggregate status
// ============================================================
struct EntityBase {
    uint32_t id;
    float    x, y;
    bool     active;

    bool isAt(float px, float py) const {
        return x == px && y == py;
    }
};

// ============================================================
// Derived aggregate (C++17+):
// EntityBase is public, non-virtual, and itself an aggregate
// Player inherits without adding any constructors or virtuals
// ============================================================
struct Player : EntityBase {
    const char* name;
    int         health;
    int         score;
};

// ============================================================
// NOT an aggregate: virtual base disqualifies both the
// base and any type derived from it
// ============================================================
struct VirtualBase {
    virtual void tick() {}
};
struct BadDerived : VirtualBase { int x; };  // not an aggregate

static_assert(std::is_aggregate_v<EntityBase>, "EntityBase must be an aggregate");
static_assert(std::is_aggregate_v<Player>,     "Player must be an aggregate");
static_assert(!std::is_aggregate_v<BadDerived>, "BadDerived must not be an aggregate");

void printPlayer(const Player& p) {
    std::cout << "[id=" << p.id << "] " << p.name
              << " at (" << p.x << ", " << p.y << ")"
              << " | HP: "    << p.health
              << " | Score: " << p.score
              << " | Active: " << std::boolalpha << p.active << "\n";
}

int main() {
    // ============================================================
    // C++17 aggregate init with a base class sub-object:
    // Values 1..4 go to EntityBase (id, x, y, active) in order
    // Values 5..7 go to Player's own members (name, health, score)
    // ============================================================
    Player hero {
        1,           // EntityBase::id
        100.0f,      // EntityBase::x
        200.0f,      // EntityBase::y
        true,        // EntityBase::active
        "Hero",      // Player::name
        100,         // Player::health
        0            // Player::score
    };

    printPlayer(hero);
    std::cout << "Hero at spawn point: " << std::boolalpha << hero.isAt(100.0f, 200.0f) << "\n";

    // ============================================================
    // Partial init — trailing derived members zero/default-init
    // name provided, health and score zero-initialised
    // ============================================================
    Player ghost {
        999,         // id
        0.0f,        // x
        0.0f,        // y
        false,       // active
        "Ghost"      // name — health=0, score=0 from zero-init
    };

    printPlayer(ghost);

    return 0;
}
Output
[id=1] Hero at (100, 200) | HP: 100 | Score: 0 | Active: true
Hero at spawn point: true
[id=999] Ghost at (0, 0) | HP: 0 | Score: 0 | Active: false
Watch Out: Base Class Init Order in Derived Aggregates
In a derived aggregate's braced-init-list, the base class members come first in declaration order, followed by the derived class's own members. If you're reading the call site without the struct definition open, the base class members are invisible — you see 7 values and don't know which 4 belong to the base without checking. This is the main readability argument against deep inheritance hierarchies for aggregates. Keep it shallow — one level of public non-virtual inheritance — and document the member order in a comment if the base has more than 2 or 3 members.
Production Insight
A developer working on a game engine added a virtual 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.
Key Takeaway
C++17 allows aggregates with public non-virtual base classes — the base sub-object is initialised first in the braced list. Designated initialisers typically cannot name base class members, so the base portion uses positional init. Adding virtual to a base class removes aggregate status from every derived type in the hierarchy. This is a non-local change with broad consequences — make it deliberately, not as an afterthought.
● Production incidentPOST-MORTEMseverity: high

Silent member-reorder bug shipped to production — width and height swapped in 200 call sites

Symptom
UI rendering was 100% correct in unit tests (which used named field assignment). Production builds showed stretched and distorted rectangles across the entire application. A 1920x1080 window rendered as 1080x1920. Portrait layouts appeared in landscape containers. The bug was invisible in code review because the brace-init syntax looked character-for-character identical before and after the refactor — same values, same braces, same line count.
Assumption
The team assumed that reordering struct members was a safe mechanical refactor because 'the compiler will catch any type mismatch'. They were thinking about type safety, not positional semantics. Positional aggregate initialisation is order-dependent and name-blind — the compiler sees two ints and sees no problem.
Root cause
The original struct was 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.
Fix
Migrated all 200 call sites to C++20 designated initialisers: 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.
Key lesson
  • 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
Production debug guideSymptom → Action mapping for common aggregate initialisation issues5 entries
Symptom · 01
Struct values are silently swapped after a refactor
Fix
Check if struct members were reordered and call sites use positional braced-init. Positional init follows declaration order — any reorder silently reassigns values. Migrate all call sites to designated initialisers (.field = value). Add a clang-tidy check that flags positional init on structs with 3+ members. Add static_assert(std::is_aggregate_v<T>) to the header so future structural changes produce immediate compile errors rather than silent runtime misbehaviour.
Symptom · 02
Aggregate initialisation fails to compile after adding a constructor
Fix
Adding any user-declared constructor — including 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.
Symptom · 03
Trailing member has unexpected value after partial initialisation
Fix
Check whether the member has a default member initialiser (e.g., int timeout = 30). Partial init uses the default member initialiser value, not zero-init. If you expected zero, either provide it explicitly in the braced list or remove the default member initialiser. The precedence is fixed: explicit brace value wins, then default member initialiser, then zero-init. Never assume omitted fields are zero when a default initialiser is present.
Symptom · 04
Designated initialisers fail to compile with 'designators must appear in the same order as the member declarations'
Fix
C++20 requires designated initialisers to follow declaration order. Unlike C99, C++ does not allow arbitrary-order designated initialisation. Reorder the designated initialisers in your braced list to match the struct member declaration order exactly. This is a hard language requirement, not a compiler preference. If you want to skip members or specify them in a different order for readability, redesign the struct to declare members in the logical order you want at the call site.
Symptom · 05
constexpr aggregate initialisation fails to compile
Fix
All members of a constexpr aggregate must be literal types. std::string is not a literal type and is not constexpr-compatible — replace it with std::string_view or const char* for compile-time use. Check that no member is initialised with a value that requires runtime evaluation: function pointers to non-constexpr functions, dynamic allocations, or results of non-constexpr function calls all disqualify the initialisation. Use a constexpr variable and see which line the error reports — it will pinpoint the non-literal member.
Aggregate Initialisation vs Constructor-Based Initialisation
Feature / AspectAggregate InitialisationConstructor-Based Initialisation
SyntaxBraces: MyStruct s { v1, v2 } or designated: { .field = v1 }Parentheses or braces: MyType t(v1, v2) or MyType t{ v1, v2 } via constructor
Requires a constructor?No — zero boilerplate for plain data typesYes — must declare, implement, and maintain
Enforces invariants?No — all values accepted as-is, no validation possible at constructionYes — constructor body validates and can throw or assert on bad input
Works with constexpr?Yes, trivially — no marking required for literal typesOnly if the constructor is explicitly marked constexpr
Partial initialisation?Yes — trailing fields use default member init, then zero-init. Never garbage.Only if default argument values are defined in the constructor signature
Self-documenting call site?Yes — with C++20 designated initialisers (.field = value)Only with named parameters pattern, builder pattern, or verbose argument comments
Compiler can optimise away?Almost always — typically reduces to compile-time constants or a single memcpy sequenceDepends on constructor complexity; inlining required for the same level of optimisation
Works with C-compatible structs and FFI?Yes — fully compatible with C aggregate initialisation and layout requirementsNo — constructors are a C++ concept; C code cannot initialise or copy types that require them
Member reorder safety?High risk with positional init; fully safe with C++20 designated initialisersLow risk — constructor arguments are decoupled from member declaration order
C++20 non-type template parameter?Yes — aggregates are structural types and qualify triviallyRequires the type to meet additional structural type requirements; not guaranteed
Best for?Value types, DTOs, config data, lookup tables, hardware register maps, shader constantsTypes with invariants (range constraints, ownership, non-empty guarantees) or resource acquisition

Key takeaways

1
An aggregate has no user-provided constructors, no private or protected non-static members, no virtual functions, and no virtual, private, or protected base classes. C++20 relaxed exactly one rule
a defaulted constructor declared in the class body no longer disqualifies. Use static_assert(std::is_aggregate_v<T>) to guard aggregate status in your headers — it converts accidental disqualification from a confusing distant call-site error into a clear compile error at the definition.
2
Partial braced-init-list initialisation is safe and deliberate
unspecified trailing members use their default member initialiser first, then fall back to zero-initialisation. The precedence chain is explicit brace value > default member initialiser > zero-init. Never assume omitted members are zero when a default member initialiser is present.
3
C++20 designated initialisers (.fieldName = value) are the correct tool for any struct with 4 or more members
they make call sites self-documenting, protect against silent member-reorder bugs, and fail loudly at compile time when a referenced field is renamed or removed. Positional init beyond 3 members is a maintenance liability.
4
Use aggregates for pure data types and DTOs; use constructors only when the type needs to enforce a specific, nameable invariant. A constructor chosen for consistency or habit costs constexpr compatibility, C interop, structural type qualification, and the clarity of brace initialisation
for zero return. The default design choice should be aggregate.

Common mistakes to avoid

5 patterns
×

Adding = default constructor and losing aggregate status in C++17

Symptom
A struct with MyStruct() = default compiles without error, but brace-init call sites either fail to compile or silently fall back to calling the defaulted constructor instead of performing aggregate initialisation. In C++17, = default is a user-declared constructor and disqualifies the type regardless of whether it has a body. The braced list no longer maps positionally to members — it invokes the constructor overload resolution path instead, which has different rules for narrowing conversions and zero-init.
Fix
Remove the = default constructor entirely — the compiler generates it implicitly for any aggregate that needs it. Or upgrade your compilation standard to C++20 where defaulted constructors declared directly in the class body no longer disqualify aggregate status. Add static_assert(std::is_aggregate_v<MyStruct>, "MyStruct must be an aggregate") to the header so any future accidental disqualification produces a clear compile error at the definition site rather than a confusing failure at a distant call site.
×

Silently reordering struct members without updating positional init call sites

Symptom
Code compiles cleanly with no warnings but values are silently swapped. A Rect struct reordered from {int width; int height;} to {int height; int width;} produces rectangles with swapped dimensions at every call site using Rect r { 800, 600 }. The bug is invisible in code review because the syntax is identical and both members are int. It surfaces only at runtime when rendered output is wrong.
Fix
Use C++20 designated initialisers for any struct with 3 or more members: Rect r { .width = 800, .height = 600 }. A member rename or removal will now produce a compile error at every call site that references the old name. Add a clang-tidy rule or a team coding standard that rejects positional aggregate initialisation for structs with more than 2 members. Enforce it in CI, not just locally.
×

Expecting partial initialisation to zero-out members that have default member initialisers

Symptom
A struct member with int timeout = 30 is omitted from the braced list. The developer expects zero-initialisation to apply (timeout = 0, meaning 'disabled'), but the default member initialiser takes priority and timeout remains 30. The application uses a 30-second timeout on every code path that was supposed to have no timeout. The bug does not crash and produces no error — just slow failure modes.
Fix
Understand and respect the precedence chain: explicit brace value > default member initialiser > zero-init. If you want a member to be zero, provide it explicitly in the braced list. If the intent is always zero for that member, remove the default member initialiser. Never rely on zero-init to override a default member initialiser — the language does not work that way.
×

Using positional initialisation for structs with 4 or more members

Symptom
Adding a new member in position 3 of a 6-member struct silently shifts all values from position 3 onward at every call site. No compile error, no warning — just wrong data flowing through the application. The bug may not surface until a specific configuration or code path exercises the shifted field weeks after the change.
Fix
Use C++20 designated initialisers for any struct with 4 or more members without exception. Positional init is reasonable for 2-member types like Vec2 { x, y } where reordering would be structurally obvious. For anything larger, designated initialisers are the only maintenance-safe option. Enforce with clang-tidy or a team coding standard.
×

Adding a private member to an aggregate and breaking all brace-init sites

Symptom
A teammate adds a private uint64_t cacheKey or mutable int hitCount member for a performance optimisation. The struct immediately loses aggregate status. Brace-init call sites { 1, 2, 3 } either stop compiling or silently fall back to default construction, depending on whether an implicit default constructor is available. Finding all affected call sites requires a grep and manual audit, not a single error message.
Fix
Add static_assert(std::is_aggregate_v<YourType>, "YourType must remain an aggregate — do not add private members or constructors") in the header file immediately below the struct definition. This converts an accidental disqualification into a compile error at the definition site with a descriptive message. If private members are genuinely needed, commit to a constructor-based design and stop depending on aggregate initialisation.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What are the exact rules that make a class an aggregate in C++20, and ho...
Q02SENIOR
If I have a struct with 6 members and I initialise it with only 3 values...
Q03SENIOR
Why would you prefer aggregate initialisation over writing a constructor...
Q04SENIOR
How do C++20 designated initialisers differ from C99 designated initiali...
Q05SENIOR
Can you use aggregate initialisation with a struct that inherits from an...
Q01 of 05SENIOR

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?

ANSWER
In C++20, an aggregate is an array or a class with: no user-provided constructors, no private or protected non-static data members, no virtual functions, and no virtual, private, or protected base classes. The key change from C++17 is the treatment of defaulted constructors: in C++17, any user-declared constructor — including = default — disqualified the type from being an aggregate. In C++20, a defaulted constructor declared directly in the class body no longer disqualifies it. Example: 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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between aggregate initialisation and list initialisation in C++?
02
Can I use aggregate initialisation with inheritance in C++?
03
Does aggregate initialisation have any runtime overhead compared to a constructor?
04
Can I use aggregate initialisation with std::array?
05
What happens if I try to aggregate-initialise a non-aggregate type with braces?
🔥

That's C++ Basics. Mark it forged?

8 min read · try the examples if you haven't

Previous
Static Members in C++
19 / 19 · C++ Basics
Next
STL in C++ — Standard Template Library