什么是聚合体和平凡类型/POD,它们有什么特殊之处以及如何/为什么它们特别?

689

这个FAQ是关于聚合体和POD的,并涵盖以下内容:

  • 什么是聚合体
  • 什么是POD(Plain Old Data)?
  • 最近,什么是平凡或可平凡复制的类型
  • 它们之间有什么关系?
  • 它们为什么特殊?
  • C++11有什么变化?

1
POD子集是什么:https://dev59.com/bXVC5IYBdhLWcg3w51vy - Ciro Santilli OurBigBook.com
这些定义背后的动机大致是:POD == 可以进行 memcpy 操作,Aggregate == 聚合可初始化? - Ofek Shilon
6个回答

715

如何阅读:

本文较长。如果您想了解聚合和POD(Plain Old Data),请花时间仔细阅读。如果您只对聚合感兴趣,请只阅读第一部分。如果您只对POD感兴趣,则必须先阅读聚合的定义、含义和示例,然后才可以跳转到POD,但我仍建议您完整阅读第一部分。聚合的概念对于定义POD至关重要。如果您发现任何错误(包括语法、格式、排版、语法等小错误),请留下评论,我会进行编辑。

本答案适用于C++03。其他C++标准请参见:

什么是聚合,它们为什么很特殊

C++标准中的正式定义(C++03 8.5.1 §1

聚合是一个数组或一个类(第9条),它没有用户声明的构造函数(12.1),没有私有或受保护的非静态数据成员(第11条),没有基类(第10条)和没有虚函数(第10.3条)。

所以,好吧,让我们解析这个定义。首先,任何数组都是聚合。如果…等等!没有提到结构体或联合体,它们不能是聚合吗?可以的。在C++中,术语“class”指的是所有类、结构体和联合体。因此,当且仅当一个类(或结构体、联合体)满足上述定义的条件时,它才是一个聚合。这些标准意味着什么?

  • 这并不意味着聚合类不能有构造函数,实际上它可以有默认构造函数和/或复制构造函数,只要它们是由编译器隐式声明的,而不是由用户显式声明的

  • 没有私有或受保护的非静态数据成员。您可以拥有尽可能多的私有和受保护的成员函数(但不是构造函数),以及尽可能多的私有或受保护的静态数据成员和成员函数,而不违反聚合类的规则

  • 聚合类可以有用户声明/定义的复制赋值运算符和/或析构函数

  • 即使它是非聚合类类型的数组,它也是一个聚合。

现在让我们看一些例子:

class NotAggregate1
{
  virtual void f() {} //remember? no virtual functions
};

class NotAggregate2
{
  int x; //x is private by default and non-static 
};

class NotAggregate3
{
public:
  NotAggregate3(int) {} //oops, user-defined constructor
};

class Aggregate1
{
public:
  NotAggregate1 member1;   //ok, public member
  Aggregate1& operator=(Aggregate1 const & rhs) {/* */} //ok, copy-assignment  
private:
  void f() {} // ok, just a private function
};

你有了基本概念,现在让我们看看聚合类的特殊之处。与非聚合类不同,它们可以用花括号 {} 进行初始化。这种初始化语法通常用于数组,我们刚刚学习过这些都是聚合类。所以,让我们从它们开始。 Type array_name[n] = {a1, a2, …, am}; 如果(m == n) 则数组的第i个元素将被初始化为ai否则,如果(m < n) 数组的前m个元素将被初始化为a1, a2, …, am,而其他的n - m个元素将会尽可能地进行值初始化(下面会解释这个术语)。 否则,如果(m > n) 编译器将会报错。 否则 (当n没有像int a[] = {1, 2, 3};这样明确指定时) 则数组的大小(n)被假定为m,因此int a[] = {1, 2, 3};等同于int a[3] = {1, 2, 3}; 当标量类型的对象(boolintchardouble、指针等)进行值初始化时,意味着它们会被初始化为该类型的0(对于boolfalse,对于double0.0等)。当具有用户声明的默认构造函数的类类型对象进行值初始化时,其默认构造函数将被调用。如果默认构造函数是隐式定义的,则所有非静态成员都将递归地进行值初始化。这个定义不太精确,有点不正确,但应该能给你一个基本的概念。引用不能进行值初始化。对于非聚合类,值初始化可能会失败,例如,如果类没有适当的默认构造函数。
数组初始化的示例:
class A
{
public:
  A(int) {} //no default constructor
};
class B
{
public:
  B() {} //default constructor available
};
int main()
{
  A a1[3] = {A(2), A(1), A(14)}; //OK n == m
  A a2[3] = {A(2)}; //ERROR A has no default constructor. Unable to value-initialize a2[1] and a2[2]
  B b1[3] = {B()}; //OK b1[1] and b1[2] are value initialized, in this case with the default-ctor
  int Array1[1000] = {0}; //All elements are initialized with 0;
  int Array2[1000] = {1}; //Attention: only the first element is 1, the rest are 0;
  bool Array3[1000] = {}; //the braces can be empty too. All elements initialized with false
  int Array4[1000]; //no initializer. This is different from an empty {} initializer in that
  //the elements in this case are not value-initialized, but have indeterminate values 
  //(unless, of course, Array4 is a global array)
  int array[2] = {1, 2, 3, 4}; //ERROR, too many initializers
}

现在让我们看看如何使用大括号初始化聚合类。这个过程和数组元素的初始化方式非常相似。我们会按照它们在类定义中出现的顺序初始化非静态数据成员(根据定义,它们都是公共的)。如果初始值少于成员数,其余成员将被值初始化。如果无法对未明确初始化的成员进行值初始化,则会在编译时出现错误。如果有多余的初始值,则同样会在编译时出现错误。

struct X
{
  int i1;
  int i2;
};
struct Y
{
  char c;
  X x;
  int i[2];
  float f; 
protected:
  static double d;
private:
  void g(){}      
}; 

Y y = {'a', {10, 20}, {20, 30}};

在上面的例子中,y.c被初始化为'a'y.x.i1被初始化为10y.x.i2被初始化为20y.i[0]被初始化为20y.i[1]被初始化为30,而y.f则被值初始化,也就是用0.0进行初始化。受保护的静态成员d没有被初始化,因为它是static类型。
聚合联合体不同之处在于,您只能使用大括号初始化它们的第一个成员。我认为,如果您已经足够熟练地掌握C++,甚至考虑使用联合体(它们的使用可能非常危险,必须仔细考虑),您可以自行查阅标准中关于联合体的规则 :).
现在我们知道了聚合体的特殊之处,让我们试着理解类的限制;也就是说,为什么会有这些限制。我们应该明白,使用大括号进行逐成员初始化意味着该类仅仅是其成员的总和。如果存在用户定义的构造函数,则表示用户需要做一些额外的工作来初始化成员,因此大括号初始化是不正确的。如果存在虚函数,则表示该类的对象(在大多数实现中)具有指向所谓类的vtable的指针,该指针在构造函数中设置,因此大括号初始化将是不充分的。您可以通过类似的方式练习出其余限制。
关于聚合体的内容就说这么多。现在我们可以定义一组更严格的类型,即POD。
什么是POD,以及它们为什么很特殊
C++标准(C++03第9 §4节)的正式定义如下:
“POD-struct是一个聚合类,该类没有非静态数据成员的类型为非POD-struct、非POD-union(或这些类型的数组)或引用,并且没有用户定义的复制赋值运算符和析构函数。同样,POD-union是一个聚合联合体,该联合体没有非静态数据成员的类型为非POD-struct、非POD-union(或这些类型的数组)或引用,并且没有用户定义的复制赋值运算符和析构函数。POD类是一个既是POD-struct又是POD-union的类。”
哇,这个定义有点难理解,是吧? :) 让我们把联合体排除在外(出于与上述相同的原因),并以更清晰的方式重新表述:
“如果聚合类没有用户定义的复制赋值运算符和析构函数,并且其非静态成员都不是非POD类、非POD数组或引用,则称其为POD。”
这个定义意味着什么?(我提到POD代表Plain Old Data了吗?)
  • 所有的POD类都是聚合体,或者反过来说,如果一个类不是聚合体,那么它肯定不是POD。
  • 类和结构体一样,即使标准术语为POD-struct,但仍可以是POD。
  • 就像聚合体一样,在类具有静态成员的情况下并不重要。

