0%

第2章 构造函数语意学

这篇文章主要从编译器的角度来分析构造函数和复制构造函数. 包括默认构造函数和复制构造函数的生成; 引入继承, 虚函数时又该如何实现; 编译器如何处理构造函数调用; 还讨论了复制消除.

只有一个参数的构造函数可以被编译器作为转换函数构造函数, 这有时候会带来程序员意料之外的结果. C++增加了关键字explicit来阻止对函数的隐式调用.

"只有一个参数的构造函数可以被编译器作为类型转换函数"从C++11起被废止, 新标准规定具有多个参数的构造函数也可以作为转换构造函数, 新的标准是"没有被声明为explicit的构造函数就可以作为转换构造函数(converting constructor)".

Default Constructor(默认构造函数)的构造操作

默认构造函数的定义: > 一个可以以空参数列表调用的构造函数称为默认构造函数, 这有两种情形, 一种是构造函数参数列表为空, 另一种是每个参数都在声明中给出了默认值.

默认构造函数可以是自己定义的, 也可以由编译器自动生成. 当用户没有定义任何构造函数时, 编译器就会为用户生成一个参数列表为空的默认构造函数.

trivial default constructor(无用默认构造函数): > 满足下面所有的条件时, 一个默认构造函数是trivial的: > > - 不是由用户提供的, 即是由编译器生成的或者声明为default. > - 类没有虚成员函数 > - 类没有虚基类 > - 类没有默认初始化的非静态成员 > - 直接基类有trivial default constructor > - 非静态类成员有trivial default constructor > > 显然, trivial default constructor不进行任何操作. 所有与C语言兼容的数据类型(POD类型)都具有trivial default constructor.

带有默认构造函数的member class object

编译器会为没有定义构造函数的类合成默认构造函数, 但是这个合成操作只有在构造函数真正需要被调用时才会发生.

那么在C++不同编译模块中, 编译器怎么避免生成多个默认构造函数呢? 解决方法是把合成的默认构造函数, 复制构造函数, 析构函数, 赋值运算符都作为inline, 而inline函数是静态链接(static linkage)的, 不会被编译模块(即文件)以外的看到. 如果函数太复杂, 作为inline不合适, 就会合成一个显式non-inline静态(explicit non-inline static)实例.

我们知道, 类对象是必须要初始化的, 当一个类的成员有其他类对象时, 就必须在构造函数中对类成员进行初始化. 如果是编译器合成的默认构造函数, 就在合成的默认构造函数中按类成员声明顺序调用它们的默认构造函数(当然, 如果没有就会引起错误). 注意一点, 对于显式定义的构造函数, 如果没有对部分类成员对象的初始化, 编译器会自动插入一些代码, 使得用户代码被执行之前, 先调用必要的默认构造函数. 成员初始化顺序与声明顺序相同, 即使初始化语句有的是由编译器插入, 有的由用户显式声明(初始化列表).

带有默认构造函数的基类

如果一个子类的基类带有默认构造函数, 那么在合成子类的构造函数时, 会在其中插入对基类的默认构造函数的调用代码, 这个代码在成员的默认构造函数调用代码之前. 即先初始化基类, 再按声明顺序初始化子类成员.

带有一个虚函数的类

对于带有虚函数的类, 不论是直接声明的还是直接/间接继承而来的, 都有虚函数表, 对应对象有虚函数表指针(vptr)作为数据成员. 那么vptr是如何确定的呢? 显然, 虚函数表是在编译阶段就可以确定的, 因此它由编译器合成. 但是vptr的确定就要分情况讨论了:

  • 对于静态初始化的对象, vptr由编译器初始化.
  • 对于动态初始化的对象, vptr由构造函数初始化. 因此编译器会在所有的构造函数中插入一些代码来完成这个任务.

带有一个虚基类的类

当存在虚基类时, 通过虚基类指针/引用访问其非虚函数和数据成员时, 照理来说是不属于多态的, 但是仍然在运行时才能决定. 指针所指对象的实际类型很多时候是未知的, 在不同类型中, 由于采用了虚继承, 同一变量偏移可能不一样(这是由实现决定的), 简而言之就是编译器不知道成员在指针所指对象的什么位置. 因此, 存在虚基类时, 就需要提供某种方法, 使我们能够通过虚基类指针访问虚基类的非虚函数和数据成员. 一种方法是在子类中插入一个指向虚基类的指针, 将原始的通过虚基类指针访问那些成员的代码替换为先访问这个指针, 再访问成员的代码. 如下所示:

1
2
virtualBasePointer->virtualBaseData; // 原始代码
virtualBasePointer->virtualBaseVptr->virtualBaseData; // 编译器替换后的代码

而这个虚基类指针的初始化就是由构造函数完成的.

上面的讨论表明不止虚函数会导致编译器在对象开始处插入一个指针(vptr), 虚继承同样如此. 但是这里的指针就不再是 vptr, 而是 virtual-table table(VTT), 指向一个保存虚表地址的数组. 相关讨论可以参考C++中虚函数、虚继承内存模型.

注意几个问题:

  1. 类的默认构造函数只有真正需要时才会被合成, 而不是没有定义构造函数时就会合成.
  2. 对于一个类的所有类成员对象, 如果没有显式初始化, 编译器会对其进行默认初始化. 但是对于内置类型, 例如int, 指针类型等, 不会进行初始化, 这是程序员的工作.

Copy Constructor的构造操作

