编译器的小动作
构造与构析
我们知道,一个类的初始化是从它的构造函数开始的。而类的构造函数是一个黑匣子,并不是表面能看到的代码那么简单。除了用户写在构造函数体中的代码外,编译器还偷偷地为构造函数增加了许多代码。编译器添加的这些代码主要是完成一些诸如检查是否分配到了足够的内存空间(注意,构造函数本身是不涉及空间分配的),初始化VPTR之类的工作。所以不要觉得某个构造函数很简单就习惯性地将其声明为inline函数。在很多时候inline节约的那一点函数调用的时间还远远比不上代码量的猛增所损失的效率。
类的构造函数执行后,首先会检查是否有足够的空间。然后就是初始化该类的VPTR,使其指向该类的VTABLE。这之后,才开始处理用户在构造函数中写的代码。
编译器也会对构析函数做类似的,但是顺序正好相反的事情。
编译器创建的函数
在C++中,编译器会在需要的时候在class中自动构建如下5个函数:默认构造函数、非虚的析构函数、复制构造函数、=操作符、&操作符。
这里需要特别注意的就是“需要的时候”这个产生条件。什么是需要的时候?一个类在某一程序中使用的时候如果没有使用=操作符,那么在该程序中该类的=操作符就不会被创建。但是构造和析构函数也不是一定会添加的!有的人会觉得,一个没有构造函数的类如何能创建一个对象呢?我现在还不清楚。不过一个简单的推理就是C中struct的构建并不需要构造函数,不是吗?或者,可以举一个例子说明问题:
class TestAutoCon { public: // TestAutoCon() { // // }
}; int main() { TestAutoCon t; return 0; } |
上面的C++代码对应的汇编代码的核心片断是:
_TEXT SEGMENT _main PROC ; COMDAT
; 643 : {
push ebp mov ebp, esp sub esp, 204 ; 000000ccH push ebx push esi push edi lea edi, DWORD PTR [ebp-204] mov ecx, 51 ; 00000033H mov eax, -858993460 ; ccccccccH rep stosd
; 644 : TestAutoCon t;
lea ecx, DWORD PTR _t$[ebp] call ??0TestAutoCon@@QAE@XZ ; TestAutoCon::TestAutoCon
; 645 : return 0;
xor eax, eax
; 646 : }
push edx mov ecx, ebp push eax lea edx, DWORD PTR $LN5@main call @_RTC_CheckStackVars@8 pop eax pop edx pop edi pop esi pop ebx mov esp, ebp pop ebp ret 0 $LN5@main: DD 1 DD $LN4@main $LN4@main: DD -5 ; fffffffbH DD 1 DD $LN3@main $LN3@main: DB 116 ; 00000074H DB 0 _main ENDP _TEXT ENDS |
上面汇编片断的绿色代码可以很容易地看出是对构造函数的调用。实验一下就知道,一旦C++代码中注释掉了class TestAutoCon显式定义的无参构造函数,那么汇编片断就不会出现绿色的代码。同样的,可以证明其它几个函数和构造函数的情况也是一样的。
那么问题来了,什么时候会产生构造函数(或是其它几个函数)呢?构造和复制构造函数的情况在Inside the C++ Object Model那本书中已经说得很清楚了。
对于某个类的构造函数而言,在如下4种情况会由编译器产生构造函数:
1. 成员中有某个类是一个有显式定义的Default Constructor。原因很简单,一旦某个成员类有显式定义的构造函数,初始化的时候是要调用它的构造函数的。而在这种情况下产生的构造函数的作用就是调用成员类的构造函数;
2. 基类中有显式定义的Default Constructor。原因和上面一样;
3. 有Virtual的基类。因为需要构造函数来初始化一个指向virtual base class的指针;
4. 有virtual函数。因为需要构造函数初始化一个指向VTABLE的指针VPTR;
而某个类也会在以上4种情况下由编译器产生复制构造函数,毕竟复制构造函数也是构造函数嘛。只不过在3、4的情况下的原因稍微有点不同:同一类的对象之间的赋值本也不需要编译器产生复制构造函数的,但是同一继承树中不同的类对象之间的赋值则不能进行bitwise copy了——VPTR的指向并不一样的。至于其它几个函数就照此类推吧。
下面是默认构造函数、无参构造函数和带参构造函数的调用规则:
1. 若是创建类时没有提供构造函数,创建对象时使用默认构造函数或不使用;
2. 若是创建类时提供了无参的构造函数,创建对象时使用无参构造函数;
3. 若是创建类时只提供了有参的构造函数(包括复制构造函数),创建对象时必须显式使用
在这里顺便提一下析构函数。当某个类是多态链中的某个类的父类时,它必须要有virtual的析构函数,因为一旦使用该类的指针指向一个构建于heap上的子类时,那么delete该指针时会因为静态链接的原因而直接调用该类的析构函数而不是子类的析构函数,这样的行为在C++标准中是没有被定义的。如果该类不处于多态链中,则不可为virtual。
构建技巧
当我们不希望以上的某些函数会被使用时,可以通过将其置于该类的private的控制域中,并且只声明不定义来实现禁止使用。一旦调用,则会在链接的过程中报错;亦可以在private继承的的父类中这么做,从而实现在编译过程中报错的目的。而其中默认构造函数也可以通过定义带参数的构造函数来禁用。实现的不完整代码如下:
#include <iostream> using namespace std; class Uncopyable { protected: // allow construction Uncopyable() {} // and destruction of ~Uncopyable() {} // derived objects... private: Uncopyable(const Uncopyable&); // ...but prevent copying Uncopyable& operator=(const Uncopyable&); };
class HomeForSale: private Uncopyable { // class no longer //... // declares copy ctor or }; |
成员变量的初始化
前面已经详细讨论了类的构建,接下来要谈到的就是构建完成后类成员的初始化问题。因为C++标准中并未规定class中的member variables有默认的初始值,所以在构建class的object之前,最好对class中所有的member variables进行初始化。
class构造函数的initialization list是真初始化,而构造函数中的赋初值操作是伪初始化。
这两种初始化的本质区别就是赋值的过程不同:真初始化是通过复制构造函数进行赋值操作的,而伪初始化则是首先利用默认构造函数构造一个对象,然后调用=操作符进行赋值的。正是由于这个区别,const和reference这两种不能被赋值的类型就不能被伪初始化,只能在initialization list中进行真初始化。
可以看出,在member variables不多且内建类型较少的情况下,应该采用真初始化,因为这样可以减少初始化时的函数调用,提高效率。而因为内建类型的创建并不需要构造函数调用的消耗,所以不在考虑之列。而当member variables很多或是有多个构造函数的时候,应该创建一个private的初始化函数,在该函数中对所有的member variables进行伪初始化(赋初值),然后在构造函数中调用它。这样可以避免冗余的代码。当然,这样会带来额外的函数调用的消耗。
需要注意的一点是,无论 initialization list 中变量出现的顺序如何, class 中 member variables 的初始化顺序都是按照相同控制域(控制域有 public,protected,private )中声明出现的顺序进行的,而非 initialization list 中变量出现的顺序!因为若是初始化的顺序按照列表中的顺序,则必须为每个类的不同初始化顺序的对象保持一份记录,以确保析构的时候能够按照逆序进行,这种做法会大大降低执行效率,尤其是在大型程序中。要记住的是不同控制域间的变量初始化顺序在 C++ 标准中是没有定义的!不过,在 cl 编译器中, member variables 的初始化顺序就是按照声明出现的顺序进行的,而与不同的控制域无关。当然, gcc 中未必如此。