Skip to content
C++
Language
since C++98
Basic

Aggregate Initialization and Designated Initializers

Braced initialization for aggregates, designated initializers (C++20), inheritance aggregates (C++17), and the evolving rules for what counts as an aggregate.

Aggregate Initializationsince C++98

Direct initialization of an aggregate β€” an array or a class type meeting specific structural constraints β€” using a braced initializer list that assigns values to members in declaration order.

Overview

Aggregate initialization predates C++98, but the rules for what qualifies as an aggregate have changed in every major standard since C++11. Getting this wrong causes silent misbehavior or hard-to-diagnose compile errors when porting code.

The aggregate definition, by standard:

ConstraintC++11C++14C++17C++20
No user-provided constructorsβœ“βœ“βœ“β€”
No user-declared constructorsβ€”β€”β€”βœ“
No inherited constructorsβœ“βœ“βœ“βœ“
No explicit constructorsβ€”β€”βœ“βœ“
No default member initializersβœ“liftedβ€”β€”
No base classes at allβœ“βœ“relaxedrelaxed
No private/protected non-static data membersβœ“βœ“βœ“βœ“
No virtual functionsβœ“βœ“βœ“βœ“

Two changes matter most in practice:

C++14 removed the ban on default member initializers. A struct with int x = 0; is an aggregate again.

C++20 tightened constructor exclusion from "user-provided" to "user-declared." A class with T() = default; written explicitly in its body is no longer an aggregate. This is a silent breaking change from C++17 code.

cpp
struct A {
    int x = 0;   // default member initializer
    int y;
};
// C++11: NOT an aggregate (has default member init)
// C++14+: IS an aggregate
A a{.y = 5};   // OK since C++20; a.x == 0

struct B {
    int x;
    B() = default;   // user-declared, even though defaulted
};
B b1{42};   // OK in C++17 β€” B is an aggregate (= default is not user-provided)
// B b2{42};  // ERROR in C++20 β€” B is no longer an aggregate

Syntax

Positional Braced Initialization

Values are assigned to members in declaration order. Trailing members not listed are value-initialized (zero for scalars, default-constructed for class types):

cpp
struct Vec3 { float x, y, z; };

Vec3 v1{1.0f, 2.0f, 3.0f};   // all fields
Vec3 v2{};                     // x=0, y=0, z=0
Vec3 v3{1.0f};                 // x=1, y=0, z=0

float arr[4]{1.0f, 2.0f};     // {1, 2, 0, 0}

Braced initialization prohibits narrowing conversions β€” the compiler rejects them at compile time, unlike constructors called with parentheses:

cpp
struct S { int n; double d; };
S s1{1, 2.5};              // OK
// S s2{1.5, 2.5};         // ill-formed: 1.5 β†’ int is narrowing
S s3{static_cast<int>(1.5), 2.5};  // OK: explicit cast

Designated Initializers (C++20)

Name the fields you are initializing. Unmentioned fields receive their default member initializer value, or are zero-initialized if none exists:

cpp
struct NetworkConfig {
    std::string host     = "127.0.0.1";
    uint16_t    port     = 8080;
    int         timeout  = 30;
    bool        tls      = false;
    size_t      max_conn = 100;
};

NetworkConfig prod{
    .host = "0.0.0.0",
    .port = 443,
    .tls  = true,
    // timeout=30, max_conn=100 unchanged
};

Rules for designated initializers:

  • Designators must appear in declaration order. Unlike C99, out-of-order designated init is ill-formed in C++.
  • You cannot mix designated and non-designated in the same list: {1, .y = 2} is ill-formed.
  • Each designator must name a direct non-static data member of the aggregate being initialized.
  • Nested aggregates can themselves use designated initializers.
cpp
struct Color { float r, g, b, a = 1.0f; };
struct Material {
    std::string name;
    Color       diffuse;
    Color       specular;
    float       roughness = 0.5f;
};

Material m{
    .name      = "brushed_metal",
    .diffuse   = {0.7f, 0.7f, 0.7f},                  // nested positional
    .specular  = {.r = 1.0f, .g = 1.0f, .b = 1.0f},   // nested designated (C++20)
    .roughness = 0.15f,
};

Examples