例子:

struct POD
{
  int x;
  char y;
  void f() {} //no harm if there's a function
  static std::vector<char> v; //static members do not matter
};

struct AggregateButNotPOD1
{
  int x;
  ~AggregateButNotPOD1() {} //user-defined destructor
};

struct AggregateButNotPOD2
{
  AggregateButNotPOD1 arrOfNonPod[3]; //array of non-POD class
};

POD类型指的是POD类、POD联合体、标量类型以及这些类型的数组。

POD类型在很多方面都是特殊的。以下是一些例子:

  • POD-classes are the closest to C structs. Unlike them, PODs can have member functions and arbitrary static members, but neither of these two change the memory layout of the object. So if you want to write a more or less portable dynamic library that can be used from C and even .NET, you should try to make all your exported functions take and return only parameters of POD-types.

  • The lifetime of objects of non-POD class type begins when the constructor has finished and ends when the destructor has finished. For POD classes, the lifetime begins when storage for the object is occupied and finishes when that storage is released or reused.

  • For objects of POD types it is guaranteed by the standard that when you memcpy the contents of your object into an array of char or unsigned char, and then memcpy the contents back into your object, the object will hold its original value. Do note that there is no such guarantee for objects of non-POD types. Also, you can safely copy POD objects with memcpy. The following example assumes T is a POD-type:

     #define N sizeof(T)
     char buf[N];
     T obj; // obj initialized to its original value
     memcpy(buf, &obj, N); // between these two calls to memcpy,
     // obj might be modified
     memcpy(&obj, buf, N); // at this point, each subobject of obj of scalar type
     // holds its original value
    
  • goto statement. As you may know, it is illegal (the compiler should issue an error) to make a jump via goto from a point where some variable was not yet in scope to a point where it is already in scope. This restriction applies only if the variable is of non-POD type. In the following example f() is ill-formed whereas g() is well-formed. Note that Microsoft's compiler is too liberal with this rule—it just issues a warning in both cases.

     int f()
     {
       struct NonPOD {NonPOD() {}};
       goto label;
       NonPOD x;
     label:
       return 0;
     }
    
     int g()
     {
       struct POD {int i; char c;};
       goto label;
       POD x;
     label:
       return 0;
     }
    
  • It is guaranteed that there will be no padding in the beginning of a POD object. In other words, if a POD-class A's first member is of type T, you can safely reinterpret_cast from A* to T* and get the pointer to the first member and vice versa.

清单还有很多...

结论

理解什么是POD很重要,因为像你看到的许多语言特性对它们的行为都不同。


