面向对象软件开发的阶段
面向对象分析(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 <typename T_Lambda>
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 equality)的概念。
首先要区别一下相等和等价的概念。对象相等要比较对象的结构和状态,而对象等价(object equivalence)则要比较对象的地址。两个不同的对象可能相等,但是不允许它们是同一个对象。
在上图中,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<< "My name is:" << m_name <<endl;
}
string getName()
{
return m_name;
}
int getAge()
{
return m_age;
}
private:
string m_name;
int m_age;
};
派生类
创建派生类对象时,程序首先调用基类构造函数,然后再调用派生类构造函数。基类构造函数负责初始化继承的数据成员;派生类构造函数主要用于初始化新增的数据成员。派生类的构造函数总是调用一个基类构造函数。可以使用初始化器列表语法指明要使用的基类构造函数,否则将使用默认的基类构造函数。
派生类对象过期时,程序将首先调用派生类析构函数,然后再调用基类析构函数。
class Student: public Person
{
public:
Student(int stuid):
Person("张三",20),m_stuid(stuid)
{
}
void write()
{
cout<<"My id is:"<< m_stuid << endl;
}
private:
int m_stuid; //学号
};
上述代码完成了哪些工作呢?
- 派生类对象存储了基类的数据成员(派生类继承了基类的实现);
- 派生类对象可以使用基类的方法(派生类继承了基类的接口)。
需要在继承特性中添加什么呢?
- 派生类需要自己的构造函数。
- 派生类可以根据需要添加额外的数据成员和成员函数.
构造函数处使用了初始化列表方式,初始化列表语法相比赋值语句可减少一个步骤,因此执行更快。
访问权限
派生类不能直接访问基类的私有成员,而必须通过基类方法进行访问。如上述的 getName()和 getAge() 。
派生类构造函数必须使用基类构造函数。如果不调用基类构造函数,程序将使用默认的基类构造函数。如 Student(int stuid) 。
成员初始化列表
派生类构造函数可以使用初始化器列表机制将值传递给基类构造函数。请看下面的例子:
derived::derived(type1,type2):base(x,y)
{
...
}
其中derived是派生类,base是基类,x和y是基类构造函数使用的变量。例如,如果派生类构造函数接收到参数10和12,则这种机制将把10和12传递给被定义为接受这些类型的参数的基类构造函数。除虚基类外,类只能将值传递回相邻的基类,但后者可以使用相同的机制将信息传递给相邻的基类,依此类推。如果没有在成员初始化列表中提供基类构造函数,程序将使用默认的基类构造函数。成员初始化列表只能用于构造函数。
什么不能被继承?
构造函数和析构函数不能被继承。
基类和派生类的关系
1.派生类与基类之间有一些特殊关系。其中之一是派生类对象可以使用基类的方法,条件是方法不是私有的。
2.基类指针/引用可以在不进行显式类型转换的情况下指向/引用派生类对象。(即派生类可以转化为基类,但基类指针或引用只能用于调用基类方法。)
多态
有时我们希望同一个方法在派生类和基类中的行为是不同的,即派生类对基类的方法进行修改,这种行为就是多态。有两种重要的机制可用于实现多态公有继承:
1.在派生类中重新定义基类的方法。
2.使用虚方法。
如果要在派生类中重新定义基类的方法,通常应将基类方法声明为虚的。这样,程序将根据对象类型而不是引用或指针的类型来选择方法版本。为基类声明一个虚析构函数也是一种惯例。
通常应给基类提供一个虚析构函数,即使它并不需要析构函数。
静态联编和动态联编
程序调用函数时,将使用哪个可执行代码块呢?编译器负责回答这个问题。将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编(binding)。在C语言中,这非常简单,因为每个函数名都对应一个不同的函数。在C++中,由于函数重载的缘故,这项任务更复杂。编译器必须查看函数参数以及函数名才能确定使用哪个函数。然而,C/C++编译器可以在编译过程完成这种联编。在编译过程中进行联编被称为静态联编(static binding),又称为早期联编(early binding)。然而,虚函数使这项工作变得更困难。正如在程序清单13.10所示的那样,使用哪一个函数是不能在编译时确定的,因为编译器不知道用户将选择哪种类型的对象。所以,编译器必须生成能够在程序运行时选择正确的虚方法的代码,这被称为动态联编(dynamic binding),又称为晚期联编(late binding)。
如果动态联编让您能够重新定义类方法,而静态联编在这方面很差,为何不摒弃静态联编呢?原因有两个——效率和概念模型。
首先来看效率。为使程序能够在运行阶段进行决策,必须采取一些方法来跟踪基类指针或引用指向的对象类型,这增加了额外的处理开销(稍后将介绍一种动态联编方法)。例如,如果类不会用作基类,则不需要动态联编。同样,如果派生类(如RatedPlayer)不重新定义基类的任何方法,也不需要使用动态联编。在这些情况下,使用静态联编更合理,效率也更高。由于静态联编的效率更高,因此被设置为C++的默认选择。Strousstrup说,C++的指导原则之一是,不要为不使用的特性付出代价(内存或者处理时间)。仅当程序设计确实需要虚函数时,才使用它们。
接下来看概念模型。在设计类时,可能包含一些不在派生类重新定义的成员函数。例如,Brass::Balance( )函数返回账户结余,不应该重新定义。不将该函数设置为虚函数,有两方面的好处:首先效率更高;其次,指出不要重新定义该函数。这表明,仅将那些预期将被重新定义的方法声明为虚的。
访问控制:protected
关键字protected与private相似,在类外只能用公有类成员来访问protected部分中的类成员。private和protected之间的区别只有在基类派生的类中才会表现出来。派生类的成员可以直接访问基类的保护成员,但不能直接访问基类的私有成员。因此,对于外部世界来说,保护成员的行为与私有成员相似;但对于派生类来说,保护成员的行为与公有成员相似。
最好对类数据成员采用私有访问控制,不要使用保护访问控制;同时通过基类方法使派生类能够访问基类数据。
抽象基类
抽象基类(abstract base class,ABC)。
ABC描述的是至少使用一个纯虚函数的接口,从ABC派生出的类将根据派生类的具体特征,使用常规虚函数来实现这种接口。
要成为真正的ABC,必须至少包含一个纯虚函数。原型中的=0使虚函数成为纯虚函数。
void move(int x,int y)=0;
可以将ABC看作是一种必须实施的接口。ABC要求具体派生类覆盖其纯虚函数——迫使派生类遵循ABC设置的接口规则。这种模型在基于组件的编程模式中很常见,在这种情况下,使用ABC使得组件设计人员能够制定“接口约定”,这样确保了从ABC派生的所有组件都至少支持ABC指定的功能。
构造函数初始化列表
构造函数初始化列表以一个冒号开始,接着是以逗号分隔的数据成员列表,每个数据成员后面跟一个放在括号中的初始化式。例如:
//@1
class A
{
public:
int a;
float b;
A(): a(0),b(9.9) {} //构造函数初始化列表
};
//@2
class A
{
public:
int a;
float b;
A() //构造函数内部赋值
{
a = 0;
b = 9.9;
}
};
上面的例子中两个构造函数的效果是一样的。使用初始化列表的构造函数是显示地初始化类的成员;而没有使用初始化列表的构造函数是对类的成员赋值,并没有显示地初始化。
初始化列表的构造函数和内部赋值的构造函数对内置类型的成员没有什么大的区别,像上面的任一个构造函数都可以。
用构造函数的初始化列表来进行初始化,写法方便、简练,尤其当需要初始化的数据成员较多时更显其优越性。对非内置类型成员变量,推荐使用类构造函数初始化列表。
但有的时候必须用带有初始化列表的构造函数:(1)没有默认构造函数的成员类对象;(2)const成员或引用类型的成员。
构造函数中有着比我们所看见的还要多的细节,构造函数可以调用其它的构造函数来初始化对象中的基类对象和成员对象的构造函数。
类的数据成员中的其它类对象,若该成员类型是没有默认构造函数,则必须进行显示初始化,因为编译器会隐式调用成员类型的默认构造函数,而它又没有默认构造函数,则编译器尝试使用默认构造函数将会失败。
class A
{
public:
A (int x)
{
i = x; // 无默认构造函数
}
private:
int i;
};
class B
{
public:
B(int y)
{
j = y; // error C2512: “A”: 没有合适的默认构造函数可用
}
private:
A a;
int j;
};
int main( )
{
B b(5);
return 0;
}
B类数据成员中有一个A类对象a,创建B类对象时,要先创建其成员对象a;A类有一个参数化大的构造函数,则编译器不会提供默认无参数的构造函数,因此a无法创建。
对成员对象正确的初始化方法是通过显示方式进行,B的构造函数应该写成:
B(int y, int z) : a(z)
{
j = y;
}
B b(5,10);
构造函数初始化列表是初始化常数据成员和引用成员的唯一方式。因为const对象或引用类型只能初始化,不能对他们赋值。
class A
{
public:
A (int x,int y) : c(x),j(y) // 构造函数初始化列表
{
i = -1;
}
private:
int i;
const int c;
int& j;
};
int main( )
{
int m;
A a(5,m);
return 0;
}
若不通过初始化列表来对常数据成员和引用成员进行初始化:
class A
{
public:
A (int x) // 构造函数初始化列表
{
i = -1;
c = 5;
j = x;
}
private:
int i;
const int c; // error C2758: “A::c”: 必须在构造函数基/成员初始值设定项列表中初始化
int& j; // error C2758: “A::j”: 必须在构造函数基/成员初始值设定项列表中初始化
};
缺省情况下,在构造函数的被执行前,对象中的所有成员都已经被它们的缺省构造函数初始化了。当类中某个数据成员本身也是一个类对象时,我们应该避免使用赋值操作来对该成员进行初始化:
class Person
{
private:
string name;
public:
Person(string& n)
{
name = n;
}
}
虽然这样的构造函数也能达到正确的结果,但这样写效率并不高。当一个Person对象创建时,string类成员对象name先会被缺省构造函数进行初始化,然后在Person类构造函数中,它的值又会因赋值操作而在改变一次。我们可以通过初始化列表来显示地对name进行初始化,这样便将上边的两步(初始化和赋值)合并到一个步骤中了。
class Person
{
private:
string name;
public:
Person(string& n): name(n){}
}
多重继承
C++的一个主要目标是促进代码重用。公有继承是实现这种目标的机制之一,但并不是唯一的机制。
MI描述的是有多个直接基类的类。与单继承一样,公有MI表示的也是is-a关系。
请注意,必须使用关键字public来限定每一个基类。这是因为,除非特别指出,否则编译器将认为是私有派生。
假设Worker是基类,
class Singer:public Worker{};
class Waiter:public Worker{};
class SingingWaiter : public Singer,public Waiter{};
那么将派生类对象的地址赋给基类指针,但现在将出现二义性。
通常,这种赋值将把基类指针设置为派生对象中的基类对象的地址。但ed中包含两个Worker对象,有两个地址可供选择,所以应使用类型转换来指定对象:
SingingWaiter ed;
Worker * pw1=(Singer *)&ed;
Waiter * pw1=(Singer *)&ed;
is-a,has-a,包含类和嵌套类
is-a即继承关系,子类是父类的特化,如特斯拉是一种车,另外多重继承也是is-a关系;
has-a,子类是父类的组成部分,如轮胎是车的一部分,包含关系通常使用包含和私有继承来实现;
包含类是指类以对象的方式存在于另一个类内,如string temp,有时也会作为成员变量来使用;
嵌套类是指在类内定义类,这时嵌套类只在类内有效,不同于包含关系,嵌套类在类内定义并在类内实例化使用。
多线程安全
传统上,操作系统(OS)只支持进程(也称为任务)。每个进程都有自己的地址空间,且有一个单独的执行线程,进程执行一个包含一系列指令的程序。但是,现在大多数操作系统都支持单进程中的多线程。一个任务可根据需要包含多个线程,单进程中的所有线程共享进程的地址空间,线程可以访问进程中的所有全局数据。
使用线程有很多优点。首先,创建线程的成本更低(且更效率)。创建一个新进程需要涉及操作系统中的大量工作(设置内存页面、注册、进程上下文等),而线程需要的大多数资源都可以从进程中获得。其次,线程间的通信更加容易。不同的进程位于不同的地址空间中,因此进程之间的通信(IPC)并不容易。但是,在单个进程中,不同的线程共享相同的地址空间,因此线程之间的通信非常容易。另外,在多线程中,可以阻止(block)操作,也可以并行处理操作。操作系统单独调度(schedule)每一个线程。例如,有一个应用程序,允许从一个设备上复制文件到另一个设备上,用户可能要求在中途停止复制操作。如果创建一个仅用于等待用户输入的单独进程,显然很不合理。在这种情况下,创建一个等待用户输入的单独线程更加合适。主应用程序执行复制操作,而辅助线程等待用户输入。如果用户决定终止复制操作,则运行等待用户输入的线程,并通知主应用程序,用户要求中止操作;然后,主应用程序线程中止复制操作。在单个应用程序中使用多线程非常普遍。再举另外一例,文档处理应用程序可以用一个线程打印文档,而另一个线程执行生成索引,同时还有另一个线程用于接收用户提供的文档摘要信息。
任何使用多线程的应用程序都可称为多线程应用程序。涉及多个线程时,同步(synchronization)和互斥(mutual exclusion)尤为重要。当一个线程正在访问某段数据时(例如,打印文档的线程),必须防止其他线程试图访问相同的数据(为了写入)。根据操作系统,实现可以使用互斥体(mutex)、信号量(semaphore)、临界区(crical section)、自旋锁定(spin-lock)、消息、事件等来达到这个目的。例如,文档中的每一页都由一个互斥体来保护,只允许一个线程访问该页。无论用何种访问控制的方案,实现必须确保不会发生死锁(deadlock)情况。
应用程序(或系统)在多线程运行的环境中正常运行称为多线程安全(multi-thread safe)。确保多线程安全并不容易,实现必须使用之前提及的某种同步方案来实现互斥。
我们在这里讨论的并不是新内容,只有在涉及多进程时,同步才是个问题。不管怎样,一个进程不能访问另一个进程地址空间内的内容,这使得同步稍微容易一些。但是,进程中的线程共享进程所拥有的资源。因此,确保适当的同步非常重要。例如,在文档处理的应用程序中,如果打印线程已锁定页面,索引线程在访问相同页面之前必须等待,直到打印线程解锁页面。
在使用引用计数(reference counting)(也称为使用计数(use count))方案的情况下,多线程安全非常关键。引用计数方案将在后续章节中讨论。修改引用计数必须是一个线程安全的操作。
在多线程环境中使用对象时,多线程安全更加重要。如果不能确保多线程安全,可能会导致灾难。一个进程内的两个线程可以使用相同的对象。记住,所有对象都共享成员函数代码。当一个线程调用一个成员函数,在成员函数内部完成执行之前,如果(操作系统)调度(schedule)另一个线程运行,且该线程也通过相同的对象调用相同的成员函数,则对象必须保证自身完整和运行良好。如果对象不能做到这一点,这样的类就不是线程安全(thread-safe)的。当然,如果一个类(成员函数和数据成员)没有任何线程安全的特殊要求,维持线程安全就完全不成问题。
在设计新的类时,注意多线程安全非常重要。如果类的对象即使在多线程环境下都能保持完整,必须在类的文档中予以说明。另一方面,如果类的对象不保证多线程安全,也要在类的头文件和文档中清楚地说明其局限性。不要误认为设计的每个类都必须保证多线程安全,事实并非如此。是否需要线程安全取决于类和客户的要求。还需记住,X类如果使用其他类作为它实现的一部分,为保证 X 类为线程安全,有必要保证它使用的其他类都为线程安全。或者,即使它所依赖的其他类非线程安全,至少必须保证 X 类线程安全(这更加困难)。为达到线程安全,下面列举了一些指导原则:
(1)如果类声明为线程安全,确保每个成员函数实现也是线程安全的。
(2)如果类在实现中使用其他的类(对象),确保仍然能保证线程安全。
(3)如果使用一些类库来实现类,确保正在使用的库函数是线程安全的。
(4)如果正在使用操作系统调用,检查以确保这些调用都是线程安全的。
(5)当使用编译器提供的库时,检查它们是否都是线程安全的。
许多库的供应商提供辅助类,用于帮助达到线程安全。例如,查看提供线程安全引用计数的类十分常见。如果你的项目需要线程安全,它可能会帮助实现一组确保线程安全的低级类。这样的类可以提供引用计数、线程安全指针、线程安全打印实用程序等。如果整个项目小组都在各自的实现中使用这些类,就能保证整个项目的线程安全。
断言和不变式
断言(assertion)
是一个用于评估真假的表达式。如果表达式评估为假,则断言失败。
每个类都会在对象中包含一些恒为真的条件,无论对象调用任何成员函数,这些条件都必须为真。这样的条件称为类不变式(class invariant)
。类不变式就是在对象的生命期内,必须保证对象状态的语句。
在进入和退出每个操作(成员函数)时,都必须检查类不变式。
其他
new/delete
指针真正的用武之地在于,在运行阶段分配未命名的内存以存储值。在这种情况下,只能通过指针来访问内存。在C++中可以使用new
操作符来实现。
Test* pTest = new Test()
程序员需要告诉new需要分配多大的空间,即数据类型Test,然后new去自动寻找Test大小的内存,并返回内存首地址。
pTest指针获取了内存首地址,又知道内存大小(Test),所以就可以锁定数据了。
new的好处是:1.主动管理变量的生命周期;2.运行时申请内存。即动态分配和手动管理内存。
因此使用new的场景有:
1.创建动态数组;
2.对于大型数据类型(一般为自定义类型和字符串),在静态存储区上分配内存。
通常,对于大型数据(如数组、字符串和结构),应使用new,这正是new的用武之地。例如,假设要编写一个程序,它是否需要数组取决于运行时用户提供的信息。如果通过声明来创建数组,则在程序被编译时将为它分配内存空间。不管程序最终是否使用数组,数组都在那里,它占用了内存。在编译时给数组分配内存被称为静态联编(static binding),意味着数组是在编译时加入到程序中的。
但使用new时,如果在运行阶段需要数组,则创建它;如果不需要,则不创建。还可以在程序运行时选择数组的长度。这被称为动态联编(dynamic binding),意味着数组是在程序运行时创建的。这种数组叫作动态数组(dynamic array)
。
main.cpp
#include<iostream>
#include<cstring>
using namespace std;
char* getname(const char*);
int main()
{
char* name;
name=getname("Boss Chen"); //pn与name共享内存地址,但函数退出后,pn会被销毁,所以只有name在管理内存,因此需要detele[] name
cout<<"you entered:"<<name<<endl;
delete[] name;
return 0;
}
char* getname(const char* s)
{
char* pn = new char[strlen(s)+1];
strcpy(pn,s);
return pn; //函数退出后指针pn将被销毁,但其内存是保存在静态存储区,所以内存不会被销毁。
}
g++ main.cpp
./a.out
you entered:Boss Chen
个人理解:
为什么要delete释放资源呢?
因为在使用内置类型和new时系统把被申请的地址加锁了,这样当有新内存被申请时,编译器才不会去访问被申请过(加锁)的内存,
而如果不使用delete,这块内存将永远不会被搜索到。。。
const成员函数
注意,要同时在成员函数的声明和定义中指明const限定符。
const成员函数到底有什么优点?回顾关于接口和客户的讨论。const成员函数向它的客户保证它运行良好,并告诉客户调用const成员函数没有危险。这将逐渐建立客户的自信,他们对正在使用的软件会更加有信心。
unsigned TIntStack::HowMany() const
{
// 如果添加_sp = 0; 语句会怎样(仅举个例子)
// _sp = 0;
return _count;
}
上面的代码不会修改对象内的任何数据。它只是读取数据成员_count 的值。我们可以将const成员函数看成只读函数。
如果上面带注释的代码行取消注释,编译器就会发现给成员函数_sp赋值的行为,并立即将其作为错误进行标记,因为我们违反了成员函数的常量性(constantness)(即在const成员函数内改变数据成员)。
如果必须在const成员函数内部修改某些数据,正确的处理方法是:声明这些数据成员时添加前缀mutable
限定符(例如,mutable bool _cacheValid)。这样,即使在const成员函数中,也可以修改_cacheValid。
性能改善
避免制作对象的副本。复制对象的开销很大(在内存和CPU时间方面)。
避免创建新对象,设法复用现有对象。创建(和销毁)对象开销很大。
在适当的时候使用const引用形参。
使用const成员函数。
尽可能地使用初始化语义(而非赋值)。
优先使用指针而不是引用作为数据成员。指针允许惰性求值(lazy evaluation),而引用不允许。
避免在默认构造函数中分配存储区。要将分配延迟到访问成员时,通过指针数据成员(pointer data member)可轻松完成。
用指针数据成员而不是引用和值成员。
尽可能地使用引用计数(在其他章节深入讨论)。
通过重新安排表达式和复用对象减少临时对象。
在编写代码的最初阶段中避免使用技巧。
在现实世界中,通常认为任何软件都是以速度作为最终评定的标准。
RTTI运行时类型识别
RTTI是运行阶段类型识别(Runtime Type Identification)的简称。
C++有3个支持RTTI的元素。
1.如果可能的话,dynamic_cast运算符将使用一个指向基类的指针来生成一个指向派生类的指针;否则,该运算符返回0——空指针。
2.typeid运算符返回一个指出对象的类型的值。
3.type_info结构存储了有关特定类型的信息。
dynamic_cast
dynamic_cast运算符是最常用的RTTI组件,它不能回答“指针指向的是哪类对象”这样的问题,但能够回答“是否可以安全地将对象的地址赋给特定类型的指针”这样的问题。
通常,如果指向的对象(*pt)的类型为Type或者是从Type直接或间接派生而来的类型,则下面的表达式将指针pt转换为Type类型的指针:
dynamic_cast<Type *>(pt)
否则,结果为0,即空指针。
typeid
在c++中,typeid用于返回指针或引用所指对象的实际类型。
注意:typeid是操作符,不是函数!
运行时获知变量类型名称,可以使用 typeid(变量).name(),需要注意不是所有编译器都输出"int"、"float"等之类的名称,对于这类的编译器可以这样使用:float f = 1.1f; if( typeid(f) == typeid(0.0f) ) ……
补充:对非引用类型,typeid是在编译时期识别的,只有引用类型才会在运行时识别。
#include <iostream>
#include <typeinfo>
using namespace std;
int main()
{
class test{};
cout << typeid(1.1f).name() << endl;
test abc;
cout<<typeid(abc).name()<<endl;
return 0;
}
f
Z4mainE4test
泛型编程
函数模版-简介
泛型编程(Generic Programming)
最初提出时的动机很简单直接:发明一种语言机制,能够帮助实现一个通用的标准容器库。所谓通用的标准容器库,就是要能够做到,比如用一个List类存放所有可能类型的对象这样的事;泛型编程让你编写完全一般化并可重复使用的算法,其效率与针对某特定数据类型而设计的算法相同。泛型即是指具有在多种数据类型上皆可操作的含义,与模板有些相似。STL巨大,而且可以扩充,它包含很多计算机基本算法和数据结构,而且将算法与数据结构完全分离,其中算法是泛型的,不与任何特定数据结构或对象类型系在一起。
泛型编程最初诞生于C++中,由Alexander Stepanov和David Musser创立。目的是为了实现C++的STL(标准模板库)。其语言支持机制就是模板(Templates)。模板的精神其实很简单:参数化类型。换句话说,把一个原本特定于某个类型的算法或类当中的类型信息抽掉,抽出来做成模板参数T。
函数模版-一般模版
模板函数定义的一般形式如下所示:
template <class type> ret-type func-name(parameter list)
{
// 函数的主体
}
例如:
template <typename AnyType>
void Swap(AnyType &a,AnyType &b)
{
AnyType temp;
temp =a;
a=b;
b=temp;
}
函数模板有两种类型的参数。
1.模板参数:位于函数模板名称的前面,在一对尖括号内部进行声明:
template
2.调用参数:位于函数模板名称之后,在一对圆括号内部进行声明:
…max (T const& a, T const& b) //a和b都是调用参数
使用模板:
int a(3),b(4);
Swap<int> temp(a,b);
函数模版-重载模版
可以像重载常规函数定义那样重载模板定义。
//常规模版
template <typename AnyType>
void Swap(AnyType &a,AnyType &b);
//重载模版
template <typename AnyType>
void Swap(AnyType &a,AnyType &b,int n);
函数模版-具体化和实例化
模板的实例化指函数模板(类模板)生成模板函数(模板类)的过程。对于函数模板而言,模板实例化之后,会生成一个真正的函数。而类模板经过实例化之后,只是完成了类的定义,模板类的成员函数需要到调用时才会被初始化。模板的实例化分为隐式实例化和显示实例化。
隐式实例化
为进一步了解模板,必须理解术语实例化和具体化。记住,在代码中包含函数模板本身并不会生成函数定义,它只是一个用于生成函数定义的方案。
编译器使用模板为特定类型生成函数定义时,这些编译器生成的版本通常被成为模板实例(instantiation)
。例如,函数调用Swap(i, j)导致编译器生成一个int型参数的Swap( )函数定义。
这种实例化方式被称为隐式实例化
(implicit instantiation),因为编译器之所以知道需要进行定义,是由于程序调用Swap( )函数时提供了int参数。
其实这一步应该是先生成函数定义,然后再完成了初始化。
Swap(a,b);
显式实例化
当模板参数和调用参数没有发生关联,或者不能由调用参数来决定模板参数的时候,你在调用时就必须显式指定模板实参。
最初,编译器只能通过隐式实例化,来使用模板生成函数定义,但现在C++还允许显式实例化
(explicit instantiation)。这意味着可以直接命令编译器创建特定的实例,如Swap
template void Swap<int>(int &,int &);
实现了这种特性的编译器看到上述声明后,将使用Swap( )模板生成一个使用int类型的实例。也就是说该声明的意思是:使用Swap( )模板生成int类型的函数定义
。
查看如下例子:
#include <iostream>
using namespace std;
template <typename T> T Max(const T& t1,const T& t2){
return (t1>t2)?t1:t2;
}
int main(){
int i=5;
//cout<<Max(i,'a')<<endl; //无法通过编译
cout<<Max<int>(i,'a')<<endl; //显示调用,通过编译
}
直接采用函数调用Max(i,’a’)会产生编译错误,因为i和’a’具有不同的数据类型,无法从这两个参数中进行类型推演。而采用Max< int>(i,’a’)调用后,函数模板的实例化不需要经过参数推演,而函数的第二个实参也可以由char转换为int型,从而成功完成函数调用。
编程过程中,建议采用显示模板实参的方式调用函数模板,这样提高了代码的可读性,便于代码的理解和维护。
显示具体化
具体化,specialization,即特殊化!
模版应该允许所有类型的输入。编程的健壮性就是如此。
但是有时类型的行为表现会不同,就无法用通用模版来表达。
如a+b,对于int 型是数值的加法运算,对于string是字符串的拼接,这样就无法用通用模版来表示,因此需要提供一个具体化函数定义——称为显式具体化(explicit specialization),其中包含所需的代码。当编译器找到与函数调用匹配的具体化定义时,将使用该定义,而不再寻找模板。
显式具体化:
- 对于给定的函数名,可以有非模板函数、模板函数和显式具体化模板函数以及它们的重载版本。
- 显式具体化的原型和定义应以template<>打头,并通过名称来指出类型。
- 具体化优先于常规模板,而非模板函数优先于具体化和常规模板。
相对于通用模版的通用型,显式具体化的作用是为了特殊化!即不使用通用实现方法,而是特殊实现方法。
与显式实例化不同的是,显式具体化使用下面两个等价的声明之一:
template <> void Swap<int>(int &,int &);
template <> void Swap(int &,int &);
template <> 已经证明了这是个模版,而不是函数定义。
区别在于,这些声明的意思是“不要使用Swap( )模板来生成函数定义,而应使用专门为int类型显式地定义的函数定义”。这些原型必须有自己的函数定义。显式具体化声明在关键字template后包含<>,而显式实例化没有.
{%r%}
试图在同一个文件(或转换单元)中使用同一种类型的显式实例和显式具体化将出错。
{%endr%}
总结
隐式实例化、显式实例化和显式具体化统称为具体化(specialization)。它们的相同之处在于,它们表示的都是使用具体类型的函数定义,而不是通用描述。
隐式实例化指的是:在使用模板之前,编译器不生成模板的声明和定义实例。只有当使用模板时,编译器才根据模板定义生成相应类型的实例。如:int i=0, j=1;swap(i, j); //编译器根据参数i,j的类型隐式地生成swap
显式实例化:当显式实例化模板时,在使用模板之前,编译器根据显式实例化指定的类型生成模板实例。如前面显示实例化(explicit instantiation)模板函数和模板类。其格式为:template typename function
显示具体化:显示具体化,指定模板函数中类型,意思是不要使用swap模板来生成函数定义,而是要使用专门为job类型显示定义的函数定义。因为job是一个结构体,所以swap不可能是直接的利用临时变量做赋值,因此需要在这个函数中重新定义swap的方法,在调用的时候需要使用显示具体化,不要用swap模板来生成函数定义,而是使用我们自己写的方法。
tempalte <> void swap
...
template<typename T>
void Swap(T &,T &);//模版原型
template <> void Swap<job>(job &,job &);//显式具体化
int main(int argc, char const *argv[])
{
short a,b;
Swap(a,b);//short型隐式模版实例化
job n,m;
Swap(n,m);//使用job型显式具体化
template void Swap<char>(char &,char &);//char型显式实例化
char g,h;
Swap(g,h);//使用char型显示模版实例化
return 0;
}
类模版-一般类模版
与函数相似,类也可以被一种或多种类型参数化。容器类就是一个具有这种特性的典型例子,它通常被用于管理某种特定类型的元素。只要使用类模板,你就可以实现容器类,而不需要确定容器中元素的类型。
在没有类模版之前,可以使用typedef进行通用型的实现,然而,这种方法有两个缺点:首先,每次修改类型时都需要编辑头文件;其次,在每个程序中只能使用这种技术生成一种栈,即不能让typedef同时代表两种不同的类型,因此不能使用这种方法在同一个程序中同时定义int栈和string栈。如:
typedef unsigned long Item;
class Stack
{
private:
enum{MAX=10};
Item items[MAX];
int top;
public:
...
};
现在C++支持模版使用模版类。
模版也是代码重用的一种方式。
类模板,可以定义相同的操作,拥有不同数据类型的成员属性。
类模版是一种泛型编程/元编程方法,其目的是用来生成数据类型的。
由于模板不是函数,它们不能单独编译。模板必须与特定的模板实例化请求一起使用。为此,最简单的方法是将所有模板信息放在一个头文件中,并在要使用这些模板的文件中包含该头文件。
仅在程序包含模板并不能生成模板类,而必须请求实例化。为此,需要声明一个类型为模板类的对象,方法是使用所需的具体类型替换泛型名。
通常使用template来声明。告诉编译器,碰到T不要报错,表示一种泛型.typename代表类型。
main.cpp
#include <iostream>
using namespace std;
template <typename T>
class Complex{
public:
//构造函数
Complex(T a, T b):m_a(a),m_b(b)
{
}
//运算符重载
Complex<T> operator+(Complex &c)
{
Complex<T> tmp(this->a+c.a, this->b+c.b);
return tmp;
}
T get_a()
{
return m_a;
}
private:
T m_a;
T m_b;
};
int main()
{
//对象的定义,必须声明模板类型,因为要分配内容
Complex<int> a(10,20);
Complex<int> b(20,30);
Complex<int> c = a + b;
cout<<c.get_a()<<endl;
return 0;
}
30
类模版-表达式参数
template <typename T,int n>
class ArrayTP
{
...
}
关键字class(或在这种上下文中等价的关键字typename)指出T为类型参数,int指出n的类型为int。这种参数(指定特殊的类型而不是用作泛型名)称为非类型(non-type)或表达式(expression)参数。假设有下面的声明:
ArrayTP<double,12>
这将导致编译器定义名为ArrayTP<double, 12>的类,并创建一个类型为ArrayTP<double, 12>的eggweight对象。定义类时,编译器将使用double替换T,使用12替换n。
表达式参数有一些限制。表达式参数可以是整型、枚举、引用或指针。因此,double m是不合法的,但double * rm和double * pm是合法的。另外,模板代码不能修改参数的值,也不能使用参数的地址。所以,在ArrayTP模板中不能使用诸如n++和&n等表达式。另外,实例化模板时,用作表达式参数的值必须是常量表达式。
这种方式的优点是执行速度快,缺点是会生成多个副本,因为每种类型都生成一个副本。而使用构造函数的方法将只生成一个副本:
Stack<double>(12);
另一个区别是,构造函数方法更通用,这是因为数组大小是作为类成员(而不是硬编码)存储在定义中的。这样可以将一种尺寸的数组赋给另一种尺寸的数组,也可以创建允许数组大小可变的类。
类模版-部分具体化
C++还允许部分具体化(partial specialization),即部分限制模板的通用性。
template <typename T1>
class Pair<T1,int>
{
};
类模版-成员模板
模板可用作结构、类或模板类的成员。
class DebugDelete{
public:
//构造函数,用于初始化输出流
DebugDelete(std::ostream &s = std::cerr):os(s){}
//T的类型由编译器推断
template <typename T> void operator()(T* p) const{
os<<"deleteing p"<<endl;
delete p;
}
private:
std::ostream &os;
};
//代替delete操作
double* p = new double;
DebugDelete d;
d(p); //释放p
类模版-将模板用作参数
模板可以包含类型参数(如typename T)和非类型参数(如int n)。模板还可以包含本身就是模板的参数,这种参数是模板新增的特性,用于实现STL。
//模板除了可以包括类型参数(class T)和非类型参数(int n)之外,还要台将模板作为参数包括进去
//tempalte<calss T>
//template<template<class T>class T1>
//这个T1类型有局限性,它要求我们在使用该模板时传递的参数必须是个模板类
//people<human>Jack;
//其中的human是用a模板声明的模板类,而people则是用b模板声明的模板类, Jack是用people这个模板类定义的一个对像
#include <iostream>
#include <string>
using namespace std;
template<class T>
class human
{
public:
human(){}
T GetAge(){ return age;}
T GetStr(){ return name;}
void SetAge(T &a){ age = a;}
void SetStr(T &b){ name = b;}
private:
T name;
T age;
};
template<template<class T>class T1>
class people
{
public:
people(){}
int GetAge(){ return s1.GetAge(); }
void SetAge(int &a){ return s1.SetAge(a); }
string GetStr(){ return s2.GetStr();}
void SetStr(string &s){ s2.SetStr(s); }
private:
T1<int> s1;
T1<string> s2;
};
int main()
{
people<human>Jack;
int i=8;
string str="hello";
Jack.SetAge(i);
cout<<Jack.GetAge()<<endl;
Jack.SetStr(str);
cout<<Jack.GetStr()<<endl;
return 0;
}
8
hello
感悟笔记
2020年03月15日22:19:22
要真正理解面向对象设计,学习C++不是一个好的方式。他太大太杂了。
类和对象的关系可以理解为鸡蛋和土鸡蛋的关系。
当你想做西红柿炒鸡蛋的时候,你脑子里只有鸡蛋的概念,这就是类,当真正做的时候,手里拿的是土鸡蛋,这就是被实例化的类--对象。
类的本意是实现概念抽象,其本质是自定义类型,代表了需要申请的内存空间。所以使用的时候需要先实例化类,这样做就是先找到一个内存地址,然后开辟了类大小的内存空间。没有实例化的类是没有起始地址的,也就是没有在内存中存在。
面向对象编程也是面向接口编程,接口作为模块间的通信方式。通信包括对象间通信和进程间通信,无论什么通信都要有接口。
功能逻辑分离,功能只实现功能,代理类实现业务和逻辑,调用功能模块,这样做的好处是可以复用功能模块,职责清晰。
2020年03月22日20:45:59
面向对象的主要思想是:重用,抽象,面向接口。
与面向过程不同的是,面向对象注重的是数据组织形式,而不是功能的实现。
重用导致数据会以最小单元存在,然后以继承关系重新组织。
一些原始的无任何继承的数据,可以通过建立新的类包含他们,并让新的类继承同一个虚基类,这样这些最小单元数据就有了逻辑上的统一性。通过继承不同的虚基类,可以让这些最小单元实现不同的组织形式。
以上也是抽象的一部分功能,即在逻辑上将最小单元抽象统一。
面向接口也是抽象。面向对象一般会通过一个抽象的接口来传递所有子类,这就导致去了解某一个功能时看不懂他在干什么,所以要完全了解接口,恐怕要完全了解他传递了哪些子类。
有些看不懂的代码,多了很多参数,要么是作为传递一类数据的接口使用的,这就必然给其他一些子类带来了多余的参数;要么就是该类或函数是某人的调试中的类或函数。所以从这两方面理解是个突破口。
面向接口编程,功能逻辑分离!
2020年03月28日22:40:09
语言的学习方法:
1.数据类型
数据型,字符串
2.容器
容器及其方法
3.特殊机制
每种语言都有其独特的内置机制。
4.库函数
语言只是一种工具,掌握库函数才能实现具体功能。
2020年04月06日20:17:20
事件就是状态变更;
对标和参考,是快速学习的有效方式,
2020年04月16日19:29:56
在面向对象编程中,能调用方法的只有对象.
2020年04月22日06:29:52
编程语言学习思路
- I/O
- 数值
- 字符串
- 集合/容器
- 关键字/特殊机制
- 标准库
- 多线程
- 进阶
2020年04月24日07:02:53
中间层,一般是抽象层,例如dbus,先融合再分发,
还有一种中间层,是应对复杂的接口的,即实现处理相同事情的各种类型;
还有一种中间层是将一种数据结构解析为另一种数据结构,
2020年05月10日12:25:54
面向对象编程核心思想1:
- 面向接口编程
- 功能逻辑分离
- 生命周期管理
- MVC架构
- 通信
2020年06月11日11:12:21
继承和初始化,继承是在.h中引用:,列表初始化是在.cpp中引用。
MVC架构,Model存储着应用的数据和业务逻辑,他不关心用户界面,他为存储和管理数据结构而生。批注:数据结构,可以这样理解,一个地图数据库里有geo,poi,lane等数据,但是你可能只使用其中的某种数据和某部分数据,而存储这些数据的类就是数据模型。
View 视图,用户界面。
Controller,控制器。是视图与模型对象的联系纽带。批注:应该是Qt中的connect或者即使BL层。
编程语言在做的就是信息传递和代码复用。
表格的列代表数据类型,表格的行代表一个数据结构(结构体)数据
关于静态库和动态库:
静态库需要先设置静态库搜索目录,使用时include
同理,动态库也需要先设置动态库搜索目录,使用时要通过命令行引用动态库-l
2020年07月02日09:17:40
语言基础:
面向对象基础
集合
文件与流
多线程
网络编程
特性
异常
作用域管理
语言进阶:
库文件,设计模式,算法。
作用域控制,读写控制(const)
函数和方法本质上都是代码块,只是在不同的地方名字不同,因为名字实际上体现了他的作用,所以名字会不同。
值与引用,值就是值,引用是地址。
内存优化与碎片
而当分配的动态内存零散无序时,会产生大量内存碎片,进而导致内存分配和回收效率降低。所以,可以事先分配一块足够大的空间(当然,不是过大)以尽量减少内存碎片的产生。