构造函数语义学
一、默认构造函数的构造操作
C++中对于默认构造函数的解释是:默认的构造函数会在需要的时候被编译器产生出来。如下述实例所示,正确的程序语意要求Foo
有一个默认构造函数,可以将它的两个成员初始化为0。那上述实例会合成出一个默认构造函数吗?
1 | class Foo |
其实并不会!默认构造函数的合成关键是在于谁需要?是程序的需要还是编译器的需要?如果是程序的需要,那初始化便是程序员的责任。而上述例子便是程序员的责任,因此程序并不会合成一个默认构造函数。那么,什么时候编译器才会合成一个默认构造函数呢?
- 当编译器需要默认构造函数的时候才会合成出一个默认构造函数! 且被合成出来的构造函数只执行编译器所需的行动。这样合成的默认构造函数是
notrivial
的,并且这个产生操作只有在默认构造函数真正被调用时才会进行合成。 - 如果编译器不需要,而程序员又没有提供,这时的默认构造函数就是
trivial
的。需要注意trivial
构造函数只存在于概念上,编译器实际上根本不会去合成出来(此类构造函数不做任何有意义的事情,所以编译器不去合成它)。
1.1 什么时候下会合成默认构造函数?
通常编译器会在以下四种情况会合成notrivial
的默认构造函数。
1.1.1 具有默认构造函数的成员类对象
- 类没有任何构造函数,但它内含一个含有默认构造函数的成员类,那么这个类的
implicit
默认构造函数就是nontrivial
, 编译器需要为该类合成出 一个默认构造函数。
考虑下面的例子:
1 | class Foo { public: Foo(), Foo(int) ... }; |
被合成的Bar
默认构造函数内含必要的代码,使其能够调用类 Foo
的默认构造函数来处理成员对象 Bar::bar
,但它并不产生任何代码来初始化Bar::str。被合成的默认构造函数如下所示:
1 | inline Bar::bar() { foo.Foo::Foo();} |
一个有趣的问题:类声明头文件可以被许多源文件所包含,如何避免合成默认构造函数、拷贝构造函数、析构函数、 赋值拷贝操作符(4 大成员函数)时不引起函数的重定义?
解决方法是以 inline 的方式完成(一个inline函数有静态链接,不会被文件以外者看到),如果函数太复杂不适合 inline,就会合成一个 explicit non-inline static 实例 (Static 函数独立于编译单元)
- 如果类
A
内含一个或一个以上的成员类对象,那么类A
的每一个构造函数必须调用每一个成员类的默认构造函数。C++
要求以成员对象在类中的声明顺序来调用各个构造函数。这一点由编译器完成,它为每一个构造函数安插程序代码,以“成员声明顺序”调用每一个成员所关联的默认构造函数。
1 | class Dopey { public: Dopey; ... }; |
1.1.2 带有默认构造函数的基类
如果一个没有任何构造函数的类派生自一个带有默认构造函数的基类,那么这个派生类的默认构造函数会被视为 nontrivial
,并且这个默认构造函数会被按照基类的声明顺序调用基类的默认构造函数合成出来。
如果设计者提供多个构造函数,但其中都没有默认构造函数的话,编译器会扩张现有的每一个构造函数,将用以调用所有必要的默认构造函数的程序代码加进去。(它不会合成 一个新的默认构造函数)。
1.1.3 带有虚函数的类
有两种情况需要合成出默认构造函数:
- 类声明(或继承)一个虚函数。
- 类派生自一个继承串链,其中有一个或者更多的虚基类。
不管哪一种情况,由于缺乏用户声明的构造函数,编译器会详细记录合成一个默认构造函数的必要信息。
1 | class Widget |
由于含有虚函数,其扩张行动会发生在编译期间:
- 编译器会产生一个虚表vtbl,其内放置类的虚函数地址。
- 每一个类对象中,编译器会额外的合成一个指针成员vptr指向与之相关的类虚表vtbl。
此外,widget.flip()的虚拟调用操作(virtual invocation)会被重新改写,以使用 widget 的 vptr 和 vtbl 中的 flip 条目:
1 | // widget.flip()的虚拟调用操作(virtual invocation) |
为了让这个机制生效,编译器必须为每一个Widget(或其派生类) 对象的 vptr 设定初值,放置适当的virtual table地址。
1.1.4 带有虚基类的类
虚基类的实现法在不同的编译器之间有极大的差异。然而,每一种
实现法的共同点在于必须使虚基类在其每一个派生类对象中的位置,能够于执行期准备妥当。
1 | class X { public: int i; }; |
编译器无法固定住 foo()
之中“经由 pa
而存取的 X::i
的实际偏移位置,因为 pa
的真正类型可以改变。编译器必须改变执行存取操作的那些代码,使 X::i
可以延迟至执行期才决定下来。
__ vbcX (或编译器所做出的某个什么东西) 是在类对象构造期间被完成的。对于类所定义的每一个构造函数,编译器会安插那些 “允许每一个虚基类的执行期存取操作” 的代码。如果类没有声明任何构造函数,编译器必须为它合成一个默认的构造函数。
被合成出来的构造函数只能满足编译器的需要,通过下属两种方法:
- 通过调用成员对象或基类的默认构造函数;
- 为每一个对象初始化其虚函数机制或虚基类机制。
C++新手常见的2个误区:
ERROR: 如果类没有定义默认构造函数就会被合成一个。首先定义了其它的构造函数就不会合成默认构造函数;再次即使没有定义任何构造函数也不一定会合成默认构造函数,可能仅仅是概念上有,但实际上不合成出来。
ERROR: 编译器合成出来的默认构造函数会显式设定类内每一个数据成员的默认值。明显不会,区分了全局对象,栈对象、堆对象 就非常明白了只有在全局上的对象会被清 0,其它的情况都不会保证被清 0。
二、拷贝构造函数的构造操作
拷贝构造函数和默认构造函数一样,只有在必须的时候才会被产生出来,对于大部分的类来说,拷贝构造函数仅仅需要按位拷贝就可以。当然,满足 bitwise copy semantics
的拷贝构造函数是 trivial
的,不会真正被合成出来,与默认构造函数一样,只有 nontrivial
的拷贝构造函数才会被真正合成出来。
2.1 什么时候一个类不展现bitwise copy semantics呢?
分为四种情况,前 2 种很明显,后 2 种是由于编译器必须保证正确设置虚机制而引起的。
- 当类内含一个成员对象而后者声明了(也可能由于
nontrivial
语意从而编译器真正合成出来的)一个拷贝构造函数时。 - 当类继承自一个基类,而后者存在一个拷贝构造函数时(不论是被显式声明或是被合成而得)。
- 当类声明了一个或多个虚函数时。
- 当类派生自一个继承串链,其中有一个或多个虚基类时。
2.1.1 重新设定虚表的指针
对于每一个新产生的类对象,如果编译器不能成功而正确的设置好其 vptr
的初值,将会导致可怕的后果。因此当编译器导入一个 vptr
到类中时,该类就不再展现 bitwise
语意了。
考虑下面的例子:
1 | class ZooAnimal { |
ZooAnimal
类对象以另一个ZooAnimal
类对象作为初值,或Bear
类对象以另一个Bear
类对象作为初值,都可以直接靠bitwise copy semantics
完成。
1 | Bear yogi; |
yogi
会被默认的 Bear
构造函数初始化。yogi
的 vptr
被设定指向 Bear
的虚表。因此,把yogi
的 vptr
值拷贝给 winnie
的 vptr
是安全的。
- 类对象以其派生类的对象内容做初始化操作时,其
vptr
复制操作也必须保证安全。franny
的vptr
不可以被设定指向Bear
的虚表。否则当下面程序片段中的draw()
被调用而franny
被传进去时,就会出问题:
1 | Bear yogi; |
也就是说,合成出来的ZooAnimal
拷贝构造函数会显式设定对象的 vptr
指向 ZooAnimal
类的虚表而不是直接从右手边的类对象中将其 vptr
现值拷贝过来。
2.1.2 虚基类子对象
虚基类的存在需要特别处理。一个类对象如果以另一个对象作为初值,而后者有一个虚基类子对象,那么也会使 bitwise copy semantics
失效。
1 | class Raccoon : public virtual ZooAnimal { |
在上述情况下,为了完成正确的 little_red
初值设定,编译器必须合成一个拷贝构造函数,安插一些代码以设定虚基类pointer/offset 初值,对每一个成员执行必要的 memberwise
初始化操作,以及执行其他的内存相关工作。
在下面的情况,编译器无法知道 bitwise copy semantics
是否还保持着,因为它无法知道 Raccoon
指针是否指向一个真正的 Raccoon
对象或者指向一个派生类对象。
1 | Raccoon *ptr; |
三、程序转化语义学
本部分是讨论编译器调用拷贝构造函数时的策略(如何优化以提高效率),或者说是是关于编译器对于程序是如何进行有效转化或者说翻译,以实现C++的语法机制。主要有以下的几种语意:
3.1 显式初始化操作
可以考虑如下的示例:
1 | X x0; |
3.2 参数的初始化
C++标准(8.5节)说,把一个类对象当做参数传递给一个函数或者把它作为一个函数的返回值时,相当于以下形式的初始化操作:
1 | // 其中xx是形式参数或者返回值,arg代表真正的参数值。 |
一般来说,编译器有两种做法:
- 引入一个临时性对象。
1 | // 1. 编译器生成一个临时性对象 |
- 采用“拷贝构建”的方式,将参数直接以拷贝构造函数建构到函数的堆栈上。
3.3 返回值的初始化
当返回值是对象时,这个对象是如何返回的呢?cfront
使用的是一个双阶段转化:
- 首先加上一个额外的参数,是类对象的引用,这个参数将放置通过拷贝构建得来的返回值。
- 在
return
之前安插一个拷贝构建函数的调用操作。
1 | X bar() |
四、成员初始化列表
当我们写下一个构造函数时,就有机会设定类成员的初值。要不是由成员初始化列表,就是在构造函数函数体之内。除了4种情况,你的任何选择其实都差不多。
4.1 初始化列表内部的真正操作是什么?
为了让你的程序能够顺利被编译,你必须使用成员列表初始化:
- 当初始化一个
reference member
时; - 当初始化一个
const member
时; - 当调用一个基类的构造函数,而它拥有一组参数时;
- 当调用一个成员类的构造函数,而它拥有一组参数时;
在这4种情况下,程序可以被正确编译并执行,但是效率不高。例如:
1 | class Word { |
一个更明显有效率的实现方法是:
1 | // 较佳的方式 |
4.1.1 成员列表初始化中会发生什么?
编译器会一一操作列表初始化,以适当顺序在构造函数之内安插初始化操作,并且在任何显式用户代码之前。且需要注意:列表中的项目顺序是由类中的成员声明顺序决定的,不是由初始化列表中的排列顺序决定的。
1 | // 由于声明顺序的缘故,成员列表初始化中的 i(j) 其实比 j(val) 更早执行。但因为j一开始未有初值,所以i(j)的执行结果导致i无法预知其值。 |
- 本文标题:深入理解C++对象模型(二)
- 创建时间:2024-04-18 10:42:13
- 本文链接:2024/04/18/深入理解C-对象模型-二/
- 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!