5
很好的回答。评论中的“如果默认构造函数被隐式定义,则所有非静态成员都将递归进行值初始化。”和“对于非聚合类的值初始化可能会失败,例如,如果该类没有适当的默认构造函数。”是不正确的:具有隐式声明的默认构造函数的类的值初始化并不需要隐式定义默认构造函数。因此,给定(适当插入private:): struct A { int const a; };,则A()是良好定义的,即使'A'的默认构造函数定义是有问题的。 - Johannes Schaub - litb
4
如果你能在翻译中将相同的信息用更简洁的方式表达出来,我们都会很乐意点赞! - sbi
3
@Armen也请注意,您可以对同一个问题提供多个答案。每个答案都可以包含解决问题的一部分。在我看来,那个已接受标记并不重要 :) - Johannes Schaub - litb
3
回答很棒。我仍然会时不时地回顾这篇文章。另外关于Visual Studio的警告,你提到了“goto语句”对于pod的忽略。但是对于switch/case语句,它会生成编译错误。基于这个概念,我制作了一些测试pod的检查器:https://dev59.com/wGfWa4cB1Zd3GeqPmPdr#17141989 - Konstantin Burlachenko
2
在以“非POD类类型对象的生命周期始于构造函数完成并在析构函数完成时结束”开头的项目符号中,最后一部分应改为“当析构函数开始时结束”。 - Quokka
显示剩余20条评论

520

C++11有哪些变化?

聚合体

标准定义的聚合体略有改变,但基本上仍然是一样的:

聚合体是一个数组或类(第9条),没有用户提供的构造函数(12.1),非静态数据成员没有花括号或等号初始化符(9.2),无私有或保护的非静态数据成员(第11条),无基类(第10条),以及没有虚函数(10.3)。

好的,有什么变化吗?

  1. 以前,聚合体不能有“用户声明的”构造函数,但现在它不能有“用户提供的”构造函数。有什么区别吗?有,因为现在你可以声明构造函数并将它们默认为:

struct Aggregate {
    Aggregate() = default; // asks the compiler to generate the default implementation
};

因为构造函数(或任何特殊成员函数)在第一次声明时使用默认值,所以这仍然是一个聚合。

现在,聚合不能对非静态数据成员具有任何花括号或等号初始化器。这意味着什么?这只是因为使用新标准,我们可以像这样直接在类中初始化成员:

struct NotAggregate {
    int x = 5; // valid in C++11
    std::vector<int> s{1,2,3}; // also valid
};

使用这个特性后,类不再是一个聚合类,因为它基本上等效于提供自己的默认构造函数。

那么,什么是聚合类呢?实际上并没有太大变化,它仍然是相同的基本思想,只是针对新功能进行了调整。

那POD是怎么样的呢?

POD 经历了很多变化。许多关于 POD 的以前的规则在这个新标准中都得到了放宽,标准中定义 POD 的方式也发生了根本性的改变。

POD 的概念基本上捕获了两个不同的属性:

  1. 它支持静态初始化,
  2. 在 C++ 中编译一个 POD 会给你与在 C 中编译的结构体具有相同的内存布局。

由于这个原因,这个定义被分成了两个不同的概念:平凡的类和标准布局类,因为它们比 POD 更实用。现在,标准很少使用术语 POD,而更喜欢更具体的平凡的标准布局概念。

新的定义基本上说,POD 是一个既平凡又具有标准布局的类,并且对于所有非静态数据成员,这个属性必须递归地保持:

一个 POD 结构是一个非联合类,它既是一个平凡的类又是一个标准布局的类, 并且没有类型为非 POD 结构体、非 POD 联合体(或这些类型的数组)的非静态数据成员。 类似地,一个 POD 联合体是一个既平凡又具有标准布局的联合体,并且没有类型为非 POD 结构体、 非 POD 联合体(或这些类型的数组)的非静态数据成员。一个 POD 类是一个既是 POD 结构也是 POD 联合体的类。

让我们分别详细了解这两个属性。

平凡的类

平凡的是上面提到的第一个属性:平凡的类支持静态初始化。如果一个类是平凡可复制的(平凡类的超集),则可以使用诸如 memcpy 等方法将其表示复制到其他位置,并期望结果相同。

标准定义了平凡类如下:

一个平凡可复制类是一个类,它:

— 没有非平凡的复制构造函数(12.8),

— 没有非平凡的移动构造函数(12.8),

— 没有非平凡的复制赋值运算符(13.5.3,12.8),

— 没有非平凡的移动赋值运算符(13.5.3,12.8),并且

— 有一个平凡的析构函数(12.4)。

一个平凡类是一个具有平凡默认构造函数(12.1)并且是平凡可复制的类。

[注意:特别地,一个可以平凡复制或平凡类没有虚函数或虚基类。—末尾注释]

对于类X的复制/移动构造函数,如果它不是用户提供的且满足以下条件,则为平凡的:

— 类X没有虚函数(10.3)和虚基类(10.1),

— 选中用于复制/移动每个直接基类子对象的构造函数是平凡的,以及

— 对于X的每个非静态数据成员(或其数组)的类类型,选中用于复制/移动该成员的构造函数是平凡的;

否则,复制/移动构造函数是非平凡的。

基本上,这意味着如果复制或移动构造函数不是用户提供的,类中没有虚拟内容,并递归地对类的所有成员和基类都具有此属性,则复制或移动构造函数是平凡的。

平凡复制/移动赋值运算符的定义非常相似,只需将“构造函数”替换为“赋值运算符”即可。

平凡析构函数也有一个类似的定义,增加的限制是它不能是虚拟的。

对于平凡默认构造函数,还存在另一个类似的规则,其中添加了一个默认构造函数是非平凡的条件,即类具有带有大括号或等号初始化程序的非静态数据成员。

以下是一些示例,以澄清所有内容:

// empty classes are trivial
struct Trivial1 {};

// all special members are implicit
struct Trivial2 {
    int x;
};

struct Trivial3 : Trivial2 { // base class is trivial
    Trivial3() = default; // not a user-provided ctor
    int y;
};

struct Trivial4 {
public:
    int a;
private: // no restrictions on access modifiers
    int b;
};

