Understanding Aggregate Initialization and "Explicitly Initialized Elements" in C++

Aggregate initialization in C++ can sometimes feel surprisingly complex, especially when diving into formal language specifications on cppreference or the C++ Standard. A common point of confusion arises when determining what qualifies as an "explicitly initialized element" during aggregate initialization when brace elision is involved.

The Problem Scenario

Consider the following nested struct definition where nested braces are omitted (a feature known as brace elision):

struct S1 {
    int a, b;
};

struct S2 {
    S1 s, t;
};

// Aggregate initialization with brace elision
S2 y = {1, 2, 3, 4};

According to cppreference (reflecting C++20 rules):

The explicitly initialized elements of the aggregate are the elements with an appertained initializer clause and the elements having a subaggregate with an appertained initializer clause.

Looking at S2 y = {1, 2, 3, 4};:

  • Initializer 1 appertains to y.s.a.
  • Initializer 2 appertains to y.s.b.
  • No initializer clause directly appertains to y.s as a whole (since there are no inner braces like {{1, 2}, {3, 4}}).

This leads to the confusing question: Is y.s considered an explicitly initialized element of y? If not, does it default-initialize/copy-initialize from an empty initializer list?

Deconstructing the Standard Rules

To clear up this confusion, we need to understand two key terms in C++ standard terminology: subaggregates and appertaining initializer clauses.

1. What is a Subaggregate?

In standard C++ terminology, any element of an aggregate that is itself of an aggregate type is called a subaggregate. In our example:

  • y is the top-level aggregate of type S2.
  • y.s and y.t are direct elements of y, and because S1 is an aggregate type, both y.s and y.t are subaggregates of y.

2. How Brace Elision Affects Explicit Initialization

When you write S2 y = {1, 2, 3, 4};, brace elision allows the compiler to bind the values 1 and 2 directly to the members of y.s (namely y.s.a and y.s.b).

Because y.s is an element of y that is a subaggregate containing subobjects with appertained initializer clauses, y.s satisfies the standard definition. It qualifies as an element having a subaggregate with an appertained initializer clause (where y.s itself is the subaggregate containing elements with appertained initializers).

Therefore, y.s is considered an explicitly initialized element of y.

What Happens to Non-Aggregate Subobjects?

What if a member subobject is not an aggregate (e.g., a class with a user-declared constructor)?

struct NonAggregate {
    NonAggregate(int x) {}
};

struct Container {
    NonAggregate n;
    int k;
};

// Syntax Error!
// Container c = {1, 2}; 

Brace elision only applies to subaggregates. If a member subobject is not an aggregate, initializer clauses cannot "reach inside" it to initialize its members. You must provide an explicit initializer for the non-aggregate subobject itself, such as Container c = { NonAggregate(1), 2 }; or Container c = { {1}, 2 };.

Summary of Key Takeaways

  • Direct initialization: If an element has its own initializer clause (e.g., inside explicit braces {1, 2}), it is explicitly initialized.
  • Brace elision with subaggregates: If an element is a subaggregate and initializer clauses appertain to its inner members, the element itself counts as explicitly initialized.
  • Trailing uninitialized elements: Any elements in an aggregate that are not explicitly initialized are value-initialized (e.g., zero-initialized for fundamental types).