Inheritance Aggregates (C++17)

Before C++17, any base class disqualified a type. C++17 allows public, non-virtual base classes. The base subobject is initialized first, positionally:

cpp
struct Base { int id; std::string name; };
struct Entity : Base { float x, y; };   // aggregate since C++17

Entity e{{1, "player"}, 0.0f, 0.0f};

// C++20: designators cover only Entity's own members;
// base subobject must still be initialized positionally or omitted
Entity e2{{42, "npc"}, .x = 10.0f};   // C++20

Virtual bases, protected bases, or private bases still disqualify.

CTAD for Aggregates (C++20)

C++17 introduced class template argument deduction but it did not work for aggregates without explicit deduction guides. C++20 generates implicit deduction guides automatically from aggregate structure:

cpp
template<typename T>
struct Tagged {
    std::string_view tag;
    T                value;
};

// C++17: requires explicit deduction guide:
// template<typename T> Tagged(std::string_view, T) -> Tagged<T>;

// C++20: implicit guide generated β€” no annotation needed
Tagged t1{"pi",   3.14159};   // Tagged<double>   since C++20
Tagged t2{"flag", true};      // Tagged<bool>     since C++20

Arrays of Aggregates and emplace_back

cpp
struct Vertex { float x, y, z; };

Vertex triangle[3]{
    { 0.0f,  1.0f, 0.0f},
    {-1.0f, -1.0f, 0.0f},
    { 1.0f, -1.0f, 0.0f},
};

std::vector<Vertex> mesh;
mesh.push_back({0.5f, 0.5f, 0.0f});          // all standards

// C++20 added parenthesized aggregate initialization (P0960),
// enabling emplace_back to forward directly into aggregate members:
mesh.emplace_back(1.0f, 0.0f, 0.5f);         // since C++20

std::array is also an aggregate and uses the same initialization rules:

cpp
std::array<int, 4> a{1, 2, 3, 4};   // aggregate init
std::array<int, 4> b{};             // all zeros

Best Practices

Use designated initializers for any aggregate with more than two fields. Positional initialization is fragile β€” inserting a field shifts all subsequent values silently. Designated init is robust to field additions as long as new fields have defaults.

cpp
// Fragile: adding `int retry_limit = 3` before `timeout` silently breaks this
// ServerConfig c{"0.0.0.0", 443, 30, true};

// Robust: new fields with defaults are invisible to existing callers
ServerConfig c{.host = "0.0.0.0", .port = 443, .tls = true};

Give all optional fields a default member initializer. This enables callers to use {} or specify only what differs from the common case, without knowing the full member list.

In C++20, omit explicit = default declarations when the implicit constructor suffices. Aggregates are default-constructible via T{} without declaring a constructor. Explicit = default buys nothing and loses aggregate status.

Common Pitfalls

The C++20 user-declared constructor trap. This is the most common portability regression when upgrading from C++17:

cpp
struct Counter {
    int value = 0;
    Counter() = default;   // explicitly defaulted
};

Counter c{42};   // C++17: OK β€” Counter is an aggregate
                 // C++20: ERROR β€” user-declared constructor disqualifies it

Remove the = default declaration; the implicitly generated default constructor has identical behavior.

Designated initializers require declaration order. Code that looks like C99 but isn't:

cpp
struct Rect { int x, y, w, h; };
// Rect r{.w = 100, .x = 10};  // ill-formed: w comes after x
Rect r{.x = 10, .w = 100};     // OK; y=0, h=0

Brace elision with nested aggregates. The compiler consumes initializers greedily across nested levels when inner braces are omitted. This is legal but fragile:

cpp
struct Inner { int a, b; };
struct Outer { Inner i; int c; };

Outer o1{{1, 2}, 3};   // explicit: i={1,2}, c=3
Outer o2{1, 2, 3};     // brace elision: identical result, but fragile

If Inner gains a third field, o2 silently reassigns what went to c. Use explicit inner braces.

Narrowing in template instantiations. Braced init checks apply inside templates, which can surface as unexpected errors on instantiation:

cpp
template<typename T>
struct Pair { T first, second; };

Pair<int> p{1LL, 2LL};   // ill-formed: long long β†’ int is narrowing

