深度探索cpp对象模型(5)

构造、析构、拷贝语意学

构造函数不能是虚函数

几个类设计原则:

1.即使是一个抽象基类,如果它有非静态数据成员,也应该给它提供一个带参数的构造函数,来初始化它的数据成员。或许你可以通过其派生类来初始化它的数据成员(假如nostatic data member为publish或protected),但这样做的后果则是破坏了数据的封装性,使类的维护和修改更加困难。由此引申,类的data member应当被初始化,且只在其构造函数或其member function中初始化。

2.一般的纯虚函数不需要定义,但析构函数是纯虚的,必须要定义,因此不要将析构函数设计为纯虚的,这不是一个好的设计。将析构函数设计为纯虚函数意味着,即使纯虚函数在语法上允许我们只声明而不定义纯虚函数,但还是必须实现该纯虚析构函数,否则它所有的继承类都将遇到链接错误。一个不能派生继承类的抽象类有什么存在的意义?必须定义纯虚析构函数,而不能仅仅声明它的原因在于:每一个继承类的析构函数会被编译器加以扩展,以静态调用方式其每一个基类的析构函数(假如有的话,不论是显示的还是编译器合成的),所以只要任何一个基类的析构函数缺乏定义,就会导致链接失败。矛盾就在这里,纯虚函数的语法,允许只声明而不定义纯虚析构函数,而编译器则死脑筋的看到一个其基类的析构函数声明,则去调用它的实体,而不管它有没有被定义。

3.真的必要的时候才使用虚函数,不要滥用虚函数。虚函数意味着不小的成本,编译很可能给你的类带来膨胀效应

  • 每一个对象要多负担一个word的vptr。
  • 给每一个构造函数(不论是显示的还是编译器合成的),插入一些代
    码来初始化vptr,这些代码必须被放在所有基类构造函数的调用之后,
    但需在任意用户代码之前。没有构造函数则需要合成,并插入代码。
  • 合成一个拷贝构造函数和一个复制操作符(如果没有的话),并插入
    对vptr的初始化代码,有的话也需要插入vptr的初始化代码。
  • 意味着,如果具有bitwise语意,将不再具有bitwise语意,然后是变大的对象、没
    有那么高效的构造函数,没有那么高效的复制控制。

4.不能决定一个虚函数是否需要 const ,那么就不要它。(因为你不知道它的覆盖版本会不会修改数据成员)

5.决不在构造函数或析构函数中使用虚函数机制。在构造函数中,每次调用虚函数会被决议为当前构造函数所对应类的虚函数实体,虚函数机制并不起作用。当一个base类的构造函数含有对虚函数vf()的调用,当其派生类derived的构造函数调用基类base的构造函数的时候,其中调用的虚函数vf()是base中的实体,而不是derived中的实体。这是由vptr初始化的位置决定的
——在所有基类构造函数调用之后,在程序员供应的代码或是成员初始化队列之前
。因构造函数的调用顺序是:有根源到末端,由内而外,所以对象的构造过程可以看成是,从构建一个最基础的对象开始,一步步构建成一个目标对象。析构函数则有着与构造相反的顺序,因此在构造或析构函数中使用虚函数机制,往往不是程序员的意图。若要在构造函数或析构函数中调用虚函
数,应当直接以静态方式(显示调用,即通过作用域方式调用)调用,而不要通过虚函数机制。

 

对于trivial 的 拷贝构造、析构、拷贝赋值函数,观念上是被编译器合成,然后调用,但实际上啥都没有发生,对于构造啥事没干,对于析构,只是释放了对象的空间,对于拷贝赋值只是按位逐次复制

符合POD类型的就会产生trivial的函数

POD文章介绍:https://blog.csdn.net/speargod/article/details/89331678

POD,表明c++中与c兼容的数据类型,通俗的讲,如果一个类或结构体通过二进制拷贝后还能保持其数据不变,那么它就是一个POD类型。(从C++20开始, POD这一概念就被废止, 取而代之的是更为精确的定义, 如TrivialType.)

对于POD(plain old data)类型, 定义一个对象时编译器不会调用其构造函数, 复制时也不会调用复制构造函数, 而是像C语言那样的按位复制.

在初始初始化列表中初始化成员比在构造函数函数体内对成员赋值效率更高. 如果函数体内部是简单地对每个成员指定一个常量, 那么编译器可能会进行优化, 将常量抽取出来对成员初始化, 结果就好像成员初始化列表一样.

单继承体系下的对象构造

对于简单定义的一个对象T object;,很明显它的默认构造函数会被调用(被
编译器合成的或用户提供的)。但是一个构造函数究竟做了什么,就显得比较
复杂了——编译器给了它很多的隐藏代码。编译器一般会做如下扩充操作:

  1. 调用所有虚基类的构造函数,从左到右,从最深到最浅:
    • 如果该类被列于成员初始化列表中,任何明确明确指定的参数,都应该被传递过来。若没有列入成员初始化列表中,虚基类的一个默认构造函数被调用(有的话,实际如果这个默认构造函数是delete的话,必报错)。
    • 此外,要保证虚基类的偏移量在执行期可存取,对于使用vbptr来实现虚基类的编译器来说,满足这点要求就是对vbptr的初始化。如果是使用vptr保存偏移量那应该是推迟到vptr的设定再进行,我猜的
    • 然而,只有在类对象代表着“most-derived class”时,这些构造函数才可能会被调用。一些支持这个行为的代码会被放进去(直观点说就是,虚基类的构造由最外层类控制)。
  2. 调用所有基类构造函数,依声明顺序:
    • 如果该基类被列入了成员初始化队列,那么所有明确指定的参数,应该被传递过来。
    • 没有列入的话,那么调用其默认构造函数,如果有的话。实际如果这个默认构造函数是delete的话,必报错
    • 如果该基类是第二顺位或之后的基类,this 指针必须被调整。

  3. 正确初始化vptr,如果有的话。
  4. 记录在成员初始化队列中的数据成员初始化操作以声明的顺序被放进构造函
    数中。
  5. 调用没有出现在初始化成员列表中的member object的默认构造函数,如果
    有的话。实际如果这个默认构造函数是delete的话,必报错
  6. 执行构造函数体