struct Trivial5 {
    Trivial1 a;
    Trivial2 b;
    Trivial3 c;
    Trivial4 d;
};

struct Trivial6 {
    Trivial2 a[23];
};

struct Trivial7 {
    Trivial6 c;
    void f(); // it's okay to have non-virtual functions
};

struct Trivial8 {
     int x;
     static NonTrivial1 y; // no restrictions on static members
};

struct Trivial9 {
     Trivial9() = default; // not user-provided
      // a regular constructor is okay because we still have default ctor
     Trivial9(int x) : x(x) {};
     int x;
};

struct NonTrivial1 : Trivial3 {
    virtual void f(); // virtual members make non-trivial ctors
};

struct NonTrivial2 {
    NonTrivial2() : z(42) {} // user-provided ctor
    int z;
};

struct NonTrivial3 {
    NonTrivial3(); // user-provided ctor
    int w;
};
NonTrivial3::NonTrivial3() = default; // defaulted but not on first declaration
                                      // still counts as user-provided
struct NonTrivial5 {
    virtual ~NonTrivial5(); // virtual destructors are not trivial
};

标准布局

标准布局是一个属性。规范提到这对于与其他语言通信非常有用,因为标准布局类具有与等效的C结构或联合相同的内存布局。

这是另一个必须递归地应用于成员和所有基类的属性。通常情况下,不允许使用虚拟函数或虚拟基类。否则,布局将与C不兼容。

这里的松散规则是,标准布局类必须具有具有相同访问控制的所有非静态数据成员。以前这些数据成员必须全部是public,但现在你可以使它们是all private或all protected,只要它们全部是private或protected即可。

在继承时,整个继承树中只有一个类可以具有非静态数据成员,并且第一个非静态数据成员不能是基类类型(这可能会破坏别名规则),否则它就不是标准布局类。

标准文本中的定义如下:

标准布局类是指:

— 没有非标准布局类(或这种类型的数组)或引用的非静态数据成员,

— 没有虚函数(10.3)和没有虚基类(10.1),

— 所有非静态数据成员均具有相同的访问控制(Clause 11),

— 没有非标准布局的基类,

— 在最派生类中没有非静态数据成员,并且最多有一个具有非静态数据成员的基类,或者没有具有非静态数据成员的基类,

— 没有与第一个非静态数据成员相同类型的基类。

标准布局结构体是使用class-key struct或class-key class定义的标准布局类。

标准布局联合体是使用class-key union定义的标准布局类。

[注意:标准布局类对于与其他编程语言编写的代码通信非常有用。它们的布局在9.2中指定。—结束注释]

让我们看一些例子。

// empty classes have standard-layout
struct StandardLayout1 {};

struct StandardLayout2 {
    int x;
};

struct StandardLayout3 {
private: // both are private, so it's ok
    int x;
    int y;
};

struct StandardLayout4 : StandardLayout1 {
    int x;
    int y;

    void f(); // perfectly fine to have non-virtual functions
};

struct StandardLayout5 : StandardLayout1 {
    int x;
    StandardLayout1 y; // can have members of base type if they're not the first
};

struct StandardLayout6 : StandardLayout1, StandardLayout5 {
    // can use multiple inheritance as long only
    // one class in the hierarchy has non-static data members
};

struct StandardLayout7 {
    int x;
    int y;
    StandardLayout7(int x, int y) : x(x), y(y) {} // user-provided ctors are ok
};

struct StandardLayout8 {
public:
    StandardLayout8(int x) : x(x) {} // user-provided ctors are ok
// ok to have non-static data members and other members with different access
private:
    int x;
};

struct StandardLayout9 {
    int x;
    static NonStandardLayout1 y; // no restrictions on static members
};

struct NonStandardLayout1 {
    virtual f(); // cannot have virtual functions
};

struct NonStandardLayout2 {
    NonStandardLayout1 X; // has non-standard-layout member
};

struct NonStandardLayout3 : StandardLayout1 {
    StandardLayout1 x; // first member cannot be of the same type as base
};

struct NonStandardLayout4 : StandardLayout3 {
    int z; // more than one class has non-static data members
};

struct NonStandardLayout5 : NonStandardLayout3 {}; // has a non-standard-layout base class

结论

有了这些新规则,现在可以将更多类型定义为POD。即使一个类型不是POD,我们也可以单独利用其中一些POD属性(如果它只是平凡的或标准布局的其中之一)。

标准库在头文件<type_traits>中提供了测试这些属性的特性:

template <typename T>
struct std::is_pod;
template <typename T>
struct std::is_trivial;
template <typename T>
struct std::is_trivially_copyable;
template <typename T>
struct std::is_standard_layout;

2
请您详细说明以下规则:a)标准布局类必须具有相同访问控制的所有非静态数据成员;b)整个继承树中只能有一个类具有非静态数据成员,并且第一个非静态数据成员不能是基类类型(这可能会破坏别名规则)。特别是它们的原因是什么?对于后一条规则,您能否提供一个破坏别名的示例? - Andriy Tylychko
5
或许需要更新关于C++14的内容,因为它取消了聚合体"没有花括号或等号初始化器"的限制。 - T.C.
@T.C. 感谢提醒。我会尽快查看这些更改并进行更新。 - R. Martinho Fernandes
对于用户声明的构造函数而言,当用户将构造函数声明为私有并没有提供定义时,这也是非常有用的。 - user3528438
2
关于别名问题:有一条C++布局规则,如果一个类C有一个(空的)基类X,并且C的第一个数据成员是类型X,那么第一个成员不能与基类X在同一偏移量上;如果需要避免这种情况,则在它之前加上一个虚拟填充字节。在同一地址处具有两个X(或子类)的实例可能会破坏需要通过它们的地址区分不同实例的事情(空实例没有其他可区分的特征…)。无论如何,添加填充字节的需要都会破坏“布局兼容性”。 - greggo
显示剩余7条评论

