0%

第5章 构造, 析构, 拷贝语意学

纯虚函数和抽象类

抽象类(abstract class): 含有(类内声明的或继承来的)纯虚函数的类即为抽象类. 纯虚函数的声明方法如下:

1
2
3
4
5
6
7
8
9
10
class point { /**/ };
class Shape // abstract class
{
point center;
public:
point where() { return center; }
void move(point p) { center=p; draw(); }
virtual void rotate(int) = 0; // pure virtual
virtual void draw() = 0; // pure virtual
};

当纯虚函数需要被调用时, 可以提供其定义, 但是定义必须放在类外. 可以通过类名加域限定符调用纯虚函数. 抽象类有下面的性质:

  • 抽象类用来表示一般的概念, 不能被实例化, 即不能创建抽象类的对象, 但是可以作为基类被实例化.
  • 不能作为参数类型, 函数返回值类型, 显式转化的目的类型, 但是可以声明抽象类的指针和引用.
  • 抽象类可以继承自非抽象类, 可以用纯虚函数重写非纯的虚函数.
  • 抽象类可以定义构造函数和析构函数, 对象构造和析构时会被调用(只会被调用一次).
  • 在抽象类的构造函数和析构函数中可以调用其他成员函数, 但是在其中调用纯虚函数的行为是未定义的.
  • 如果析构函数被声明为纯虚函数, 必须提供其定义.(构造函数不能是虚函数, 所以不必讨论)

对象构造

从C++20开始, POD这一概念就被废止, 取而代之的是一系列更为精确的定义, 如TrivialType.

对于POD(plain old data)类型, 定义一个对象时编译器不会调用其构造函数, 复制时也不会调用复制构造函数, 而是像C语言那样的按位复制. 而使用构造函数时, 尽可能在初始化列表中初始化成员, 这比在构造函数函数体内对成员赋值效率更高. 构造函数设置数据成员的值可以分为两种方式, 一个是初始化, 一个是赋值. 初始化是必不可少的, 即使没有在初始化列表中声明, 编译器也会生成默认的初始化语句来初始化成员, 在这之后才会执行构造函数函数体内的赋值语句. 所以说"赋值"效率更低. 如果函数体内部是简单地对每个成员指定一个常量, 那么编译器可能会进行优化, 将常量抽取出来对成员初始化, 结果就好像成员初始化列表一样. 但是不要依赖于此.

构造函数按顺序执行下面这些操作:

  1. 如果当前对象是继承体系的最底层(因为类初始化是从下往上递归调用构造函数的, 即继承层次不断变化), 就初始化虚基类
  2. 初始化直接基类
  3. 初始化vptr
  4. 进行初始化列表对数据成员的初始化操作
  5. 如果有成员没有出现在初始化列表, 但是有默认构造函数, 调用之.
  6. 执行构造函数函数体.

下面具体讨论一下每个操作. 对于第一个操作, 主要问题是如何判断构造函数是否应该初始化虚基类, 即如何判断当前构造函数所在类是否是继承体系的最底层. 书中提到的方法是给虚基类的子类的构造函数增加一个参数is_most_derived. 那么函数体中初始化虚基类的语句就是这样:

1
2
3
4
if(is_most_derived)
{
this->VirtualBase::VirtualBase(param_list);
}

而在此构造函数中调用父类对象的构造函数时就将该参数值设为false, 就能保证构造函数只在最底层类被调用. 不过, 按照此方法, 虚基类的每个子类都需要判断一次, 降低了程序效率. 而实际上在编译时我们就知道子对象的构造函数不需要执行此操作. 所以一种优化是提供两种构造函数, 一种不带此参数, 不初始化虚基类, 另一种带参数, 负责初始化虚基类. 这样就可以省略if语句, 但是可能会使生成的可执行文件更大.

函数的初始化列表在编译后会分化为上面的多个步骤, 对虚基类的初始化对应操作1, 对直接基类的初始化对应操作2, 对数据成员的初始化对应操作4. 另外注意一点, 对虚基类的初始化不能放在函数体中, 必须放在初始化列表中. 否则编译器还需要检查函数体中是否有对虚基类的构造函数的调用, 并将其转化为上面的两种形式.

在构造函数中调用成员函数

可以在构造函数初始化列表中调用成员函数, 但是如果调用函数时存在直接基类没有被初始化, 行为就是未定义的.

