C++ Aggregate Initialization: What Counts as an Aggregate, C++17/20 Rule Changes, and Designated Initializers
Key takeaways
An aggregate is a struct or array that the compiler lets you initialize member by member with braces, without writing a constructor. The rules are a cliff edge: one extra declaration can turn every brace initialization of a type into a compile error, and the edge moved in C++20.
What an aggregate is
An aggregate is a type the compiler lets you initialize by listing its members in braces, in declaration order, without writing a constructor:
struct Point {
int x;
int y;
};
Point p = {10, 20}; // copy-list form
Point q{10, 20}; // direct-list form, same meaning for an aggregate
Arrays are aggregates, and so are structs and classes that meet these conditions (C++20 wording):
- No user-declared or inherited constructors.
- No private or protected non-static data members.
- No virtual functions.
- No virtual, private or protected base classes.
The reason to learn these precisely is that aggregate status is all or nothing. A type either is an aggregate, and every T{a, b, c} initializes members directly, or it is not, and every T{a, b, c} becomes a constructor call that must find a matching overload. Adding one declaration for an unrelated reason — a constructor that logs, a private member for bookkeeping — breaks every brace initialization of the type across the codebase at once, usually with a “no matching function for call” error that does not mention the word aggregate. When I review changes to plain data structs, this is the first thing I check.
A one-line guard makes such a change fail at the definition instead:
#include <type_traits>
static_assert(std::is_aggregate_v<Point>, "Point is used with brace initialization");
The C++20 change: defaulted constructors
struct Settings {
Settings() = default;
int width;
int height;
};
Settings s{800, 600};
// C++17: OK, Settings is an aggregate
// C++20: error: no matching function for call to 'Settings::Settings(<brace-enclosed initializer list>)'
Up to C++17, the rule was “no user-provided constructors”, and a constructor defaulted on its first declaration was not user-provided, so Settings was still an aggregate. C++20 (P1008) tightened it to “no user-declared constructors”. The motivation was a loophole: under the old rule, a type with Settings() = delete; was still an aggregate, so Settings{} compiled despite the deleted constructor.
Checked with GCC 10, std::is_aggregate_v<Settings> is true in -std=c++17 and false in -std=c++20. Upgrading the language standard is therefore a source-breaking change for code that wrote = default “for clarity” on data structs. The fix is to remove the defaulted declaration; the implicit default constructor does the same job.
Omitted members and default member initializers
struct Person {
std::string name;
int age;
double height;
};
Person a = {"Alice", 30, 165.5};
Person b = {"Bob", 25}; // height value-initialized: 0.0
Person c = {}; // name empty, age 0, height 0.0
Trailing members you leave out are initialized from their default member initializer if they have one, otherwise value-initialized. That makes Person c = {} a reliable way to zero a plain struct, unlike Person c;, which leaves age and height indeterminate for a local variable (see default initialization).
Default member initializers combine well with aggregates (allowed since C++14):
struct Config {
int width = 800;
int height = 600;
bool fullscreen = false;
};
Config c1{}; // 800, 600, false
Config c2{1920}; // 1920, 600, false
Only trailing members can be omitted positionally. There is no way to say “default width, custom height” with a plain brace list; that is what designated initializers are for.
Designated initializers (C++20)
Config cfg{.width = 1920, .height = 1080}; // fullscreen keeps its default
Config windowed{.fullscreen = false}; // width and height keep defaults
The call site now says which value goes where, which matters most for structs with several members of the same type: in Rect{10, 20, 300, 200} it is easy to swap width and height and have the compiler accept it.
C++ is stricter than C here. Designators must appear in declaration order, and they cannot be mixed with positional initializers:
struct P { int x, y; };
P q{.y = 2, .x = 1};
// error: designator order for field 'P::x' does not match declaration order in 'P'
Once callers use designated initializers, the member names and their order effectively become part of the struct’s interface. Renaming a member breaks callers; reordering members breaks callers that designate both. The designated initializers guide covers nested structs and the remaining rules.
A pattern I use constantly for configuration: in-class defaults plus designated overrides at each call site. It reads almost like named arguments, which C++ otherwise lacks, and keeps each default next to its field instead of spread across constructor overloads.
struct DatabaseConfig {
std::string host = "localhost";
int port = 5432;
std::string database = "app";
int poolSize = 10;
};
DatabaseConfig prod{.host = "db.internal", .poolSize = 50};
Base classes (C++17)
Since C++17, an aggregate may have public, non-virtual base classes. The base is initialized first, as if it were a leading member:
struct Base { int x; };
struct Derived : Base { int y; };
Derived d{{10}, 20}; // Base{10}, then y = 20
The inner braces for the base are optional under brace elision (Derived d{10, 20} also works), but writing them makes the structure visible. Before C++17, any base class made the type a non-aggregate.
Arrays
int a[5] = {1, 2, 3, 4, 5};
int b[5] = {1, 2}; // remaining elements are 0
int c[] = {1, 2, 3}; // size deduced as 3
int m[2][3] = {{1, 2, 3}, {4, 5, 6}};
// int d[2] = {1, 2, 3}; // error: too many initializers
int b[5] = {1, 2} zero-fills the rest; int buf[256] = {}; is the idiomatic way to zero a local array. std::array is an aggregate that wraps a built-in array, which is why std::array<int, 3> arr = {1, 2, 3}; works without an initializer_list constructor.
Narrowing is an error
struct S { int a; };
S s = {3.9};
// error: narrowing conversion of '3.8999999999999999e+0' from 'double' to 'int' [-Wnarrowing]
Brace initialization of an aggregate is list initialization, and list initialization forbids narrowing conversions: floating to integer, a wider integer to a narrower one when the value may not fit, and so on. With a constant that fits (S s = {3}; into a short member, for example) it is allowed. This is one of the real advantages of aggregates over assigning members one by one: s.a = 3.9; compiles and silently stores 3.
Parenthesized aggregate initialization (C++20)
Point p(1, 2); // C++20: aggregate initialization with parentheses
C++20 (P0960) lets aggregates be initialized with parentheses, mainly so that generic code such as std::make_unique<Point>(1, 2) and vector::emplace_back(1, 2) works for aggregates without writing a constructor. It behaves differently from braces in two ways worth knowing: parentheses allow narrowing, and they do not support designated initializers or brace elision. For direct use, braces remain the better default; the parenthesized form mostly matters inside templates.
Aggregates, constructors, and API design
Aggregates are the right choice for plain data: configuration, messages, rows in a table-driven test, return values that bundle a few fields. They are the wrong choice when the type has an invariant. A Range whose min must not exceed max, or a Percentage limited to 0..100, needs a constructor that can enforce the rule, and a constructor ends aggregate status by design.
struct TestCase {
int input;
int expected;
std::string description;
};
std::vector<TestCase> cases = {
{0, 0, "zero"},
{5, 25, "five squared"},
{-3, 9, "negative input"},
};
Table-driven tests are the other place aggregates earn their keep: adding a case is a one-line diff, and the table reads like a specification.
| Syntax | For an aggregate S |
|---|---|
S a; | Default initialization: members of scalar type are indeterminate for locals |
S a{}; | Value initialization: every member zeroed or default-constructed |
S a{1, 2}; / S a = {1, 2}; | Aggregate initialization, narrowing rejected |
S a{.x = 1}; | Designated initialization (C++20), declaration order |
S a(1, 2); | Parenthesized aggregate init (C++20), narrowing allowed |