3.3 复制构造函数
C++面向对象高效编程(第2版)③ TIntStack s2 = s1;
这是一个小小的技巧。我们试图创建TIntStack类的另一个对象s2。但是,我们希望用s1初始化这个对象。换句话说,必须通过s1创建s2。当然,前提是s1已经存在。这类似于以下声明:
int j;
int k = j; // 创建一个k并初始化为j```
在这种情况下,编译器知道如何用j初始化k,因为int是语言定义(内置)类型。然而,③中的TIntStack是程序员定义类型,这表明由程序员负责将s1初始化s2(在语言的一些帮助下)。这里要用到复制构造函数(一种特殊的构造函数)。无论何时我们需要通过现有对象创建一个新的对象,都要使用类提供的复制构造函数。该例中,我们会用到TIntStack类中的复制构造函数,类中已经声明了一个。复制构造函数的声明如下:
`TIntStack(TIntStack& source)`
或者
`TIntStack(const TIntStack& source)`
该复制构造函数有一个它所属类的参数(引用)。
警告:
如果我们在类中未提供复制构造函数,编译器会为类生成一个1。但是,生成的复制构造函数可能并不满足要求。我们将在第4章中进一步学习复制构造函数。下面先针对以下示例,了解如何实现自己的复制构造函数。
TIntStack::TIntStack(const TIntStack& source)
{
// 由于复制构造函数是该类的成员函数,
// 写于此处的代码可访问TIntStack类中的所有区域。
// 源实参(argument source)是TIntStack的一个对象。
_size = source._size;
if (_size > 0) { // 只有size大于0,才分配内存。
_sp = new int[_size]; // 为栈的元素分配内存
_count = source._count; // 栈中元素的数目
// 从“源”中复制所有的元素至栈内
for (int i =0; i < _count; ++i)
_sp[i] = source._sp[i];
}
else {
_sp = 0; // 为指针设置独特的值
_count = 0; // 栈内无元素
}
}`
和其他构造函数一样,在调用复制构造函数时,新栈中的数据成员不存在有意义的值(它们都包含无用单元)。我们必须检查传递给复制构造函数的对象(复制操作的源,对象
图3-1
源)中数据成员的值,并正确地复制这些值。首先,检查源中是否有元素。如果有,就为其分配内存。但不要就此止步,因为我们正在把一个栈复制至另一个栈,真正的深复制(deep copy)操作要求复制所有的元素(浅复制和深复制的概念将在后续章节中介绍)。我们必须遍历栈上的所有元素,并将它们复制到目标栈上。
在复制操作中,如何将问题中的对象映射至复制构造函数的参数上?一图胜千言,下面我们用图来进行说明(见图3-1)。
成员函数只能通过现有对象调用。编译器初始化s2(根据TIntStack s2 = s1
),然后通过s2调用复制构造函数,并把现有对象s1作为复制操作的源,复制给s2。此时,s2的数据成员中包含的是无用单元。在复制操作完成之后,栈s2和栈s1完全一样,如图3-2所示。
现在,用户可以独立地操作s1和s2。即操作s1并不会影响s2,操作s2也不会影响s1。在C++中,也可以这样写:
图3-2
int j;
int k(j); // 相当于int k = j;
char *p(“ABCD”); // 相当于char *p = “ABCD”;```
以上代码是将对象的初始化语法扩展至基本类型。
思考:
当栈很大时,这样的复制操作代价很大(从内存和CPU时间方面来说)。是否可以优化复制操作,不用再重复地复制存储区和元素?要记住,可以独立操作s1和s2,我们将在第4章中介绍解决方案。
`④ TIntStack *dsp = new TIntStack(200)`
在①中,编译器负责为栈对象myStack分配内存(不是为对象中的元素分配内存,构造函数为元素动态分配内存,并将其地址储存在_sp中)。为TIntStack类对象分配的内存包括其数据成员(_size、_count和_sp)的内存,以及编译器需要的其他内部信息。编译器控制这种对象的分配和生存期。在④的声明中,使用了new()操作符,这表明dsp所指向的对象应该通过动态分配内存,在堆(heap)上创建。这种动态分配对象的生存期应该由程序员控制,而非编译器控制。
创建对象时发生了以下3个步骤(无论怎样创建):
(1)编译器需要获得对象所要求的内存数量。
(2)获得的原始内存被转换成一个对象。这涉及将对象的数据成员放置在正确的位置,还有可能建立成员函数指针表(涉及虚函数时)等。这些都在编译器内部进行,程序员完全不用担心(详见第13章)2。
(3)最后,在(1)和(2)都完成后,编译器通过新创建的对象调用构造函数(由类的实现者提供或编译器生成的默认构造函数)。
在静态创建对象的情况下,如果内存已在进程数据区(process data area)或运行时栈(run-time stack)中预留,则只需完成步骤(2)和(3)。如果有动态分配的对象,编译器会请求new()操作符为该对象分配内存3。然后,在新分配的内存上完成步骤(2)和(3)。最后,返回指向该对象(地址储存在dsp中)的指针。为了通过该对象调用成员函数,其语法与C结构中使用的语法非常相似。例如,将数字10压入dsp所指向的栈中,代码如下:
`dsp->Push(10); // 将10压入dsp所指向的栈`
对栈s2进行相同操作,代码如下:
`s2.Push(10);`
这两种方法非常相似。相比较而言,s2.Push(10)更为直接,对象直接被引用。但是dsp->Push(10)也间接引用对象。我们通过指针定位对象,并通过该对象调用Push。->是成员访问操作符(在C中也广泛应用)。->操作符与指向对象的指针一起使用。
###3.3.1 访问对象的数据成员—— C++模型
细心的读者(特别是熟悉Eiffel或Smalltalk的读者)会发现,上面的复制构造函数代码有些奇怪。在Eiffel和Smalltalk中,如果对象调用某成员函数,那么这个成员函数便可以访问该对象中的数据成员,这和C++相同。换言之,成员函数可以访问当前对象的private(在C++中,还包括protected)成员(当前对象正调用该方法)4。这在C++中也完全正确。但是,在上面的复制构造函数示例中,代码还试图访问参数源(一个完全不同的对象)的私有成员_count。在C++对象模型中,保护应用于类层次,而非对象层次。这意味着,只要类的成员函数访问某个对象,它便可以访问同类其他对象的数据成员(和成员函数)。也就是说,X类的对象beta(有一个bar成员函数),可以访问X类的另一个对象alpha内部的任何成员,不受任何限制。这样的访问必须使用圆点语法(或->语法)。然而,Eiffel和Smalltalk强制执行对象层次的保护。也就是说,在Smalltalk和Eiffel中,即使对象都属于相同的类,成员函数也只能访问自己对象(也就是当前实例,即调用成员函数的对象)的私有数据,不能访问其他对象的私有数据。在这些语言中,要访问某个对象(同类或不同类)的私有数据,必须通过该对象的成员函数才行。当前对象没有任何特权可以访问其他对象的私有数据,只能使用提供给普通客户的方法。这是Eiffel和Smalltalk的程序员在学习C++时最容易混淆的地方。
注意:
在C++、Smalltalk和Eiffel中,每个对象都会获得它在类中声明的非静态(即关键字static未出现在声明中)数据成员的副本。然而,Smalltalk和C++支持共享成员的概念。在类中有这样一种成员,无论该类创建了多少对象,在程序运行过程中,只产生一个副本。这样的成员在Smalltalk中称为类变量(class variable)。在C++中,通过使用静态数据成员,也能达到类似的效果。这个概念将在第5章中详细讨论。
`⑤ TIntStack s3 = TIntStack(250);`
实际上,⑤和下面的代码一样:
`TIntStack S2 = S1;`
但是,编译器优化可能会改变这个声明的实现。⑤这个声明表明我们正在请求创建一个TIntStack对象s3,而且为了初始化s3,指定必须创建一个包含250个元素的临时TIntStack类对象。临时对象将在s3创建之后消失。其实,我们不必创建一个临时对象,然后将它复制给s3,这样很浪费时间。为何不直接创建一个大小为250的对象s3?这就是大多数编译器所完成的工作。它们优化语句,并直接创建一个大小为250的TIntStack类对象。
接下来,我们举例说明其余部分。编写一个PrintStack函数,它按顺序从栈中弹出元素,并打印它们。
/*
- 该函数用于打印栈中的所有元素
- 按顺序Pop()元素,并按相同的顺序打印。
- /
- PrintStack(TIntStack thisOne)
{
// 找出栈中元素的个数
unsigned i = thisOne.HowMany();
for (unsigned j = 0; j < i; j++)
cout << “[” << thisOne.Pop() << “]” << endl; // 打印弹出的元素
}`
下面是使用TIntStack类的main()程序。
#include “IntStack.h”
#include <iostream.h>
main()
{
// 写在这里的代码可访问TIntStack类 public区域中的任意成员,
// 但不能访问该类的其他区域。
TIntStack a(5); // 自动对象,退出main函数即销毁。
TIntStack b(20); // 自动对象,退出main函数即销毁。
TIntStack *ip = new TIntStack; // 动态对象
for (int i = 1; i <= 5; i++) { // 连续压入整数
a.Push(i);
b.Push(-i); // 压入负值
}
// 继续通过b执行压入操作
for (; i <= 10; i++) { // 连续压入整数
b.Push(-i); // 压入负值
}
PrintStack(a); // 打印a中包含的信息
a = b; // 将一个栈赋值给另一个栈
PrintStack(*ip);
delete ip; // 为什么要删除?稍后解释
return 0;
}```
现在来分析一下。我们在运行时栈(run-time stack)上静态创建了两个TIntStack类对象,a和b,并且在运行时堆(run-time heap)上创建另一个由指针ip所指向的TIntStack类对象。我们在这些TIntStack中压入了一些数字,然后通过对象a调用PrintStack。
但是,PrintStack函数按值接受参数(即,a按值进行传递)。这说明不得不制作一个a的副本,并将该副本传递给PrintStack。那么,谁负责复制并稍后删除它?下面将详细介绍整个过程。
如果按值传递语言定义类型(如`int`和`char`),则由编译器负责复制和传递它们,并在函数返回后删除它们。但是在以上示例中,我们要按值传递一个程序员定义对象(TIntStack)。编译器如何对其进行复制?很明显,它并不知道关于对象的任何信息,也不知道复制对象需要什么。在这里要注意,我们通过现有对象创建了一个新对象。
正如C++中的定义(前面解释过),无论何时需要对象的副本,编译器都会调用对象所属类(在该例中是TIntStack)的复制构造函数。复制构造函数负责对对象进行有意义且安全的复制。因此,当调用PrintStack时,编译器将调用TIntStack类的复制构造函数制作一个a的副本。
在这种情况下,复制操作的源对象(source object)是调用的实参——a,目的对象(destination object)是PrintStack函数的形参thisOne。从main()中调用PrintStack时,将从实参a初始化形参thisOne。该操作将调用复制构造函数。此时,编译器先通过目的对象调用复制构造函数,并为该复制构造函数提供对象a作为源实参。我们已经知道了复制如何工作(参见前面复制构造函数的代码)。现在,PrintStack函数获得了原始对象的副本,而且,对thisOne的任何改动都不会影响main()中的原始对象。因此,保证了原始数据的安全。
如果离开PrintStack函数,对象thisOne会发生什么情况?在运行时栈分配的一切(自动变量)都将被清除,而且编译器将回收它们所占用的内存。所有的语言定义类型都是这样。因此,从PrintStack退出时,局部变量i和j不在作用域内(即它们在程序控制返回的main作用域内不可用),编译器将回收之前被它们所占用的空间。与此类似,局部对象thisOne不在作用域内,因此也应该回收它所占用的内存。编译器清楚地知道对象本身的大小,但是,它并不知道对象中的由指针_sp所指向的某些动态分配的内存。这时,类的析构函数会派上用场。
无论何时对象离开作用域,在编译器真正回收该对象所占用的内存之前,都会通过对象调用析构函数(类中仅有一个)。析构函数应该释放对象获得的任何资源。在该例中,需要回收TIntStack类对象中指针_sp所指向的内存。注意,虽然析构函数与其他成员函数类似,但是,只有当对象在作用域中不再可见时,编译器才会调用析构函数。[ 需要程序员直接调用析构函数的情况非常少见。] 一旦析构函数执行完毕,在此作用域内的对象就不能再被访问。注意,析构函数只在函数返回之前(或离开代码块之前)被调用。在退出某作用域之前,编译器通过该作用域内(函数或块)已创建的对象调用析构函数。但是,程序员无法使用这些对象,因为对象名在作用域退出时不可见。由于将要被销毁的对象在此时仍然存在,且运行良好,因此,析构函数可以通过它调用该类的其他成员函数。
以下是析构函数的实现:
TIntStack::~TIntStack()
{
cout << “Executing the destructor for TIntStackn”;
delete [] _sp;
}`
该析构函数的代码非常简单。它只用于释放对象在生存期内获得的所有资源(储存在_sp中的是TIntStack类对象所分配的内存)。注意,我们无需担心其他数据成员(_count和_size)所占用的空间,因为编译器知道它们是对象的一部分,会负责处理。当创建TIntStack类对象时,对象的大小包含所有数据成员的大小。编译器不知道指针_sp所指向的内容,但它知道_sp本身的大小。
在析构函数执行完毕后,编译器将进行一些内部清理操作,以释放对象所占用的内存。
注意:
每个类只有一个析构函数。读者可能会奇怪,为什么C++不允许有多个析构函数?为什么不是每个构造函数都有一个析构函数(构造函数-析构函数对)?编译器应该记住调用了哪一个构造函数,稍后再调用与之匹配的析构函数。为了提供这样的功能,需要在对象中储存额外的信息,而且也大大增加了实现者的负担,他们不得不为每个构造函数都编写一个析构函数。如果真有这样的要求,类的实现者会保留一些数据成员,用于跟踪被使用的构造函数,然后在析构函数中使用这些信息,确保操作正确。基本上,不同析构函数(如果语言允许)的代码都会被转移到语言允许的唯一一个析构函数中,类的实现者根据在创建对象时所保存的信息执行正确的代码。
注意到在main程序示例的末尾,通过指针ip调用了delete
。为什么要这样做?原因非常简单。无论何时,只要我们使用动态分配,就明确地表示我们将负责控制它的生存期。编译器不会通过动态分配的元素调用析构函数。ip所指向的对象由new操作符动态分配,编译器不会自动释放它(即编译器不会通过该对象自动地调用析构函数)。当我们不再使用动态分配的对象时,要自行负责处理和销毁。正确的方法是,通过指向对象的指针调用delete
操作符。当我们希望处理动态分配的存储区时,只需通过指向该处的指针调用delete操作符即可,正如我们对数据成员_sp所进行的操作。通过指针调用delete操作符时,将发生以下两步骤:
(1)如果指针是一个指向类的指针(如该例中的ip
),则通过该指针指向的对象调用类(该例中为TIntStack
)的析构函数。此步骤由程序员执行清理工作。
(2)回收指针所引用的内存。
在main中为ip指针调用new()
的步骤,基本与以上顺序相反。
调用析构函数可释放已获得的所有资源,然后,通过delete
操作符释放new()
所分配的内存。这就是在main()程序中调用
delete ip;
背后的原因。
1大多数编译器只在使用时才生成默认复制构造函数。
2 C++对象模型的细节详见第13章。
3后面的章节中将介绍,可由编译器提供new()操作符,或者由类的实现者提供的特化的new()
操作符。
4一定要记住,public
成员访问不受限。
本文仅用于学习和交流目的,不代表异步社区观点。非商业转载请注明作译者、出处,并保留本文的原始链接。