繁体   English   中英

如果显式默认或删除构造函数,为什么自 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): 错误 C2440: 'initializing': 无法从 'initializer list' 转换为 'Foo'

(6): 注意:没有构造函数可以取源类型,或者构造函数重载解析不明确

该项目使用/std:c++latest标志编译。 我在Godbolt上复制了它。 如果我将它切换到/std:c++17 ,它会像以前一样编译得很好。

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

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

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

请注意,括号中的部分“允许明确默认或删除的构造函数”已删除,并且“用户提供”更改为“用户声明”。

所以我的第一个问题是,我是否认为标准中的这种变化是我的代码之前编译但不再编译的原因?

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

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

所以我的实际问题是:从 C++17 到 C++20 的这种变化背后的原因什么? 这种向后兼容性的中断是故意的吗? 是否有一些权衡,比如“好吧,我们在这里打破了向后兼容性,但这是为了更大的利益。”? 这更大的善是什么?

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聚合默认构造函数似乎不再是好的做法。

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

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

聚合的常见概念是“没有构造函数的类”。 如果Typename() = default; 在类定义中,大多数人将其视为具有构造函数。 它的行为类似于标准的默认构造函数,但该类型仍然有一个。 这是来自许多用户的想法的广泛概念。

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

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

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

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

在 C++20 之后,显而易见的答案是有效的。

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

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

class Agg
{
  Agg() = default;
};

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

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

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

现在,如果默认/删除的构造函数未公开声明,您可以通过说它是一个聚合来更有针对性地解决这个问题。 但这感觉更加不一致。 有时,具有明显声明的构造函数的类是聚合,有时则不是,这取决于明显声明的构造函数所在的位置。

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

骨料类型修改规范

在 Visual Studio 2019 中,在 /std:c++latest 下,具有任何用户声明的构造函数(例如,包括声明为 = default 或 = delete 的构造函数)的类不是聚合。 以前,只有用户提供的构造函数才会取消类作为聚合的资格。 此更改对如何初始化此类类型设置了额外限制。

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

为了与所有读者在同一页面上,让我们首先提到聚合类类型构成了一个特殊的类类型系列,特别是可以通过聚合初始化,使用直接列表初始化复制列表进行初始化。 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+ 中的聚合+20。 如上所述,如果对象是聚合类型,则使用 direct-list-init 或 copy-list-init 初始化对象将导致聚合初始化。 因此,看起来像值初始化(例如这里的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的私有和已删除的用户声明的构造函数来使 C++14 和 C++17 中的wrappedValueIsN验收测试失败,特别是在我们明确地为单个value member 因此覆盖了指定的成员初始... value{N};... 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访问指定器)我们仍然可以以内存泄漏为代价,通过例如动态分配来破坏验收测试(并且永远不会删除)使用聚合初始化的detail::NumberImpl对象( wrappedValueIsN(*(new Number<42>{41})) )。

但是为什么detail::NumberImpl在 C++14 和 C++17 中聚合,而在 C++11 和 C++20 中为什么不是聚合呢? 我们将转向不同标准版本的相关标准段落来回答。

C++11 中的聚合

[dcl.init.aggr]/1涵盖了管理类是否为聚合的规则,其中我们引用了C++11 的 N3337(C++11 + 编辑修复) [强调我的]:

聚合是一个数组或类(条款 [class]),没有用户提供的构造函数([class.ctor]),没有用于非静态数据成员([class.mem])的大括号或相等初始化器,没有私有或受保护的非静态数据成员(子句 [class.access]),没有基类(子句 [class.derived]),也没有虚函数([class.virtual])。

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

用户提供的功能

detail::NumberImpl类确实声明了四个构造函数,因此它有四个用户声明的构造函数,但它没有这些构造函数中的任何一个提供定义; 它在构造函数的第一个声明中使用显式默认显式删除的函数定义,分别使用defaultdelete关键字。

根据[dcl.fct.def.default]/4 的规定,在第一个声明中定义显式默认或显式删除的函数不算作用户提供的函数 [提取,强调我的]:

[…] 一个特殊的成员函数是用户提供的,如果它是用户声明的,并且在第一次声明时没有明确默认或删除。 […]

因此, detail::NumberImpl满足关于没有用户提供的构造函数的聚合类要求。

对于一些额外的聚合混淆(适用于 C++11 到 C++17),其中显式默认定义是外部提供的,请参阅我的其他答案here

指定成员初始值设定项

尽管detail::NumberImpl类没有用户提供的构造函数,但它确实为单个非静态数据成员值使用了大括号或等号初始化器(通常称为指定成员初始化器)。 这是为什么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 中的一些explicit困惑。

C++17 中的聚合

与其形式一样,聚合在 C++17 中再次改变,现在允许聚合从基类公开派生,但有一些限制,并禁止聚合的explicit构造函数。 [dcl.init.aggr]/1来自N4659((2017 年 3 月后 Kona 工作草案/C++17 DIS) ,声明 [强调我的]:

聚合是一个数组或一个类

  • (1.1) 没有用户提供的、显式的或继承的构造函数([class.ctor]),
  • (1.2) 没有私有或受保护的非静态数据成员(条款 [class.access]),
  • (1.3) 没有虚函数,并且
  • (1.4) 没有虚拟、私有或受保护的基类 ([class.mi])。

关于explicit的部分在这篇文章的上下文中很有趣,因为我们可以通过更改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 中的聚合。 将此示例表示为(*) 除了带有空的花括号初始化列表的复制列表初始化(在我的其他答案中查看更多详细信息):

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禁止使用用户声明的构造函数的聚合)的实现,上面涵盖的大多数经常令人惊讶的聚合行为都已得到解决,特别是不再允许聚合具有用户声明的构造函数,对类作为聚合的要求比仅仅禁止用户提供的构造函数更严格。 我们再次转向[dcl.init.aggr]/1 ,现在指的是N4861(2020 年 3 月布拉格后工作草案/C++20 DIS) ,其中指出 [强调我的]:

聚合是一个数组或一个类([class])

  • (1.1) 没有用户声明的或继承的构造函数([class.ctor]),
  • (1.2) 没有私有或受保护的非静态数据成员 ([class.access]),
  • (1.3) 没有虚函数([class.virtual]),以及
  • (1.4) 没有虚拟、私有或受保护的基类 ([class.mi])。

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

避免总体意外

上面的所有示例都依赖于具有公共非静态数据成员的类类型,这通常被认为是设计“非 POD 类”类的反模式。 根据经验,如果您想避免设计一个无意中成为聚合的类,只需确保其非静态数据成员中的至少一个(通常甚至全部)是私有的(/protected)。 对于由于某种原因无法应用的情况,并且您仍然不希望该类成为聚合,请确保转向相应标准的相关规则(如上所列)以避免编写一个类不可移植 wrt 是一个聚合体,或者不是在不同的 C++ 标准版本上。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM