无继承情形下的对象构造
Plain Old Data(POD)类型
如果一个类型定义:
- 无用户定义的构造函数。(确保对象可以通过简单的内存复制进行初始化和复制)
- 无用户定义的拷贝构造函数和赋值操作符。(如果用户定义可能会改变语义)
- 无用户定义的析构函数。(可能涉及复杂的资源清理,不符合POD的简单性)
- 无虚函数。(虚函数表会改变内存布局,不再是连续内存块)
- 无虚拟继承。
- 类必须是标准布局类型。(所有非静态成员应该有相同的访问控制)
- 所有非静态数据成员都是POD类型。
- 基类也必须是标准布局。
或者说(引用自chatGPT):
- 是trivial的。
- 是标准布局的。
如果满足条件,编译器会给类型打上Plain Old Data标签。
对于声明和delete操作,该类型的trivial构造函数和trivial析构函数要不是没有产生就是没有被调用,程序的行为移入它在C语言中的表现一样。
对于赋值操作。由于是一个POD类型,所以赋值操作将只是像C语言那样的纯粹位搬移操作。
抽象数据类型
一个类经过封装,其大小不会改变,无论private或public存取层,都不会占用额外的对象空间。
如果要将class中所有成员都设定常量初值,那么给予一个显式的initialization list会比较有效率些。
继承的引入
虚函数的导入促使每一个对象拥有一个虚函数表指针vptr。这个指针给我们提供virtual接口的弹性,其成本是:每一个对象需要额外的一个word空间。
除了每一个对象多负担一个vptr之外,虚函数的引入也引发了编译器对于我们的class产生膨胀作用:
- 我们所定义的构造函数被附加了一些代码,以便将vptr初始化。(这些代码必须被附加在任何基类构造函数调用之后,但必须在任何程序员供应的代码之前)
- 合成一个复制构造函数和一个复制赋值操作符,而且其操作不再是trivial(但隐式析构函数仍然是trivial)。(bitwise操作可能会对vptr带来非法设定)
继承体系下的对象构造
构造函数可能内含大量的隐藏码,因为编译器会扩充欸一个构造函数,扩充程度视classT的继承体系而定。一般而言所做的扩充操作如下:
- 记录成员初始化列表中数据成员初始化操作会被放进构造函数体,并以members的声明顺序为顺序。
- 如果有一个成员并没有出现在成员初始化列表中,但它有一个默认构造函数,那么该默认构造函数必须被调用。
- 在那之前,如果类对象有vptr,它们必须被设定初始值,指向适当的虚函数表。
- 在那之前所有上一层的基类构造函数必须被调用,以基类的声明顺序为顺序。
- 如果基类被列于成员初始化列表中,那么任何显式指定的参数都应该传递过去。
- 如果基类没有被列于成员初始化列表中,而它有一个默认构造函数(或默认memberwise复制构造函数),那么就调用它。
- 如果基类是多重继承下的第二或者后继基类,那么this指针必须有所调整。
- 在那之前,所有虚基类的构造函数必须被调用,从左到右,从深到浅。
虚拟继承
传统的构造函数扩充并没有用,这是因为虚基类的“共享性”。
CRE:一般需要继承链中最底层的类(most-derived)来负责共享subobject的构造。
vptr初始化语义
所以如果在基类的构造函数中调用了虚函数,则调用的是基类版本的函数,而不是派生类版本的,因为此时的对象vptr还指向基类的vtable。因为虚函数依赖于虚函数表(vtable)和虚函数表指针(vptr),在基类的构造函数执行期间,对象的vptr指向的是基类的vtable。派生类的vtable只有在派生类的构造函数执行后才会设置。
虚函数指针vptr的初始化时机:
- 在派生类构造函数中,所有基类的构造函数被调用。
- 上述操作完成后,对象的vptr(s)被初始化,指向相关的vtable。
- 如果有成员初始化列表(member initialization list)的话,将在构造函数体内扩展开,这必须在vptr被设定之后才做,以免有一个virtual成员函数被调用。
- 最后,执行程序员所提供的代码。
也就是说对象构造过程中,vptr会被设定多次,每当进入一个基类构造函数时,vptr都会被设定。
也就是说对象首先形成一个基类对象,然后才形成一个派生类对象。
《深入C++对象模型》:以上解决方法并不完美。如果不涉及虚函数调用,vptr不需要再每一个基类构造函数中被设定。
对象复制语义
当我们设计一个class,并以一个类对象指定给另一个类对象时,我们有三种选择:
- 什么都不做,因此得以实施默认行为。
- 提供一个显式的复制构造操作符。
- 显式地拒绝吧一个类对象指定给另一个类对象。
如果要选择第三种,只需要将复制构造操作符声明为private,并且不提供其定义即可。
如果不需要禁止对象复制,就要考虑默认行为是否足够。只有默认行为所导致的语义不安全或不正确时,我们才需要设计一个复制构造操作符。
如果类表现出bitwise复制语义,隐式的复制构造函数/复制赋值操作符就会被视为毫无用处,也根本不会被合成出来。
CRE:看起来默认的复制构造函数都是memberwise的。
有时候需要复制构造函数来实现NRV优化。
析构语义
如果class没有定义析构函数,那么只有在class内含的类对象成员或者基类拥有析构函数的情况下,编译器才会自动合成出一个来。否则,析构函数被视为不需要,也就不需被合成(当然更不用被调用)。
并不是定义了构造函数就一定需要定义析构函数。如果class不需要析构函数,为它们提供一个析构函数反而是低效率的。
析构函数被展开的方式类似构造函数被展开的方式,但是顺序相反。
一个object的生命周期结束于其析构函数开始执行之时。由于每一个基类析构函数被轮番调用,派生类对象在归还其内存空间之时,会依次变成各层级的基类对象。
(END)