面向对象程序设计之所以能够有效提高程序开放效率是因为:
- 分类管理程序代码,即类与对象编程
- 重用类代码
- 使用类定义对象
- 使用已有类定义新类
- 组合
- 继承
本章将讲解使用已有的类定义新的类,即类的组合和继承。
一、代码重用
程序 = 数据 + 算法,程序中的数据,包括原始数据,中间结果和最终结果,如何根据所处理的数据来合理的使用和管理内存,是编写程序的第一项工作内容,c++语言通过定义变量语句来申请内存空间,定义变量就是与数据相关的代码;将数据的处理过程,细分成一组严格的操作步骤,这组操作步骤就被称为算法,如何设计数据处理算法,是编写程序的第二项工作内容,c++语言通过定义函数来描述算法模块,函数是与算法相关的代码。这一节,首先比较结构化程序设计与面向对象程序设计在代码重用方面的不同,然后再介绍3种面向对象程序中3种代码重用的不同方法。
1.1 结构化程序设计中的代码重用
结构化程序设计重用的是函数代码,换句话说,结构化程序设计重用的是算法代码,没有重用数据代码。
1.2 面向对象程序设计中的代码重用
面向对象程序设计既重用数据代码,也重用函数代码,因此开发效率更高。
重用类代码有3种形式:
- 用类定义对象
- 通过组合定义新的类(称为组合类)
- 通过继承定义新的类(称为派生类)
类的5大要素:
- 数据成员
- 函数成员
- 访问权限
- 构造函数
- 析构函数
本章主要代码,以圆形类Circle进行讲解,这个段代码分为两个文件,分别如下:
类头文件:类声明部分,Circle.h
class Circle
{
private:
double r;
public:
void Input();//输入半径
double CRadius();//读取半径
double CArea();//求面积
double CLen();//求周长
Circle();//无参构造函数
Circle(doublex);//有参构造函数
Circle(Circle&);//拷贝构造函数
};
类源程序文件:类实现部分,Circle.cpp
#include <iostream>
using namespace std;
#include "Circle.h"
void Circle::Input()
{
cin>>r;
while (r<0)//检查数据合法性
{
cin>>r;//如果r<0,则重新输入
}
}
double Circle::CRadius()//读取半劲
{ return r; }
double Circle::CArea()//求面积
{ return(3.14*r*r); }
double Circle::CLen()//求周长
{ return(3.14*r*2); }
Circle::Circle()//无参构造函数
{ r = 0; }
Circle::Circle(double x)//有参构造函数
{
if(x<0) r=0;//如果r<0则,置0
else r=x;
}
Circle::Circle(Circle&x)//拷贝构造函数
{ r = x.r;}
使用类Circle定义对象的典型流程:
Circle obj;
obj.Input();
cout<<obj.CRadius()<<endl;
cout<<obj.CArea()<<endl;
cout<<obj.CLen()<<endl;
在使用圆形类Circle定义对象的时候,也可以对对象进行初始化
Circle obj1;//调用无参构造函数
Circle obj2(5);//调用有参构造函数
Circle obj3(obj2);//调用拷贝构造函数
因为我们没有为Circle定义析构函数,c++语言编译器将自动为该类添加一个空的析构函数,因为一个类从语法上讲,必须有析构函数,即:
~Circle(){}
如何使用Circle类定义更复杂的累?就要用到类的组合方法和继承方法。
二、类的组合
类不是c++语言预定义的基本数据类型,而是由多个类型的数据成员组合在一起,形成的自定义数据类型,用简单的零件组装复杂的整体,是人们常用的一种方法,程序员可以将别人编写的类,当做零件,就是零件类,在此基础上定义自己的新类,我们称为整体类,这就是类的组合。
组合的编程原理是:程序员在定义新类的时候,使用已有的类来定义数据成员。这些数据成员是类型的对象,被称为类的对象成员。c++语言将数据成员中包含对象成员的类称为组合类。
按照数据类型的不同,组合类中数据成员可分为2种,即类类型的对象成员和基本数据类型的非对象成员。
使用组合类定义对象,即组合类对象,其成员中也将包含对象成员和非对象成员。
-
访问组合类对象中的非对象成员的方法和普通类的方式一样,没有区别
组合类对象名.非对象成员名
-
组合类中的对象成员还包含自己的下级成员,也就是说组合类对象包含多级成员,可以访问组合类对象中对象成员的下级成员,这是一种多级访问,多级访问的语法形式是:
组合类对象名.对象成员名.对象成员的下级成员名
多级访问将受到多级权限的控制
2.1 组合类的定义方法
假设有一个几何图形是由3个圆组成的,我们要编写一个类TriCircle来描述这样的几何图形,编写TriCircle类可以从零开始编写,也可以基于前文中的Circle类编写组合类。类TriCircle可以认为是由3个Circle类对象组合而成的。我们使用组合类的方法来定义TriCircle,代码如下:
头文件:TriCircle.h
#include "Circle.h" //声明类Circle
class TriCircle //类声明部分
{
//二次封装
public:
Circle c0,c1,c2;//3个公有Circle类对象成员
double TArea();//求面积
double TLen();//求周长
};
类源程序文件:TriCircle.cpp
#include "TriCircle.h"//声明类TriCircle
//类实现部分,具体的函数代码
double TriCircle::TArea()//求面积
{
double totalArea;
totalArea = c0.CArea()+c1.CArea()+c2.CArea();//访问对象成员的下级成员受权限控制
return totalArea;
}
double TriCircle::TLen()//求周长
{
double totalLen;
totalLen = c0.totalLen()+c1.totalLen()+c2.totalLen();
return totalLen;
}
1、组合类的定义和访问:
与普通类一样,可以使用组合类来定义对象和访问对象实例,计算机执行对象定义语句时,会自动为对象分配内存空间,一个组合类对象所占用的内存空间等于类中全部数据成员,其中包括对象成员,它们所需内存空间的总和。
TriCircle obj;//定义对象
obj.c0;obj.c1;obj.c2;obj.TArea();Obj.TLen();//访问对象
2、多级访问的语法形式:
组合类对象名对象成员名.对象成员的下级成员名
综上所述,主函数调用TriCircle进行计算时,代码如下:
#include <iostream>
using namespace std;
#include "TriCircle.h"
int main()
{
TriCircle obj;//定义一个组合类TriCircle的对象obj
//调用组合类对象obj中对象成员c0的下级函数成员Input,输入c0半径
obj.c0.Input();
//再调用c0的下级函数成员CArea和CLen计算c0的面积和周长。
cout<<obj.c0.CArea()<<","<<obj.c0.CLen()<<endl;
//同理可以计算出c1和c2的面积和周长
obj.c1.Input();cout<<obj.c1.CArea()<<","<<obj.c1.CLen()<<endl;
obj.c2.Input();cout<<obj.c2.CArea()<<","<<obj.c2.CLen()<<endl;
//调用组合类obj中的非对象成员TArea和TLen,计算显示总面积和总周长。
cout<<obj.TArea()<<","<<obj.TLen()<<endl;
return 0;
}
通过对象指针,也可以间接访问组合类对象及其下级成员,实例如下:
TriCircle obj;//定义一个组合类TriCircle的对象obj
TriCircle *p = &obj;//定义一个组合类TriCircle的对象指针p,让其指向对象obj。
//通过对象指针,间接访问组合类对象obj中对象成员的下架函数成员
p->c0.Input();
cout<< p->c0.CArea() << "," << p->c0.CLen() << endl;
3、如何设计组合类中对象成员的访问权限
组合类将零件类的对象作为自己的数据成员,及对象成员,相当于是用零件组装产品,用零件组装产品时,要考虑,是将零件直接暴露给用户还是将零件隐藏起来。通常这个问题是要根据产品及零件的功能来决定的。
组合类编程中有2种角色,分别是定义组合类的程序员和使用组合类的程序员
- 定义组合类的程序员在使用对象成员组装组合类时,可根据功能要求决定将哪些对象成员开放给使用组合类的程序员,哪些隐藏起来,开放就是将对象成员设定为公有权限,隐藏就是设定为保护权限或私有权限,这就是对象成员的二次封装。
- 使用组合类的程序员使用组合类定义对象,在所定义出的组合类对象中,开放的对象成员可以访问,隐藏的则不可以访问,组合类对象中的对象成员还包含下级成员,这些下级成员也都有各自的访问权限,公有的才可以访问,否则不可以访问。
- 多级访问将受到多级权限的控制。访问组合类对象成员的下级成员,只有对象成员和下级成员是公有权限才可以访问,否则就不能访问。
4、组合类对象的构造和析构
按照数据类型的不同,组合类中的数据成员可以分为2种,即类类型的对象成员及基本类型的非对象成员,构造组合类类型时,将首先构造对象成员,然后在构造非对象成员,在构造过程中,计算机将自动调用组合类的构造函数,先初始化对象成员,再初始化非对象成员。
在组合类的析构过程中,对象成员与非对象成员的析构顺序和构造时的顺序正好相反。及先析构非对象成员再析构非对象成员。析构时会自动调用组合类的析构函数。
组合类的构造函数:
构造函数通过形成传递初始值,实现对新建成员的初始化,组合类构造函数不能直接初始化类中的对象成员,因为对象成员的下级数据成员可能是私有的,不能访问赋值,要想初始化这些对象成员,必须要通过其所属类的构造函数才能完成,调用对象成员所属类的构造函数,其语法形式是在组合类构造函数的函数头后面添加初始化列表,语法如下:
组合类构造函数名(形参列表):对象成员名1(形参1),对象成员名2(形参2),......
{
... ...//在函数体中初始化其他非对象成员。
}
- 组合类对象中各数据成员的初始化顺序是:先调用对象成员所属的构造函数,初始化对象成员;再执行组合类构造函数的函数体,初始化其他非对象成员。
- 如果组合类中有多个对象成员,那么这些对象成员的初始化顺序由其在组合类中的声明顺序决定,先声明者先初始化。
//有参构造函数
TriCircle::TriCircle(double p0,double p1, double p2):c0(p0),c1(p1),c2(p2)
{
......//非对象成员在函数体中初始化
}
//无参构造函数
TriCircle::TriCircle(){}
//拷贝构造函数
TriCircle::TriCircle(TriCircle&rObj):c0(rObj.c0),c1(rObj.c1),c2(rObj.c2){}
组合类中的析构函数:
- 当对象生存期结束时,计算机销毁对象,释放其内存空间,这个过程就是对象的析构。销毁对象时计算机会自动调用其所属类的析构函数。
- 组合类对象中数据成员的析构顺序是:先执行组合类析构函数的函数体,清理非对象成员;再调用对象成员所属类的析构函数,清理对象成员。
- 简单的说,对象析构顺序与构造顺序相反,即先析构非对象成员,再析构对象成员。
5、类的聚合
聚合类:数据成员中包含对象指针的类
聚合类pTriCircle定义代码:
类头文件:pTriCircle.h
#include "Circle.h"//声明类Circle
class pTriCircle //声明部分,即声明成员
{
public:
Circle *p0,*p1,*p2;//公有Circle类的对象指针
double TArea();//求总面积
double TLen();//求总周长
};
c++语言将数据成员中包含对象成员的类,称为组合类,而将数据成员中包含对象指针的类,称为聚合类。聚合类是一种特殊形式的组合类。
类源程序文件:pTriCircle.cpp
#include "pTriCircle.h"//声明类pTriCricle
//pTriCricle类实现部分
double pTriCircle::TArea()
{
double totalArea;
//用对象指针,间接访问对象成员的下级成员
totalArea = p0->CArea() + p1->CArea() + p2->CArea();
return totalArea;
}
double pTriCircle::TLen()
{
double totalLen;
//用对象指针,间接访问对象成员的下级成员
totalLen = p0->CLen() + p1->CLen() + p2->CLen();
return totalLen;
}
主函数代码:
#include <iostream>
using namespace std;
#include "pTriCircle.h"//类pTriCircle的声明头文件
int main()
{
Circle c0,c1,c2;//先定义3个类Circle的对象c0,c1,c2
c0.Input(); c1.Input(); c2.Input();//输入3个圆的半径
pTriCircle obj1;//定义1个聚合类pTriCircle的对象obj1
//将obj1中的3个对象指针分别指向已经创建的Circle类对象c0,c1,c2
obj1.p0 = &c0; obj1.p1 = &c1; obj1.p2 = &c2;
//调用obj1中的函数成员TArea和TLen,计算并显示总面积和总周长
cout<<obj1.TArea()<<","<<obj1.Tlen()<<endl;
return 0;
}
6、总结
类的组合和聚合:
- 数据成员中包含对象成员的类称为组合类
- 数据成员中包含对象指针的类称为聚合类,聚合类是一种特殊形式的组合类
区别:
- 聚合类的对象成员是独立创建的,聚合类对象只包含指向对象成员的指针。
- 聚合类对象可以公用对象成员
组合类总结:
- 代码重用:组合类是一种有效的代码重用形式,程序员在设计新类的时候,应先去了解有哪些可以重用的类,这些类可以是自己以前编写的,可以是集成开发环境IDE提供的,也可以是从市场上购买的,根据功能选择自己需要的类,然后用组合的方法定义新的类。
- 自底向上:类可以多级组合,用零件定义组合类,组合类可以继续作为零件类去定义更大的组合类,这就是类的多级组合,多级组合是一种自底向上的程序设计方法,类越往上组合,其功能就越有针对性,应用面也就越窄,多级组合过程中,每一级组合类都会根据自己的功能需要设定对象成员的访问权限。有少级组合,就有多少层封装。
三、类的继承与派生
设计新类时可继承已有类,这个已有的类呗称为基类或父类。
基类是为解决以前的老问题设计的,在面对新问题时其功能可能会显得不够完善,程序员需要在继承的基础上对基类进行派生,例如添加新功能,或者对从基类继承来的功能进行某些修改,派生的目的时为了解决新问题。
通过继承与派生所得到的新类被称为派生类或子类。
3.1继承与派生的编程原理:
- 程序员在定义新类的时候,首先继承基类的数据成员和函数成员;在此基础上进行派生,为派生类添加新的成员,或对从基类继承的成员进行重新定义或修改其访问权限,在继承与派生的过程中,继承实现了基类代码的重用,派生则实现了积累代码的进化。
- 派生类中的成员可以分为2种,一是从基类继承来的成员,称为派生类中的基类成员,二是定义时新增的成员,称为派生类中的新增成员。
定义派生类的语法:
class 派生类名:继承方式1 基类1,继承方式2 基类2,... ... //派生类声明部分
{
public:
新增公用成员
protected:
新增保护成员
private:
新增私有成员
};
//派生类实现部分,各函数成员的完整定义代码
语法说明:
- 定义派生类时,在派生类名后面添加继承列表,在声明部分的大括号里声明新增成员,在实现部分编写各新增函数成员的完整定义代码。
- 继承列表指定派生类从哪些基类继承,派生类可以只从一个基类继承(单继承),可以从多个基类继承(多继承)。每个基类以“继承方式 基类名”的形式声明,多个基类之间用逗号“,”隔开
- 派生类将继承基类中除构造函数、析构函数之外的所有数据成员和函数成员,基类的构造函数和析构函数不能被继承,派生类需重新编写自己的构造函数和析构函数。
- 继承后,派生类会对基类成员按照继承方式进行再次封装,继承方式有3种:public、protected、private
- public(公有继承):派生类对其基类成员不做任何封装,它们在派生类中的访问权限与原来在基类中的权限相同
- private(私有继承):派生类对其基类成员做全封装,它们在派生类中的访问权限统统改为private(私有权限),不管它们原来在基类成员中的权限是什么,使用私有继承,实际上是派生类要将其基类成员全部隐藏起来
- protected(保护继承):派生类对其基类成员进行半封装,基类中的public成员被继承到派生类后,其访问权限被降级成protected,基类中的protected和private成员被继承到派生类后,其访问权限不变。
- 在类声明部分的大括号中声明新增的数据成员、函数成员,并指定各新增成员的访问权限,在类实现部分编写各新增函数成员的完整定义代码。
代码实例:假设要定义一个圆环类BorderCircle类
基于已有的基类Circle类来编写派生类,BorderCircle可以继承Circle类中的半径r、求面积和周长的函数CArea、CLen;在此基础上新增圆环宽度w,求内院面积和边框面积的函数InnerArea、BorderArea;因为Circle中的input函数,只能输入半径,为此BorderCircle从新定义了1个Input函数,这相当于是修改了原Input函数。新的Input函数能够同时输入半径和圆环的宽度。
类文件头:BorderCircle.h
#include "Circle.h"//声明基类Circle
class BorderCircle:public Circle //公有继承
{
public:
double w; //宽度
double InnerArea();//求内圆面积
double BorderArea();//圆环面积
void Input();//输入半径和圆环宽度,与Circle中的Input重名,将覆盖基类中的Input
};
类程序文件:BorderCircle.cpp
#include <iostream>
using namespace std;
#include "BorderCircle.h
"double BorderCircle::InnerArea()
{
double x = CRadius();//读取半径
return(3.14*(x-w)*(x-w));
}
double BorderCircle::BorderArea()
{
return(CArea()-InnerArea());
}
void BorderCircle::Input()
{
Circle::Input();//调取输入半径函数,访问被同名覆盖后的基类成员Input()
cin>>w;//输入边框宽度}
派生类中,新增函数成员可以和基类函数成员重名,但不是重载函数,例如上述例子中的Input函数,这属于同名覆盖。
同名覆盖:派生类中定义与基类成员重名的新增成员,新增成员讲覆盖基类成员。通过成员名访问时,所访问到的是新增成员,这就是新增成员对基类成员的同名覆盖。同名覆盖后,被覆盖的基类成员任然存在,只是被隐藏了,可以访问被覆盖的基类成员,其访问形式是:“基类名::基类成员名”。
同名覆盖的目的是修改基类中的功能。
3.2 派生类对基类成员的二次封装
派生类通过继承方式,对向基类继承来的成员进行二次封装,如果采用公有继承的方式,则是将基类的成员中的公有成员全部开放,如果是私有或保护继承,者将基类的成员隐藏起来,在派生类外部是无法访问的,如果基类的成员本身就是私有或保护成员,派生类继承过后改成员的权限不变,只能通过基类的函数进行间接访问,不能再派生类中直接调用。
3.3 派生类对象的定义与访问
一个派生类所占用的数据空间,等于该类中全部数据成员所占空间,其中包含基类的数据成员和新增的数据成员。派生类对象的定义方法和普通类一样。
BorderCircle obj;//定义一个派生类对象obj,包含数据成员r(基类)和w(新增)
访问公有的基类成员:
obj.Circle::Input();//Input被同名覆盖,访问基类中的Input,需指定类名。
//基类公有成员的方法方法。
obj.CRadius();
obj.CArea();
obj.CLen();
r是基类Circle中的私有成员,不能访问。
访问公有的新增成员:
//与普通对象方法一样
obj.w;
obj.InnerArea();
obj.BorderArea();
obj.Input;
派生类中的所有成员,不管是继承于基类还是新增的成员,只要是公有权限,就都可以访问。私有权限和保护权限就不能访问。可以看出,访问派生类对象中的成员和普通类是一样的,只有在同名覆盖的情况下有例外。
主函数:main.cpp
#include <iostream>
using namespace std;
#include "BorderCircle.h"
int main()
{
BorderCircle obj;//定义一个派生类对象BorderCircle的对象obj
//调用新增成员(同名覆盖)Input()函数,输入半径和边框宽度
obj.Input();
//调用基类中的CArea和CLen计算圆的面积和周长
cout<<obj.CArea()<<","<<obj.CLen()<<endl;
//调用新增成员InnerArea和BorderArea计算内圆面积和圆环面积
cout<<obj.InnerArea()<<","<<obj.BorderArea()<<endl;
return 0;
}
3.4 如何设计派生类的继承方式
通过继承基类,重用基类代码,可以降低派生类的工作量,提供工作效率
- 派生类编程有2种角色,分别是定义派生类的程序员甲和使用派生类的程序员乙,甲在设计派生类时需要根据派生类的功能和要求来决定:是将基类成员继续开放给乙,还是将它们隐藏起来。公有继承就是继续开放基类成员,私有继承和保护继承就是隐藏基类成员,这就是派生类对基类成员的二次封装。
- 乙使用派生类定义对象,是否可以访问其中的基类成员,这取决于该基类成员的访问权限,派生类对象中基类成员的访问权限由如下2个方面的因素决定:
- 因素1:派生类的继承方式
- 因素2:基类成员原来在基类中的访问权限
- 派生类也可以任意多级,用基类定义派生类,派生类可以继续作为基类去定义更下级的派生类,这就是多级派生,多级派生过程中每一级派生类都会根据自己功能需要设定继承方式,这相当于对所继承的基类成员进行再次封装。
3.5 保护权限与保护继承
保护权限protected是半开放的,那么保护权限是在什么情况下是开放的,什么情况下是封闭的。先看一个例子:
程序员甲,定义类A(头文件A.h)
//数据成员x,y,z分别是公有权限,私有权限和保护权限
class A
{
public:
A(int p1=0, int p2=0, int p3=0)//构造函数
{
x = p1; y = p2; z = p3;
}
int x;
private:
int y;
protected:
int z;
};
程序员乙,使用类A定义对象(1.cpp)
#include <iostream>
using namespace std;
#include "A.h"
int main()
{
A obj(10,20,30);
cout<<obj.x<<endl;//正确,公有权限
cout<<obj.y<<endl; //错误,私有权限
cout<<obj.z<<endl;//错误,保护权限
return 0;
}
程序员丙,使用类A定义派生类B(头文件B.h)
#include <iostream>
using namespace std;
#include "A.h"
class B:public A
{
public:
void funB()//新增成员访问基类成员
{
cout<<x<<endl;//正确,公有权限
cout<<y<<endl; //错误,私有权限
cout<<z<<endl;//正确,保护权限
}
};
通过上述例子,可以看出,通过对象访问成员,只能访问公有对象的成员,而保护权限和私有权限都不能访问;在派生类新增函数成员中访问基类成员时,此时公有成员和保护成员可以被访问,但是私有成员同样不能被访问。故类的保护权限是向其派生类定向开放的一种权限。
保护继承:基类中的public成员在保护继承后,访问权限被降级为protected,而基类中的保护成员和私有成员的访问权限不变。
派生类通过继承的方式,对基类继承来的成员进行二次封装,公有继承和私有继承,对派生类外部函数一视同仁,要么不封装,要么全部封装,而保护继承protected则不同,派生类保护继承基类,对下级新增函数成员来说,派生类的基类成员没有被封装,但是对派生类所有其他外部函数来说,这些成员是被封装起来的。这就是所谓的半封装,也就是说,保护继承,对下级派生类保持开放,而对其他函数来实,这些基类成员被隐藏了。
总结:派生类的保护继承是向其下级派生类定向开放的一种半封装。
3.6 派生类对象的构造和析构
派生类有2类成员,基类成员和新增成员,这两个成员的构造和析构是不一样的。在构造派生类对象过程中,计算机将自动调用派生类的构造函数,先初始化基类成员,在初始化新增成语,在派生类的析构过程中,顺序与构造时的顺序相反,即先析构新增成员,再析构基类成员,
1、派生类的构造函数
构造函数通过形参传递初始值,实现对新建对象数据成员的初始化,派生类中的构造函数不能直接初始化基类中的数据成员,因为它们再基类中可能是私有的,不能访问赋值,要想初始化这些基类成员,必须通过基类的构造函数才能完成,调用基类构造函数,其语法形式,是在派生类构造函数的函数名后面添加初始化列表,
派生类构造函数名(形参列表):基类名1(形参1),基类名2(形参2),... ...
{
... ...//在函数体中初始化新增成员。
}
- 派生类对象中,各数据成员的初始化顺序是:先调用基类构造函数,初始化基类成员;再执行派生类构造函数的函数体,初始化新增成员。
- 如果派生类继承了多个基类,那么各基类成员的初始化顺序由其在派生类继承列表中的声明顺序决定,声明在前的基类成员先初始化。
例如:为派生类BorderCircle可添加如下3个重载构造函数
-
有参构造函数
BorderCircle::BorderCircle(double p1,double p2):Circle(p1) { w = p2 }
-
无参构造函数
BorderCircle::BorderCircle(){ w =0; }
-
拷贝构造函数
BorderCircle::BorderCircle(BorderCircle&robj):Circle(robj) { w = robj.w; }
定义一个对象并初始化,c++语言会根据形实结合,自动调用对应得构造函数。
BorderCircle obj(5,2);// 调用有参构造函数
BorderCircle obj1;//调用无参构造函数
BorderCircle obj2(obj);//调用拷贝构造函数
3.7 派生类的析构函数
- 派生类中数据成员析构的顺序是:先执行派生类析构函数的函数体,清理新增成员,再调用基类析构函数,清理基类成员。
- 组合派生类的构造与析构:如果派生类的新增成员中包含对象成员,则该派生类称为组合派生类,也就是应用了组合和派生的方法综合定义的类。
- 组合派生类的成员可分为3种:基类继承来的成员(基类成员),二是新增的对象成员,三是新增的非对象成员
- 组合派生类的构造函数需依次初始化基类成员、新增对象成员、新增非对象成员,其中,初始化基类成员和新增对象成员需通过初始化列表,初始化新增的非对象成员则是在函数体中直接赋值。
- 析构顺序与构造顺序相反,先析构新增的非对象成员,再析构新增对象成员,最后才析构基类成员
组合派生类c++示意代码:
首先构造4个类,A1,A2,B1,B2。这4个类无实际意义仅用于代码演示。
//类A1
class A1
{
public:
int a1;
A1(int x = 0)//构造函数
{ a1=x; }
};
//类A2
class A2
{
public:
int a2;
A2(int x=0)//构造函数
{ a2 = x ;}
};
//类B1
class B1
{ public:
int b1;
B1(int x=0)
{ b1 = x ;}
};
//类B2
class B2
{
public:
int b2;
B2(int x=0)
{ b2 = x;}
};
定义组合派生类C:
class C:public A1, public A2 //继承基类A1和A2
{
public:
B1 Bobj1;//类B1的对象成员Bobj1
B2 Bobj2;//类B2的对象成员Bobj2
int c;
//组合派生类的构造函数;初始化基类成员、新增成员、新增非对象成员
C(int p1=0,int p2=0,int p3=0,int p4=0,int p5=0):A1(p1),A2(p2),Bobj1(p3),Bobj2(p4)
{ c = p5; }};
四、多态性
源程序中相同的程序元素可能会具有不同的语法解释,C++称这些程序元素具有多态性。常见的有:
- 关键字多态
- 重载函数多态
- 运算符多态
- 对象多态
- 参数多态
对于源程序中具有多态性的程序元素,什么时候对他们做出最终明确的语法解释呢?任何下达给计算机的指令,必须在具有明确的语法解释后,才能被计算机执行,否则不能执行。对具有多态性的程序元素作出最终明确的语法解释,这称为多态的实现。
实现多态有2个时间点,分别是在程序编译的时候,或是在程序执行的时候。不同的多态形式具有不同的实现时间点,编译时实现的多态称为编译时多态,执行时实现的多态称为执行时多态。
C++中某些关键字是多义词,具有多态性,例如static、const、void、public等等,关键字多态,是由编译器在编译源程序文件时进行解释的,是一种编译时多态。
之前学习的重载函数,如果两个函数的形参个数不同,或者类型不同,那么这两个函数就可以重名,被称为重载函数。编译时,由编译器根据实参的个数和类型的不同,自动调用形参最匹配的那个重载函数,相同的重载函数名,调用时会调用不同的函数,这就是重载函数多态。这也是由编译器在编译时实现的,这是一种编译时多态。所谓实现重载函数多态,就是在编译时,将调用语句中的函数名,转换成某个重载函数的存储地址。将源程序中具有多态的函数名,转换成某个具体的特定的函数储存地址,这种函数名到存储地址的转换,被称为对函数的绑定。
本章中重点讲解运算符多态和对象多态
4.1 运算符多态与重载
C++语言中的运算符,具有多态性,例如,2+3和2.0+3.0,分别是整数加法和浮点数加法,它们使用的是同一个运算符,加号+。对计算机中CPU运算器做进一步细分,通常说的运算器是定点运算器,只能进行整数运算,而浮点运算,则是通过浮点运算器,或称为协处理器来完成的。相同的运算符,计算机会根据数据类型来选择执行不同的运算,这就是运算符的多态性,运算符多态是编译器在编译时进行语法解释的,是一种编译时多态。
C++预定义了40多种运算符,但只能对基本数据类的数据进行运算。那么对类这样的自定义数据类型,应该如何运算呢,我们看一个例子,复数类Complex:
class Complex
{
private:
double real,image;//实数和虚数部分
public:
Complex(double x=0,double y=0){ real=x; image=y; }//构造函数
Complex(Complex&c){ real = C.real; image=c.image; }//拷贝构造函数
void Show(){ cout<< real<<"+"<<image<<"i"<<endl; }//显示复数
};
定义对象:c1,c2,c3
Complex c1(1,3),c2(2,4),c3;
c3 = c1+c2; //是否能被执行?,答案是可以的。需要程序员自定义运算规则。
上述列子中,要想实现复数类的加减法,就需要程序员自定义复数类型的运算符的运算规则,因为这是程序员自定义的数据类型。
重新定义C++语言已有运算符的运算规则,使同一运算符作用于不同类型数据时执行不同的运算,这就是运算符重载。正是因为C++语言支持运算符多态,程序员才能重载运算符,实现类运算。
- 程序员可以为类重载运算符,实现类运算
- 重载运算符使用函数的形式来重新定义运算符的运算规则。
程序员定义重载运算符的常见语法形式为:
函数类型 operator 运算符(形式参数)
{ 函数体 }
- 可以将运算符函数定义为类的函数成员
- 也定义为类外的一个友元函数
这两种方法在实现的功能是相同的,但在定义时,形参和函数体实现部分会有一些差别,另外针对不同运算符,其运算符函数的具体实现方法也有所不同,例如单目运算符和双目运算符、前置和后置运算符等等。
1、复数类的加法运算
代码1:重载运算符为复数类Complex的函数成员
class Complex
{
private:
double real,image;//实数和虚数部分
public:
Complex(double x=0,double y=0){ real=x; image=y; }//构造函数
Complex(Complex&c){ real = C.real; image=c.image; }//拷贝构造函数
void Show(){ cout<< real<<"+"<<image<<"i"<<endl; }//显示复数
Complex operator+(Complex c)
{
Complex result;
result.real = real + c.real;
result.image = image + c.image;
return result;
}
};
代码2:重载函数为复数类的友元函数
class Complex
{
private:
double real,image;//实数和虚数部分
public:
Complex(double x=0,double y=0){ real=x; image=y; }//构造函数
Complex(Complex&c){ real = C.real; image=c.image; }//拷贝构造函数
void Show(){ cout<< real<<"+"<<image<<"i"<<endl; }//显示复数
friend Complex operator+(Complex c1,Complex c2);
};
Complex operator+(Complex c1,Complex c2);
{
Complex result;
result.real = c1.real + c2.real;
result.image = c1.image + c2.image;
return result;
}
从上述例子可以看出,如果不将运算符函数定义成类中的函数成员,那么他就是类外的普通函数,为了让类外的函数能够访问类中的非公有成员,就必须将他们定义成类的友元函数。
在对复数类进行加法的重载后,就可以使用复数类进行加法运算了。例如:
Complex c1(1,3),c2(2,4),c3;
c3 =c1+c2;
c3.show();//显示结果为3+7i
计算机执行“c1+c2”的加法运算,相当于是执行了一次函数调用,其调用形式如下:
- 若运算符“+”被重载为复数类的函数成员,则调用形式为“c1.+(c2)”,其中c1是对象名,“.”是成员运算符,“+”是函数成员名,c2是实参。
- 如果将运算符“+”重载为复数类的友元函数,则调用形式为“+(c1,c2)”,其中,“+”是友元函数名,c1和c2是实参。
2、为复数类重载单目运算符“++”
class Complex
{
private:
double real,image;//实数和虚数部分
public:
Complex(double x=0,double y=0){ real=x; image=y; }//构造函数
Complex(Complex&c){ real = c.real; image=c.image; }//拷贝构造函数
void Show(){ cout<< real<<"+"<<image<<"i"<<endl; }//显示复数
Complex & operator ++()//实现前置++
{
real++;image++;//规则,实部与虚部均加1
return *this;//返回前置“++”表达式结果:加1后对象的引用
}
Complex operator ++(int)//后置++
{
Complex temp(*this);
real++;image++;
return temp;//返回后置++表达式的结果:加1之前的对象
}
};
c++语言规定,前置单目运算符重载时没有形参,后置单目运算符重载时需要有一个int形参,这个int型形参没有参数名,这时语法规定,在函数体中并不使用这个形参,其目的是使两个重名函数拥有不同的形式参数,才能实现重载。
#include <iostream>
using namespace std;
#include "complex.cpp"
int main()
{
Complex c1(1,3),c2(2,4),c3,c4;
c3 = ++c1;//前置++,返回实部虚部加一后的结果
c1.Show();//返回结果为2+4i,也就是自增1后的结果
c3.Show();//返回结果为2+4i,也就是c1自增1后的结果
//经过前置++运算,此时c1保存的值是2+4i
c4 = c1++;//后置++,返回实部虚部自增一前的结果
c1.Show();//显示结果为:3+5i,即自增1后的结果。
c4.Show();//显示结果是2+4i,即c1自增1之前的结果
return 0;
}
3、复数类重载关系运算符“==”
class Complex
{
private:
double real,image;//实数和虚数部分
public:
Complex(double x=0,double y=0){ real=x; image=y; }//构造函数
Complex(Complex&c){ real = c.real; image=c.image; }//拷贝构造函数
void Show(){ cout<< real<<"+"<<image<<"i"<<endl; }//显示复数
bool Complex::operator ==(Complex c)
{
return(real==c.real&&image==c.image);
}
};
此处教学代码有误,待修正
4、赋值运算符”=“
Complex& Complex::operator=(Complex &c)
{
real = c.real; image = c.image;
return *this;
}
代码有误。
总结:为方便程序员,C++语言已经默认为所有类重载了赋值运算符,如果某个类在构造函数中动态分配了内存,那么就需要为这个类编写析构函数来释放这些内存,此时,拷贝构造函数和重载运算符”=“的函数都需要程序员自己来重新编写,其目的是进行深拷贝,为新建对象或被赋值对象,动态在分配同样多的内存,
5、运算符重载的语法细则
- 除却下面5个运算符,C++语言中的其他运算符都可以重载,5个不能重载的运算符是:
- 条件运算符”?:“
- sizeof运算符
- 成员运算符”.“
- 指针运算符"*"
- 和作用域运算符"::"
- 重载后,运算符的优先级和结合性不会改变。改变的只是运算规则。
- 重载后,运算符的操作数个数不能改变,同时至少要有一个操作数是自定义数据类型。
- 重载后,运算符的含义应与原运算符相似,否则会给使用类的程序员造成困扰。
4.2 对象的替换与多态
在类的继承与派生过程中,除构造函数和析构函数之外,派生类将继承所有基类中的数据成员和函数成员,派生类和基类之间存在着这样一种特殊的关系,就是:派生类是一种基类,具有基类的所有功能。
面向对象程序设计,利用派生类和基类之间的这种特殊关系,常常将派生类对象当做基类对象来使用,或者用基类来代表派生类,其目的是为提高程序代码的可重用性。
程序代码的可重用性:C++语言对数据一致性的要求比较严格,属于强类型检查的计算机语言,C语言、java语言也属于强类型检查的语言,因为数据不一致,不能重用不同类型的函数去处理不同类型的数据。例如:
void fun(int x){... ...}
fun(5);//正确,形参和实参相匹配
fun(5.0);//错误,形参是int型,而实参是double型
//要想处理double数据,就必须定义个重载函数。
void fun(double x){... ...}
fun(5.0);//编译器根据形实结合,自动调用fun(double x)
那么在类中,情况是怎么样的呢?
class A{... ...};//定义一个类A
void afun(A x){... ...}//处理A类数据的函数afun,x是A类的对象
A aObj;//定义一个A类对象aObj
afun(aObj);//正确,形实一致。
class B{... ...};//定义一个类B
B bObj;//定义一个B类对象bObj
afun(bObj);//错误,bObj的类型与afun中形参的类型不一致
结论:不能重用函数afun的代码来处理B类的对象数据。
但是在面向对象程序设计中,有一种特殊情况是可以的,这就是类B公有继承类A,即B是A的派生类,这时就能重用A类的代码来处理B类的数据
class B:public A
{... ...};
在面向对象程序设计中,重用基类对象的程序代码来处理派生类对象,这是非常普遍的需求。如果派生类对象能够与基类对象一起共用程序代码,它将极大的提高程序开发和维护的效率。面向对象程序设计方法,利用派生类和基类之间存在的特殊关系,提出了对象的替换与多态。
- liskov替换准则:将派生类对象当做基类对象来使用。
- 对象多态性:用基类代表派生类
其目的还是提高程序代码的可重用性,本节用一个钟表的例子来讲解。
class Clock
{
private:
int hour,minute,second;//时分秒
public:
void Set(int h, int m, int s)//设置时间
{
hour = h; minute=m; second=s;
}
void Show()//显示时间
{
cout<<hour<<":"<<":"<<second;
}
};
派生类:手表类
#include "Clock.h"
class Watch:public Clock
{
public:
int band;
void Show()
{
cout<<"Watch";
Clock::Show();
}
};
派生类:挂钟类
#include "Clock.h"
class WallClock:public Clock
{
public:
int size;
void Show()
{
cout<<"WallClock";
Clock::Show();
}
};
派生类:潜水表类
#include "Watch.h"
class DivingWatch:public Watch
{
int depth;
void Show()
{
cout<<"DivingWatch";
Clock::Show();
}
};
类的继承和派生可以任意多级,基类即下面的各级派生类共同组成了一个具有继承关系和共同特性的类的家族,我们称之为类族。类祖中的子类具有共同的祖先,都继承了基类中的成员。
1、liskov替换准则
全球根据地理精度,被换分为24个市区,不同市区的时间与格林威治时间(GMT)它们是存在时差的,例如北京时间比格林威治时间晚8个小时,可以定义一个函数GMT,将格林威治时间转换为北京时间,
void SetGMT(Clock &rObj,int hGMT,int mGMT, int sGMT)
{
rObj.Set(hGMT+8,mGMT,sGMT);//小时加8,即晚8个小时。
}
Clock obj;//定义1个基类Clock的对象obj
SetGMT(obj,8,30,0);//将基类对象obj的时间设为8:30分(GMT时间)
obj.Show();//显示基类对象obj中的时间,结果为:16:30:0
问题:可否使用SetGMT去设置Clock的派生类?
为了让基类对象及派生类对象之间可以重用代码,c++语言指定了如下的类型兼容语法规则:
- 派生类对象可以赋值给基类对象
- 派生类的对象可以初始化基类引用
- 派生类对象的地址可以赋值给基类的对象指针,或者说基类的对象指针可以指向派生类对象。
应用类型兼容语法规则有1个前提条件和1个使用限制:
- 前提条件:派生类必须公有继承基类
- 使用限制:通过基类对象、引用或对象指针访问派生类对象,只能访问其基类成员
简单来说,就是将派生类对象,当做基类来使用。
Watch obj1;//定义1个派生类Watch的对象obj1
obj1.Set(8,30,0);//将Watch对象obj1的时间设为8点30分(北京时间)
obj1.Show();//显示Watch对象obj1的时间,显示结果为Watch 8:30:0
obj1.band =1;//设置Watch对象obj1的表带类型,假设1代表皮革
//如何将派生类Watch,当做基类使用?
//演示1:将派生类对象obj1赋值给基类对象obj
Clock obj;
obj=obj1;//派生类对象赋值给基类对象
obj.Show();//访问赋值后基类对象obj的成员Show,显示时间:8:30:0
cout<<obj.band;//错误。赋值后基类对象obj不包含派生类对象obj1中的新增成员。
//演示2:通过基类引用rObj访问派生类obj1
Clock &rObj = obj1;
rObj.Show();//访问赋值后基类对象obj的成员Show,显示时间:8:30:0
cout<<rObj.band;//错误。通过引用访问派生类对象obj1,不能访问新增成员。
//通过基类对象指针pObj访问派生类obj1
Clock *pObj = &obj1;
pObj->Show();//访问赋值后基类对象obj的成员Show,显示时间:8:30:0
cout<<pObj->band;//错误。通过指针访问派生类对象obj1,不能访问新增成员。
应用liskov替换准则,可以将派生类对象当做基类来处理,即用基类对象替换派生类对象,将派生类对象当做基类对象处理的好处是,使某些处理基类对象的代码可以被派生类对象重用。
void SetGMT(Clock &rObj,int hGMT,int mGMT, int sGMT)
{
rObj.Set(hGMT+8,mGMT,sGMT);//小时加8,即晚8个小时。
}
Clock obj;//定义1个基类Clock的对象obj
SetGMT(obj,8,30,0);//将基类对象obj的时间设为8:30分(GMT时间)
obj.Show();//显示基类对象obj中的时间,结果为:16:30:0
Watch obj1;//定义Watch对象obj1
SetGMT(obj1,8,30,0);//使用SetGMT函数设置派生类的对象。SetGMT的形参是基类的引用。
obj.Show();//能够显示派生类的时间:16:30:0,因为基类只能访问派生类继承的基类成员。
2、对象多态性
显示基类Clock对象的时间
Clock obj; SetGMT(OBJ,8,30,0);
OBJ.Show();//显示基类对象obj的时间,显示结果:16:30:0
void ShowBeijing(Clock &rObj)
{
rObj.Show();cout<<"(北京时间)";
}
ShowBeijing(obj);//显示:16:30:0(北京时间)
显示派生类Watch对象时间
void ShowBeijing_Watch(Watch &rObj)
{
rObj.Show();cout<<"(北京时间)";
}
Watch obj1;SetGMT(obj1,8,30,0);
ShowBeijing_Watch(obj);//显示:Watch 16:30:0(北京时间)
如果我们使用基类的方法去显示Watch的时间,显示时间和基类的时间相同,没有Watch标签。
ShowBeijng(obj1);//显示时间为:16:30:0(北京时间)
其原因在liskov替换原则中已经讲过,通过基类对象去访问派生类对象时,只能访问到派生类对象继承的基类成员,实际上通过基类Clock的方法ShowBeijng去访问派生类Watch对象时,调用的是基类的Show方法。
那么如果通过重用函数ShowBeijng的代码,并能区分基类和派生类对象,再分别调用其对应的显示时间函数Show呢?
那么什么是对象的多态性呢?例如米老鼠和唐老鸭是老鼠类和鸭子类的对象,
- 向米老鼠下达指令“Go”,米老鼠将迈开4条腿迅速移动
- 向唐老鸭下达指令“Go”,唐老鸭将迈开2条腿蹒跚而行
- 不同对象在执行相同指令“Go”的时候,会表现出不同的形态,这就是对象的多态性。
在面向对象程序设计中,不同的对象,可能有相同的名称的函数成员,例如使用类A、类B分别定义对象aObj和bObj,假设它们都有1个名为Fun的函数成员,但是其算法和功能都各不相同,将调用对象函数成员Fun的操作做如下类比:
- 调用对象的函数成员Fun:aObj.Fun();bObj.Fun();类似于向对象aObj和bObj分别下达了相同的指令“Fun”
- 执行函数Fun:类似于对象aObj和bObj各自执行指令“Fun”
- 完成不同的功能,类似于对象aObj和bObj表现出不同的形态
对象的多态:
- 面向对象程序设计,借用拟人的说法,将调用对象的某个函数成员称为向对象发生一条消息或指令。
- 将执行函数成员完成某种程序功能称为对象响应该消息所表现出的行为。
- 不同对象接收相同的消息,但会表现出不同的行为,这就称为对象的多态性,或称为对象具有多态。
从程序角度,对象多态性就是,调用不同对象的同名函数成员,但执行的函数不同,完成的程序功能不同,导致对象多态的同名函数成员有3种不同的形式:
- 不同类之间的同名函数成员:类成员具有类作用域,不同类之间的函数成员可以重名,互不干扰。
- 类中的重载函数:类中的函数成员可以重名,只要他们的形参个数不同或类型不同,重载函数成员导致的多态本质上属于重载函数多态。
- 派生类中的同名覆盖:派生类中新增成员可以与从基类继承而来的函数成员重名,但它们不是重载函数。
对象的多态,重点是研究同名覆盖,为了扩展和修改基类的功能,类族中的派生类可能会定义新的函数成员,来覆盖同名的基类成员,这样同一个类族中的基类及各个派生类都具有各自的同名函数成员,这些函数成员虽然名字相同,但实现算法有所不同。
应用liskov替换准则,将派生类对象当作基类对象来处理,即用基类对象替换派生类对象,其目的是让某些处理基类对象的代码可以被派生类对象重用,这里“某些”代码的含义是:这些代码在通过基类的引用或对象指针访问派生类成员时,只能访问其基类成员。例如前文中的北京时间设置函数:
void SetGMT(Clock &rObj,int hGMT,int mGMT, int sGMT)
{
rObj.Set(hGMT+8,mGMT,sGMT);//不管是基类或派生类对象,都调用基类成员Set
}
还有另外一些代码,这些代码在通过基类的引用或对象指针访问同一类族的对象时,需要根据实际的引用或指向的对象类型,自动调用该类同名函数成员中的新增成员(而不是基类成员),例如:
void ShowBeijing(Clock &rObj)
{
rObj.Show();cout<<"(北京时间)";//希望能够区分基类和派生类对象,自动调用对应的成员
}
应用对象多态性,相当于时用基类来代表派生类,通过基类引用或对象指针调用派生类对象的函数成员,应能够根据实际引用或指向的对象类型,自动调用该类同名函数成员中的新增成员。C++语言使用虚函数的语法形式来实现类族中对象的多态性
实现对象的多态性:
- 首先在定义基类时使用“virtual”关键字将函数成员声明成虚函数
- 然后通过公有继承定义派生类,并重写虚函数成员,也就是新增1个与虚函数同名的函数成员。
- 然后使用基类引用或对象指针,来调用函数成员
- 调用基类对象的函数成员:自动调用基类成员
- 调用派生类对象的函数成员:
- 普通类函数成员:自动调用基类成员
- 虚函数成员:自动调用派生类成员,即派生类的新增成员
虚函数的声明与调用:
定义基类:A
class A
{
public:
virtual void fun1();//声明fun1为虚函数
void fun2();//fun2为普通函数,即非虚函数
};
void A::fun1(){ cout<<"Base class A: virtual fun1() called."<<endl;}
void A::fun2(){ cout<<"Base class A: non-virtual fun2() called."<<endl; }
定义派生类:B
class B:public A
{
public:
virtual void fun1();//重新基类的虚函数成员fun1
void fun2();//重新基类的非虚函数成员fun2
};
void B::fun1(){ cout<<"Derived class B:virtual fun1() called."<<endl; }
void B::fun2(){ cout<<"Derived class B:non-virtual fun2() called."<<endl; }
声明虚函数的语法细则:
- 只能在类声明部分声明虚函数,在类实现部分定义函数成员时不能使用“virtual”关键字
- 基类中声明的虚函数成员,被继承到派生类后,自动成为派生类中的虚函数成员
- 派生类可以重写基类虚函数成员,如果重写后的函数原型与基类函数成员一致,则该函数自动称为派生类的虚函数成员,无论声明时加不加“virtual”关键字
- 类函数成员中的静态函数、构造函数不能是虚函数。析构函数可以是虚函数
调用实例:
A aobj;//定义1个基类对象aobj
B bobj;//定义1个派生类对象bobj
aobj.fun1();//调用结果:调用了基类对象的虚函数成员fun1
aobj.fun2();//调用结果:调用了基类对象的虚函数成员fun2
bobj.fun1();//调用结果:调用了派生类对象bobj的新增函数成员fun1
bobj.fun2();//调用结果:调用了派生类对象bobj的新增函数成员fun2
//结论1:通过对象名访问派生类对象的成员,将访问器新增成员(同名覆盖)
调用虚函数:通过基类引用分别调用虚函数成员和非虚函数成员,对比结果
A &raobj = aobj;//定义1个基类引用raobj,引用基类对象aobj
raobj.fun1();//调用结果:调用了基类对象aobj的虚函数成员fun1
raobj.fun2();//调用结果:调用了基类对象aobj的非虚函数成员fun2
A &raobj = bobj;//定义1个基类引用raobj,引用派生类对象bobj
rbobj.fun1();//调用结果:调用了派生类对象bobj的新增函数成员fun1
rbobj.fun2();//调用结果:调用了派生类对象bobj的基类函数成员fun2
//结论2:通过基类引用访问派生类对象的虚函数成员将访问其新增成员(多态)
//通过基类引用范围派生类对象的非虚函数成员将访问其基类成员
通过对象指针访问:
A *paobj = &aobj;//定义1个基类对象指针paobj,指向基类对象aobj
paobj->fun1();//调用结果:调用了基类对象aobj的虚函数成员fun1
p->fun2();//调用结果:调用了基类对象aobj的非虚函数成员fun2
A *paobj = &bobj;//定义1个基类对象指针paobj,指向派生类对象bobj
rbobj.fun1();//调用结果:调用了派生类对象bobj的新增函数成员fun1
rbobj.fun2();//调用结果:调用了派生类对象bobj的基类函数成员fun2
//结论2:通过基类对象指针访问派生类对象的虚函数成员将访问其新增成员(多态)
//通过基类对象指针访问派生类对象的非虚函数成员将访问其基类成员
实现基类对象与派生类对象之间的多态性要满足以下3个条件:
- 在基类中声明虚函数成员
- 派生类需公有继承基类,并重写虚函数成员(属于新增成员)
- 通过基类的引用或对象指针调用虚函数成员
只有满足这3个条件,基类对象和派生类对象才会分别调用各自的虚函数成员,呈现出多态性。
为了让某天类族共用程序代码:
- 定义基类时首先需确定将哪些函数成员声明成虚函数,将需要自动调用派生类新增的同名函数成员定义成虚函数。
- 定义派生类时,公用继承基类,并重写那些从基类继承来的虚函数成员
- 编写类族共用的程序代码时,需定义基类引用或对象指针来访问该类族的对象(不管时基类对象,还是派生类对象),然后通过基类引用或对象指针来调用派生类对象的函数成员,调用虚函数将自动调用派生类中重写的虚函数,否则将调用从基类继承来的函数成员
3、抽象类
面向对象程序设计方法,来设计解决某个实际问题的计算机程序,先从实际问题中提取出一个个具体的对象,并将具有共性的对象划分为类,可以继续将多个不同类中的共性抽象出来,形成基类,编码时再从基类进行派生,还原出各个不同的类,抽象出基类可以凝练类代码,有效减小程序中的重复代码,从对象抽象出类,从类继续抽象出基类,这是一个自底向上,逐步抽象的过程,越往上,类就越宽泛,越抽象.
定义一个圆形类:Circle
class Circle
{
public:
double r;
double CArea();
double CLen();
};
//类实现部分,代码省略
定义一个矩形类:Rectangle
class Rectangle
{
public:
double w,h;
double RArea();
double RLen();
};
//类实现部分,代码省略
在圆和矩形的基础上进行抽象,定义一个形状:Shape
class Shape
{
public:
double Area();
double Len();
};
形状类有2个函数成员,仔细分析会发现这两个函数成员无法定义,因为形状是一个纯抽象的概念,无法计算其面积和周长,形状类就是一种抽象类。其中有2个只声明未定义的函数成员,他们就是一种纯虚函数。这一节将结合形状类Shape,来具体介绍纯虚函数,抽象类已经它们在面向对象程序设计中的作用。
纯虚函数:类定义中,只声明,未定义的函数被称为纯虚函数。纯虚函数的声明语法形式是,用关键字virtual声明,并在其声明部分加上等于0(”=0“),例如:
class Shape
{
public:
virtual double Area()=0;
virtual double Len()=0;
};
纯虚函数是一种虚函数,据有虚函数的特性,最重要的就是纯虚函数成员在调用时具有多态性。
抽象类:含有纯虚成员的类就是抽象类,例如形状类Shape,抽象类有以下特点:
-
抽象类不能实例化
不能用抽象类定义对象(即不能实例化),因为抽象类中含有未定义的纯虚函数,其类型定义还不完整。但可以定义抽象类引用、对象指针,所定义的引用、对象指针可以引用或指向其他派生类的实例化对象。
-
抽象类可以作为基类定义派生类
- 抽象类可以作为基类定义派生类,派生类继承抽象类中除构造函数、析构函数之外的所有成员,包括纯虚函数成员
- 纯虚函数成员只声明了函数原型,没有定义函数体代码,因此派生类继承纯虚函数成员时,只是继承了其函数原型,即函数接口,派生类需要为纯虚函数成员编写函数体代码,称为实现纯虚函数成员。
- 派生类如果实现了所有的纯虚函数成员,那么它就变成了一个普通的类,可以实例化。
例如:利用类Shape定义派生类Circle和派生类Rectangle
//派生类Circle
class Circle:public Shape
{
public:
double r;//新增数据成员,半径r
Circle(double x=0)//构造函数
{ r = x; }
double Area()//同名覆盖,实现纯虚函数Area
{ return(3.14*r*r);}
double Len()//同名覆盖,实现纯虚函数Len
{ return(3.14*2*r);}
};
//派生类Rectangle
class Rectangle:public shape
{
public:
double a,b;//新增数据成员,长宽:a,b
Rectangle(double x=o,double y=0)//构造函数
{ a= x; b=y; }
double Area()//同名覆盖,实现纯虚函数Area
{ return(a*b); }
double Len()//同名覆盖,实现纯虚函数Len
{ return((a+b)*2);}
};
以上两个派生类,都实现了纯虚函数,因此可以实例化。
抽象类的应用:
-
统一类族接口
通常,派生类继承基类是为了重用基类代码,如果基类是抽象类,其中的纯虚函数成员并没有定义函数体代码,只是声明了函数原型,基类声明纯虚函数成员的目的不是为了重用其代码,而是为了统一类族对外的接口。在基类中声明纯虚函数成员,各派生类按照各自的功能要求实现这些纯虚函数,这样类族中所有的派生类都具有相同的接口。统一类族可以方便类族的使用,程序员只需呀记忆一套函数名即可。
Circle cObj; Rectangle rObj; cObj.Area(); cObj.Len(); //求圆的面积和周长 rObj.Area(); rObj.Len();//求矩形的面积和周长 //在求圆和矩形的周长和面积时,调用的是一样的函数,这就是接口的统一。
-
重用代码
抽象类定义的纯虚函数具有虚函数的特性,调用时具有多态性,在基类中声明纯虚函数成员的另一个目的是利用虚函数调用时的多态性,让类族中的所有派生类对象可以重用相同的代码。
//为Shape定义一个显示信息的函数ShapeInfo void ShapeInfo(Shape *pObj)//显示面积和周长信息 { cout<< pObj->Area()<<","<<PObj->Len()<<endl; } //调用主函数代码 int main() { Circle cObj(10);//定义1个圆形类对象cObj Rectangle rObj(5,10);//定义1个矩形类对象rObj ShapeInfo(&cObj);//显示圆形对象cObj的周长和面积信息 ShapeInfo(&rObj);//显示矩形对象rObj的周长和面积信息 return 0; }
五、关于多继承的讨论
派生类可以从多个基类继承,这就是多继承,多继承派生类存在比较复杂的成员重名问题,其表现形式有3种:
-
新增成员与基类成员重名:在新增成员与基类成员重名的情况下访问派生类对象,所放问到的是新增成员,还是基类成员?这要由访问形式来决定:
- 如果通过派生类的对象名、引用或对象指针访问派生类对象,则访问到的是新增成员,此时新增成员覆盖同名的基类成员(同名覆盖)。
- 如果通过基类的对象名、引用或对象指针访问派生类对象,则访问到的是基类成员。此时派生类对象被当做基类对象使用(liskov替换准则)
- 如果基类定义虚函数成员,派生类公有继承基类并重写虚函数(属于新增成员),那么通过基类的引用或对象指针访问派生类对象,所访问到的将是新增的虚函数成员。这就是调用对象中虚函数成员时所呈现出的多态性(对象多态性)
- 多个基类之间的成员重名:如果多个基类之间有重名的成员,同时继承这些基类会造成派生类中基类成员之间的重名。
- 同一基类成员被重复继承:多级派生时,从同一基类派生出多个派生类,这多个派生类再被多继承到同一个下级派生类,该下级派生类将包含多分基类成员的拷贝,也就是同一个基类被重复继承。
第一种已经在第4节中详细讲解,本小节重点讲解后面两种。
5.1多个基类之间的重名
基类A1:
class A1
{
public:
int a1;
int a;
void fun()
{ cout<<a1<<","<<a<<endl; }
};
基类A2:
class A2
{
public:
int a2;
int a;
void fun()
{ cout<<a2<<","<<a<<endl; }
};
双继承派生类B,继承A1:a1,a,fun;继承A2:a2,a,fun
class B:public A1,public A2
{
public:
//...不新增任何成员,因此派生B只包含从基类A1、A2继承的基类成员
};
使用多继承派生类B定义对象:
B bObj;//定义派生类对象bObj
cin>>bObj.a1>>bObj.a2;//访问不重名的基类成员,直接使用成员名
cin>>bObj.A1::a>>bObj.A2::a;//访问重名的基类成员,需在成员名前加“基类名::”
bObj.A1::fun();//调用从A1类继承来的基类函数成员fun,显示a1和A1::a的值
bObj.A2::fun();//调用从A2类继承来的基类函数成员fun,显示a2和A2::a的值
5.2 重复继承
基类A:
class A
{
public:
int a;
void fun(){ cout<<a<<endl; }
};
派生类A1:
class A1:public A //公有继承基类A
{
public:
//不新增任何新成员
//派生类A1继承了1份基类A的成员
};
派生类A2:
class A2:public A //公有继承基类A
{
public:
//不新增任何新成员
//派生类A2继承了1份基类A的成员
};
二级派生类B:
class A:public A1,public A2 //公有继承基类A
{
public:
//不新增任何新成员
//派生类B同时继承类A1,A2,继承后将包含了2份完全相同的基类A的成员
}
多分基类被重复继承,不仅造成内存的浪费,也会造成使用上的混乱,c++语言引入了虚基类的概念,对重复继承时,不希望需保存多份拷贝的基类,在第一层继承时,就使用virtual关键字,将其声明虚基类。例如,上述代码中的,A1、和A2的代码可以修改如下:
//A1代码
class A1:virtual public A //继承虚基类A
{
public:
//不新增任何新成员
//派生类A1继承了1份基类A的成员
};
//A2代码
class A2:virtual public A //继承虚基类A
{
public:
//不新增任何新成员
//派生类A2继承了1份基类A的成员
};
这样class B在同时继承A1、A2时只会拷贝一份基类A的成员。
5.3 关于多继承的讨论
类的继承与派生主要有2个作用:
- 重用类代码:派生类继承基类,就是重用基类的代码,试用其功能,从而提高程序的开发效率
- 统一类族接口:抽象类中包含纯虚函数成员,纯虚函数只声明函数原型,没有定义代码,没有实现任何功能。如果基类是抽象类,在基类中声明纯虚函数成员,其派生类按照各自的功能要求实现这些纯虚函数。这样以该类为根的类族中的所有派生类都将具有相同的对外接口,更便于类族的使用。
多继承会造成重复继承,为解决重复继承中的多拷贝问题导致的内存浪费,c++语言又引入了虚基类的概念。虚基类又会引出更复杂的语法,例如虚基类只定义了一个带形参的构造函数,那么整个继承关系中的所有直接或间接继承虚基类的派生类,都必须在构造函数的初始化列表中对虚基类的成员进行初始化,c++因为使用多继承,引发了一系列非常复杂的语法形式,并且难以掌握,后来的其他面向对象设计语言都放弃了多继承,例如:java和c#语言,只允许单继承,派生类只能继承一个基类,即只能重用一个基类代码,但是为了统一类族接口,它们引入了一个新的概念:接口。接口类似于抽象类,但接口只包含纯虚函数,不能包含数据成员,派生类可以继承多个接口,但不会造成重复继承中的多拷贝问题,引入接口,取消多继承,这有效简化了java语言在继承和派生方面的语法学习时也便于掌握