两条及以上路线的,虚拟继承下的构造抑制

有如下继承体系:

imageimage

这种跟上面单继承体系的构造没有大致区别,也是上面6个顺序,但是需要进行虚基类的构造抑制,也就是保证我这个虚基类(subobject)只初始化了一次。

如果没有抑制会怎么样?

当我们定义Vertex3d cv;时,Vertex3d的
构造函数中调用Point的构造函数、而随之调用它的子对象,Point3d和Vertex的
构造函数中也调用了Point的构造函数。先不说,对于同一个子对象进行三次初
始化是否有效率,更重要的是,这将不可避免的带来错误。由Vertex3d指定的子
对象Point的值,会被覆盖掉。

根据c++ 语法,Point 的初始化应有most-derived class来施行。也就是说当
Vertex3d为most-derived class的时候,应当由它的构造函数来调用Point的构
造函数初始化Point子对象,Vertex3d的子对象的构造函数对于Point的调用则
应当抑制。

那怎么实现呢?有两种方法

1)使用一个条件变量来表示是否为most-derived class,也就是判断当前构造函数所在类是否是继承体系的最底层. 各构造函数根据这个条件变量来决定是否调用虚基类的构造函数,因此通过控制这个条件变量,
就可以抑制非most-derived class调用虚基类的构造函数。

那么函数体中初始化虚基类的语句就是这样:

if(is_most_derived)
{
    this->VirtualBase::VirtualBase(param_list);
}

而在此构造函数中调用父类对象的构造函数时就将该参数值设为false, 就能保证构造函数只在最底层类被调用. 不过, 按照此方法, 虚基类的每个子类都需要判断一次, 降低了程序效率. 而实际上在编译时我们就知道子对象的构造函数不需要执行此操作.

2)所以另外一种方法,是提供两种构造函数, 一种针对完整的object,一种针对subobject,完整的无脑上虚基类构造函数,subobject不执行虚基类构造函数

这样就可以省略if语句, 但是可能会使生成的可执行文件更大. 函数的初始化列表在编译后会分化为上面的多个步骤, 对虚基类的初始化对应操作1, 对直接基类的初始化对应操作2, 对数据成员的初始化对应操作4.

另外注意一点, 对虚基类的初始化不能放在函数体中, 必须放在初始化列表中. 否则编译器还需要检查函数体中是否有对虚基类的构造函数的调用, 并将其转化为上面的两种形式.

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

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

如果调用的是虚函数, 并且调用时基类已初始化, 那么调用时的实际类型就是函数调用点所在构造函数的类类型. 这是很容易理解的, 对象类型绝不会沿着继承体系向下, 因为最底层的对象还没有完成构造. 如果是纯虚函数, 如上文所说, 是未定义行为,一般来说是程序直接挂掉(对象模型p156). 在析构函数中调用虚函数同理.

如果从编译器的角度来解释上面的两条规则, 就需要考虑vptr的初始化. 我们知道, 编译器在构造函数中插入代码来初始化对象的vptr. 但是具体这段代码放在什么位置呢? 如上文所述, 是放在调用基类构造函数之后, 成员初始化语句之前. 所以, 在构造函数的数据成员初始化语句和函数体内调用成员函数时, 对象vptr刚刚被设置为构造函数所属类对应的vptr. 那么, 在调用虚函数时结果就如上文所说.

对象复制

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

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

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

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

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

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

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

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

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

Trivial copy assignment operator

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

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

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

另一个问题在虚拟继承情况下,copy assignment opertator会遇到一个不可避免的问题,virtual base class subobject的复制行为会发生多次,与前面说到的在虚拟继承情况下虚基类被构造多次是一个意思,不同的是在这里不能抑制非most-derived class 对virtual base class 的赋值行为。(p223 有个解释,不过还不太能理解)

所以 安全的做法是在最底层的类的拷贝赋值运算符定义中把虚基类的赋值放在最后,避免被覆盖。

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

对象析构

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

Trivial destructor

只有在基类拥有,或者object member拥有非trivial析构函数的时候,编译器才为类
合成析构函数,否则都被视为不需要。

trivial析构函数不进行任何操作, 析构时只需要释放对象的空间即可. 

非trivial析构的顺序正好与构造相反:

  • 本身的析构函数被执行。
  • 以声明的相反顺序调用member object 的析构函数,如果有的话。
  • 重设vptr 指向适当的基类的虚函数表,如果有的话。
  • 以声明相反的顺序调用上一层的析构函数,如果有的话。
  • 如果当前类是 most-derived
    class,那么以构造的相反顺序调用虚基类的析构函数。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值