如果显式默认或删除构造函数,为什么自 C++20 以来聚合初始化不再起作用?

Why does aggregate initialization not work anymore since C++20 if a constructor is explicitly defaulted or deleted?

我正在将 C++ Visual Studio 项目从 VS2017 迁移到 VS2019。

我现在遇到一个错误,以前没有发生过,可以用这几行代码重现:

struct Foo
{
    Foo() = default;
    int bar;
};
auto test = Foo { 0 };

错误是

(6): error C2440: 'initializing': cannot convert from 'initializer list' to 'Foo'

(6): note: No constructor could take the source type, or constructor overload resolution was ambiguous

该项目是用 /std:c++latest 标志编译的。我在 godbolt 上复制了它。如果我将它切换为 /std:c++17,它会像以前一样正常编译。

我试图用 clang-std=c++2a 编译相同的代码,但得到了类似的错误。此外,默认或删除其他构造函数会产生此错误。

显然,VS2019 中添加了一些新的 C++20 功能,我假设这个问题的根源在 https://en.cppreference.com/w/cpp/language/aggregate_initialization 中有所描述。 那里说聚合可以是一个结构(除其他标准外)具有

请注意,括号 "explicitly defaulted or deleted constructors are allowed" 中的部分已删除,"user-provided" 更改为 "user-declared"。

所以我的第一个问题是,我假设标准中的这种变化是我的代码之前编译但现在不再编译的原因是否正确?

当然,解决这个问题很容易:只需删除显式默认的构造函数即可。

但是,我在我的所有项目中都明确默认并删除了很多构造函数,因为我发现以这种方式使代码更具表现力是一个好习惯,因为与隐式默认或删除相比,它只会导致更少的意外构造函数。然而,随着这一变化,这似乎不再是一个好习惯了......

所以我的实际问题是: 从 C++17 到 C++20 的这种变化背后的原因是什么?这种向后兼容性的中断是故意的吗?是否有像 "Ok, we're breaking backwards compatibility here, but it's for the greater good." 这样的权衡?这是什么更大的好处?

来自 P1008 的摘要,导致更改的提案:

C++ currently allows some types with user-declared constructors to be initialized via aggregate initialization, bypassing those constructors. The result is code that is surprising, confusing, and buggy. This paper proposes a fix that makes initialization semantics in C++ safer, more uniform,and easier to teach. We also discuss the breaking changes that this fix introduces.

他们给出的例子之一如下。

struct X {
  int i{4};
  X() = default;
};

int main() {
  X x1(3); // ill-formed - no matching c’tor
  X x2{3}; // compiles!
}

对我来说,很明显,提议的更改值得他们承担向后不兼容的问题。事实上,= default 聚合默认构造函数似乎不再是好的做法。

实际上,MSDN 在以下文档中解决了您的问题:

Modified specification of aggregate type

In Visual Studio 2019, under /std:c++latest, a class with any user-declared constructor (for example, including a constructor declared = default or = delete) isn't an aggregate. Previously, only user-provided constructors would disqualify a class from being an aggregate. This change puts additional restrictions on how such types can be initialized.

The reasoning from P1008 (PDF)最好从两个方向理解:

  1. 如果你让一个相对较新的 C++ 程序员坐在 class 定义前问 "is this an aggregate",他们是正确的吗?

聚合的常见概念是"a class with no constructors"。如果 Typename() = default; 在 class 定义中,大多数人会将其视为具有构造函数。它将表现得像标准的默认构造函数,但类型仍然有一个。这是许多用户的广泛概念。

聚合应该是 class 的纯数据,能够让任何成员假设给定的任何值。从这个角度来看,你没有必要给它任何类型的构造函数,即使你默认了它们。这给我们带来了下一个推理:

  1. 如果我的class满足聚合的要求,但我不希望它成为聚合,我该怎么做?

最明显的答案是 = default 默认构造函数,因为我可能属于第 1 组。显然,那是行不通的。

C++20 之前的版本,您的选择是为 class 提供一些其他构造函数或实现其中一个特殊成员函数。这些选项都不是可口的,因为(根据定义)它不是您实际上 需要 实现的东西;你这样做只是为了产生一些副作用。

Post-C++20,显而易见的答案有效。

通过以这种方式更改规则,它使聚合和非聚合之间的区别可见。聚合没有构造函数;所以如果你想让一个类型成为一个聚合,你就不要给它构造函数。

哦,这是一个有趣的事实:C++20 之前的版本,这是一个聚合:

class Agg
{
  Agg() = default;
};

请注意,默认构造函数是 private,因此只有拥有 Agg 私有访问权限的人才能调用它...除非他们使用 Agg{},绕过构造函数,完全合法。

这个 class 的明确意图是创建一个可以复制的 class,但只能从具有私有访问权限的人那里获得其初始构造。这允许转发访问控制,因为只有被赋予 Agg 的代码才能调用以 Agg 作为参数的函数。并且只有有权访问 Agg 的代码才能创建一个。

或者至少,它应该是这样的。

现在你可以更有针对性地解决这个问题,如果 defaulted/deleted 构造函数没有公开声明,它就是一个聚合。但这感觉更加不一致;有时,具有明显声明的构造函数的 class 是聚合,有时不是,具体取决于明显声明的构造函数所在的位置。

在 C++20 中实现不那么令人惊讶的聚合

