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

52

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

现在我遇到了一个之前没有出现过的错误,可以通过以下代码几行来复现:

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

错误为:

  

(6):error C2440:“initializing”:无法将“初始化器列表”转换为“Foo”

  

(6):注意:没有构造函数可以获取源类型,或构造函数重载分辨率不明确

该项目使用/std:c++latest标志进行编译。我在godbolt上重新创建了它。如果我将其切换到/std:c++17,则像以前一样编译正常。

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

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

  • 没有用户提供、继承或显式构造函数(自C++17以来,显式默认或删除的构造函数是允许的)(直到C++20)
  • 没有用户声明的或继承的构造函数(自C++20起)

请注意,括号中的部分“显式默认或删除的构造函数是允许的”已被删除,而“用户提供”的内容已更改为“用户声明”。

因此,我的第一个问题是,我是否正确地假设标准的这种变化是我之前可以编译但现在无法编译的代码的原因?

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

然而,在我所有的项目中,我明确地默认和删除了很多构造函数,因为我发现这是一个很好的习惯,可以使代码更加表达性,这比隐式默认或删除的构造函数简单得多,并且可以避免一些意外。但是,随着这个变化,这似乎不再是一个好习惯了...

所以我的实际问题是: 这种从C++17到C++20的变化背后的原因是什么?这种向后兼容性的打破是有意为之吗?是否存在某种权衡,例如:“好吧,我们正在打破向后兼容性,但这是为了更大的好处。”?这种好处是什么?


5
这是这篇文章的链接:http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1008r1.pdf。我不认为其论据(据我看来,就是“这些牵强的例子太令人惊讶了,必须加以修正”)具有说服力,但这可能因人而异。 - T.C.
2
我仍然认为这是一个好习惯,我更倾向于认为聚合初始化是一个不好的习惯... - Aconcagua
1
回答相对较不重要的第一个问题,显式默认构造函数被认为是用户声明的,但不是用户提供的。因此,那里措辞的改变确实是新错误的原因。(作为一则注记,标准在 "用户声明" 这个术语上有点摇摆不定,未能对其进行定义。然而,与“隐式声明”这个术语结合使用足以(呃)隐式地定义它。) - Justin Time - Reinstate Monica
如果定义了一些构造函数,那么人们期望所有对象都使用这些构造函数进行初始化...因此我认为这是标准的一个受欢迎的修复。 - Phil1970
4个回答

34

P1008的摘要,这是导致改变的提案:

C++目前允许通过聚合初始化绕过某些带有用户声明构造函数的类型的初始化,这会导致出人意料、令人困惑和有缺陷的代码。本文提出了一种修复方案,使得C++中的初始化语义更加安全、统一且易于教授。我们还讨论了此修复引入的不兼容更改。

他们给出的例子之一如下所示:

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

int main() {
  X x1(3); // ill-formed - no matching c’tor
  X x2{3}; // compiles!
}
对我而言,提议的更改虽然会导致向后不兼容性,但其价值是非常明显的。实际上,现在使用= default聚合默认构造函数似乎已经不再是一个好的做法了。

1
应该是 X x2{3} 而不是在第一个变量后使用逗号代替分号吗?除此之外,我认为默认构造函数是一种好的实践,而聚合初始化则是一种不好的实践(我们现在可以通过这种方式来避免)。 - Aconcagua
@Aconcagua,你为什么认为聚合初始化是不良实践? - Patrick Fromberg
1
对于聚合初始化,您依赖成员顺序,因为我们没有像C语言中那样的指定初始化程序(至少目前还没有)。想象一下,您需要添加一个新成员,并且由于对齐或其他原因,您需要将其放置在其他一些成员之间。这将破坏聚合初始化。相比之下,构造函数可以适当地修复,因此它们更加安全。 - Aconcagua
@T.C. 真的吗?您能提供一些参考资料吗?尽管在我的眼中是一个糟糕的错误... - Aconcagua

29

P1008 (PDF) 的推理可以从两个方向最好地理解:

  1. 如果你让一个相对较新的 C++ 程序员坐在一个类定义前,并问“这是一个聚合体吗?” 他们会正确吗?

