迈向C++20中不那么令人惊讶的聚合体
为了与所有读者保持一致,让我们先提到聚合类类型是一种特殊的类类型家族,可以通过聚合初始化,使用直接列表初始化或复制列表初始化,分别使用T aggr_obj{arg1, arg2, ...}
和T aggr_obj = {arg1, arg2, ...}
进行初始化。
判断一个类是否为聚合体的规则并不完全简单,尤其是这些规则在不同版本的C++标准之间发生了变化。在本文中,我们将介绍这些规则以及它们在从C++11到C++20的标准发布中如何更改。
在我们查看相关的标准章节之前,请考虑下面这个人为构造的类类型的实现:
namespace detail {
template <int N>
struct NumberImpl final {
const int value{N};
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;
};
}
template <int N>
using Number = detail::NumberImpl<N>;
这段内容讲述的是一个非可复制、非可移动的单例类模板,它将其唯一的非类型模板参数包装成公共常量数据成员。对于每个实例化的单例对象来说,它是此特定类专业化中唯一可能被创建的对象。作者定义了别名模板“Number”,仅用于禁止 API 的用户显式专门化潜在的“detail::NumberImpl”类模板。
忽略这个类模板的实际有用性(或者说无用性),作者是否正确实现了它的设计意图?换句话说,给定下面的函数 "wrappedValueIsN" 作为预期公共使用的 "Number" 别名模板的验收测试,此函数是否总是返回 true?
template <int N>
bool wrappedValueIsN(const Number<N>& num) {
// Always 'true', by design of the 'NumberImpl' class?
return N == num.value;
}
我们将回答这个问题,假设没有用户滥用接口来专门使用语义上隐藏的
detail::NumberImpl
,那么答案是:
- C++11:是的
- C++14:否
- C++17:否
- C++20:是的
关键区别在于类模板
detail::NumberImpl
(对于它的任何非显式特化)在C++14和C++17中是一个聚合体,而在C++11和C++20中不是。如上所述,使用直接列表初始化或复制列表初始化初始化对象将导致聚合初始化,如果对象是聚合类型。因此,可能看起来像
值初始化(例如,在这里
Number<1> n{}
),我们可能期望会产生
零初始化后跟随
默认初始化,因为存在
用户声明但不是
用户提供的默认构造函数,或者类类型对象的
直接初始化(例如,在这里
Number<1>n{2}
),实际上将绕过任何构造函数,即使删除了这些构造函数,如果类类型是一个聚合体。
struct NonConstructible {
NonConstructible() = delete;
NonConstructible(const NonConstructible&) = delete;
NonConstructible(NonConstructible&&) = delete;
};
int main() {
NonConstructible nc{};
}
因此,我们可以通过聚合初始化绕过
detail::NumberImpl
的私有和删除的用户声明构造函数,明确为单个
value
成员提供一个值,从而覆盖指定的成员初始化器(
... value{N};
),否则该初始化器将其值设置为
N
,从而在C++14和C++17中失败
wrappedValueIsN
验收测试。{{}}
constexpr bool expected_result{true}
const bool actual_result =
wrappedValueIsN(Number<42>{41})
// ^^^^ aggr. init. int C++14 and C++17.
请注意,即使
detail :: NumberImpl
声明了一个私有的并明确默认的析构函数(
〜NumberImpl()= default;
与
private
访问指定符),我们仍然可以通过动态分配(并从未删除)使用聚合初始化(
wrappedValueIsN(*(new Number<42> {41}))
)破坏验收测试,尽管会造成内存泄漏。
但是,在C ++14和C ++17中为什么
detail :: NumberImpl
是聚合体,而在C ++11和C ++20中却不是? 我们将转向不同标准版本的相关标准段落以获得答案。
C ++11中的聚合体
规定类是否为聚合体的规则由
[dcl.init.aggr] / 1涵盖,其中我们引用
N3337(C ++11 +编辑修复)作为C ++11 [
强调我的]:
聚合体是没有用户提供的构造函数([class.ctor]),非静态数据成员没有大括号或等号初始化器([class.mem]),没有私有或保护的非静态数据成员([class.access]),没有基类([class.derived])和没有虚函数([class.virtual])的数组或类(子句[class])。
这些突出显示的部分是上下文答案最相关的部分。
用户提供的函数
detail :: NumberImpl
类确实声明了四个构造函数,因此它具有四个用户声明的构造函数,但它没有为任何这些构造函数提供定义; 它在构造函数的第一次声明时使用
default
和
delete
关键字使用显式默认和显式删除的函数定义。
根据
[dcl.fct.def.default] / 4的规定,在其第一次声明时定义显式默认或显式删除的函数不算作该函数被
用户提供[摘录,
强调我的]:
[…]如果特殊成员函数是用户声明的并且在其第一次声明时没有明确指定或删除,则为用户提供[…]
因此,
detail::NumberImpl
满足聚合类的要求,即没有用户提供的构造函数。
对于一些额外的聚合混淆(适用于C++11到C++17),如果提供了带有明确默认值的定义,请参阅
我的其他答案。
指定成员初始化器
尽管
detail::NumberImpl
类没有用户提供的构造函数,但它确实使用了一个“花括号或等号初始化器”(通常称为“指定成员初始化器”)来初始化单个非静态数据成员 value。这是
detail::NumberImpl
类
在 C++11 中不是聚合的唯一原因。
C++14中的聚合体
对于C++14,我们再次转向
[dcl.init.aggr]/1,现在引用
N4140(C++14 + 编辑修复),它几乎与C++11中相应的段落相同,只是已删除关于“花括号或等于初始化器”的部分[
我强调]:
引用:
聚合是数组或类(第[class]节),
具有无用户提供的构造函数([class.ctor]),无私有或受保护
非静态数据成员(第[class.access]节),没有基类
(第[class.derived]节),也没有虚函数(第[class.virtual]节)。
因此,
detail::NumberImpl
类
符合 C++14 中聚合体的规则,因此允许通过聚合初始化绕过所有私有、默认或已删除的“用户声明”的构造函数。
一旦我们到达C++20,我们将回到始终强调的关于“用户提供”的构造函数段落,但是我们首先要解决C++17中的一些显式困惑。
C++17中的聚合体
在C++17中,聚合体再次发生了变化,现在允许聚合体公开继承一个基类(有一些限制),并禁止使用
explicit
构造函数来定义聚合体。
[dcl.init.aggr]/1来自
N4659((2017年3月后Kona工作草案/C++17 DIS),其中强调如下:
“聚合体是一个数组或一个类,其具备以下特征:(1.1)没有用户提供的、显式的或继承的构造函数([class.ctor]),(1.2)没有私有或保护的非静态数据成员(Clause[class.access]),(1.3)没有虚函数,(1.4)没有虚拟、私有或保护的基类([class.mi])。”
在这篇文章中,关于
explicit
的部分很有趣,因为我们可以通过改变
detail::NumberImpl
的私有用户声明的默认构造函数的声明来进一步增加聚合体跨标准版本的不稳定性。
template <int N>
struct NumberImpl final {
// ...
private:
NumberImpl() = default;
// ...
};
to
template <int N>
struct NumberImpl final {
private:
explicit NumberImpl() = default;
};
因此,在C++17中,detail::NumberImpl
不再是一个聚合体,而在C++14中仍然是一个聚合体。将这个例子表示为(*)
。除了使用空花括号初始化列表进行复制列表初始化(有关详细信息,请参见我在此处的另一个答案)之外:
struct Foo {
virtual void fooIsNeverAnAggregate() const {};
explicit Foo() {}
};
void foo(Foo) {}
int main() {
Foo f1{};
Foo f2 = {};
foo({});
}
在没有参数的默认构造函数中,
explicit
只有在
(*)
中展示的情况下才会产生影响。
C++20中,特别是由于实现
P1008R1(禁止带有用户声明构造函数的聚合体)大部分上述经常令人惊讶的聚合行为已得到解决,具体来说,不再允许聚合体具有用户声明的构造函数,这是一个比仅禁止用户提供的构造函数更严格的类成为聚合体的要求。我们再次转向
[dcl.init.aggr]/1,现在引用
N4861(March 2020 post-Prague working draft/C++20 DIS),它声明 [
强调我的]:
聚合体是一个数组或一个类([class]),其具有:
- (1.1) 没有用户声明或继承的构造函数([class.ctor]),
- (1.2) 没有非静态私有或保护数据成员([class.access]),
- (1.3) 没有虚函数([class.virtual]),以及
- (1.4) 没有虚拟、私有或保护基类([class.mi])。
我们还可以注意到关于
explicit
构造函数的部分已被删除,现在是多余的,因为如果我们甚至不能声明构造函数,那么我们就不能将构造函数标记为
explicit
。
避免聚合体带来的惊喜。上述所有示例都依赖于具有公共非静态数据成员的类类型,这通常被认为是设计“非POD-like”类的反模式。作为一个经验法则,如果您想避免设计一个无意中成为聚合体的类,请确保其至少一个(通常是全部)非静态数据成员是私有(/受保护的)。对于某些原因无法应用此规则的情况,而且您仍然不希望该类成为聚合体,请确保转向相应标准的相关规则(如上所列),以避免编写一个在不同的C++标准版本中不可移植的类,无论它是否是聚合体。