Changed rules for protected constructors in C++17?

Solution 1:

The definition of aggregate changed since C++17.

Before C++17

no base classes

Since C++17

no virtual, private, or protected (since C++17) base classes

That means, for B and D, they're not aggregate type before C++17, then for B{} and D{}, value-initialization will be performed, then the defaulted default constructor will be called; which is fine, because the protected constructor of base class could be called by derived class's constructor.

Since C++17, B and D become aggregate type (because they have only public base class, and note that for class D, the explicitly defaulted default constructor is allowed for aggregate type since C++11), then for B{} and D{}, aggregate-initialization will be performed,

Each direct public base, (since C++17) array element, or non-static class member, in order of array subscript/appearance in the class definition, is copy-initialized from the corresponding clause of the initializer list.

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.

That means the base class subobject will be value-initialized directly, the constructor of B and D are bypassed; but the default constructor of A is protected, then the code fails. (Note that A is not aggregate type because it has a user-provided constructor.)

BTW: C (with a user-provided constructor) is not an aggregate type before and after C++17, so it's fine for both cases.

Solution 2:

In C++17, rules about aggregates has changed.

For example, you can do this in C++17 now:

struct A { int a; };
struct B { B(int){} };

struct C : A {};
struct D : B {};

int main() {
    (void) C{2};
    (void) D{1};
}

Note that we're not inheriting constructor. In C++17, C and D are now aggregates even if they have base classes.

With {}, aggregate initialization kicks in, and sending no parameters will be interpreted the same as calling the parent's default constructor from the outside.

For example, aggregate initialization can be disabled by changing the class D to this:

struct B { protected: B(){} };

struct D : B {
    int b;
private:
    int c;
};

int main() {
    (void) D{}; // works!
}

This is because aggregate initialization don't apply when having members with different access specifiers.

The reason why with = default works is because it's not a user provided constructor. More information at this question.