Aggregate Initialization in C++17: Relaxed Rules

C++17 changed the aggregate criterion from "no user-provided constructors" to "no user-provided constructors" β€” but critically, C++20 changed it further to "no user-declared constructors." The distinction between user-provided and user-declared is subtle: = default and = delete are user-declared but not user-provided. In C++17 a struct with an explicitly defaulted constructor is still an aggregate; in C++20 it is not.

cpp
// C++14
struct Point14 {
    int x, y;
    Point14() = default;   // user-declared, not user-provided
};
Point14 p{1, 2};   // ERROR in C++14 β€” user-declared ctor disqualifies (C++14 rule:
                   // no user-provided OR user-declared defaulted/deleted ctors... actually
                   // C++14: no user-PROVIDED; = default is not user-provided β†’ IS aggregate)
// Correction: C++14 aggregate allows = default (not user-provided)
// C++20 tightened this: = default IS user-declared β†’ no longer aggregate

C++17 also lifted the ban on public, non-virtual base classes entirely. A derived struct with only public bases and no virtual functions is now a valid aggregate, enabling a clean composition idiom without inheritance boilerplate.

cpp
// C++17
struct Vec2 { float x, y; };
struct Vec3 : Vec2 { float z; };   // aggregate since C++17

Vec3 v{{1.0f, 2.0f}, 3.0f};   // base subobject first, then own members
// C++17

struct Tagged {
    std::string_view label;
    int              value = 0;
    Tagged() = default;            // user-declared
};
Tagged t1{"score", 42};   // OK in C++17 (= default not user-provided)
// Tagged t2{"score", 42}; // ERROR in C++20 (= default is user-declared)
// C++17

The practical rule for portable code: avoid writing any constructor declaration in structs that you want to remain aggregates. Rely on the implicitly generated default constructor instead.

Nested Aggregate Initialization

Nested aggregates are initialized by providing a nested braced-init-list for each inner aggregate member. The outer initializer list matches members of the outer type in order; when the next member is itself an aggregate, a nested {...} initializes it recursively.

cpp
struct Inner { int a, b; };
struct Outer { Inner i; int c; };

Outer o1{ {1, 2}, 3 };   // explicit inner braces: i.a=1, i.b=2, c=3
Outer o2{1, 2, 3};        // brace elision: identical result but fragile

Brace elision is legal but fragile β€” if Inner gains a new field the elided form silently reassigns c without a compile error. Always use explicit inner braces for nested aggregates.

C++20 designated initializers compose naturally with nested aggregates. The outer designator names the inner member, and the inner braced-init-list can itself use designated initializers, provided the inner type is also an aggregate.

cpp
// C++20
struct Server {
    std::string host = "127.0.0.1";
    uint16_t    port = 8080;
};

struct Config {
    Server server;
    int    timeout = 30;
    bool   verbose = false;
};

Config c{
    .server  = { .host = "0.0.0.0", .port = 443 },   // nested designated
    .verbose = true,
    // timeout stays 30
};
// C++20

Inner structs must themselves be aggregates for the nested designated-initializer syntax to work. A non-aggregate inner type must be initialized through its constructor using parenthesized syntax, not a designated braced-init-list.

Aggregate Initialization with Base Classes (C++17)

C++17 extended aggregates to include classes with public, non-virtual base classes, enabling the base subobject to be initialized inline in the braced-init-list. The base subobject is treated as the first "member" in declaration order, so it occupies the first position (or positions, if multiple bases) in the initializer list.

cpp
// C++17
struct Base { int id; std::string name; };
struct Derived : Base { int extra; };

Derived d{ {1, "hero"}, 42 };   // base-subobject first, then extra
// C++17

Multiple bases are supported. Each base subobject is listed in left-to-right declaration order before the derived class's own members.

cpp
// C++17
struct Flags  { bool active, visible; };
struct Coords { float x, y; };
struct Sprite : Flags, Coords { int layer; };

Sprite s{ {true, true}, {320.0f, 240.0f}, 2 };
// Flags subobject, Coords subobject, then layer
// C++17

Virtual bases are explicitly excluded from the C++17 relaxation. A class that introduces or inherits a virtual base cannot be an aggregate in any standard. Similarly, protected or private bases still disqualify the type. If you need virtual inheritance you must provide a constructor.

