类的构造函数、析构函数与赋值函数
学过C++的人都说自己知道构造函数、析构函数与赋值函数。它们看似太普通,以致让人容易麻痹大意,但请你认真读完这部分内容,你会发现这些貌似简单的函数实则像没有顶盖的下水道那样危险。
每个类只有一个析构函数和一个赋值函数,但可以有多个构造函数(包含一个拷贝构造函数,其它的称为普通构造函数)。对于任意一个类A,如果不想编写上述函数,C++编译器将自动为A产生四个缺省的函数,如
A(void); // 缺省的无参数构造函数 A(const A &a); // 缺省的拷贝构造函数 ~A(void); // 缺省的析构函数 A & operate =(const A &a); // 缺省的赋值函数
这不禁让人疑惑,既然能自动生成函数,为什么还要程序员编写?
原因如下:
(1)如果使用“缺省的无参数构造函数”和“缺省的析构函数”,等于放弃了自主“初始化”和“清除”的机会,C++发明人Stroustrup的好心好意白费了。
(2)“缺省的拷贝构造函数”和“缺省的赋值函数”均采用“位拷贝”而非“值拷贝”的方式来实现,倘若类中含有指针变量,这两个函数注定将出错。
这部分内容中以类String 的设计与实现为例,深入阐述被很多教科书忽视了的道理。事实上,这个例子至今仍然被很多公司哪来做笔试面试题,去年笔试的时候遇到过好几次,希望大家能从中得到一点帮助。String的结构如下:
class String { public: String(const char *str = NULL); // 普通构造函数 String(const String &other); // 拷贝构造函数 ~ String(void); // 析构函数 String & operate =(const String &other); // 赋值函数 private: char *m_data; // 用于保存字符串 }; 。
1 构造函数与析构函数初步
作为比C更先进的语言,C++提供了更好的机制来增强程序的安全性。C++编译器具有严格的类型安全检查功能,它几乎能找出程序中所有的语法问题,这的确帮了程序员的大忙。现在比较容易犯的是级别高的错误,它们通常隐藏得很深。而笔试面试中考察的也多是这些错误,而其中和构造析构函数相关的问题不少。
不少难以察觉的程序错误是由于变量没有被正确初始化或清除造成的,而初始化和清除工作很容易被人遗忘。Stroustrup在设计C++语言时充分考虑了这个问题并很好地予以解决:把对象的初始化工作放在构造函数中,把清除工作放在析构函数中。当对象被创建时,构造函数被自动执行。当对象消亡时,析构函数被自动执行。这下就不用担心忘了对象的初始化和清除工作。
构造函数与析构函数的名字不能随便起,必须让编译器认得出才可以被自动执行。构造函数、析构函数与类同名,由于析构函数的目的与构造函数的相反,就加前缀‘~’以示区别。
除了名字外,构造函数与析构函数的另一个特别之处是没有返回值类型,这与返回值类型为void的函数不同。构造函数与析构函数的使命非常明确,就象出生与死亡,光溜溜地来光溜溜地去。如果它们有返回值类型,那么编译器将不知所措。为了防止节外生枝,干脆规定没有返回值类型。
2 构造函数的初始化表
这是经常出的考点以至于要单独拿出一节来提一提。
构造函数有个特殊的初始化方式叫“初始化表达式表”(简称初始化表)。初始化表位于函数参数表之后,却在函数体{}之前。这说明该表里的初始化工作发生在函数体内的任何代码被执行之前。
以下是构造函数初始化表的使用规则和注意点:
1)如果类存在继承关系,派生类必须在其初始化表里调用基类的构造函数。
例如
class A {… A(int x); // A的构造函数 }; class B : public A {… B(int x, int y);// B的构造函数 }; B::B(int x, int y) : A(x) // 在初始化表里调用A的构造函数 { … }
2)类的const 常量只能在初始化表里被初始化,因为它不能在函数体内用赋值的方式来初始化。
3)类的数据成员的初始化可以采用初始化表或函数体内赋值两种方式,这两种方式的效率不完全相同。
非内部数据类型的成员对象应当采用第一种方式初始化,以获取更高的效率。例如
class A {… A(void); // 无参数构造函数 A(const A &other); // 拷贝构造函数 A & operate =( const A &other); // 赋值函数 }; class B { public: B(const A &a); // B的构造函数 private: A m_a; // 成员对象 };
例下图左,类B的构造函数在其初始化表里调用了类A的拷贝构造函数,从而将成员对象m_a初始化。
下图右中,类B的构造函数在函数体内用赋值的方式将成员对象m_a初始化。我们看到的只是一条赋值语句,但实际上B的构造函数干了两件事:先暗地里创建m_a对象(调用了A的无参数构造函数),再调用类A的赋值函数,将参数a赋给m_a。
对于内部数据类型的数据成员而言,两种初始化方式的效率几乎没有区别,但后者的程序版式似乎更清晰些。若类F的声明如下:
class F { public: F(int x, int y); // 构造函数 private: int m_x, m_y; int m_i, m_j; }
下图左F的构造函数采用了第一种初始化方式,下图右F的构造函数采用了第二种初始化方式。
3 构造和析构的次序
构造从类层次的最根处开始,在每一层中,首先调用基类的构造函数,然后调用成员对象的构造函数。析构则严格按照与构造相反的次序执行,该次序是唯一的,否则编译器将无法自动执行析构过程。
一个有趣的现象是,成员对象初始化的次序完全不受它们在初始化表中次序的影响,只由成员对象在类中声明的次序决定。这是因为类的声明是唯一的,而类的构造函数可以有多个,因此会有多个不同次序的初始化表。如果成员对象按照初始化表的次序进行构造,这将导致析构函数无法得到唯一的逆序。
4 典型例子:类String的构造函数与析构函数
// String的普通构造函数 String::String(const char *str) { if(str==NULL) { m_data = new char[1]; *m_data = ‘