聚合体的普遍概念是“没有构造函数的类”。如果一个类定义中有 Typename() = default;,大多数人会把它看作有构造函数。它的行为将像标准默认构造函数一样,但类型仍然有一个构造函数。这是许多用户的广泛理解。

聚合体应该是纯数据的类,能够使任何成员假定给定值。从这个角度来看,您没有任何理由给它任何类型的构造函数,即使默认情况下也是如此。这带我们到下一个推理:

  1. 如果我的类符合聚合体的要求,但我不希望它是聚合体,我该怎么办?

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

C++20 之前,您的选择是给类提供其他构造函数或实现其中一个特殊成员函数。这两个选项都不可取,因为(按定义)这不是您真正需要实现的东西;您只是为了产生某些副作用而这样做。

C++20 之后,最明显的答案行得通了。

通过这种方式改变规则,使聚合体和非聚合体之间的差异变得“明显”。聚合体没有构造函数;因此,如果要将类型设置为聚合体,则不要给它构造函数。

哦,还有一个有趣的事实:在 C++20 之前,这是一个聚合体:

class Agg
{
  Agg() = default;
};

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

这个类的明显意图是创建一个可以复制的类,但只能从具有私有访问权限的对象中获得初始构造。这允许访问控件的转发,因为只有获得Agg的代码才能调用接受Agg作为参数的函数。只有具有Agg访问权限的代码才能创建一个实例。

或者至少应该是这样。

现在,您可以通过声明默认/删除的构造函数不公开来更有针对性地解决这个问题。但这感觉更不协调;有时,一个具有可见构造函数的类是一个聚合体,有时它不是,这取决于可见构造函数的位置。


我明白了...好的,这里有一些令人信服的论点,谢谢。如果您将= default更改为= delete,那么您最后的示例甚至会更好,因为这更能表达意图(并且在C++17中Agg{}仍然有效,哎呀...我不知道)。 - sebrockm
1
右边,意图略有不同。然而,“没有人应该创建它(但仍然每个人都可以)”比“只有一些被选中的人应该创建它(但仍然每个人都可以)”更具有说服力。那就是我的观点 :) - sebrockm
3
但这很容易被反驳,即“如果根本没有人能够创建该对象...那么这种类型为什么还存在?”虽然您可以指向各种元编程工具,这些工具将类型纯粹用作计算引擎,但是它们中没有一个积极地禁止创建此类类型的实例,也不会在概念上出错,如果有人这样做。因此,为什么重要的是能够创建一个不能实例化的类型,而不仅仅是实例化无用的类型呢? - Nicol Bolas
1
我理解并且完全同意!不过,对我来说,“惊喜因素”更大,即使这种惊喜的起源是一开始做了一些愚蠢的事情(因此不应该成为标准变更的动机)。 - sebrockm
底线/TLDR; 在 +17 中聚合初始化存在问题,对于任何具有任何方法的东西都应该避免使用,以保持一致性。 - lurscher
显示剩余3条评论

15

迈向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};
    // 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" 作为预期公共使用的 "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;  // 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的私有和删除的用户声明构造函数,明确为单个value成员提供一个值,从而覆盖指定的成员初始化器(... value{N};),否则该初始化器将其值设置为N,从而在C++14和C++17中失败wrappedValueIsN验收测试。{{}}
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})))破坏验收测试,尽管会造成内存泄漏。
但是,在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 类确实声明了四个构造函数,因此它具有四个用户声明的构造函数,但它没有为任何这些构造函数提供定义; 它在构造函数的第一次声明时使用defaultdelete关键字使用显式默认和显式删除的函数定义。
根据[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{};    // OK: direct-list-initialization

    // Error: converting to 'Foo' from initializer
    // list would use explicit constructor 'Foo::Foo()'
    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++标准版本中不可移植的类,无论它是否是聚合体。

class avoid_aggregate; explicit none_aggregate::none_aggregate(avoid_aggregate); - Red.Wave

3

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

聚合类型的修改规范

在使用/std:c++latest编译选项的Visual Studio 2019中,任何具有用户声明构造函数的类(例如,包括声明为=default或=delete的构造函数)都不是聚合类型。之前,只有用户提供的构造函数才会使类失去聚合类型资格。这个更改对此类类型的初始化增加了其他限制。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接