知识屋:更实用的电脑技术知识网站
所在位置:首页 > 科技

C++面向对象编程

发表时间:2022-03-25来源:网络

面向对象软件开发的阶段

面向对象分析(OOA)

面向对象分析(Object-Oriented Analysis,缩写OOA)涉及从类和对象的角度分析问题,这些类和对象都要从问题领域(problem domain)中找出。
本阶段的任务主要是,彻底地分析问题和明确地指定要求。要在客户(真实的客户,人)的问题领域找出类和对象,并用其完整地描述什么方案可行,什么方案不可行。换言之,我们应采用客户能够理解的类和对象来描述问题。这些类和对象都可以直接在问题领域中找到。

面向对象设计(OOD)

OOD(面向对象设计)阶段在OOA(面向对象分析)阶段之后,在本阶段中,我们将在OOA阶段开发的框架中加入细节,待解决的问题将被分解为类和对象用于实现中。本阶段需要完成的任务包括定义类之间的关系、描述对象如何才能适应系统的进程模型、如何跨地址空间划分对象、系统如何完成动态行为等。OOD阶段的成果将更加清楚,而且更容易理解。

OOA和OOD都不是C++语言所特有的,它们是解决任何面向对象问题的基本方法。事实上,OOA 和 OOD 并不依赖于任何语言。

面向对象编程(OOP)

这是面向对象软件开发环节的最后一个阶段。将 OOD 阶段的成果输出,将其输入至OOP 阶段中。这个阶段,将用选定(或根据项目要求指定)的语言编写真正的代码。如前所述,面向对象编程是一种由合作对象(就是类的实例)构成程序的编程方法,可通过继承关系设计出相关联的类。

面向对象编程的特性有:

抽象; 封装和数据隐藏; 多态; 继承; 代码的可重用性。

面向对象程序设计的优点:
1、数据抽象的概念可以在保持外部接口不变的情况下改变内部实现,从而减少甚至避免对外界的干扰;
2、通过继承大幅减少冗余的代码,并可以方便地扩展现有代码,提高编码效率,也减低了出错概率,降低软件维护的难度;
3、结合面向对象分析、面向对象设计,允许将问题域中的对象直接映射到程序中,减少软件开发过程中中间环节的转换过程;
4、通过对对象的辨别、划分可以将软件系统分割为若干相对为独立的部分,在一定程度上更便于控制软件复杂度;
5、以对象为中心的设计可以帮助开发人员从静态(属性)和动态(方法)两个方面把握问题,从而更好地实现系统。

对象模型的关键要素

数据抽象,封装,层次

在OOP中普遍存在两种层次:is-a和has-a。
is-a关系指子类是父类的特殊类型,即特殊化,如候鸟是一种鸟。
has-a关系指被继承类是新类的一部分,如轮胎是汽车的一部分。

对于 OOP,继承是另一项非常重要的特性。不支持继承的语言不能成为面向对象编程语言。某些语言支持数据抽象和封装,但并不支持任何形式的继承。这样的语言不是面向对象编程语言,它们被称为基于对象语言(object-based language),虽然可以实现对象,但是,却无法通过继承扩展它们(如Ada和Modula-2等都属于这个范畴的语言)。

继承是区别基于对象语言和面向对象语言的关键特性。

对象模型的优点:1.代码复用;2.模块化;3.数据保护等。

批注

面向对象和面向过程思想的区别是:
面向过程重在思考使用函数实现功能,思考的重点是函数;
面向对象的基础是类,类从结构体发展而来,因此类的本质应该是一组数据的集合,思考的重点是数据.
而类内的成员函数(方法)即是处理数据的过程.面向对象的所有过程应该思考为类内数据的组成与变更,而类间交互的过程是数据的io,
同时最显著的区别是,面向过程的版面全是函数,面向对象的版面全是类(自定义类型).
模板允许传入参数类型,并生成相应类型的类或函数,模板大量用于容器(存储某种类型的数据),除此之外其行为更像是一个面向过程的通用函数,因为它思考的不是对一块数据的处理,而是对一个功能的通用化实现.
C++也强调了让编译器参与检查代码部分,例如使用const后就可以不用再考虑这块数据的安全性了,因为这相当于告诉编译器这块由它来检查.
另一个思想是将将一切都看做为:输入,输出和权限.函数/对象的输入是参数,输出是返回值,同时如果想使用只读操作,则加const&,如果想执行写操作可使用&操作.指针目前在new和传入c风格字符串外不知道还有什么用,对了指针还有一个独有的特性,可移动(自增自减运算).
成员变量即属性,如ps中的属性面板,这是对象的核心含义,方法是为了改变属性,最终还是要返回属性值的.