130

C++14有哪些变化

可以参考C++14标准草案

聚合体

在第8.5.1聚合体部分,给出了以下定义:

聚合体是一个没有用户自定义构造函数(12.1)、没有非静态私有或保护数据成员(第11条)、没有基类(第10条)和没有虚函数(10.3)的数组或类(第9条)。

唯一的变化是现在添加类内成员初始化器不会使一个类不再是聚合体。因此,以下示例来自于C++11带成员就地初始化的类的聚合初始化

struct A
{
  int a = 3;
  int b = 3;
};

C++11中,聚合体不支持成员初始化,但在C++14中支持。这一变化在N3605: Member initializers and aggregates中有详细说明:

Bjarne Stroustrup和Richard Smith提出了关于聚合初始化和成员初始化不能同时使用的问题。本文提议通过采用Smith提出的措辞来解决这个问题,即取消限制,允许聚合体具有成员初始化。

POD结构保持不变

POD (plain old data) 结构的定义包含在第9节的Classes中,该节规定:

一个POD结构体110是指既是平凡类又是标准布局类,并且没有类型为非POD结构体、非POD联合体(或此类类型的数组)的非静态数据成员。同样地,POD联合体是指既是平凡类又是标准布局类,并且没有类型为非POD结构体、非POD联合体(或此类类型的数组)的非静态数据成员。POD类是指既是POD结构体也是POD联合体的类。

这与C++11的措辞相同。

C++14标准布局更改

如评论中所指出的,pod依赖于standard-layout的定义,而此定义在C++14中已有更改,但这是通过缺陷报告在C++14之后应用的。

有三个DR:

因此,standard-layout从C++14之前的以下内容:

标准布局类是指:

  • (7.1) 没有类型为非标准布局类(或此类类型的数组)或引用的非静态数据成员,
  • (7.2) 没有虚函数([class.virtual])和虚基类([class.mi]),
  • (7.3) 对于所有非静态数据成员,具有相同的访问控制(第[class.access]条款),
  • (7.4) 没有非标准布局基类,
  • (7.5) 在最派生类中没有非静态数据成员,在最多一个具有非静态数据成员的基类中有一个非静态数据成员,或者没有具有非静态数据成员的基类,
  • (7.6) 没有与第一个非静态数据成员类型相同的基类。109

变为C++14中的以下内容

如果一个类S满足以下条件,则它


4
有一个提案,允许聚合体具有基类,只要该基类可以进行默认构造,详见N4404 - Shafik Yaghmour
尽管POD可能保持不变,但是C++14 StandardLayoutType(这是POD的要求)已经根据cppref发生了变化:https://en.cppreference.com/w/cpp/named_req/StandardLayoutType - Ciro Santilli OurBigBook.com
如果你能够找到一个C++14中是POD类型但在C++11中不是的例子,请告诉我 :- ) 我已经开始在这里列出详细的示例:https://dev59.com/bXVC5IYBdhLWcg3w51vy#52989731 - Ciro Santilli OurBigBook.com

69

C++17的变化

在此处下载C++17国际标准最终草案

聚合体

C++17扩展和增强了聚合体和聚合体初始化。标准库现在也包括一个std::is_aggregate 类型特征类。这是来自第11.6.1.1和11.6.1.2节的正式定义(省略了内部引用):

聚合体是一个数组或一个类,具有以下特点:
— 无用户提供、显式或继承构造函数,
— 没有私有或受保护的非静态数据成员,
— 没有虚函数,以及
— 没有虚、私有或受保护的基类。
[注意:聚合初始化不允许访问受保护和私有基类的成员或构造函数。—end note]
聚合体的元素为:
— 对于数组,按递增下标顺序的数组元素,或者
— 对于类,按声明顺序的直接基类,后跟不属于匿名联合的直接非静态数据成员,按声明顺序排列。

有什么变化?

  1. 现在聚合体可以拥有公共的、非虚拟的基类。此外,基类不必是聚合体。如果它们不是聚合体,则进行列表初始化。
struct B1 // not a aggregate
{
    int i1;
    B1(int a) : i1(a) { }
};
struct B2
{
    int i2;
    B2() = default;
};
struct M // not an aggregate
{
    int m;
    M(int a) : m(a) { }
};
struct C : B1, B2
{
    int j;
    M m;
    C() = default;
};
C c { { 1 }, { 2 }, 3, { 4 } };
cout
    << "is C aggregate?: " << (std::is_aggregate&ltC>::value ? 'Y' : 'N')
    << " i1: " << c.i1 << " i2: " << c.i2
    << " j: " << c.j << " m.m: " << c.m.m << endl;

//stdout: is C aggregate?: Y, i1=1 i2=2 j=3 m.m=4
  1. 禁止使用显式默认构造函数
struct D // not an aggregate
{
    int i = 0;
    D() = default;
    explicit D(D const&) = default;
};

继承构造函数是被禁止的。
struct B1
{
    int i1;
    B1() : i1(0) { }
};
struct C : B1 // not an aggregate
{
    using B1::B1;
};


琐碎类

C++17重新制定了琐碎类的定义,以解决C++14中未解决的几个缺陷。这些更改是技术性的。以下是12.0.6的新定义(省略内部引用):

