The fickle aggregate
What is an aggregate class?
Aggregate class types make up a special family of class types that can be,
particularly, initialized by means of aggregate initialization, using
direct-list-init or copy-list-init, T aggr_obj{arg1, arg2, ...}
and T aggr_obj = {arg1, arg2, ...}
, respectively.
The rules governing whether a class is an aggregate or not are not entirely straight-forward, particularly as the rules have been changing between different releases of the C++ standard. In this post we’ll go over these rules and how they have changed over the standard release from C++11 through C++20.
Before we visit the relevant standard passages, consider the implementation of the following contrived class type:
namespace detail {
template <int N>
struct NumberImpl final {
const int value{N};
// Factory method for NumberImpl<N> wrapping non-type
// template parameter 'N' as data member 'value'.
static const NumberImpl& get() {
static constexpr NumberImpl number{};
return number;
}
private:
NumberImpl() = default;
NumberImpl(int) = delete;
NumberImpl(const NumberImpl&) = delete;
NumberImpl(NumberImpl&&) = delete;
NumberImpl& operator=(const NumberImpl&) = delete;
NumberImpl& operator=(NumberImpl&&) = delete;
};
} // namespace detail
// Intended public API.
template <int N>
using Number = detail::NumberImpl<N>;
where the design intent has been to create a non-copyable, non-movable singleton
class template which wraps its single non-type template parameter into a
public constant data member, and where the singleton object for each
instantiation is the only that can ever be created for this particular class
specialization. The author has defined an alias template Number
solely to
prohibit users of the API to explicitly specialize the underlying
detail::NumberImpl
class template.
Ignoring the actual usefulness (or, rather, uselessness) of this class template,
have the author correctly implemented its design intent? Or, in other words,
given the function wrappedValueIsN
below, used as an acceptance test for the
design of the publicly intended Number
alias template, will the function
always return true
?
template <int N>
bool wrappedValueIsN(const Number<N>& num) {
// Always 'true', by design of the 'NumberImpl' class?
return N == num.value;
}
We will answer this question assuming that no user abuses the interface by
specializing the semantically hidden detail::NumberImpl
, in which case the
answer is:
- C++11: Yes
- C++14: No
- C++17: No
- C++20: Yes
The key is, as one might guess given the title of this post, that the class
template detail::NumberImpl
(for any non-explicit specialization of it) is an
aggregate in C++14 and C++17, whereas it is not an aggregate in C++11 and C++20.
As covered above, initialization of an object using direct-list-init or
copy-list-init will result in aggregate initialization if the object is of
an aggregate type.
Thus, what may look like value initialization (e.g. Number<1> n{}
here)—which we may expect will have the effect of zero initialization followed
by default initialization as a user-declared but not user-provided default
constructer exists—or direct initialization (Number<1> n{2}
here) of a class
type object will actually bypass any constructors, even deleted ones, if the
class type is an aggregate.
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{};
}
Thus, we can fail the wrappedValueIsN
acceptance test in C++14 and C++17 by
bypassing the private and deleted user-declared constructors of
detail::NumberImpl
by means of aggregate initialization, specifically where we
explicitly provide a value for the single value
member thus overriding the
default member initializer (... value{N};
) that otherwise sets its value to
N
.
constexpr bool expected_result{true};
const bool actual_result =
wrappedValueIsN(Number<42>{41}); // false
// ^^^^ aggr. init. int C++14 and C++17.
Note that even if detail::NumberImpl
were to declare a private and explicitly
defaulted destructor (~NumberImpl() = default;
with private
access
specifyer) we could still, at the cost of a memory leak, break the acceptance
test by e.g. dynamically allocating (and never deleting) a detail::NumberImpl
object using aggregate initialization ( wrappedValueIsN(*(new Number<42>{41}))
).
But why is detail::NumberImpl
an aggregate in C++14 and C++17, and why is
it not an aggregate in C++11 and C++20? We shall turn to the relevant standard
passages for the different standard versions for an answer.
Aggregates in C++11
The rules governing whether a class is an aggregate or not is covered by {dcl.init.aggr}/1, where we refer to N3337 (C++11 + editorial fixes) for C++11 [ emphasis mine]:
An aggregate is an array or a class (Clause [class]) with no user-provided constructors ([class.ctor]), no brace-or-equal-initializers for non-static data members ([class.mem]), no private or protected non-static data members (Clause [class.access]), no base classes (Clause [class.derived]), and no virtual functions ([class.virtual]).
The emphasized segments are the most relevant ones for the context of this post.
User-provided functions
The detail::NumberImpl
class does declare four constructors, such that it
has four user-declared constructors, but it does not provide definitions for
any of these constructors; it makes use of explicitly-defaulted and
explicitly-deleted function definitions at the constructors' first
declarations, using the default
and delete
keywords, respectively.
As governed by {dcl.fct.def.default}/4, defining an explicitly-defaulted or explicitly-deleted function at its first declaration does not count as the function being user-provided [extract, emphasis mine]:
[…] A special member function is user-provided if it is user-declared and not explicitly defaulted or deleted on its first declaration. […]
Thus, the detail::NumberImpl
fulfills the aggregate class requirement
regarding having no user-provided constructors.
Example: out-of-line definitions for explicitly defaulted constructors
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.
A takeaway from this example can be to, as a rule of thumb, never to define explicitly-defaulted constructors out-of-line.
Default member initializers
Albeit the detail::NumberImpl
class has no user-provided constructors, it does use a brace-or-equal-initializer for the single non-static data member value
, such that the data member value
has a default
member initializer. This is the sole reason as to why the detail::NumberImpl
class is not an aggregate in C++11.
Aggregates in C++14
For C++14, we once again turn to {dcl.init.aggr}/1, now referring to N4140 (C++14 + editorial fixes), which is nearly identical to the corresponding paragraph in C++11, except that the segment regarding brace-or-equal-initializers has been removed [ emphasis mine]:
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]).
Thus, the detail::NumberImpl
class fulfills the rules for it to be an
aggregate in C++14, thus allowing circumventing all private, defaulted or
deleted user-declared constructors by means of aggregate initialization.
We will get back to the consistently emphasized segment regarding
user-provided constructors once we reach C++20 in a minute, but we shall first
visit some explicit
puzzlement in C++17.
Aggregates in C++17
True to its form, the aggregate once again changed in C++17, now allowing an
aggregate to derive publicly from a base class, with some restrictions, as
well as prohibiting explicit
constructors for aggregates. {dcl.init.aggr}/1
from N4659 ((March 2017 post-Kona working draft/C++17 DIS), states [ emphasis
mine]:
An aggregate is an array or a class with
- (1.1) no user-provided,
explicit
, or inherited constructors ([class.ctor]),- (1.2) no private or protected non-static data members (Clause [class.access]),
- (1.3) no virtual functions, and
- (1.4) no virtual, private, or protected base classes ([class.mi]).
The segment in about explicit
is interesting in the context of this post, as
we may further increase the aggregate cross-standard-releases volatility by
changing the declaration of the private user-declared explicitly-defaulted
default constructor of detail::NumberImpl
from:
template <int N>
struct NumberImpl final {
// ...
private:
NumberImpl() = default;
// ...
};
into:
template <int N>
struct NumberImpl final {
// ...
private:
explicit NumberImpl() = default;
// ...
};
with the effect that detail::NumberImpl
is no longer an aggregate in C++17,
whilst still being an aggregate in C++14. Denote this example as (*)
. Apart
from copy-list-initialization with an empty braced-init-list:
struct Foo {
virtual void fooIsNeverAnAggregate() const {};
explicit Foo() {}
};
void foo(Foo) {}
int main() {
Foo f1{}; // OK: direct-list-initialization
// Error: converting to 'Foo' from initializer
// list would use explicit constructor 'Foo::Foo()'
Foo f2 = {};
foo({});
}
the case shown in (*)
is the only situation where explicit
actually has an
effect on a default constructor with no parameters.
Aggregates in C++20
As of C++20, particularly due to the implementation of P1008R1 (Prohibit aggregates with user-declared constructors) most of the frequently surprising aggregate behaviour covered above 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. We once again turn to {dcl.init.aggr}/1, now referring to N4861 (March 2020 post-Prague working draft/C++20 DIS), which states [ emphasis mine]:
An aggregate is an array or a class ([class]) with
- (1.1) no user-declared or inherited constructors ([class.ctor]),
- (1.2) no private or protected direct non-static data members ([class.access]),
- (1.3) no virtual functions ([class.virtual]), and
- (1.4) no virtual, private, or protected base classes ([class.mi]).
We may also note that the segment about explicit
constructors has been
removed, now redundant as we cannot mark a constructor as explicit
if we may
not even declare it.
Avoiding aggregate surprises
All the examples above relied on class types with public non-static data members, which is commonly considered an anti-pattern for the design of “non-POD-like” classes. As a rule of thumb, if you’d like to avoid designing a class that is unintentionally an aggregate, simply make sure that at least one (typically even all) of its non-static data members is private (/protected). For cases where this for some reason cannot be applied, and where you still don’t want the class to be an aggregate, make sure to turn to the relevant rules for the respective standard (as listed above) to avoid writing a class that is not portable w.r.t. being an aggregate or not over different C++ standard versions.