Why does this code compile without errors in C++17?

(For a thorough walk-through of this topic, see the blog article The fickle aggregate)


Aggregate initialization

Class Ax is an aggregate in C++11, C++14 and C++17, as it has no user-provided constructors, which means that Ax{} is aggregate initialization, bypassing any user-declared constructors, even deleted ones.

struct NonConstructible {
    NonConstructible() = delete;
    NonConstructible(const NonConstructible&) = delete;
    NonConstructible(NonConstructible&&) = delete;
};

int main() {
    //NonConstructible nc;  // error: call to deleted constructor

    // Aggregate initialization (and thus accepted) in
    // C++11, C++14 and C++17.
    // Rejected in C++20 (error: call to deleted constructor).
    NonConstructible nc{};
}

The definition of what is an aggregate class has changed through various standard versions (C++11 through C++20), and these rules can have somewhat surprising consequences. As of C++20, particularly due to the implementation of

  • P1008R1: Prohibit aggregates with user-declared constructors

most of the frequently surprising aggregate behaviour has been addressed, specifically by no longer allowing aggregates to have user-declared constructors, a stricter requirement for a class to be an aggregate than just prohibiting user-provided constructors.


User-provided or only user-declared explicitly-defaulted constructors

Note that providing an explicitly-defaulted (or deleted) definition out-of-line counts as a user-provided constructor, meaning that in the following example, B has a user-provided default constructor, whereas A does not:

struct A {
    A() = default; // not user-provided.
    int a;
};

struct B {
    B(); // user-provided.
    int b;
};

// Out of line definition: a user-provided
// explicitly-defaulted constructor.
B::B() = default;

with the result that A is an aggregate, whereas B is not. This, in turn, means that initialization of B by means of an empty direct-list-init will result in its data member b being left in an uninitialized state. For A, however, the same initialization syntax will result in (via aggregate initialization of the A object and subsequent value initalization of its data member a) zero-initialization of its data member a:

A a{};
// Empty brace direct-list-init:
// -> A has no user-provided constructor
// -> aggregate initialization
// -> data member 'a' is value-initialized
// -> data member 'a' is zero-initialized

B b{};
// Empty brace direct-list-init:
// -> B has a user-provided constructor
// -> value-initialization
// -> default-initialization
// -> the explicitly-defaulted constructor will
//    not initialize the data member 'b'
// -> data member 'b' is left in an unititialized state

This may come as a surprise, and with the obvious risk of reading the uninitialized data member b with the result of undefined behaviour:

A a{};
B b{};     // may appear as a sound and complete initialization of 'b'.
a.a = b.b; // reading uninitialized 'b.b': undefined behaviour.

In C++17, your example is an aggregate. For C++17 aggregates only need to have no user-provided constructors; user-declared (but explicitly deleted or defaulted) constructors are fine.

In this case, then, aggregate initialization is performed when you do Ax{}, which doesn't call any of the constructors... including the deleted ones, and so this compiles.

In C++20 the rules were changed so that any user-declared constructors prevent the type from being an aggregate, and so the example will fail to compile.

See also https://en.cppreference.com/w/cpp/language/aggregate_initialization

Tags:

C++

C++17