OOP术语

在C++中,类的接口作为函数在该类中列出,这些函数称为成员函数(member function);在Smalltalk中,称为方法(method);在Ada中,称为操作(operation)(不要与C++的操作符(operator)混淆)。这些函数提供类的接口,因此也称为接口函数(interface function)。
在C++中,不是函数的元素称为数据成员(data member)。良好的抽象(即设计良好的接口)绝不会把任何数据成员置于public区域。

类(class)和类型(type):
类型有基本类型和复合类型(用户自定义类型);
类(class)就是复合类型(用户自定义类型)。

允许客户设置对象中的数据成员值的方法,通常称为设值方法(setter)。用于返回数据成员值的方法称为获值方法(getter)。

C++中的数据抽象

数据抽象

面向对象编程的一项基本任务是创建带有适当功能的类,并隐藏不必要的细节(即抽象数据)。

数据抽象的目的是,提供清晰和充足的接口,在方便且受控的模式下允许用户访问底层实现。接口应满足用户使用对象的基本需求。我们的唯一目标是:牢记客户,为让她们的生活更加舒适而不懈努力。因此,抽象的首要目标是,为客户简化操作。

如果能理解接口的概念,就很容易理解实现。接口告诉客户可以做什么,实现则负责如何做,所有的工作都在实现中完成。客户无需了解类如何实现接口所提供的操作。因此,实现用于支持由对象表现的接口。

因此,数据抽象,接口和实现都是为了客户的方便和安全。

数据抽象引出了相关的概念:数据封装(data encapsulation)。只要存在由实现支持的带接口的对象,就一定存在实现隐藏(implementation hiding)(也称为信息隐藏)。有些信息对实现而言相当重要,但是对使用接口的用户而言却毫无价值,这些信息将被隐藏在实现中。实现由接口封装,而且接口是访问该实现的唯一合法途径。

对于类来说,接口是Public部分,
对于模块来说,接口是虚基类。

有时,人们谈论的是抽象数据类型(abstract data type),而不是数据抽象(data abstraction),这可能让学习OOP的人感到困惑。其实,它们的关系非常密切。
抽象数据类型是由程序员定义的新类型,附带一组操控新类型的操作。定义新抽象类型将直接应用数据抽象的概念。抽象数据类型也称为程序员定义类型(programmer defined type)。

利用数据抽象,我们创建了一个新类型,并且为这个新类型提供了一组有用的操作。因为语言没有直接支持这个类型,所以程序员只好利用抽象实现它,因此它是一个抽象数据类型。鉴于此,数据抽象有时也被定义为:定义抽象数据类型以及一组操作并隐藏实现的过程。

我们希望让抽象数据类型也拥有和语言定义类型相同的特权和责任(也就是说,不应该让新类型的客户发现语言定义类型和抽象数据类型之间有任何区别)。要达到这个目标,必须让语言支持操作符重载。


C++中数据抽象的基本单元是类(class)。
每个类都有3个不同的访问区域。在我使用过的所有OOP语言中,只有C++精心设计了这3个区域。
public 区域是最重要的区域,为类的用户指定了类的接口。任何客户(任何使用类创建对象或通过继承使用类创建另一个类的人)都可以访问public区域。
作为public区域的对立面,private区域是任何客户都不能直接访问的区域,只供实现使用。换言之,只有类的实现才能访问private区域。
第3个区域是protected区域,用于代码的扩展和复用(继承)。
在一个类中,可以声明多个这些区域(public,protected和private),编译器将负责合并。

C++中类和结构的区别

