Jerry @DOA&INPAC, SJTU

Working out everything from the first principles.

导航

聚合类型与POD类型

Lippman在《深度探索C++对象模型》的前言中写道:

I have heard a number of people over the years voice opinions similar to those of your colleagues. In every case, those opinions could be attributed to a lack of factual knowledge about the C++ language. Just last week I was chatting with an acquaintance who happens to work for an IC testing manufacturer, and he said they don't use C++ because "it does things behind your back." When I pressed him, he said that he understood that C++ calls malloc() and free() without the programmer knowing it. This is of course not true. It is this sort of "myth and legend" that leads to opinions such as those held by your colleagues….

Finding the right balance [between abstraction and pragmatism] requires knowledge, experience, and above all, thought. Using C++ well requires effort, but in my experience the returns on the invested effort can be quite high.

要想C++学得好、用得好,了解编译器在你背后做的事情是很有必要的。

在C++中有一类特殊的类型,聚合类型,可以被看作是纯正的数据类型;聚合类型有一个子集,POD类型,C++可以通过POD类型与其他语言交互。C++11定义了标准的内存模型,POD的概念被淡化,取而代之的是平凡性与标准布局。

在介绍这些类型分类之前,我们先来了解几种初始化。

零初始化

零初始化,顾名思义,就是初始化成零。零是指该类型的零,可能不是每一位都是0的表示。

在以下情形中,零初始化被执行:

  • static T object;

  • T();

  • T t = {};

  • T{};

  • CharT array[n] = "";

最后一种指的是字符串字面量长度不足数组长度,剩余的部分被零初始化。

零初始化和常量初始化与下面要讲的默认初始化和值初始化不在一个层次上,前者是静态存储期限对象的行为,后者是初始化器的行为。

默认初始化

当一个对象没有初始化器时,它被默认初始化,包括以下情形:

  • T object;

  • new T

  • 没有包含在构造函数初始化列表的成员对象。

默认初始化的效果是递归的:

  • 对于类类型,默认构造函数被调用;

  • 对于数组类型,每个元素被默认初始化;

  • 其他情况,什么都不做,对象被初始化为非确定值。

依据上面的规则,全局作用域下的int i;会被默认初始化,i会拥有非确定值,然而我们又知道,作为一个静态存储期限的对象,i的初始值是0。事实上,i先被零初始化,再被默认初始化,第二步中它的0值保持不变。

合成的默认构造函数对每个对象执行默认初始化,即使是显式= default也一样,类中的非类类型对象的初始值是非确定的,参见C++类成员默认初始值

访问一个非确定值的行为是未定义的,尽管那个对象可能只是一个普通整数,但如果较起真来,程序直接崩溃也是符合预期的。没有什么理由需要使用一个非确定值,如果需要随机数可以用专门的随机数设施

值初始化

当一个对象的初始化器为空时,它被值初始化,包括以下情形:

  • T()

  • new T()

  • Class::Class(...) : member() { ... }

  • 空的列表初始化

列表初始化包括前三种把()换成{},以及T object{};,但后者没有对应的圆括号版本,因为T object();是在声明一个函数。

值初始化的行为是:

  • 对于有用户提供的默认构造函数的类型,执行默认初始化;

  • 对于隐式声明或显式= default默认构造函数的类型,先执行零初始化,再执行默认初始化;

  • 如果是数组类型,对每个元素值初始化;

  • 否则,零初始化。

所谓用户提供的构造函数,是指有{...}定义的,= default= delete不在此列。

值初始化的意义在于它可以提供确定的对象。标准库容器在以容器大小为参数的构造函数中,执行的初始化就是元素类型的值初始化。

聚合类型

聚合类型的概念在C++的发展中有过许多细节调整,这里先根据C++11标准讲解。

聚合类型是数组类型,或满足以下条件的类类型:

  • 没有privateprotected非静态数据成员;

  • 没有用户提供的构造函数;

  • 没有基类;

  • 没有虚函数;

  • 没有类内初始化。

注意,这个定义不是递归的——聚合类型的成员完全可以是非聚合类型。

初始化一个聚合类型的方式有:

  • T object = {arg1, arg2, ...};

  • T object{arg1, arg2, ...};

C++14解禁了类内初始化;C++17允许基类,但不能是privateprotectedvirtual,相应地构造函数不能有继承的,还加了一条不能有explicit的;C++20又把构造函数的要求改回了没有用户声明的构造函数(= default也不行了)。虽然每次修改都有道理,但是频繁修改语言核心真心头疼。至于C99中的指定元素名称的初始化,也在C++20中才进入标准。