Aggregate Initialization vs Constructor Overload Resolution

When braces are used, the compiler prefers list-initialization semantics over ordinary constructor resolution β€” this produces surprising results for types that have both an initializer_list constructor and a constructor taking individual arguments. The canonical example is std::vector:

cpp
std::vector<int> a(3);    // constructor: three default-initialized elements {0, 0, 0}
std::vector<int> b{3};    // initializer_list constructor: one element {3}
std::vector<int> c{3, 1}; // initializer_list constructor: two elements {3, 1}

The preference order when {...} is used is:

  1. std::initializer_list<T> constructor (if one exists and the types convert)
  2. Aggregate initialization (if the type is an aggregate)
  3. Regular constructors, in the usual overload-resolution order

For aggregate types, braces bypass all constructors and initialize members directly. Use parentheses when you need to invoke a specific constructor.

cpp
struct Point { int x, y; };

Point p1{1, 2};       // aggregate init: x=1, y=2
Point p2(1, 2);       // ERROR: no matching constructor (Point has none declared)
// parentheses for aggregates only work in C++20 (P0960)

// When you need a constructor, parentheses are unambiguous:
std::string s1{"hello"};   // char* constructor
std::string s2(5, 'x');    // fill constructor: "xxxxx"
// std::string s3{5, 'x'}; // ERROR: no initializer_list<char> matches {int, char}

The safest rule: use {} for aggregates and value-initialization; use () for types that have constructors you intend to invoke by signature.

Partial Initialization and Zero-Initialization

Listing fewer initializers than there are members is well-defined: trailing members that are not mentioned are value-initialized. For scalar types, value-initialization means zero-initialization; for class types it means default construction.

cpp
// C++11
struct Point { int x, y; };

Point p1{1};    // x=1, y=0 (y value-initialized)
Point p2{};     // x=0, y=0 (all value-initialized)
Point p3;       // default-initialized: x and y are indeterminate (UB to read without writing)
// C++11

The distinction between T{} and T matters for trivial types. T{} guarantees zero-initialization; bare T (default-initialization) leaves scalar members indeterminate, which is undefined behavior to read.

cpp
// C++11
struct Config {
    int  width  = 800;
    int  height = 600;
    bool vsync  = false;
    int  msaa   = 0;
    int  aniso  = 1;
    // ... many more fields with defaults
};

// Intentional partial initialization: caller specifies only what differs
Config hd{.width = 1920, .height = 1080};   // all other fields keep defaults
// C++11 (default member initializers) + C++20 (designated)

For large configuration structs, design every field with a default member initializer so that any partial braced-init-list is safe. A bare Config c; then default-constructs all fields to their specified defaults rather than leaving them indeterminate.

constexpr Aggregate Initialization

Aggregate types are naturally constexpr-compatible: provide a constexpr variable with a braced-init-list and the entire initialization is evaluated at compile time, with no runtime cost. No constexpr constructor declaration is needed β€” the aggregate rules apply directly.

cpp
// C++11
struct Point { int x, y; };

constexpr Point origin{0, 0};
constexpr Point p{3, 4};

static_assert(p.x == 3);
static_assert(p.y == 4);
// C++11

Nested constexpr aggregates work recursively. This makes compile-time lookup tables straightforward to express:

cpp
// C++11
struct Entry { std::string_view key; int value; };

constexpr Entry table[]{
    {"zero",  0},
    {"one",   1},
    {"two",   2},
    {"three", 3},
};
static_assert(table[2].value == 2);
// C++11

C++20 introduced constinit, which enforces constant initialization without implying constexpr (the variable can still be mutated at runtime). It is useful for globals that must be zero-initialized before any code runs, preventing the static initialization order fiasco:

cpp
// C++20
struct Registry {
    int  count   = 0;
    bool enabled = true;
};

constinit Registry global_registry{};   // guaranteed constant-initialized; mutable at runtime
// C++20

Prefer constexpr for truly immutable compile-time data and constinit for mutable globals that must be safely accessible during static initialization. Both work seamlessly with aggregate initialization because aggregates have no constructor code to violate constexpr evaluation rules.

See Also