C++初学解惑(5)——构造函数(中)
三、复制构造函数
1.存在的理由
厨师做烹饪的时候总要往锅里加入各式各样的调料,调料的种类、数量在相当大的程度上就决定了菜肴的口感;经验丰富的厨师总是擅长于根据顾客的品味差异来调节调料的投入,以迎合顾客的喜好。我们在炮制对象的时候亦如此:通过重载不同具有参数表的构造函数,可以按我们的需要对新创建的对象进行初始化。譬如,对于复数类Complex,我们可以在创建时指定实部、虚部,它通过“投入”的两个double参数来实现;而对于整型数组类IntArray,我们亦可以在构造函数中“投入”一个int值作为数组对象的初始大小,如果需要,我们还可以再“撒进”一个内部数组作为数组各元素的初始值,等等。嗯,总之,就是说我们可以通过向类的构造函数中传入各种参数,从而在对象创建之初便根据需要可对它进行初步的“调制”。假使我们已经做好了一道菜,呃,不,是已经有了一个对象,比如说,一个复数类Complex的对象c,现在我们想新创建一个复数对象d,使之与c完全相等,应该怎么办?噢,有了上节的经验,我知道你会兴冲冲地走到电脑,敲出类似于下面的代码:
Complex d(c.getRe(), c.getIm()); // getRe()与getIm()分别返回复数的实、虚部
很好,它可以正确的工作,不是吗?不过,再回过头两欣赏几眼之后,你是否和我一样开始觉得似乎这样有些冗长?而且,应该看到复数类是一个比较简单的抽象数据类型,它提供的公有接口可以让我们访问到它所有的私有成员变量,所以我们才得以获得c的所有的“隐私”数据来对d进行初始化;但有相当多的类,是不能也不该访问到它所有的私有对象的,退一步说,就算可以完全访问,我们的构造函数参数表也未必总能精细地、一丝不苟地刻画对象,例如对于IntArray类的某个对象,例如a,可能它会包含一千个元素,但我们不可能写
IntArray copy_of_a(a.size(), a[0], a[1], a[2], ..., a[999]); // 足够详细的刻画,同时也足够愚蠢
唔,看来我们错就错在试图用数量有限的辅助参数来刻画我们的对象,而它往往是不合适的。要想以对象a来100%地确定对象b,就必须让对象b掌握对象a的100%的信息,也就是说,构造函数中所传入的参数应该包含对象a的100%的信息。谁包含了“对象a的100%的信息”呢?看上去似乎是一道难题,哦,我们似乎被前面各个杂乱的参数表弄得头晕脑胀,但我们不应该忘却从简单的方面考虑问题的法则,包含“对象a的100%的信息”的家伙,不应该是一群数量巨大的变量,不应该是一组令人恐惧的参数,它应该就是...就是对象a本身。
啊哈,真是废话。别管这么多,我们现在要让复数d初始化之后立刻等于复数c,就可以写
Complex d(c); // 嗯...完美的表示
“等等...”你也许会提醒说,“你好像还没有定义与之对应的构造函数。”
这是一个非常好心的提醒,不过我可以带着愉悦的心情告诉你:C++对于“以与自己同类型的对象为作为唯一参数传入的构造函数”已经做了默认的定义,对于这个默认的构造函数所创建的对象,将与作为参数传入的对象具有完全相同的内容:实际上,这个过程一般是以“位拷贝”的方式进行的,即是将参数对象所占的那一块内存的内容“一股脑”地拷贝到新创建的对象所处的内存区块中,这样做的结果,便使得新创建的对象与参数对象内有完全相同的成员变量值,无论是公有的还是私有的,因而,我们也就得以以一个已存在的对象来初始化一个新的对象,当然,两者应该是同一类型的。
你也许会对“为何C++要默认提供这样一个构造函数”感兴趣。实质上,不仅仅是上面的“声明对象并初始化”的例子要使用到这个特性,在一些更普遍的情况、同时也是我们所不注意的情况中,必须要使用到这个功能,例如:进行值传递的函数调用。考虑下面的代码片断:
void swap(int a, int b)
{
int temp = a;
a = b;
b = temp;
}
int main()
{
int x = 1, y = 2;
swap(x, y); // 噢...
... // others
return 0;
}
问题:执行了swap(x, y)之后,x, y的值分别是多少?毫无疑问,当然是x==1, y==2,因为执行swap时,交换的并不是x,y变量的值,而只是它们的一个副本。我们可以从某个角度认为:在调用swap函数时,将会“新创建”两个变量a, b,而a, b分别以x, y的值进行初始化,像这样:
int a(x), b(y);
... // 此后的操作都与x, y无关了
同样,假如有这样一个函数:
void swap(Complex a, Complex b)
{
...
}
在出现诸如swap(p, q)这样的调用时,也会对p, q进行复制操作,类似于:Complex a(p), b(q);
与参数的传入一样,函数在返回时(如果有返回的话),只要不是返回某个引用,则也会出现类似的复制操作(这里不考虑某些省却复制步骤的编译优化)。
所以,假如系统不会为我们默认定义这样的特殊的构造函数,我们将不能定义任何出现Complex类型的参数,或者返回值为Complex类型的函数,而这简直是没有道理的,至少,看上去不是那么好令人接受。
由于这样的构造函数可以看作是从一个既有的同型对象中“复制”创建出一个新对象,所以也把这个构造函数称为复制构造函数,或拷贝构造函数(copy constructor).实质上它与其它的构造函数没有区别,只是:
1.如果没有明确给出,系统会默认提供一个复制构造函数(类似于不带参数的构造函数,以及析构函数);
2.进行值传递的函数调用或返回时,系统将使用此构造函数(相对于其它的通常只存在人为调用的构造函数)。
噫,以上便是有关复制构造函数的讲解。时间不早,该休息了。朋友,祝您晚安,下次见...
哦,不不不,弄错了,没有这么简单。我们...我们才刚刚开始...唔,至少我还得在睡前把这篇文章打完,而且下面似乎内容不少,总之,还有相当多的因素要考虑。嗯,你可能说,C++提供的默认复制构造函数不是已经尽职尽责地、一个bit不漏地为我们将对象原原本本地克隆过去么,那么所得的新对象肯定与被复制对象完全一样,还有什么要考虑的?
问题就出在对于“完全一样”的理解上。我们说两个三角形完全一样,是指它们具有对应相同的边、角;我们说两个复数完全一样,是指它们具有对应相等的实部、虚部;我们说两个文件完全一样,是指它们的内容一丝不苟地相同...那我们说某个类型的两个对象完全一样,当然,从朴素的意义上说,应该是指这两个对象具有相同的成员,包括成员函数与成员变量;不过,同型对象当然有相同的成员函数,所以更确切地说,完全相同即是具有相同的成员变量。
但C++中的类的对象并非、也不应该仅被视为绻缩于内存中某个角落的一系列二进制数据,那是机器的观点。事实上,对象作为现实模型的一个描述,它本身应该具有一定的抽象而来意义。一个Orange类的对象,在编程时也许我们更多的时候会有意无意地将其作为一个活生生的桔子进行考虑,而非一撮二进制流。C++的面向对象抽象使我们更好地刻画现实模型,并进行正确的、恰当的使用。因此,当我们说两个对象“完全相等”,基于抽象的思想,我们所指的,或者说所要求的,只是对象所代表的现实模型的意义上的相等,至于在二进制层面的实现上,它们内部的0101是否一样,倒不是我们最关心的,当然,可能在许多情况下,两者是等同的,但也不尽然。譬如两个IntArray数组对象,当然,它们各包含了一个整型指针作为私有成员,以存放数组所处内存空间的首地址。但我们说这两个数组对象相同,更多意义上指的是它们含有相同的维数、尺寸,以及对应相等的元素,至于存放首地址的指针值,尽管它们在大多数情况下都不相等,但这并不妨碍我们认为两个数组相同。在这个例子中,“对象”所代表的概念上的意义与它在机器层面的意义出现了分岐,程序语言的任务之一就在于提供更恰当的抽象,使程序具有某种吻合于现实模型的条理性。所以两者出现分岐之时,既然我们使用的是具有相当抽象能力的高级语言,就应当倾向于尊重现实的概念,而不是返朴归真。
说了这么多,现在该回过头来看一看默认复制构造函数所采用的“完全照搬”复制模式将有可能带来怎样的“概念层面”与“机器层面”的矛盾。刚才我们已经抓到了IntArray类的尾巴,现在来看看,假如使用代表着“机器层面理解”的默认复制函数对它进行复制构造,会发生什么事情。嗯,没错,新创建的IntArray对象--不妨称为b--将具有与原对象--不妨称为a--完全相同的成员变量,当然也包括用于存储数组内存空间首地址的指针成员,也就是说,a与b实际上是“共享”了同一块内存以存放“它们”的数据。从此,如果我改变a数组中的某个元素的值,则b中相应元素也会改变,反之亦然--我们的程序闹鬼了。更为严重的是,如果我们在IntArray的析构函数中加入了释放数组内存的代码(几乎所有的容器都会这样做),那么由于有多个对象共享同一块内存,这块可怜的内存将会被释放多次,事实上当第二次来临时我们的程序很可能就已经崩溃了,这种情况在值传递调用函数时最为典型。
我们概念意义上对“数组复制”的理解是产生一个新数组,使之与原数组的元素均对应相同,而绝不是什么共享内存的鬼把戏。但我们忠厚的C++编译器可不了解这些,毕竟它只是机器,所以也只会尽职尽责地为我们施行位拷贝。这样一来,我们就有义务把我们对概念本身的理解告诉C++,当然,这个途径就是显示地定义一个复制构造函数。
2.关于声明的讨论
首先,以IntArray类为例,我们来看看复制构造函数的声明应该是什么样子:构造函数是没有返回值的,复制构造函数当然也不例外,因此我们只须考虑参数。复制构造函数只有一个参数,由于在创建时传入的是同种类型的对象,所以一个很自然的想法是将该类型的对象作为参数,像这样:
IntArray(IntArray a);
不幸的是,即使是这样朴实无华的声明也隐含了一个微妙的错误,呵,我们来看看:当某个时候需要以一个IntArray对象的值来为一个新对象进行初始化时,当然,编译器会在各个重载的构造函数版本(如果有多个的话)搜寻,它找到的这个版本,发现声明参数与传入的对象一致,因此该构造函数将会被调用。目前为止,一切都在我们的意料之中,但问题很快来了:该函数的参数我们使用了值传递的方式,按照前面的分析,这需要调用复制构造函数,于是编译器又再度搜寻,最后当然又找到了它,于是进行调用,但同样地,传参时又要进行复制,于是再调用...这个过程周而复始,每次都是到了函数入口处就进行递归,直到堆栈空间耗尽,程序崩溃...
当然,这样的好戏在现实中不大会出现,因为编译器会及时发现这一明显是问题的问题,并报告错误,终止编译;所以接下来我们还得想想其它办法。我们刚才之所以没有获得想当然的成功是由于在传参时出现了复制构造函数的调用--而这本来就是它需要解决的操作--从而产生无穷递归。由是观之,值传递看来是行不通的了;我想C语言的用户这时很快会反应到与值传递对应的方式:地址传递(传址),于是声明变为这样:
IntArray(IntArray *p);
只作为一般的构造函数,它应该可以运行得很好,但别忘了我们要提供的是复制构造函数,它要求能够接受一个同类型对象,像这样:
IntArray a;
... // 对a操作
IntArray b(a);
而不是接受指针:
IntArray a;
... // 对a操作
IntArray b(&a); // 还要取地址?当然,它可以正确运行,但...
否则,虽然在初始化对象时可以像上面一样人为加一个取址符,但在函数参数表中(或者函数返回)进行值传递时,编译器可不知道在找不着合适定义的情况下牵就选择你的指针版本。
既然复制构造函数括号里放的必须是某个对象,但又不能通过值传递,也不能通过传址代替,解决的方案,我想你一定早想到了。没错,就是使用引用:
IntArray(IntArray& a);
由于引用产生的只是一个别名,而实质上使用的还是“原来的”被引用的对象,所以,当然,嗯,也就不会存在什么参数复制的问题,从而不会再去调用复制构造函数--也就是它自己!
成功之后我们再来看看能不能再做一些改进:我们在复制对象的过程中,当然,从语义上说,一般不会改变被复制的“母体对象”的值。我们难以想象,诸如:
Complex b(a);
的语句过后,会使复数a变得面目全非;同时,假如“母体对象”是const类型,将意味着我们不能用它来初始化其它对象:因为参数IntArray& a不能保证a不会被修改!将const常量传给非const引用会导致编译出错,但这个限制显然并非我们所要的;并且,我们有可能不小心的情况在复制构造函数中修改了本不希望改动的“母体对象”,而这一切将很难在今后查出。综上所述,我们通常会给复制构造函数的引用参数前加上const进行修饰:
IntArray(const IntArray& a);
这样一来就更完美了,它避免了上述的问题;而且即使是非const的对象也可以作为const型的引用(如前文所分析,反过来就不可以)。
3.定义的实现
好,我们已经找到了复制构造函数的一个合适的参数,对于声明的讨论也就告一段落了。现在我们还是以IntArray为例,来看看它的实现应当是什么样子。如果你读过《构造函数(上)》,那么还记得上一节开头我所给出的有关IntArray的声明么?如果你的回答是Yes,那么我很佩服你的用心,不过我自己是记不得了,为了我自己,以及那些同我一样的朋友和暂时没有读过上节内容的朋友,我把上一节的IntArray主要部分声明粘贴如下(其实很简单):
class IntArray
{
public:
... // others
private:
int *_p;
int _size;
};
如你所推测,IntArray使用整型指针_p存储在自由存储区上以new表达式分配得来的内存空间的首地址,而_size则存放该空间可以容纳的整型元素的个数,换句话说,它也就代表了数组的尺寸。以机器的观点,在IntArray中,_p和_size是标识两个IntArray对象是否相同的特征变量(我们假设pulic区段所声明的都是成员函数,而没有成员变量),但从我们对数组概念上的理解,_size和_p所指的内存中元素是否均对应相同,才是数组相同的意义;换言之,_size和_p所指的内存的内容(而不是_p存储的地址值)才是构成我们“概念理解上”的数组的信息。因而在进行复制的时候,基于尊重“概念抽象”的观点,应当对_p所指的内存空间进行复制,而不是简单地复制_p本身。这就是我们在自定义的复制构造函数中所要表明的;下面是一个可能的定义,我把分析放在注释部分:
IntArray(const IntArray& a)
{
_size = a._size; // 我们显示定义了复制构造函数之后,默认构造函数就不存在了,
// 所以必须自己实现所有必要的成员变量的复制
if (_size > 0) // 只有a的尺寸大于0,也就是a有内容时,才需要复制
{
_p = new int[_size]; // 构造函数总是在创建新对象时调用,所以别忘了分配内存
for (int i = 0; i < _size; ++i) // 逐个复制a中的内容
_p[i] = a._p[i];
}
else
_p = 0; // 安全起见,我们把零内容数组的指针设为0
}
嗯,再补充一点。如果你的程序非常在意效率,那么你可以用诸如memcopy()的内存拷贝函数把整块a._p所指的内存复制过来,一般这样会比使用循环快一些。不过在C++中对这个函数的使用不是不需要经过慎重考虑的:和相当部分的默认复制构造函数一样,它只是简单机械地拷贝内存,所以你如果将此函数应用在诸如IntArray的对象上,以希望“有效率地”拷贝多个IntArray对象,那么几乎一定会出现问题:它不会知道应该拷贝_p所指的内容,因而将产生前面所说的种种问题;甚至我们显式定义了IntArray的复制构造函数,它也不会调用--因为你使用它就意味着你要求的是“位拷贝”,而不是“复制构造”。所以,在C++中,只有当memcopy应用在内置基本类型上时,我才敢保证它是安全的。当然,如果你把握不准,那么在C++中把这个C时代遗留下来的函数忘了不失为一个很好的对策。
4.组合或继承情况下的复制构造
不知你注意到没有,我刚刚说memcopy()的机械拷贝只是和“相当部分”的默认复制构造函数一样,呃,应当反过来说,只是“相当部分”的默认复制构造函数采用“位拷贝”模式。咦,难道还有某种神秘的力量在控制着另一种默认复制构造函数?
先考虑以下例子:假如我现在要设计一个类,不妨叫A,它包含了一个IntArray对象作为其中一个成员,当然,这是不奇怪的,像这样:
class A
{
public:
... // others
private:
IntArray _array; // that's it
... // others
};
然后我的代码中出现了A的对象的复制构造,这也是很有可能的,像这样一个函数:
A dosomething(A a)
{
... // 一些操作
A b(a); // 以a复制构造出b
...
return b;
}
以上的片断就隐含了三个关于A对象的复制构造(你看得出来吗?),但如果我没有为A定义复制构造函数,如前面所说,编译器当然就会建立一个默认复制构造函数,然后在需要的时候调用它。问题在于,这个默认复制构造函数假如还是傻呵呵地采用简单的位拷贝,那至少可以断定我们的_array成员将遭遇不测。当然,你或许会责怪我没有为A添置一个复制构造函数,但我可以说,而且是很有理由地辩解说,class A的其余成员并不需要提供复制构造函数,至于IntArray类型成员,我只是这个类的用户,它封装好的内部机制不应当由我负责,事实上我也无从负责:即使我真的愿意专门为之设计一个复制构造函数,我又该怎么实现呢?作为用户,我得到的只是IntArray的接口,也就是说我只有权利使用它,而无法关心也不该它内部是怎样运作的,而复制构造函数通常需要涉及上述内容。
作为一门成功的语言,C++当然不能允许存在上述的尴尬。事实上,假如一个类存在需要调用(用户定义的,可能是间接的)复制构造函数的成员对象,而类本身又没有提供复制构造函数,则其默认构造函数不是简单地采用位拷贝模式,而是将其对象“逐一”分别拷贝,对于需要调用复制构造函数的对象,则进行相应的调用,以保持复制构造的概念上的意义。
对于继承的情况也是类似,假如有一个SuperIntArray类继承至IntArray:
class SuperIntArray: public IntArray
{
public:
... // something
private:
... // something
};
即使SuperIntArray本身没有提供复制构造函数,在其对象需要复制构造时,对基类IntArray部分的构造,同样也会调用IntArray(const IntArray&)来实现。
嗯,如果前面所提到的组成与继承的情况中,新的类型显示提供了复制构造函数,则相应成员,或者基类的复制构造函数也同样会被调用。实际上前一节我们提到构造函数时已经就这个问题讨论过了,所以这一部分基本上没有太多的新意:别忘了,复制构造函数只不过是构造函数的一种。