聚合初始化的行为是:

  • 所有基类、数组、非静态数据成员,按照在类中的出现顺序与数组下标的顺序,从初始化列表中的对应项拷贝初始化;

  • 除了列表初始化以外,隐式转换都是允许的;

  • 聚合初始化是递归的——如果初始化列表中有嵌套列表,对应项会被列表初始化;

  • 不指定长度的数组可以从初始化列表推导长度,非静态数据成员除外;

  • 静态数据成员与未命名的位域跳过;

  • 初始化列表的长度不能超过需要初始化的基类与成员的数量;

  • 如果长度不够,包括初始化列表为空:

    • 如果有类内初始化,用它;

    • 否则按照列表初始化的规则,对于非类类型和非聚合类类型,值初始化;

    • 对于聚合类型,递归使用该规则;

    • 没有对应初始化项的成员不能有引用类型的,否则报错;

  • 对于联合体,只有第一个成员被初始化。

学习聚合类型的规则,重在理解聚合初始化的行为——初始化要做的就是、只是拷贝构造每一个成员。这样就不难解释一些行为,比如,由于静态数据成员不是对象的一部分,因此在初始化时被跳过;虚函数和虚基类会引入vptr之类的东西,在初始化列表中没有体现,因而不被允许。这种思考方式在C++中是很实用的。

POD类型

在C++11以前,POD(Plain Old Data)类型定义为下列类型之一:

  • 标量类型,包括算术类型(整数与浮点数)、指针、成员指针、枚举类型、std::nullptr_t(C++11特性,只是为了给标量类型一个完整的定义);

  • POD类型的数组类型;

  • 类类型,满足:

    • 是聚合类型;

    • 没有非POD的非静态数据成员;

    • 没有引用类型的成员;

    • 没有用户提供的拷贝构造函数;

    • 没有用户提供的析构函数。

从C++11起,上述最后一大类修改为:

  • 是平凡类型(见下);

  • 是标准布局类型(见下);

  • 没有非POD的非静态数据成员、

那我们先来看这两个定义。

平凡

一个平凡(trivial)的类型是这样的类型:

  • 符合TriviallyCopyable要求;

  • 有一个或多个默认构造函数,每个都是平凡的(稍后解释)或删除的,至少有一个不是删除的。

对应类型特征(type trait)is_trivial

TriviallyCopyable的要求是指:

  • 每个拷贝构造函数都是平凡的或删除的;

  • 每个移动构造函数都是平凡的或删除的;

  • 每个拷贝赋值运算符都是平凡的或删除的;

  • 每个移动赋值运算符都是平凡的或删除的;

  • 析构函数是平凡的、非删除的;

  • TriviallyCopyable类型的数组仍然是TriviallyCopyable的。

这里有很多平凡,我不打算一一列出其要求,它们大致上讲了同一件事:

  • 不是用户提供的;

  • 所在类没有虚拟,包括虚基类和虚函数;

  • 对类型中的每个非静态成员,递归该要求。

平凡的构造函数还有一条:没有类内初始化。

标准布局

平凡规定了对象控制行为,标准布局(StandardLayoutType,is_standard_layout)则规定了对象模型:

  • 所有非静态数据成员都有相同的访问控制等级,即同为public、同为protected或同为private(这是因为,编译器有权把相同访问等级的成员安排在一起,那样会破坏布局);

  • 没有虚拟;

  • 没有非静态数据成员是引用类型;

  • 对基类和非静态数据成员类型递归要求StandardLayoutType;

  • 不能有同一个基类被继承两次,即所谓菱形继承(virtual早就已经否决了);

  • 继承链中只有一个类型有非静态数据成员;

  • 为了不与空基类优化冲突,基类不能有以下类型:

    • 对于非联合类型,第一个非静态数据成员的类型;

    • 对于联合类型,所有非静态成员类型;

    • 对于数组类型,其元素类型;

    • 以及这些类型递归调用这条规则产生的类型,有点计算LL(1)分析算法中FIRST集的味道。

现在可以回到POD类型了。POD是特殊的类型,它有许多非POD类型不具有的性质:

  • 完全与C兼容,但是仍然可以有成员函数;POD类型标准到甚至可以与其他语言兼容;

  • 可以用std::memcpy拷贝(对于非POD类型,即使满足TriviallyCopyable,用std::memcpy拷贝的行为也是未定义的);

  • 有更长的生命周期,从资源获取到资源释放,而非POD类型的是从构造函数结束到析构函数结束;

  • goto语句不能跳过变量的定义,但POD类型的是允许的;

  • POD类型对象的前部没有填充字节,即对象指针与第一个成员的指针是相等的。

posted on 2020-05-09 01:32  Jerry_SJTU  阅读(1643)  评论(2编辑  收藏  举报