《C++面向对象高效编程(第2版)》——3.3 复制构造函数

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()元素,并按相同的顺序打印。
  • /
  1. 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成员访问不受限。
本文仅用于学习和交流目的,不代表异步社区观点。非商业转载请注明作译者、出处,并保留本文的原始链接。

时间: 2024-09-16 05:09:14

《C++面向对象高效编程(第2版)》——3.3 复制构造函数的相关文章

《C++面向对象高效编程(第2版)》——4.2 无用单元收集问题

4.2 无用单元收集问题 C++面向对象高效编程(第2版) 在我们讨论无用单元收集1(garbage collection)之前,先了解一下何为无用单元(garbage),何为悬挂引用(dangling reference). 4.2.1 无用单元 所谓无用单元(garbage),是一块存储区(或资源),该存储区虽然是程序(或进程)的一部分,但是在程序中却不可再对其引用.按照C++的规定,我们可以说,无用单元是程序中没有指针指向的某些资源.以下是一个示例: main() { char* p =

《C++面向对象高效编程(第2版)》——4.6 对象赋值的语义

4.6 对象赋值的语义 C++面向对象高效编程(第2版) 赋值与复制的操作非常类似.在C++中,绝大多数的复制操作都由语言隐式调用(当对象按值传递或按值返回时).当通过现有对象创建新对象时,也进行了复制操作(但不是很频繁).与复制相反的是,赋值是必须由程序员显式调用的操作.然而,在Eiffel和Smalltalk中,赋值和复制操作都由程序员显式调用.这也是基于值的语言与基于引用的语言之间的区别. 在C++中,对于对象和基本类型赋值都具有相同的含义.把基本类型变量赋值给另一个(兼容的)基本类型变量

《C++面向对象高效编程(第2版)》——2.21 确保抽象的可靠性——类不变式和断言

2.21 确保抽象的可靠性--类不变式和断言 C++面向对象高效编程(第2版) 任何抽象都必须与客户履行它的契约(contract).当客户使用类时,他希望类的对象像其发布描述的那样运行正常.另一方面,类的实现者必须千方百计地确保对象运行正常.但是,类只有在客户履行自己那部分契约后,才能正确行使它的职责.例如,类的成员函数可能要求传入的参数为非零指针(non-zero pointer).只有满足此前提条件,成员函数才能保证它的行为.因此,客户必须履行一些义务.换言之,如果客户履行了她那部分契约,

《C++面向对象高效编程(第2版)》——2.30 has-a关系的重要性

2.30 has-a关系的重要性 C++面向对象高效编程(第2版) "has-a"关系(也称为关联.聚集.包含.组合)是在OOD(面向对象设计)中频繁使用的重要关系.但是,许多设计者和程序员都没有很好地理解其相关性,从而导致复杂僵化的设计和不必要的继承. 在OOD阶段,软件的复用主要通过两种方式完成:继承和包含.它们各有优缺点.优秀的设计者了解它们的局限性.优点和代价,可以灵活自如地应用它们.继承将在第5章.第6章以及第二部分的第12章中详细讨论. 包含是一项强大的设计技术,它比继承更

《C++面向对象高效编程(第2版)》——2.27 关联

2.27 关联 C++面向对象高效编程(第2版) 关联表示对象与不同类之间的结构关系(structual relationship),大多数关联都是二元关系(binary relation).类之间的多重关联(multiple association)和类本身的自关联(self association)都是合法的(见图2-19). 关联可以有一个名称,表明阅读方向的箭头为可选.注意,方向箭头为可选,但关联名必须显示.关联在不同的方向可以有不同的名称,但是,大多数情况下,没必要注明(特别是在已标出

《C++面向对象高效编程(第2版)》——3.2 类要素的细节

3.2 类要素的细节 C++面向对象高效编程(第2版) 3.2.1 访问区域 客户可以访问在类的public区域中声明的任何成员.我们可以把该区域看做是通用公共(general public)的接口,它没有任何保护,是类限制最少的区域.一个设计良好的类绝不会将数据成员包含在public区域,该区域只能包含成员函数.如果在public区域包含数据成员,那么无需类的实现者,仅通过编译器即可访问这些数据成员.这违反了数据抽象和封装原则.这也是我们为什么总将数据成员放在private或protected

《C++面向对象高效编程(第2版)》——第2章 什么是数据抽象

第2章 什么是数据抽象 C++面向对象高效编程(第2版) 面向对象编程的一项基本任务是创建带有适当功能的类,并隐藏不必要的细节(即抽象数据).下面,我们将用一个现实生活中的例子来解释数据抽象的概念. 绝大多数人都见过影碟播放机(laser disc player)(或LD播放机).现在,提出一个简单的问题:设计一个影碟播放机,要求易于使用和修改,可后续添加更多有用的功能. 注意: 如果难以理解影碟播放机,可以用CD播放机代替LD播放机,其设计原理类似.实际上,影碟播放机的功能是CD播放机功能的超

《C++面向对象高效编程(第2版)》——1.5 什么可以作为类

1.5 什么可以作为类 C++面向对象高效编程(第2版) 用简单的例子详细讨论类和对象非常容易,但是难点在于如何为给定的问题找出合适的类.我们必须理解类代表什么,何时将问题中的某些部分转化为类,而非数据,反之亦然.根据我们的定义,类拥有一组对象的共同属性(或者特性).怎样的共同才是共同?何时说这是一个类,而不是另一个类的对象?这些都是我们在学习OOP时会遇到的,和真正关心的问题. 当我们决定创建一个类时,第一个问题就是"是否确实需要这个类的多个实例?",如果答案为"是&quo

《C++面向对象高效编程(第2版)》——4.3 C++中的无用单元收集

4.3 C++中的无用单元收集 C++面向对象高效编程(第2版) C++提供类的析构函数专门处理无用单元收集,但是,这并不意味着无用单元收集只发生在析构函数中.实际上,某些其他成员函数也必须考虑无用单元收集. 类的析构函数给予对象最后一次机会释放它所获得的所有资源.在退出某作用域之前,由语言自动为在该作用域中创建的自动(基于栈)对象调用析构函数.此时,对象即将被销毁(也就是说,被对象占用的内存即将被系统回收).一旦析构函数完成,对象将彻底地消失. 删除(使用delete操作符)指向某对象的指针时

《C++面向对象高效编程(第2版)》——2.10 抽象数据类型—栈的实现

2.10 抽象数据类型-栈的实现 C++面向对象高效编程(第2版) 下面的示例用于说明,在C中一个简单栈的实现. Stack.h文件--让所有的抽象数据类型用户都可以使用Stack. typedef Stack* Stackld; typedef int bool; struct Stack { int data; / 在栈上存储元素 */ unsigned count; / 栈上元素的数量 / int top; / 栈顶部的指针 */ / 略去其他细节 .../ };``` Stack.c文件