如果调用的是虚函数, 并且调用时基类已初始化, 那么调用时的实际类型就是函数调用点所在构造函数的类类型. 这是很容易理解的, 对象类型绝不会沿着继承体系向下, 因为最底层的对象还没有完成构造. 如果是纯虚函数, 如上文所说, 是UB. 在析构函数中调用虚函数同理.

如果从编译器的角度来解释上面的两条规则, 就需要考虑vptr的初始化, 因为我们在运行时通过vtpr判断对象类型, 决定虚函数的调用. 在调用基类构造函数之后, 成员初始化语句之前, 编译器在构造函数中插入代码来初始化对象的vptr. 所以, 在构造函数的数据成员初始化语句和函数体内调用成员函数时, 对象vptr已经被设置为构造函数所属类对应的vptr, 也就能调用虚函数了.

对象复制

这一节讨论的是复制赋值运算符 operator =. 当将一个对象赋值给另一个对象时, 有下面三种选择:

  • 采用默认行为, 即不提供复制赋值运算符或使用默认复制赋值运算符.
  • 显式定义复制赋值运算符,
  • 拒绝赋值行为.

对于第三点, C++11之前需要将operator =声明为private, 并且不提供其定义. 而C++11之后, 可以用下面的语句实现:

1
ClassName& ClassName::operator =(const ClassName&) = delete;

另外C++11提供的一个语法是可以将其显式声明为default, 虽然用户显式声明之, 但是定义是由编译器隐式生成的.

1
ClassName& ClassName::operator =(const ClassName&) = default;

当不需要拒绝赋值时, 就需要考虑是不是显式提供一个operator =. 一个原则是:

只有在默认复制赋值运算符的行为不安全或不正确时, 才需要显式定义复制赋值运算符.

那么问题来了, 默认复制赋值运算符的行为是什么?

Trivial copy assignment operator

当复制赋值运算符满足下面的条件是, 就是tirivial的:

  • 不是用户提供的(隐式定义的或声明为default).
  • 类没有虚函数.
  • 类没有虚基类.
  • 直接基类的复制赋值运算符都是trivial的.
  • 非静态成员的复制赋值运算符是tirvial的.

满足这个条件的对象的赋值行为是bitwise的, 就如同调用std::memmove一样. 所有与C语言兼容的数据类型都满足此条件. 不满足上面的的条件时, 就采用member-wise复制赋值行为. 以上的bitwise和member-wise就是默认复制赋值运算符的行为.

另一个问题是存在虚基类时复制赋值运算符可能会多次对基类子对象调用 operator =, gcc-8就是如此. 一般含有虚基类的子类的复制赋值运算符定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
struct A { /*...*/ };
struct B : virtual A { /*...*/ };
struct C : virtual A { /*...*/ };
struct D : B, C { /*...*/ };

A& A::operator =(const A& a)
{
/*
... // member copy assignment
*/
}

B& B::operator =(const B& b)
{
this->A::operator=(b); // 直接调用 A::operator =
/*
... // member copy assignment
*/
}

C& C::operator =(const C& c)
{
this->A::operator=(c); // 直接调用 A::operator =
/*
... // member copy assignment
*/
}

D& D::operator =(const D& d)
{
this->A::operator=(d); // 直接调用 A::operator =
this->B::operator=(d); // 间接调用 A::operator =
this->C::operator=(d); // 间接调用 A::operator =
/*
... // member copy assignment
*/
}

C++并没有提供类似复制构造函数的语法来保证虚基类只会被复制一次. 所以, 书中建议将虚基类的复制赋值运算符声明为delete, 甚至不要再虚基类中声明数据成员.

对象析构

书中提到一个值得注意的问题, 并不是定义了构造函数就需要定义析构函数, 这种"对称"是无意义的. 只有当需要一个析构函数时, 我们才应该显式定义之. 那么什么时候需要呢? 首先要搞清楚析构函数的作用, 她是对象的生命周期的终结, 而函数体内执行的主要是是对对象持有的资源的释放, 例如在构造函数中动态申请的空间. 析构函数的操作与构造函数类似, 但是顺序相反.

Trivial destructor

类的析构函数如果满足下面的条件, 就是trivial的:

  • 析构函数不是用户定义的.(隐式声明或声明为default)
  • 析构函数非虚.(这就要求父类的虚函数也非虚)
  • 直接父类的析构函数是trivial的.
  • 非静态数据成员(数组的数据成员)的析构函数是trivial的.

trivial析构函数不进行任何操作, 析构时只需要释放对象的空间即可. 所有与C语言兼容的数据类型都是trivial destructible的.