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// ============================================================structColour {
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// ============================================================structColourWithCtor {
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// ============================================================structSecretColour {
uint8_t red;
private:
uint8_t green; // private — disqualifies regardless of standard versionpublic:
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// ============================================================structColourDefaulted {
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 definitionstatic_assert(std::is_aggregate_v<Colour>,
"Colour must remain an aggregate — do not add constructors or private members");
intmain() {
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 constructorColour 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";
return0;
}
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// ============================================================structVec2 {
float x;
float y;
};
structSprite {
std::string_view name;
Vec2 position; // nested aggregateVec2 scale; // nested aggregate
float rotation; // degreesbool visible;
int zLayer = 0; // default member initialiser — not zero-init
};
voidprintSprite(constSprite& 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";
}
intmain() {
// ============================================================// 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 }, // scale0.0f, // rotation
true, // visible5// 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";
}
return0;
}
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// ============================================================structServerConfig {
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");
voidprintConfig(constServerConfig& 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";
}
intmain() {
// ============================================================// 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// ============================================================constexprServerConfig loopback {
.host = "127.0.0.1",
.port = 5432
};
std::cout << "\n=== Loopback (constexpr) ===\n";
printConfig(loopback);
return0;
}
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// ============================================================structVec3 {
float x, y, z;
// Member functions are fine — they don't disqualify aggregate statusconstexprfloatlengthSquared() const { return x*x + y*y + z*z; }
constexprVec3operator+(constVec3& 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// ============================================================classSafeColour {
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);
}
voidprint() 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// ============================================================structShaderConstant {
constchar* 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 <Vec3Gravity>
structPhysicsWorld {
voidprintGravity() const {
std::cout << "Gravity: ("
<< Gravity.x << ", "
<< Gravity.y << ", "
<< Gravity.z << ")\n";
}
};
intmain() {
// constexpr — fully compile-time, zero runtime costconstexprVec3 gravity { 0.0f, -9.81f, 0.0f };
constexprVec3 unitRight { 1.0f, 0.0f, 0.0f };
constexprVec3 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";
SafeColourfireRed(220, 30, 30);
fireRed.print();
std::cout << "\nShader uniforms:\n";
for (constauto& 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();
return0;
}
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// ============================================================structEntityBase {
uint32_t id;
float x, y;
bool active;
boolisAt(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// ============================================================structPlayer : EntityBase {
constchar* name;
int health;
int score;
};
// ============================================================// NOT an aggregate: virtual base disqualifies both the// base and any type derived from it// ============================================================structVirtualBase {
virtualvoidtick() {}
};
struct BadDerived : VirtualBase { int x; }; // not an aggregatestatic_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");
voidprintPlayer(constPlayer& 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";
}
intmain() {
// ============================================================// 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::id100.0f, // EntityBase::x200.0f, // EntityBase::y
true, // EntityBase::active
"Hero", // Player::name100, // Player::health0// 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, // id0.0f, // x0.0f, // y
false, // active
"Ghost" // name — health=0, score=0 from zero-init
};
printPlayer(ghost);
return0;
}
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 / Aspect
Aggregate Initialisation
Constructor-Based Initialisation
Syntax
Braces: 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 types
Yes — must declare, implement, and maintain
Enforces invariants?
No — all values accepted as-is, no validation possible at construction
Yes — constructor body validates and can throw or assert on bad input
Works with constexpr?
Yes, trivially — no marking required for literal types
Only 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 sequence
Depends 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 requirements
No — 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 initialisers
Low risk — constructor arguments are decoupled from member declaration order
C++20 non-type template parameter?
Yes — aggregates are structural types and qualify trivially
Requires the type to meet additional structural type requirements; not guaranteed
Types 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.
Q02 of 05SENIOR
If I have a struct with 6 members and I initialise it with only 3 values in a braced-init-list, what happens to the other 3? Does the answer change if those members have default member initialisers?
ANSWER
The 3 unspecified trailing members are value-initialised. For scalars this means: zero-initialisation (0 for ints, 0.0f for floats, nullptr for pointers). For class types it means default construction.
Yes, the answer changes when default member initialisers are present. If a member has int timeout = 30 and you omit it from the braced list, the default member initialiser value (30) is used — not zero. The precedence chain is: explicit brace value > default member initialiser > zero-initialisation. This precedence is fixed and does not change based on how you write the partial init. If you want a member to be zero, provide 0 explicitly or remove the default member initialiser. Relying on zero-init to override a default initialiser is a bug.
Q03 of 05SENIOR
Why would you prefer aggregate initialisation over writing a constructor for a plain data struct? Are there any cases where that preference should flip?
ANSWER
Prefer aggregate initialisation because: (1) zero boilerplate — no constructor to write, document, or maintain; (2) trivially constexpr-compatible for literal types without any explicit marking; (3) C interop — aggregates work across FFI boundaries where C code needs to initialise the struct; (4) C++20 designated initialisers make the call site self-documenting without any framework or builder pattern; (5) aggregates are structural types and qualify as non-type template parameters in C++20.
The preference flips when the type needs to enforce an invariant at construction time — a BoundedFloat that must stay between 0 and 1, a NonEmptyString that must always contain at least one character, a UniqueHandle that acquires a resource at construction. The invariant check is the specific justification for the constructor. If you cannot name a concrete invariant the constructor protects, the constructor should not exist.
Q04 of 05SENIOR
How do C++20 designated initialisers differ from C99 designated initialisers?
ANSWER
Three key differences:
(1) Order — C++20 requires designated initialisers to follow the member declaration order exactly. C99 allows arbitrary order, so you could initialise the last member first if you wanted. C++ disallows this because arbitrary-order designated initialisation would interact ambiguously with overload resolution and template argument deduction, which C does not have.
(2) Repetition — C++20 prohibits naming the same member more than once. C99 allows it with the last value winning. In C++20, naming a member twice is a hard compile error.
(3) Mixing — C++20 restricts mixing designated and positional initialisers more strictly than C99. In practice, once you use a designator you should use them consistently for all members you want to name.
These constraints make C++20 designated initialisers slightly less flexible than their C99 counterpart, but the constraints exist to maintain consistency with the rest of the C++ type system.
Q05 of 05SENIOR
Can you use aggregate initialisation with a struct that inherits from another struct? What are the restrictions?
ANSWER
Yes, from C++17 onwards. Before C++17, any base class disqualified a type from being an aggregate. From C++17, a derived type can still be an aggregate if: (1) the inheritance is public and non-virtual; (2) the base class itself is an aggregate.
In the braced-init-list, the base class sub-object's members come first in declaration order, followed by the derived class's own members. Example: struct Base { int a; float b; }; struct Derived : Base { int c; }; Derived d { 1, 2.0f, 3 }; — 1 and 2.0f initialise Base, 3 initialises Derived::c.
Restrictions: virtual base classes disqualify the type entirely. Private or protected base classes disqualify it. Designated initialisers typically cannot name base class members in compiler implementations — the base portion must use positional initialisation even if you use designated initialisers for the derived class's own members. In practice, keep aggregate inheritance shallow — one level — for this reason.
01
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?
SENIOR
02
If I have a struct with 6 members and I initialise it with only 3 values in a braced-init-list, what happens to the other 3? Does the answer change if those members have default member initialisers?
SENIOR
03
Why would you prefer aggregate initialisation over writing a constructor for a plain data struct? Are there any cases where that preference should flip?
SENIOR
04
How do C++20 designated initialisers differ from C99 designated initialisers?
SENIOR
05
Can you use aggregate initialisation with a struct that inherits from another struct? What are the restrictions?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
What is the difference between aggregate initialisation and list initialisation in C++?
List initialisation is the broader term for any initialisation using braces {}. It covers several distinct code paths depending on the target type. Aggregate initialisation is one specific form of list initialisation that applies when the target type is an aggregate.
When you write MyStruct s { 1, 2 } and MyStruct is an aggregate, the compiler dispatches to the aggregate initialisation rules: values map positionally to members in declaration order, trailing members are zero-initialised or use default member initialisers. If MyStruct instead has a constructor that takes two arguments, the same brace syntax invokes that constructor through overload resolution — that is still list initialisation but is not aggregate initialisation. The distinction matters because the two code paths have different rules for narrowing conversions, implicit conversions, and initialisation of unspecified members.
Was this helpful?
02
Can I use aggregate initialisation with inheritance in C++?
Yes, from C++17 onwards. Before C++17, any base class disqualified a type from being an aggregate — which meant that even simple public non-virtual inheritance for code reuse broke brace initialisation. In C++17 and later, a derived type can be an aggregate as long as the inheritance is public and non-virtual and the base class itself is an aggregate.
The braced-init-list initialises the base sub-object's members first in their declaration order, followed by the derived class's own members. The base portion must use positional initialisation in most compiler implementations — designated initialisers cannot name base class members directly. For this reason, aggregate inheritance is most practical when kept to one level and when the base struct has no more than two or three members.
Was this helpful?
03
Does aggregate initialisation have any runtime overhead compared to a constructor?
In practice, no — and in many cases it is strictly faster. Aggregate initialisation with constant values is resolved at compile time or reduces to a single initialisation sequence at runtime — there is no function call, no vtable lookup, and no constructor prologue. For constexpr aggregates the entire initialisation happens at compile time and the result lives in read-only memory with no startup cost at all.
This is why embedded systems code, game engines, and high-performance networking stacks use aggregates for their constant data tables — hardware register maps, shader parameter defaults, protocol constant definitions, lookup tables. The compiler can reason about the entire initial state at translation time and emit optimal code. Constructors can achieve the same result through inlining and constant propagation, but that requires the optimiser to succeed; aggregates give you the guarantee by construction.
Was this helpful?
04
Can I use aggregate initialisation with std::array?
Yes. std::array is a class template that wraps a C-style array as its only data member, and it is an aggregate. You can brace-initialise it directly: std::array<int, 3> a { 1, 2, 3 };.
In C++11, you sometimes saw the double-brace form {{ 1, 2, 3 }} — the outer braces are for std::array's aggregate initialisation and the inner braces are for the inner C-style array member. In C++17 and later, brace elision rules mean the single-brace form works cleanly and the double-brace form, while still valid, is no longer necessary for clarity. Most modern codebases use single braces with std::array and rely on the C++17 rules.
Was this helpful?
05
What happens if I try to aggregate-initialise a non-aggregate type with braces?
The compiler falls back to constructor-based list initialisation. It searches for a constructor that can be called with the arguments you provided in the braced list — including constructors that take std::initializer_list<T>. If a matching constructor is found, it is called. If no matching constructor exists, you get a compile error.
This fallback does not happen silently in the sense that it changes which code path runs — but it can be subtle because the same brace syntax now has different semantics. Narrowing conversions that aggregate initialisation would reject may be accepted if there is a matching constructor with looser conversion rules, or vice versa. Use static_assert(std::is_aggregate_v<T>) on any type you depend on being initialised via aggregate rules — this makes the code path explicit and catches the transition from aggregate to non-aggregate at the point of the type definition.