Compilation error when using empty list initialization constructor in C++17

In C++14, the definition of aggregate was:

An aggregate is an array or a class (Clause [class]) with no user-provided constructors ([class.ctor]), no private or protected non-static data members (Clause [class.access]), no base classes (Clause [class.derived]), and no virtual functions ([class.virtual]).

Hence, B is not an aggregate. As a result B{} is surely not aggregate initialization, and B{} and B() end up meaning the same thing. They both just invoke B's default constructor.

However, in C++17, the definition of aggregate was changed to:

An aggregate is an array or a class with

  • no user-provided, explicit, or inherited constructors ([class.ctor]),
  • no private or protected non-static data members (Clause [class.access]),
  • no virtual functions, and
  • no virtual, private, or protected base classes ([class.mi]).

[ Note: Aggregate initialization does not allow accessing protected and private base class' members or constructors.  — end note ]

The restriction is no longer on any base classes, but just on virtual/private/protected ones. But B has a public base class. It is now an aggregate! And C++17 aggregate initialization does allow for initializing base class subobjects.

In particular, B{} is aggregate initialization where we just don't provide an initializer for any subobject. But the first (and only) subobject is an A, which we're trying to initialize from {} (during aggregate initialization, any subobject without an explicit initializer is copy-initialized from {}), which we can't do because A's constructor is protected and we are not a friend (see also, the quoted note).


Note that, just for fun, in C++20 the definition of aggregate will change again.


From my understanding of https://en.cppreference.com/w/cpp/language/value_initialization

B{} does an aggregate_initialization,

and since C++17:

The effects of aggregate initialization are:

  • Each direct public base, (since C++17) [..] is copy-initialized from the corresponding clause of the initializer list.

and in our case:

If the number of initializer clauses is less than the number of members and bases (since C++17) or initializer list is completely empty, the remaining members and bases (since C++17) are initialized by their default initializers, if provided in the class definition, and otherwise (since C++14) by empty lists, in accordance with the usual list-initialization rules (which performs value-initialization for non-class types and non-aggregate classes with default constructors, and aggregate initialization for aggregates). If a member of a reference type is one of these remaining members, the program is ill-formed.

So B{/*constructor of A*/} need to construct base class A, which is protected...


The final draft of C++17 n4659 has a compatibility section which contains the changes with respect to previous versions.

C.4.4 Clause 11: declarators [diff.cpp14.decl]

11.6.1
Change: Definition of an aggregate is extended to apply to user-defined types with base classes.
Rationale: To increase convenience of aggregate initialization.
Effect on original feature: Valid C++ 2014 code may fail to compile or produce different results in this International Standard; initialization from an empty initializer list will perform aggregate initialization instead of invoking a default constructor for the affected types:

struct derived;
struct base {
friend struct derived;
private:
base();
};
struct derived : base {};
derived d1{}; // Error. The code was well-formed before.
derived d2; // still OK

I compiled the above example code with -std=c++14 and it compiled but failed to compile with -std=c++17.

I believe that could be the reason why the code in the OP fails with B{} but succeeds with B().