3种情况下会调用复制构造函数:

  1. 用一个对象作为参数初始化另一个对象时.
  2. 对象作为函数参数时, 会用参数对象在函数作用域构造一个新的对象.
  3. 对象作为返回值时, 会用函数内部的对象在返回值所在作用域构造一个新的对象.

注意, 2, 3不一定会发生, 因为可能会存在右值参数, 返回值优化等, 具体情况不做详述.

如果不显式定义复制构造函数, 编译器有两种复制对象的方法: bitwise copydefault memberwise copy, 区别如下:

  • bitwise copy 并不调用复制构造函数, 可能的实现方式如利用 memcpy 等, 因此效率更高, 复制出的对象和原对象完全相同.

  • default memberwise copy 就如同对每个成员分别赋值一样, 对于内置类型直接初始化, 而对于类类型, 递归调用其默认复制构造函数来初始化. 默认构造函数是由编译器合成的, 或者被声明为default. 其产生的新对象的用户定义的数据成员与原对象是一样的, 但是隐式的成员(如vptr), 内存布局(如用子类初始化父类时)等不一定相同.

注意:

bitwise copy和浅复制(shallow copy)是不同的, 浅复制更侧重于当在类内部保存指针成员, 用指针指向实际数据的时候, 复制时仅仅复制指针的值. 这种情况包含在bitwise copy中.

那么在没有定义复制构造函数的时候, 编译器在什么情况下采用bitwise copy, 在什么情况下合成默认复制构造函数(即采用default memberwise copy)? 下面四种情况, 会采用后者, 其他情况采用前者.

  1. 当类含有类对象成员, 且这个成员含有复制构造函数时(不论是编译器合成的还是显式定义的).
  2. 当类继承自一个基类, 并且基类含有复制构造函数时(不论是编译器合成的还是显式定义的).
  3. 当类含有虚函数时.
  4. 当类有虚基类时.

上面的情况很容易理解. 对于1和2, 由于复制对象时, 要复制数据成员和基类, 既然它们提供了复制构造函数, 就可以认为需要在它们的复制构造函数中进行某些bitwise copy无法实现的操作, 因此不能采用bitwise copy. 对于3, 由于含有虚函数, 所以需要初始化对象的vtpr, 而vptr的值显然不一定等于参数对象的值, 例如用子类对象初始化父类对象时. 所以bitwise不能满足需求. 对于4, 由于含有虚基类, 父子基类的内存布局可能存在区别, 也不能采用bitwise copy.

程序转化语意学(Program Transformation Semantics)

尽管在程序中可以使用不同的形式来初始化一个类对象, 但在编译阶段都会被转化成相同的形式. 例如:

1
2
3
4
5
6
class X;
X x0(paras);
X x1 = X(paras);
X x2(x0);
X x3 = x0;
X x4 = X(x0);

会被转化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
X x0; // 声明但不初始化
X x1; // 声明但不初始化
X x2; // 声明但不初始化
X x3; // 声明但不初始化
X x4; // 声明但不初始化

// 调用构造函数初始化对象
x0.X::X(paras)
x1.X::X(paras)

// 调用复制构造函数初始化对象
x2.X::X(x0)
x3.X::X(x0)
x4.X::X(x0)

参数复制优化和返回值优化都是指省略不必要的复制构造函数的调用, 后面统称为复制优化或copy elision. 从C++17开始, 标准规定了必须进行copy elision的情况:

  1. 类似下面的情形:

    1
    T t = T(T(T())); // 只会调用一次复制/移动构造函数, 要求类型相同(不考虑cv).

  2. 在返回类对象时, 如果直接在return语句中创建对象, 并且该对象与函数返回值类型一致(不考虑cv)时, 一般称这个优化为RVO(return value optimization)(注意, RVO在C++17之前都不是强制的, 从C++17开始才规定为mandatory的.). 如下例子:

    1
    2
    3
    4
    5
    6
    T f()
    {
    ......
    return T();
    }
    T t = f(); // 只会调用一次复制/移动构造函数.

同样也规定了可以实施copy elision, 但不强制的情况, 比如NRVO(named return value optimization), 是指函数返回一个具名对象, 该对象是函数体内部定义的自动存储期变量, 并且是non-volatile的, 与函数返回值具有相同类型(不考虑cv). 具体可以参考copy elision

注意几个问题:

  1. 只有当存在复制构造函数(不论是显式定义的还是编译器生成的)时, 编译器才有可能实施复制优化.
  2. 谨慎对待copy elision, 因为类设计者可能需要在复制/移动构造函数中进行某些特殊操作, 省略了之后可能带来难以调试的错误.

成员初始化列表(Member Initialization List)

应该用成员初始化列表来初始化变量的情况:

  1. 初始化一个引用时.(也可以在类内给出默认值)
  2. 初始化一个常量成员时.(也可以在类内给出默认值)
  3. 调用基类的构造函数, 并且这个构造函数有一组参数时.
  4. 调用类成员的构造函数, 并且这个构造函数有一组参数时.

类成员的初始化顺序与初始化列表的顺序无关, 而是与成员在类声明中的顺序一致. 所以, 尽量使初始化列表的顺序与声明顺序一致, 最好不要用一个成员来初始化另一个成员. 在编译阶段, 会将初始化列表转化为成员的初始化代码, 并置于构造函数体内的代码之前.

注意一点, 用成员函数的返回值来作为初始化列表的参数语法上是没有问题的, 但是需要保证这个成员函数不依赖于成员的数据对象, 因为很可能这个在调用此函数时还没有初始化其依赖的数据成员, 这就会引起难以发现的错误. 另外, 最好不要将其用于初始化基类成员, 详情见后面的讨论.