一个可平凡复制的类是一个类:
- 每个复制构造函数、移动构造函数、复制赋值运算符和移动赋值运算符要么被删除,要么是平凡的,
- 至少有一个未删除的复制构造函数、移动构造函数、复制赋值运算符或移动赋值运算符,
- 具有平凡的、未删除的析构函数。
一个平凡类是一个既可平凡复制又至少有一个默认构造函数的类,所有默认构造函数都是平凡的或已删除的,其中至少有一个未删除。[注意:特别地,平凡可复制或平凡类不具有虚函数或虚基类。-注]

更改:

  1. 在C++14中,一个类要想成为平凡的,该类不能有任何非平凡的复制/移动构造函数/赋值运算符。然而,一个隐式声明为默认的构造函数/运算符可能是非平凡的,但被定义为删除,因为例如,该类包含一个无法复制/移动的类类型子对象。这样的非平凡、定义为删除的构造函数/运算符的存在会导致整个类变得非平凡。析构函数也存在类似的问题。C++17澄清了这种构造函数/运算符的存在不会导致类成为非平凡可复制的,因此非平凡,并且平凡可复制的类必须具有平凡的非删除析构函数。DR1734, DR1928
  2. C++14允许一个平凡可复制的类(因此是一个平凡类)将每个复制/移动构造函数/赋值运算符声明为已删除。如果这样的类还是标准布局,则可以使用std::memcpy进行合法的复制/移动。这是一个语义上的矛盾,因为通过将所有构造函数/赋值运算符定义为已删除,该类的创建者明确地表达了该类不能被复制/移动,但该类仍符合平凡可复制类的定义。因此,在C++17中,我们有了一个新的条款,规定平凡可复制的类必须至少具有一个平凡的非删除复制/移动构造函数/赋值运算符(尽管不一定是公开可访问的)。请参见N4148, DR1734
  3. 第三个技术变更涉及默认构造函数的类似问题。在C++14中,一个类可以具有隐式定义为删除的平凡默认构造函数,但仍然是一个平凡类。新定义澄清了一个平凡类必须至少具有一个平凡的非删除默认构造函数。请参见DR1496

标准布局类

标准布局的定义也进行了修订,以解决缺陷报告。同样,这些更改在技术上具有意义。以下是标准中的文本(12.0.7)。与之前一样,省略了内部引用:

A class S is a standard-layout class if it:
— has no non-static data members of type non-standard-layout class (or array of such types) or reference,
— has no virtual functions and no virtual base classes,
— has the same access control for all non-static data members,
— has no non-standard-layout base classes,
— has at most one base class subobject of any given type,
— has all non-static data members and bit-fields in the class and its base classes first declared in the same class, and
— has no element of the set M(S) of types (defined below) as a base class.108
M(X) is defined as follows:
— If X is a non-union class type with no (possibly inherited) non-static data members, the set M(X) is empty.
— If X is a non-union class type whose first non-static data member has type X0 (where said member may be an anonymous union), the set M(X) consists of X0 and the elements of M(X0).
— If X is a union type, the set M(X) is the union of all M(Ui) and the set containing all Ui, where each Ui is the type of the ith non-static data member of X.
— If X is an array type with element type Xe, the set M(X) consists of Xe and the elements of M(Xe).
— If X is a non-class, non-array type, the set M(X) is empty.
[ Note: M(X) is the set of the types of all non-base-class subobjects that are guaranteed in a standard-layout class to be at a zero offset in X. —end note ]
[ Example:

struct B { int i; }; // standard-layout class
struct C : B { }; // standard-layout class
struct D : C { }; // standard-layout class
struct E : D { char : 4; }; // not a standard-layout class
struct Q {};
struct S : Q { };
struct T : Q { };
struct U : S, T { }; // not a standard-layout class
—end example ]
108) This ensures that two subobjects that have the same class type and that belong to the same most derived object are not allocated at the same address.

变更:

  1. 澄清了只有派生树中的一个类“拥有”非静态数据成员的要求是指这样的数据成员首次声明的类,而不是可能继承它们的类,并将此要求扩展到非静态位域。还澄清了标准布局类“至多具有任何给定类型的一个基类子对象”。请参见DR1813DR1881
  2. 标准布局的定义从未允许任何基类的类型与第一个非静态数据成员的类型相同。这是为了避免偏移量为零的数据成员与任何基类具有相同的类型。C++17标准提供了更严格的递归定义,“保证在标准布局类中偏移量为零的所有非基类子对象的类型集合”以禁止此类类型成为任何基类的类型。请参见DR1672DR2120

注意: C++标准委员会打算根据缺陷报告对上述更改适用于C++14,尽管新语言不在已发布的C++14标准中。它在C++17标准中。


请注意,我刚刚更新了我的回答,标准布局变更缺陷已被C++核心工作组确认,并且已经实际应用于C++14中。这就是为什么我的答案没有包含它们的原因,因为这发生在我写答案之后。 - Shafik Yaghmour
注意,我在这个问题上开始了悬赏。 - Shafik Yaghmour
感谢@ShafikYaghmour。我将审查缺陷报告状态并相应修改我的答案。 - ThomasMcLeod
@ShafikYaghmour,经过对C++14进程的一些审查,我发现,虽然这些DR在2014年6月的Rapperswil会议上被“接受”,但来自2014年2月Issaquah会议的语言才成为了C++14。请参见https://isocpp.org/blog/2014/07/trip-report-summer-iso-c-meeting,“根据ISO规则,我们没有正式批准任何对C++工作文件的编辑。”我有什么遗漏吗? - ThomasMcLeod
他们具有'CD4'状态,这意味着他们应该在C ++14模式下申请。 - Shafik Yaghmour
@ShafikYaghmour。我在我的回答末尾添加了有关缺陷报告的注释。感谢您的贡献! - ThomasMcLeod

54

