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):

  1. No user-declared or inherited constructors.
  2. No private or protected non-static data members.
  3. No virtual functions.
  4. 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.

SyntaxFor 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