11.3 拷贝构造函数
介绍了C++中引用的基本概念后,我们将讲述一个更令人混淆的概念:拷贝构造函数,它常被称为X(X&)(“X引用的X”)。在函数调用时,这个构造函数是控制通过传值方式传递和返回用户定义类型的根本所在。事实上,我们将会看到,这是很重要的,以至于编译器在没有提供拷贝构造函数时将会自动地创建。
11.3.1 按值传递和返回
为了理解拷贝构造函数的需要,看一下C语言在调用函数时处理通过按值传递和返回变量的方法。如果声明了一个函数并调用它:
编译器如何知道怎样传递和返回这些变量?其实它天生就知道!因为它必须处理的类型的范围是如此之小(char、int、float、double和它们的变量),这些信息都被内置在编译器中。
如果能了解编译器怎样产生汇编代码和确定调用函数f()而产生的语句,以上语句就相当于:
这个代码已被认真整理过,使之具有普遍意义;b和a的表达式根据变量是全局变量(在这种情况下它们是_b和_a)或局部变量(编译器将在堆栈上对其索引)将有差异。g表达式也是这样。对f()调用的形式取决于名字修饰表,“寄存器a”取决于CPU寄存器在汇编程序中是如何命名的。但不管代码如何,逻辑是相同的。
在C和C++中,参数是从右向左进栈的,然后调用函数,调用代码负责清理栈中的参数(这一点说明了add sp,4的作用)。但是要注意,通过按值传递方式传递参数时,编译器简单地将参数拷贝压栈—编译器知道拷贝有多大,并知道如何对参数压栈,对它们正确地拷贝。
f()的返回值放在寄存器中。编译器同样知道返回值的类型,因为这个类型是内置于语言中的,于是编译器可以通过把返回值放在寄存器中返回它。在C的基本数据类型中,拷贝这个值的位的行为就等同于拷贝这个对象。
11.3.1.1 传递和返回大对象
现在来考虑用户定义的类型。如果创建了一个类,希望通过传值方式传递该类的一个对象,编译器怎样知道做什么?这是编译器所不知的非内建数据类型,是别人创建的类型。
为了研究这个问题,首先从一个简单的结构开始,这个结构太大以至于不能在寄存器中返回:
在这里列出汇编代码有点复杂,因为大多数编译器使用辅助(helper)函数而不是简单插入功能性的语句。在main()函数中,正如我们猜测的,首先调用函数bigfun(),整个B的内容被压栈(我们可能发现有些编译器把B的地址和大小装入寄存器,然后调用辅助函数把它压栈)。
在先前的例子中,调用函数之前要把参数压栈。然而,在PassingBigStructures.cpp中,将看到附加的操作:在函数调用之前,B2的地址压栈,虽然它明显不是一个参数。为了理解这里发生的事,必须了解当编译器调用函数时对编译器的约束。
11.3.1.2 函数调用栈框架
当编译器为函数调用产生代码时,它首先把所有的参数压栈,然后调用函数。在函数内部,产生代码,向下移动栈指针为函数局部变量提供存储单元。(在这里“下”是相对的,在压栈时,机器的栈指针可能增加也可能减小。)但是在汇编语言CALL中,CPU把程序代码中的函数调用指令的地址压栈,所以汇编语言RETURN可以使用这个地址返回到调用点。当然,这个地址是非常重要的,因为没有它程序将迷失方向。这里提供一个在CALL后栈框架的样子,此时在函数中已为局部变量分配了存储单元。
函数的其他部分产生的代码希望能完全按照这个方法安排内存,因此它可以谨慎地从函数参数和局部变量中存取而不触及返回地址。称在函数调用过程中被函数使用的这块内存为函数框架(function frame)。
另外,试图从栈中得到返回值是合理的。因为编译器简单地把返回值压栈,函数可以返回一个偏移值,它告诉返回值的开始在栈中所处的位置。
11.3.1.3 重入
因为在C和C++中的函数支持中断,所以这将出现语言重入的难题。同时,它们也支持函数递归调用。这就意味着在程序执行的任何时候,中断都可以发生而不打乱程序。当然,编写中断服务程序(ISR)的作者负责存储和还原所使用的所有的寄存器(可以把ISR看成没有参数和返回值是void的普通函数,它存储和还原CPU的状态。有些硬件事件触发一个ISR函数的调用,而不是在程序中显式地调用)。
现在来想象一下,如果普通函数试着在堆栈中返回值,将会发生什么。因为不能触及堆栈返回地址以上任何部分,所以函数必须在返回地址以下将值压栈。但当汇编语言RETURN执行时,堆栈指针必须指向返回地址(或正好位于它下面,这取决于机器),所以恰好在RETURN语句之前,函数必须将堆栈指针向上移动,这便清除了所有局部变量。但如果试图从堆栈中的返回地址下返回数值,因为中断可能此时发生,此时是最易被攻击的时候。这个时候ISR将向下移动堆栈指针,保存返回地址和局部变量,这样就会覆盖掉返回值。
为了解决这个问题,在调用函数之前,调用者应负责在堆栈中为返回值分配额外的存储单元。然而,C不是按照这种方法设计的,C++也一样。正如不久将看到的,C++编译器使用更有效的方案。
下一个想法可能是在全局数据区域返回数值,但这不可行。重入意味着任何函数可以中断任何其他的函数,包括当前所处的相同函数。因此,如果把返回值放在全局区域,可能又返回到相同的函数中,这将重写返回值。对于递归也是同样的道理。
惟一安全的返回场所是寄存器,问题是当寄存器没有用于存放返回值的足够大小时该怎么做。答案是把返回值的地址像一个函数参数一样压栈,让函数直接把返回值信息拷贝到目的地。这也是在PassingBigStructures.cpp的main()中bigfun()调用之前将B2的地址压栈的原因。如果看了bigfun()的汇编输出,可以看到它接收这个隐藏的参数并在函数内完成向目的地的拷贝。
11.3.1.4 位拷贝与初始化
迄今为止,一切都很顺利。对于传递和返回大的简单结构有了可使用的方法。但注意所用的方法是从一个地方向另一个地方拷贝位,这对于C考虑的变量的原始方法当然进行得很好。但在C++中,对象比一组比特位要复杂得多,因为对象具有含义。这个含义也许不能由它具有的位拷贝来很好地反映。
下面来考虑一个简单的例子:一个类在任何时候都知道它存在多少个对象。从第10章了解到可以通过包含一个静态数据成员的方法来做到这点。
HowMany类包括一个静态变量int objectCount和一个用于报告这个变量的静态成员函数print(),这个函数有一个可选择的消息参数。每当一个对象产生时,构造函数增加记数,而对象销毁时,析构函数减小记数。
然而,输出并不是所期望的那样:
在h生成以后,对象数是1,这是对的。我们希望在f()调用后对象数是2,因为h2也在范围内。然而,对象数是0,这意味着发生了严重的错误。这从结尾两个析构函数执行后使得对象数变为负数的事实得到确认,有些事根本就不应该发生。
让我们来看一下函数f()通过按值传递方式传入参数那一处。原来的对象h存在于函数框架之外,同时在函数体内又增加了一个对象,这个对象是通过传值方式传入的对象的拷贝。然而,参数的传递是使用C的原始的位拷贝的概念,但C++HowMany类需要真正的初始化来维护它的完整性。所以,默认的位拷贝不能达到预期的效果。
在对f()的调用的最后,当局部对象出了其范围时,析构函数就被调用,析构函数使objectCount减小。所以,在函数外面,objectCount等于0。h2对象的创建也是用位拷贝产生的,所以,构造函数在这里也没有调用。当对象h和h2出了它们的作用范围时,它们的析构函数就使objectCount值变为负值。