C++11中的POD基本上被拆分为两个不同的轴线:平凡性和布局。平凡性关于一个对象的概念值与其存储内部数据位之间的关系。布局则是关于一个对象的子对象的布局。只有类类型具有布局,而所有类型都具有平凡性关系。

因此,这里是平凡性轴线所关注的内容:

  1. 非平凡可复制:这些类型的对象的值可能不仅仅是直接存储在对象中的二进制数据。

    例如,unique_ptr<T> 存储一个 T*;这是该对象中二进制数据的总和。但这并不是 unique_ptr<T> 的全部价值。 unique_ptr<T> 存储 nullptr 或由 unique_ptr<T> 实例管理生命周期的指向对象的指针。 这个管理是 unique_ptr<T> 值的一部分。 而且,该值不是对象的二进制数据的一部分; 它是由该对象的各种成员函数创建的。

    例如,将 nullptr 分配给 unique_ptr<T> 不仅仅是改变对象中存储的位。 这样的分配必须销毁由 unique_ptr 管理的任何对象。 未经过其成员函数处理就操纵 unique_ptr 的内部存储会损坏这个机制,而更改其内部的 T* 而不破坏它当前管理的对象,将违反对象具有的概念值。

  2. 平凡可复制:这些对象的值恰好且只是其二进制存储内容。 这就是允许复制该二进制存储等同于复制对象本身的原因。

    定义平凡复制能力(平凡析构函数、平凡/删除复制/移动构造函数/赋值)的特定规则是使类型成为仅二进制值的必要条件。对象的析构函数可以参与定义对象的“值”,例如 unique_ptr。 如果该析构函数是平凡的,则不会参与定义对象的值。

    专用的复制/移动操作也可以参与对象的值。 unique_ptr 的移动构造函数通过将源空置来修改移动操作的源。 这就确保了 unique_ptr 的值是唯一的。 平凡的复制/移动操作意味着没有进行此类对象值骗局,因此对象的值只能是它存储的二进制数据。

  3. 平凡:此对象被认为对其存储的任何位都具有功能值。平凡可复制定义对象的数据存储的含义只是该数据。但是这种类型仍然可以控制如何将数据放在那里(在某种程度上)。这种类型可以具有默认成员初始化程序和/或确保特定成员始终具有特定值的默认构造函数。 因此,对象的概念值可以限制为它可以存储的二进制数据的子集。

    对具有平凡默认构造函数的类型执行默认初始化将使该对象具有完全未初始化的值。因此,具有平凡默认构造函数的类型在其数据存储中具有任何二进制数据都是逻辑上有效的。

布局轴非常简单。编译器在决定类的子对象如何存储在类的存储中时有很大的自由度。但是,在某些情况下,这种自由度是不必要的,并且具有更严格的排序保证是有用的。
此类类型为标准布局类型。而C++标准实际上并没有明确说明该布局具体是什么。它基本上对标准布局类型有三个方面的规定:
1. 第一个子对象与对象本身位于同一地址。 2. 您可以使用offsetof从外部对象到其一个成员子对象的字节偏移量。 3. 如果活动成员(至少部分)使用与访问的非活动成员相同的布局,则联合体可以通过访问联合体的非活动成员来访问子对象。
编译器通常允许标准布局对象映射到在C中具有相同成员的结构类型。但是,在C++标准中没有这样的声明;这只是编译器想要做的事情。
POD基本上是一个无用的术语。它只是平凡复制性(值只是其二进制数据)和标准布局(其子对象的顺序更加定义明确)的交集。人们可以从这些事情推断出该类型类似于C,并且可以映射到类似的C对象。但是,标准没有这方面的声明。
标准布局类必须具有相同访问控制的所有非静态数据成员。这很简单:所有非静态数据成员必须全部为public、private或protected。不能同时使用一些public和一些private。
它们的推理与是否有“标准布局”和“非标准布局”的区别的推理相同,即为编译器提供尽可能多的自由度以选择如何将事物放入内存中。这不仅仅涉及虚函数表指针。
当他们在1998年对C++进行标准化时,他们基本上预测了人们将如何实现它。虽然他们在各种C++版本中有着相当多的实现经验,但他们对某些事情并不确定。因此,他们决定要谨慎:尽可能给编译器更多的自由度。
这就是为什么C++98中POD的定义如此严格。它为大多数类的成员布局为C++编译器提供了极大的自由度。基本上,POD类型旨在成为特殊情况,您为某个原因专门编写的内容。
当C++11正在开发时,他们对编译器有了更多的经验。他们意识到...... C++编译器编写者非常懒惰。他们拥有所有这些自由度,但是他们什么也没做。
标准布局规则更多的是将常见做法编码化:大多数编译器实际上不需要改变太多,如果需要改变,也只是为了相应的类型特性做一些调整。但是,当涉及到public/private时,情况就不同了。重新排序成员变量的public/private属性实际上可能会影响编译器,尤其是在调试构建中。由于标准布局的目的是与其他语言兼容,因此不能在调试版本和发布版本中使用不同的布局。此外,这并不会对用户造成太大的影响。如果你正在创建一个封装类,很可能所有的数据成员都是private的。你通常不会在完全封装的类型上公开数据成员。因此,这只会给那些想要这样做、想要这种分割的少数用户带来问题。所以这不是什么大问题。
关于继承树中只能有一个类具有非静态数据成员的原因,这又回到了为什么他们再次标准化标准布局的原因:常见做法。当涉及到两个继承树成员实际存储东西时,没有常见的做法。有些人把基类放在派生类前面,而其他人则反过来。如果它们来自两个基类,你会按什么顺序排列成员?等等。编译器在这些问题上差异很大。此外,由于零/一/无穷大规则,一旦你说可以有两个具有成员的类,你就可以说你想要多少个。这需要添加许多布局规则来处理这个问题。你必须说明多重继承如何工作,哪些类将其数据放在其他类之前等。这是很多规则,但收益却很小。
并且,并不是所有没有虚函数和默认构造函数的东西都可以成为标准布局。
第一个非静态数据成员不能是基类类型(这可能会破坏别名规则)。我不能对这个问题做出很好的解释。我对C++的别名规则了解不够深入。但它与基类成员将与基类本身共享相同的地址有关。也就是说:
struct Base {};
struct Derived : Base { Base b; };