类和结构之间只有一个微小的差别:如果不予以指定,类中的元素都为private,而结构中的元素都为public。这是C++中,类和结构的唯一区别。

构造函数

构造函数(constructor):所有与类名(本例为 TInt)相同的该类成员函数都称为构造函数,它们用于创建和初始化新对象。
为什么我们需要构造函数?
当然是为了分配内存和初始化默认值。

析构函数

析构函数(destructor):名称与类名相同,且带前缀~的成员函数称为析构函数。
从一个函数(或块)中退出时,编译器将自动销毁在该函数(或块)中创建的对象。但是,对象可能已经聚集了资源(动态内存、磁盘块、网络连接等),这些资源储存在对象的数据成员中,由成员函数操控。由于对象被销毁(退出函数)后不可再用,因此,必须释放该对象储存的资源。
为了帮助对象完成这些工作,在退出函数(或块)时,所有在该函数(或块)中静态创建(即不使用 new()操作符创建)的对象都将调用析构函数。析构函数将释放对象储存的所有资源。换言之,析构函数提供了一种机制,即对象在被销毁前可自行处理掉自身储存的资源。

复制构造函数

复制构造函数(copy constructor):这是一个特殊的构造函数,用于通过现有对象创建新对象,因而称为复制构造函数。
当内置数据类型变量(如int和char)从一个函数按值传递至另一个函数时,由编译器负责复制该变量,并将其副本传递给被调函数(called function)。
如果类的实现者不提供复制构造函数,编译器将会自动生成一个复制构造函数,当然其中所有数据都是值传递行为,这在动态内存中是不允许的。

出现下列情况时,将调用复制构造函数:

对象从一个函数按值传递至另一个函数时; 对象从函数按值返回时; 通过现有对象初始化一个新对象时。

赋值操作符

赋值操作符(assignment operator):复制构造函数用于通过现有对象创建新对象,而赋值操作符用于将现有对象显式赋值给另一现有对象。赋值是用户显式完成的操作。
与复制构造函数相同,赋值操作符也是值传递行为。

对于任何赋值操作符,都应注意以下几点:

确保对象没有自我赋值(如a = a)。 复用被赋值对象中的资源或销毁它。 从源对象中将待复制内容复制到目的对象。 最后,返回对目的对象的引用。

this指针

类的每个成员函数都有一个特殊的指针——this。这个this指针内含调用成员函数的对象的地址(即this指针总是指向目标对象)。this指针只在成员函数内部有效,this是C++中的关键字。
即this指针指向的是对象地址/对象名。

a.Push(i);

通过a对象调用Push成员函数。在Push成员函数内部,this指针持有a对象的地址。以这样的方式,成员函数可以访问对象内的任何元素(数据成员和成员函数)。如第2章所述,编译器像实现其他函数那样,实现每个成员函数,但是,每个成员函数应该可以通过某种方法访问调用它的对象。为达到这个目的,this指针将作为隐藏的参数传递给每个成员函数,且 this 指针通常是函数接收的第1个参数(其后是已声明的其他参数)。

什么时候必须使用 this 指针?当我们希望返回对调用某函数的对象的引用时,必须使用*this;另一种情况是,我们希望获得对象的地址,也必须显式使用this名称。到目前为止,这是显式使用this名称最常见的两种情况;还有一种情况是防止命名冲突时,还有想将对象本身的指针或者引用给别的函数时。

return this; // 返回对象本身的指针 return *this; // 返回对象本身的引用 void a::fun(int x) { this->x=x+1; //此处如果不使用this,将无法区分x属于谁 }

编译器生成的成员函数

当您使用一个对象来初始化另一个对象时,编译器将自动生成上述构造函数(称为复制构造函数,因为它创建对象的一个副本)。
具体地说,C++自动提供了下面这些成员函数:

默认构造函数,如果没有定义构造函数; 默认析构函数,如果没有定义; 复制构造函数,如果没有定义; 赋值运算符,如果没有定义; 地址运算符,如果没有定义。

更准确地说,编译器将生成上述最后三个函数的定义——如果程序使用对象的方式要求这样做。例如,如果您将一个对象赋给另一个对象,编译器将提供赋值运算符的定义。

