类定义一个对象会调用成员函数吗_《深度探索C++对象模型》阅读笔记-第2章

我的博客​oven-yang.github.io

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

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

Default Constructor的构造操作

默认构造函数(default constructor)的定义:

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

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

trivial default constructor(无用默认构造函数)

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

带有default constructor的member class object

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

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

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

"带有default constructor"的Base Class

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

"带有一个Virtual Function"的Class

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

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

"带有一个Virtual Base Class"的Class

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

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

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

注意

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

Copy Constructor的构造操作

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

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

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

如果不显式定义复制构造函数, 编译器有两种复制对象的方法: bitwise copy和default 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.

当合成/用户定义的复制构造函数的语意和bitwise copy相同时, 是否应该用bitwise copy替换复制构造函数?

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

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

class X;
X x0(paras);
X x1 = X(paras);
X x2(x0);
X x3 = x0;
X x4 = X(x0);

会被转化为:

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的情况:

  • 类似下面的情形:
T t = T(T(T())); // 只会调用一次默认构造函数, 要求类型相同(不考虑cv).
  • 在返回类对象时, 如果直接在return语句中创建对象, 并且该对象与函数返回值类型一致(不考虑cv)时, 一般称这个优化为RVO(return value optimization)(注意, RVO在C++17之前都不是强制的, 从C++17开始才规定为mandatory的.), 如下例子:
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. 调用类成员的构造函数, 并且这个构造函数有一组参数时.

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

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值