Derived d;
static_cast<Base*>(&d) == &d.b;

这可能违反了C++的别名规则。在某种程度上。

然而,请考虑一下:拥有这种能力到底有多有用呢?由于只有一个类可以拥有非静态数据成员,那么Derived必须是那个类(因为它有一个Base作为成员)。所以Base必须是空的(没有数据)。如果Base为空,以及一个基类...为什么还要有它的数据成员呢?

由于Base是空的,它没有状态。因此,任何非静态成员函数都将根据其参数而不是它们的this指针执行它们的操作。

所以,再次强调:没有什么大的损失。


1
谢谢您的解释,对我很有帮助。尽管static_cast<Base*>(&d)&d.b是相同的Base*类型,但它们指向不同的东西,因此违反了别名规则。请纠正我。 - Andriy Tylychko
1
为什么如果只有一个类可以拥有非静态数据成员,那么Derived必须是那个类呢? - Andriy Tylychko
3
为了让Derived的第一个成员变为它的基类,它必须具备两个条件:一个基类和一个成员。而且由于在继承体系中只有一个类可以拥有成员(同时还保持标准布局),这意味着它的基类不能有成员。 - Nicol Bolas
3
@AndyT,是的,你对别名规则的理解在我看来基本上是正确的。要求两个不同的类型实例具有不同的内存地址(这允许使用内存地址跟踪对象标识)。原始对象和第一个派生成员是不同的实例,因此它们必须具有不同的地址,这迫使添加填充以影响类的布局。如果它们是不同类型的,则无关紧要;不同类型的对象被允许具有相同的地址(例如,类及其第一个数据成员)。 - Adam H. Peterson

38

有哪些变化?

随着每个标准的推出,聚合体的含义和用法也在不断变化。未来将会有几个关键性的变化。

P1008:具有用户声明构造函数的类型

在C++17中,该类型仍然是一个聚合体:

struct X {
    X() = delete;
};

因此,X{} 仍然能编译通过,因为这是聚合初始化而不是构造函数调用。参见:何时私有构造函数不是私有构造函数? 在 C++20 中,限制将会从要求:

没有用户提供的、显式的或继承的构造函数

变为

没有用户声明的或继承的构造函数

这已被采纳到 C++20 工作草案 中。在 C++20 中,既不是这里的 X,也不是链接问题中的 C,都不会是聚合体。
这也导致以下示例出现了“上下起伏”的效应:
class A { protected: A() { }; };
struct B : A { B() = default; };
auto x = B{};

在C++11/14中,由于基类的存在,B不是一个聚合体,因此B{}执行值初始化,调用B::B(),进而调用A::A(),在这个点上它是可访问的。这是符合规范的。
在C++17中,由于允许基类,B成为了一个聚合体,使得B{}成为聚合初始化。这需要从{}中复制列表初始化一个A,但是从B外部的上下文中,它是不可访问的。在C++17中,这是不符合规范的(尽管auto x = B();是可以的)。
现在在C++20中,由于上述规则的改变,B再次不是一个聚合体(不是因为基类,而是由于用户声明的默认构造函数 - 即使它是默认的)。所以我们又回到了通过B的构造函数,这段代码变得符合规范了。

使用括号括起来的值列表初始化聚合体 P960

一个常见的问题是想要在聚合类型中使用emplace()风格的构造函数:

struct X { int a, b; };
std::vector<X> xs;
xs.emplace_back(1, 2); // error

这段代码无法工作,因为emplace将尝试有效地执行初始化X(1, 2),但这是无效的。典型的解决方案是向X添加构造函数,但是使用这个提案(目前正在通过核心层面),聚合体将有效地具有合成构造函数,做正确的事情并且像常规构造函数一样工作。在C++20中,上述代码将按原样编译。

用于聚合体的类模板参数推导(CTAD)P1021(特别是P1816

在C++17中,以下代码无法编译:

template <typename T>
struct Point {
    T x, y;
};

Point p{1, 2}; // error

用户需要为所有聚合模板编写自己的扣除指南:

template <typename T> Point(T, T) -> Point<T>;

但是,由于这在某种意义上是“显而易见的事情”,基本上只是样板文件,因此语言会为您完成这项工作。在C++20中,此示例将编译(无需用户提供的推断指南)。


虽然我会点赞,但现在加入这个功能似乎有点早,不过在 C++2x 完成之前,我不知道是否会有任何重大变化。 - Shafik Yaghmour
@ShafikYaghmour 是的,可能太早了。但是考虑到 SD 是新语言功能的截止日期,这些是我所知道的唯一正在进行中的两个功能 - 最坏情况下,我稍后可以删除其中一个部分吗?我刚刚看到这个带有悬赏的问题很活跃,所以觉得现在是时候参与讨论了,以免忘记。 - Barry
我理解,我也曾因类似情况而动心过几次。我总是担心会有重大变化,最终不得不重新编写它。 - Shafik Yaghmour
@ShafikYaghmour 看来这里什么都不会改变 :) - Barry
@NooneAtAll 没错,在C++20中引进了聚合体的CTAD。已更新。 - Barry
6
您应该在"指定初始化器"上添加一些说明文字。 - user7769147

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