1. 默认构造函数

如果没有提供任何构造函数,C++将创建默认构造函数。
例如,假如定义了一个Klunk类,但没有提供任何构造函数,则编译器将提供下述默认构造函数:

Klunk::Klunk(){} //上述默认构造函数使Lunk类似于一个常规的自动变量,也就是说,它的值在初始化时是未知的。 Klunk Lunk;

因此为了防止这种现象,必须显示写出默认构造函数,如:

Klunk::Klunk(){ klunk_ct=0; ... }

带参数的构造函数也可以是默认构造函数,只要所有参数都有默认值。例如,Klunk类可以包含下述内联构造函数:

Klunk::Klunk(int n=0){ klunk_ct=n; ... }

因此不要同时声明两种形式的默认构造函数,否则编译器不知道将参数传给谁,会报错!

什么时候会调用默认构造函数呢?
答案是对于未被初始化的对象,程序将使用默认构造函数来创建,也就是说默认构造函数将用于初始化过程中。

//使用默认构造函数 Animal cat; //使用默认构造函数 Animal *dog=new Animal;

2. 复制构造函数

复制构造函数用于将一个对象复制到新创建的对象中。也就是说,它用于初始化过程中(包括按值传递参数),而不是常规的赋值过程中。类的复制构造函数原型通常如下:

Class_name(const Class_name &)

什么时候会调用复制构造函数呢?
1、新建一个对象并将其初始化为同类现有对象时,复制构造函数都将被调用。
例如,假设motto是一个StringBad对象,则下面4种声明都将调用复制构造函数:

StringBad ditto(motto); StringBad metoo=motto; StringBad also=StringBad(motto); StringBad * pStringBad=new StringBad(motto);

其中中间的2种声明可能会使用复制构造函数直接创建metoo和also,也可能使用复制构造函数生成一个临时对象,然后将临时对象的内容赋给metoo和also,这取决于具体的实现。最后一种声明使用motto初始化一个匿名对象,并将新对象的地址赋给pstring指针。

2、每当程序生成了对象副本时,编译器都将使用复制构造函数。
具体地说,当函数按值传递对象或函数返回对象时,都将使用复制构造函数。记住,按值传递意味着创建原始变量的一个副本。
编译器生成临时对象时,也将使用复制构造函数。例如,将3个Vector对象相加时,编译器可能生成临时的Vector对象来保存中间结果。何时生成临时对象随编译器而异,但无论是哪种编译器,当按值传递和返回对象时,都将调用复制构造函数。
{%g%}
由于按值传递对象将调用复制构造函数,因此应该按引用传递对象。这样可以节省调用构造函数的时间以及存储新对象的空间。
{%endg%}

浅复制和深复制
默认的复制构造函数逐个复制非静态成员(成员复制也称为浅复制),复制的是成员的值。
例如,sports是StringBad的实体对象,那么:

StringBad sailor=sports;

将与下面代码等效:

StringBad sailor; sailor.str=sports.str;//@1 sailor.len=sports.len;//@2

需要注意的是,@2的复制是正常的,但@1这里复制的并不是字符串,而是一个指向字符串的指针。也就是说,将sailor初始化为sports后,得到的是两个指向同一个字符串的指针。
当析构函数被调用时,将导致同一块内存被释放两次,即释放已经释放的内存,这将导致不确定后果!另一个症状是,试图释放内存两次可能导致程序异常终止。
因此必须定义一个显示复制构造函数,以进行深复制.

如果类中包含了使用new初始化的指针成员,应当定义一个复制构造函数,以复制指向的数据,而不是指针,这被称为深度复制。复制的另一种形式(成员复制或浅复制)只是复制指针值。浅复制仅浅浅地复制指针信息,而不会深入“挖掘”以复制指针引用的结构.

3. 赋值运算符

将已有的对象赋给另一个对象时,将使用重载的赋值运算符。
初始化对象时,并不一定会使用赋值运算符:

StringBad metoo=motto;