为了与所有读者保持一致,首先要提到聚合 class 类型构成了一个特殊的 class 类型家族,特别是可以通过 聚合初始化,使用direct-list-initcopy-list-initT aggr_obj{arg1, arg2, ...}T aggr_obj = {arg1, arg2, ...}

管理 class 是否为聚合的规则并不完全 straight-forward,特别是因为规则在 C++ 标准的不同版本之间不断变化。在此 post 中,我们将回顾这些规则,以及它们如何在从 C++11 到 C++20 的标准版本中发生变化。

在我们访问相关标准段落之前,请考虑以下人为 class 类型的实现:

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>;

设计意图是创建一个 non-copyable、non-movable 单例 class 模板,将其单个 non-type 模板参数包装到 public常量数据成员,并且每个实例化的单例对象是唯一可以为此特定 class 专业化创建的对象。作者定义了一个别名模板 Number 只是为了禁止 API 的用户显式特化底层 detail::NumberImpl class 模板。

忽略此 class 模板的实际用途(或者更确切地说,无用),作者是否正确实现了其设计意图?或者,换句话说,给定下面的函数 wrappedValueIsN,用作 public 预期数字别名模板设计的验收测试,函数是否总是 return 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:是

关键区别在于 class 模板 detail::NumberImpl(对于它的任何 non-explicit 特化)是 C++14 和 C++17 中的聚合,而它不是 C++11 和 C++20 中的聚合。如上所述,如果对象是聚合类型,则使用 direct-list-init 或 copy-list-init 初始化对象将导致聚合初始化。因此,可能看起来像 value-initialization(例如这里的 Number<1> n{})——我们可能期望它会产生 zero-initialization 的效果 后跟 default-initialization 作为 user-declared 但不是 user-provided 默认构造函数存在——或者 class 类型对象的 direct-initialization(例如这里的 Number<1>n{2})实际上会绕过任何构造函数,如果 class 类型是聚合,甚至删除的。

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{};
}

因此,我们可以通过绕过私有和删除的 user-declared 构造函数来使 C++14 和 C++17 中的 wrappedValueIsN 验收测试失败detail::NumberImpl 通过聚合初始化,特别是我们为单个 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 中的聚合

关于 class 是否为聚合的规则包含在 [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]).

强调的部分是与此答案的上下文最相关的部分。

User-provided 函数

detail::NumberImplclass声明四个构造函数,因此它有四个user-declared 构造函数,但它不 提供 任何这些构造函数的定义;它在构造函数的第一个声明中使用 explicitly-defaultedexplicitly-deleted 函数定义,使用 defaultdelete 个关键字。

根据 [dcl.fct.def.default]/4,在其第一个声明中定义 explicitly-defaulted 或 explicitly-deleted 函数不算作 user-provided [摘录,强调我的]:

[…] A special member function is user-provided if it is user-declared and not explicitly defaulted or deleted on its first declaration. […]

因此,detail::NumberImpl 满足关于没有 user-provided 构造函数的聚合 class 要求。

对于一些额外的聚合混淆(适用于 C++11 到 C++17),其中提供了 explicitly-defaulted 定义 out-of-line,请参阅

指定的成员初始值设定项

尽管 detail::NumberImpl class 没有 user-provided 构造函数,它确实使用了 brace-or-equal-initializer (通常称为一种为单个 non-static 数据成员值指定成员初始值设定项)。这是 detail::NumberImpl class 在 C++11.

中不是聚合的唯一原因

C++14 中的聚合

对于C++14,我们再次转向[dcl.init.aggr]/1, now referring to N4140 (C++14 + editorial fixes),这与C++11中的相应段落几乎相同,只是关于[=240=的段]s 已被删除 [重点 我的]:

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]).

因此,detail::NumberImpl class 满足了在 C++14 中作为聚合的规则,从而允许规避所有私有的、默认的或通过聚合初始化删除user-declared个构造函数

一旦我们在一分钟内达到 C++20,我们将回到关于 user-provided 构造函数的一贯强调的部分,但我们将首先访问一些 explicit C++17 中的困惑.

C++17 中的聚合

与其形式一样,聚合在 C++17 中再次发生变化,现在允许聚合从基数 class 派生 publicly,但有一些限制,并禁止explicit 聚合构造函数。 [dcl.init.aggr]/1 from N4659 ((March 2017 post-Kona working draft/C++17 DIS),声明[强调我的]:

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]).

关于 explicit 的部分在这个 post 的上下文中很有趣,因为我们可以通过更改私有 [=225] 的声明来进一步增加总 cross-standard-releases 波动性=] explicitly-defaulted detail::NumberImpl 的默认构造函数来自:

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 中仍然是聚合。将此示例表示为 (*)。除了带有 空 braced-init-listcopy-list-initialization(请参阅 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 (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) 的实现,它指出 [强调 我的]:

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 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]).

我们可能还会注意到,关于 explicit 构造函数的部分已被删除,现在是多余的,因为我们无法将构造函数标记为 explicit,如果我们甚至不能声明它的话。

避免聚合意外

上面的所有示例都依赖于具有 public non-static 数据成员的 class 类型,这通常被认为是 anti-pattern 用于设计“non-POD-like” classes。根据经验,如果您想避免设计无意中成为聚合的 class,只需确保其 non-static 数据成员中至少有一个(通常甚至全部)是私有的( /受保护)。对于由于某种原因无法应用的情况,并且您仍然不希望 class 成为聚合,请确保转向相应标准(如上所列)的相关规则以避免编写一个不可移植的 class w.r.t。是或不是不同 C++ 标准版本的集合。