在 C++20 中实现一个不那么令人惊讶的聚合
为了与所有读者保持一致,让我们首先提到聚合类类型构成了一个特殊的类类型家族,特别是可以通过聚合初始化来初始化,使用direct-list-init 或 copy-list-init,分别为 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};
// 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>;
设计意图是创建一个不可复制、不可移动的单例类模板,该模板将其单个非类型模板参数包装到公共常量数据成员中,并且每个实例化的单例对象是唯一的可以为这个特定的类专业化创建。作者定义了一个别名模板Number,只是为了禁止API的用户显式地专门化底层detail::NumberImpl类模板。
忽略这个类模板的实际有用性(或者,更确切地说,无用),作者是否正确地实现了它的设计意图?或者,换句话说,给定下面的函数wrappedValueIsN,用作公开预期的数字别名模板设计的验收测试,该函数是否总是返回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++17 中它不是一个聚合C++20。如上所述,如果对象是聚合类型,则使用 direct-list-init 或 copy-list-init 初始化对象将导致聚合初始化。因此,可能看起来像 value-initialization(例如这里的 Number<1> n{})——我们可能期望它会产生 zero-initialization 的效果,然后是 default-初始化 作为 user-declared 但不存在 user-provided 默认构造函数 - 或 direct-initialization (例如Number<1>n{2} 这里) 的类类型对象实际上会绕过任何构造函数,即使是已删除的构造函数,如果类类型是聚合的话。
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{};
}
因此,我们可以通过聚合初始化绕过 detail::NumberImpl 的私有和删除的 user-declared 构造函数,使 C++14 和 C++17 中的 wrappedValueIsN 验收测试失败,特别是我们显式地为单个 value 成员提供值,从而覆盖指定的成员初始化程序 (... value{N};),否则将其值设置为 N。
constexpr bool expected_result{true};
const bool actual_result =
wrappedValueIsN(Number<42>{41}); // false
// ^^^^ aggr. init. int C++14 and C++17.
请注意,即使 detail::NumberImpl 声明了一个私有且显式默认的析构函数(~NumberImpl() = default; 和 private 访问指定器),我们仍然可以以内存泄漏为代价,通过例如使用聚合初始化 (wrappedValueIsN(*(new Number<42>{41}))) 动态分配(并且从不删除)detail::NumberImpl 对象。
但是为什么是 detail::NumberImpl 是 C++14 和 C++17 中的聚合,为什么不是 C++11 和 C 中的聚合++20?我们将转向不同标准版本的相关标准段落寻求答案。
C++11 中的聚合
[dcl.init.aggr]/1 涵盖了管理类是否为聚合的规则,我们在其中引用 N3337 (C++11 + editorial fixes) 来表示 C++11 [强调我的]:
聚合是一个数组或一个类(子句 [class]),没有
用户提供的构造函数([class.ctor]),否
非静态数据成员的大括号或等号初始化器
([class.mem]),没有私有或受保护的非静态数据成员(子句
[class.access]),没有基类(子句 [class.derived]),也没有
虚函数([class.virtual])。
强调的部分是与此答案的上下文最相关的部分。
用户提供的功能
detail::NumberImpl 类声明四个构造函数,因此它有四个用户声明构造函数,但它不提供定义对于这些构造函数中的任何一个;它在构造函数的第一个声明中使用 explicitly-defaulted 和 explicitly-deleted 函数定义,分别使用 default 和 delete 关键字。
根据[dcl.fct.def.default]/4 的规定,在第一次声明时定义显式默认或显式删除的函数不计入用户提供的函数 [摘录,强调强>我的]:
[…] 如果一个特殊的成员函数是用户声明的,并且在其第一次声明时没有显式默认或删除,则它是用户提供的。 […]
因此,detail::NumberImpl 满足关于没有用户提供的构造函数的聚合类要求。
对于一些额外的聚合混淆(适用于 C++11 到 C++17),其中显式默认定义是离线提供的,请参阅my other answer here。
指定的成员初始化器
尽管detail::NumberImpl 类没有用户提供的构造函数,但它确实使用了大括号或相等初始化器(通常称为指定成员初始化器 ) 用于单个非静态数据成员值。这是 detail::NumberImpl 类在 C++11 中不是聚合的唯一原因。
C++14 中的聚合
对于 C++14,我们再次转向 [dcl.init.aggr]/1,现在指的是 N4140 (C++14 + editorial fixes),这与 C++11 中的相应段落几乎相同,除了关于 brace-or 的段-equal-initializers 已被移除 [emphasis mine]:
聚合是一个数组或一个类(子句 [class]),没有
用户提供的构造函数([class.ctor]),没有私有或受保护
非静态数据成员(子句 [class.access]),无基类
(子句 [class.derived]),并且没有虚函数([class.virtual])。
因此,detail::NumberImpl 类满足了在 C++14 中作为聚合的规则,从而允许规避所有私有、默认或删除的用户声明 通过聚合初始化的构造函数。
一旦我们在一分钟内到达 C++20,我们将回到关于用户提供的构造函数的一贯强调的部分,但我们将首先访问 C++17 中的一些 explicit 困惑.
C++17 中的聚合
在 C++17 中聚合再次改变了它的形式,现在允许聚合从基类公开派生,但有一些限制,以及禁止聚合的 explicit 构造函数。 [dcl.init.aggr]/1 来自 N4659 ((March 2017 post-Kona working draft/C++17 DIS),声明 [强调 我的]:
聚合是一个数组或一个类
- (1.1) 没有用户提供的、显式的或继承的构造函数([class.ctor]),
- (1.2) 没有私有或受保护的非静态数据成员(子句 [class.access]),
- (1.3) 没有虚函数,并且
- (1.4) 没有虚拟、私有或受保护的基类 ([class.mi])。
关于explicit 的部分在这篇文章的上下文中很有趣,因为我们可以通过更改@987654368 的私有用户声明的显式默认默认构造函数的声明来进一步增加跨标准发布的总体波动性@来自:
template <int N>
struct NumberImpl final {
// ...
private:
NumberImpl() = default;
// ...
};
到
template <int N>
struct NumberImpl final {
// ...
private:
explicit NumberImpl() = default;
// ...
};
detail::NumberImpl 在 C++17 中不再是聚合,而在 C++14 中仍然是聚合。将此示例表示为(*)。除了 copy-list-initialization 和一个 empty braced-init-list(在my other answer here 中查看更多详细信息):
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({});
}
(*) 中显示的情况是explicit 实际上对没有参数的默认构造函数产生影响的唯一情况。
C++20 中的聚合
从 C++20 开始,特别是由于 P1008R1 的实现(禁止使用用户声明的构造函数进行聚合),上面提到的大多数经常令人惊讶的聚合行为已经得到解决,特别是通过不再允许聚合具有 user-declared 构造函数,对于一个类作为聚合的要求比仅仅禁止 user-provided 构造函数更严格。我们再次转向[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 类”类的反模式。根据经验,如果您想避免设计一个无意中聚合的类,只需确保其非静态数据成员中的至少一个(通常甚至全部)是私有的(/受保护的)。对于由于某种原因无法应用的情况,并且您仍然不希望该类成为聚合,请确保转向相应标准的相关规则(如上所列)以避免编写一个不便携wrt是否是不同 C++ 标准版本的聚合。