这里,metoo是一个新创建的对象,被初始化为knot的值,因此使用复制构造函数。然而,正如前面指出的,实现时也可能分两步来处理这条语句:使用复制构造函数创建一个临时对象,然后通过赋值将临时对象的值复制到新对象中。这就是说,初始化总是会调用复制构造函数,而使用=运算符时也可能调用赋值运算符。

与复制构造函数相似,赋值运算符的隐式实现也对成员进行逐个复制。如果成员本身就是类对象,则程序将使用为这个类定义的赋值运算符来复制该成员,但静态数据成员不受影响.

赋值运算符会导致和浅复制一样的效果,将两个指针指向同一块地址!

因此必须显示提供赋值运算符以进行深复制!
但是赋值运算符和复制构造函数有些不同,复制构造函数只存在于初始化过程中,而赋值运算符在初始化和赋值过程中都可能存在。
因此为了处理赋值过程,需要做一些调整:

由于目标对象可能引用了以前分配的数据,所以函数应使用delete[ ]来释放这些数据。 函数应当避免将对象赋给自身;否则,给对象重新赋值前,释放内存操作可能删除对象的内容。 函数返回一个指向调用对象的引用。 StringBad & StringBad::operator=(const StringBad & st) { if(this==&st)//自检,避免给自己赋值 { return *this; } delete [] str;//释放旧字符串 len=st.len; str=new char[len+1]; std::strcpy(str,st.str); return *this;//返回指向调用对象的引用 }

如果地址不同,函数将释放str指向的内存,这是因为稍后将把一个新字符串的地址赋给str。如果不首先使用delete运算符,则上述字符串将保留在内存中。由于程序中不再包含指向该字符串的指针,因此这些内存被浪费掉。

参数传递模式

每种方法都应清楚地指明参数的传递模式,参数可以按值、按引用或按指针传递。与const联合使用,参数会更加安全可靠。函数的原型用于向客户传达这些信息。
应尽可能的使用const。

在接下来的示例中,术语主调函数(caller)指的是g()函数(或者main程序),它调用另一个函数f()。在这种情况下,f()就是被调函数(callee),即g()所调用的函数。换言之,主调函数是发起转移控制权的函数,被调函数是接受控制权的函数。

1.void X::f(T arg)  // 第一例,按值传递(pass by value)
被调函数可以对arg(原始对象的副本)进行读取和写入。
在f()内改动arg不会影响f()的主调函数,因为主调函数已提供原始对象的副本。这也许是参数传递最佳和最安全的模式,主调函数和被调函数互相保护。
但是按值传递在复制大型对象非常耗时。

2.void X::f(const T arg)  // 第二例,按值传递
只读型按值传递参数,这种形式没人使用。

3.void X::f(T& arg) // 第一例,按引用传递(pass by reference)
这种形式意味着被调函数可对参数arg进行读写操作。
注意,arg属于主调函数,f()不会销毁它。
通常,在这种情况下,arg 是一个未初始化的对象,仅用于返回值(只是一个输出形参)。

4.void X::f(const T& arg)  // 第二例,按引用传递
这种形式意味着被调函数对参数arg只具备读权利,不具备写权利。
在传递大型对象时,此传递样式为高效之道,强烈推荐使用。

5.void X::f(T argp)*   // 第一例,按指针传递
这种形式在采用语义(adopt semantics)中很有用。它真正的含义是:主调函数将argp所指向的存储区(实际上是资源的生存期)的所有权职责传递给被调函数(即,属于f()的对象)。
主调函数创建了一个T类型的动态对象(可能使用new()),但是主调函数并不知道何时delete该动态对象(这种情况经常出现)。这是因为,被调函数可能仍然在使用它(或主调函数无法删除它),也可能是被调函数希望使用主调函数提供的存储区。在这种情况下,主调函数将argp所指向的对象的所有权职责移交给被调函数。换言之,被调函数采用argp指向的存储区。当被调函数不再需要argp所指向的对象时,要负责删除该对象。

6.void X::f(const T argp)*  // 第二例,按指针传递。
这种形式意味着被调函数不能修改指针内容,但可以进行指针运算(++或--)。

7.void X::f(T const argp)*
这种形式意味着不能移动指针(即不允许对argp 进行运算)。
注意,你也可以删除 argp。虽然无法删除(编译时错误)指向const的指针,但const指针没有这样的限制。

8.void X::f(const T const argp)*
此例为(6)和(7)的组合。被调函数宣称它既不会修改argp所指向的内容,也不会对argp进行任何运算。这意味着,argp是一个只输入形参(in-only parameter)。

为参数选择正确的模式

1.需要传递对象时,不要传递指针。

2.如果被调函数需要将真正的对象作为参数,则传递引用,而不是指针。

3.如果希望被调函数在对象中写入(输出形参),则传递引用,但不是对const的引用。

4.当传递基本类型参数时,要坚持使用值参数(value argument)。

尽量对参数和函数使用const限定符。前面介绍过,编译器能识别const限定符,这样做让编译器也参与其中,使得程序更加强健稳定。

函数返回值

许多函数向主调函数返回值、引用或指针。要正确和高效地使用它们,必须先理解它们的含义。有以下几种模式返回:

T X::f();      // 按值返回T T* X::f();     // 返回T类对象的指针/地址 const T* X::f(); // 返回指向const T类对象的指针 T& X::f();     // 返回对T对象的引用 const T& X::f(); // 返回对const T类对象的引用

1.绝不返回对局部变量的引用(或指向局部变量的指针)。一旦离开函数,局部变量将被销毁,但在此之后,引用(或指针)仍然存在。

2.如果在函数内部创建新对象,并且希望将该对象的所有权移交给主调函数,那么该函数必须返回一个指针。
当被调函数创建了一个新对象(或指向某对象的指针),但却不能控制该对象的生存期时,通常会出现这种情况。为这样的函数使用一种命名约定是个不错的想法(如CreateXXX())。

3.如果不允许主调函数修改返回的指针所指向的字符(或者对象),则返回指向const的指针。
记住:绝不返回指向某个数据成员的非 const 指针。否则,不仅会为你带来不必要的麻烦,而且,还会把实现数据暴露给客户,从而削弱了抽象,破坏了数据封装。
空指针能指出函数本应返回的值出现问题或不存在(如上面示例中的GetNameOfPerson)。这就是为什么从函数返回指针很常见的原因之一。

4.如果要返回一个基本类型(char、int、long等),那么,按值返回和按引用或指针返回效率相同。但是,按值返回较为安全和容易,而且易于理解。

5.要尽可能避免从函数返回引用。

6.如果希望从函数多态返回,唯一的选择就是返回引用或者指针。

新建一个类需要做的事

属性 方法 默认构造函数 含参构造函数 复制构造函数 赋值运算符重写 相等性判断(可选) 有效性判断(可选) function object(括号复写,可选) get()/set() (属性控制,可选) class Agent { Agent(); Agent(Agent const&); Agent& operator=(Agent const&); bool operator==(Agent const&) const; bool operator!=(Agent const&) const; template bool schedule(T_Lambda const&) const; bool isAvailable() const; }

对象及初始化

对象的标识

main() { TPerson person0(“Albert Einstein”, 0, 0, “12-11-1879”); TPerson person1(“12-11-1879”); TPerson* person2 = new TPerson(“10-11-95”); // 动态对象 TPerson* person3 = new TPerson(“6-27-87”); TPerson* person4 = 0; // 未指向person TPerson* person5 = 0; // 未指向person person1.SetName(“Albert Einstein”); person2->SetName(“Foo Bar”); person4 = person3; // 参见下图 }

显然,对象person1是一个独立的对象,它的名称为person1。但是,person2不是对象真正的名称,它表示内存中另外创建的一个无名称的对象。类似地,person3也表示内存中无名称的另一个对象。在涉及 person2 所表示的对象名时,我们可以通过 *person2 间接地表示的该对象名。
在该例中,识别对象很容易,但并不是通过它们的名称来识别。严格来说,只有person0和person1是对象的名称。而person2、person3是指向内存中匿名对象的指针,person4和person3都表示相同的对象。
记住,person3指定了一个唯一的对象。此时,该对象获得了一个名为person4的别名。现在,如果我们操作person3或person4所表示的对象,实际上是在操作同一个对象。实际上,我们现在已经在两个名称之间共享了一个对象,即共享了对象的结构(因此也共享了状态)。

初始化

初始化是在创建变量(或常量)时,向变量储存已知值的过程。这意味着该变量在被创建(无论以何种方式)时即获得一个值。
如果在创建变量时未进行初始化:

int i;

根据C++(和C)中的定义,i中的值是未定义的。该值就是在创建i的内存区域中所包含的值(在运行时栈上),没人知道是什么。

一定要记住,用合适的值初始化对象的所有数据成员。

默认情况下,C++编译器不会初始化对象的任何数据成员。即使是类的默认构造函数,也不会为对象的数据成员储存任何预定义的值。

使用初始化语法是初始化内嵌对象和const变量的唯一方法,同时初始化语法也可以用于初始化普通变量。

深复制

术语浅复制和深复制源自Smalltalk,这些术语通常用于描述复制语义。一般而言,深复制操作意味着递归地复制整个对象,而浅复制则意味着在复制对象的过程中,源对象和副本之间只共享状态(只传递值)。
如果对象不包含任何指针和引用,浅复制完全满足需要。
对于指针,浅复制只是复制了指针(地址),即创建了指针别名,而我们希望在复制操作完成后,源对象和目的对象之间不会共享任何东西。
每个类都需要提供具有深复制的复制构造函数和赋值运算符。

深复制样例:

class CA { private: int a; char *str; public: CA(int b,char* cstr) { a=b; str=new char[b];//深复制--分配内存 strcpy(str,cstr);//深复制--转移内容 } ... }

写时复制(copy-on-write):在对资源进行写入之前,资源(在该例中就是字符)是共享的。当某共享资源的对象试图在资源中写入时,就制作一个副本。

注意:

一定要完全初始化对象。所有构造函数都应确保用合适的值初始化所有数据成员。

一定要为所有的类都实现复制构造函数、赋值操作符和析构函数。由编译器生成的默认版本在实际的商业级程序中几乎没用。

充分理解无用单元收集和悬挂引用的概念,确保设计的类不会发生内存泄漏。

正确理解对象的标识,不要混淆指向对象的指针和真正的对象。

为类提供复制和赋值(如果有意义的话)。在类不允许复制和赋值语义的地方,关闭(或控制)复制和赋值。

如果设计的实现将用于多线程系统中,应确保引用计数是多线程安全的。

为了让实现更加高效,使用“写时复制”的方案。

用复制构造函数操作代替使用默认构造函数后立即使用赋值的操作。

相等性判断

显然,赋值和复制是类的设计者和实现者必须考虑的重要问题。另一个相关问题也同等重要,即对象相等(object e)的概念。

首先要区别一下相等和等价的概念。对象相等要比较对象的结构和状态,而对象等价(object e)则要比较对象的地址。两个不同的对象可能相等,但是不允许它们是同一个对象。

在上图中,person2和person3是相等对象,而person3和person4是等价对象。

在处理对象时,不同的语言定义对象相等的方式不同。例如,C++对于对象等价并未定义任何默认的含义,而Eiffel和Smalltalk则定义了默认含义。再者,不同程序员对对象间相等的解释也不同。接下来,先介绍基于引用的语言如何表示对象等价和对象相等。

Smalltalk

Smalltalk为等价判断提供了方法,所有对象都可以使用该方法。如果方法返回值为 true,则待比较的两个对象是相同的对象(它们等价)。换言之,这两个对象是对相同对象的不同引用。为了判断对象是否相等,Smalltalk 提供了=方法。该方法通过比较两个对象中相应实例变量的值来实现。任何加入新实例变量的类都需要重新实现这个方法。例如,比较链表对象要涉及比较链表的长度,以及比较链表中的每个元素是否相等。这与递归导航整个对象树的深复制操作非常类似。与==对应的是~~,它用于判断两个对象是否不等价。与此类似,=对应的是~=,即不相等操作符。

C++

C++与Smalltalk和Eiffel完全不同,这可以理解。C中定义的比较操作符是,它用于比较值(C 中不使用操作符来比较结构)。C++中并未定义默认的比较机制。在需要使用类的比较语义时,由设计者负责实现操作符== 和在重载操作符==函数中提供正确的比较语义。比较指针与比较整数类似,而且也是语言的一部分。

另一个需要实现的操作符是不相等操作符!=。如果类实现了,则最好也实现!=。成对实现操作符可以保证在比较对象时,两操作符中只有其中之一(或!=)为真。如果缺少其中一个,类的接口则看起来就不完整,而且即使使用另一个操作符更加切合实际,客户也只能被迫使用类所提供的不成对的操作符。

记住:

如果对象需要比较语义,要实现==操作符。 如果实现==操作符,记住要实现!=操作符。 还需注意,==操作符可以是虚函数(将在第5章中讨论),而!=操作符通常是非虚函数。

Smalltalk有一个与对象散列值(hash value)相关的概念。每个类都支持hash方法,作为类本身基本运算的一部分。该方法为每个对象都返回一个唯一的整数。任何相等的两个对象都会返回相同的散列值。但是,不相等的对象也可以(或不可以)返回相同的散列值。通常,该方法用于链表、队列等中的快速查找对象。即使 C++和 Eiffel 都未提供这样的方法作为语言的一部分,但许多商业软件产品仍提供hash成员函数。而且,在许多实现中,系统中的每个类都要求提供hash方法的实现。语言结构被用于强制执行这样的限制。有时,某些方法也要求强制执行这样的限制,如 isEqual()和 Clone()方法。决定哪些固有方法需要所有类的支持是设计的难点,任何设计团队都应在早期设计阶段处理这些问题。

无用单元收集问题

所谓无用单元(garbage),是一块存储区(或资源),该存储区虽然是程序(或进程)的一部分,但是在程序中却不可再对其引用。按照 C++的规定,我们可以说,无用单元是程序中没有指针指向的某些资源。以下是一个示例:

main() { char* p = new char[1000]; // 分配一个包含1000个字符的动态数组 char* q = new char[1000]; // 另一块动态内存 // 使用p和q进行一些操作的代码 p = q;   // 将q赋值给p,覆盖p中的地址 /* p所指向的1000个字符的存储区会发生什么?此时,p和q指向相同的区域, 没有指针指向之前p指向的旧存储区!该储存区还在,仍然占用着空间, 但程序却不可访问(使用)该区域。这样的区域则称为无用单元。*/ }

现在,在main()中为p分配的内存便是无用单元,因为它仍然是正在运行程序的一部分,但是,所有对它的引用都被销毁了。
无用单元不会立即对程序造成损害,但它将逐渐消耗内存,最终耗尽内存导致系统中止运行。

当指针所指向的内存被删除,但程序员认为被删除内存的地址仍有效时,就会产生悬挂引用(dangling reference)。例如:

main() { char *p; char *q; p = new char[1024]; // 分配1k字符的动态数组 // ... 使用它 q = p; // 指针别名(pointer aliasing) delete [] p; p = 0; // 现在q是一个悬挂引用,如果试图 *q = ‘A’,将导致程序崩溃。 }

如果试图访问q所指向的内存,将引发严重的问题。在该例中,指针q称为悬挂引用。指针别名(即多个指针持有相同的地址)通常会导致悬挂引用。与无用单元相比,悬挂引用对于程序而言是致命的,因为它必定导致严重破坏(大多数可能是运行时崩溃)。

继承

面向对象编程的主要目的之一就是提供可重用的代码。
尽管复制源码进行修改也可以实现代码重用,但是C++提供了更好的方法--继承,可以不用复制源码便可进行扩展和修改。

最原始的类叫做基类,继承基类的类叫做派生类。

要正确地使用继承,必须充分理解继承(或is-a)关系的含义。is-a关系意味着基类与派生类之间的一般/特殊的关系。
基类是一般类,派生类可以扩展基类或者覆写基类方法。

基类

class Person { public: Person(string name,int age): m_name(name),m_age(age) { } void speak(){ cout
收藏
  • 人气文章
  • 最新文章
  • 下载排行榜
  • 热门排行榜