目录
面向对象程序设计语言有封装、继承和多态三种机制,这三种机制能够有效提高程序的可读性、可扩充性和可重用性。
“多态” 指的是同一名字的实物可以完成不同的功能。多态可以分为编译时的多态和运行时的多态。前者主要是指函数的重载(包括运算符的重载)、对重载函数的调用,在编译时就能根据实参确定应该调用哪个函数,因此叫编译时的多态;而后者则和继承、虚函数等概念有关。
6.1 多态的基本概念
6.1.1 通过基类指针实现多态
派生类对象的地址可以赋值给基类指针。对于通过基类指针,调用基类和派生类中都有的同名、同参数表的虚函数这样的语句,编译时并不确定要执行的是基类还是派生类的虚函数;而当程序运行到该语句时,如果该基类指针指向的是一个基类对象,则基类的虚函数被调用;如果该基类指针指向的是一个派生类对象,则派生类的虚函数被调用。这种机制就称为“多态”。
“虚函数” 就是在声明时前面加了 virtual 关键字的成员函数。virtual 关键字只在类定义中的成员函数声明处使用,不能在类外部编写成员函数体时使用。静态成员函数不能是虚函数。
包含虚函数的类,称为“多态类”。
多态可以简单地理解成同一条函数调用语句能调用不同的函数,或者说,对不同对象发送同一消息,使得不同对象有各自不同的行为。
多态在面向对象的程序设计语言中是如此重要,以至于有类和对象的概念,但是不支持多态的语言,只能被称为 “基于对象的程序设计语言”,面不能被称为 “面向对象的程序设计语言”,如 Visual Basic 就是 “基于对象的程序设计语言”。
下面是一个体现多态规则的例子。
#include <iostream>
using namespace std;
class A
{
public:
virtual void Print() { cout << "A::Print"<<endl ; }
};
class B: public A
{
public:
virtual void Print() { cout << "B::Print" <<endl; }
};
class D: public A
{
public:
virtual void Print() { cout << "D::Print" << endl ; }
};
class E: public B
{
virtual void Print() { cout << "E::Print" << endl ; }
};
int main()
{
A a; B b; D d; E e;
A *pa = &a; B *pb = &b;
pa->Print(); //多态, a.Print()被调用,输出:A::Print
pa = pb; //基类指针pa指向派生类对象b
pa -> Print(); //b.Print()被调用,输出:B::Print
pa = & d; //基类指针pa指向派生类对象d
pa -> Print(); //多态, d. Print ()被调用,输出:D::Print
pa = & e; //基类指针pa指向派生类对象d
pa -> Print(); //多态, e.Print () 被调用,输出:E::Print
return 0;
}
程序的输出结果:
A:: Print
B:: Print
D::Print
E:: Print
程序中,4个类之间的派生关系如下图所示。
每个类都有同名、同参数表的虚函数 Print(每个 Print 函数声明时都加了 virtual 关键字)。根据多态的规则,对于 “pa-> Print()” 这样的语句,由于 Print 是虚函数,尽管 pa 是基类 A 的指针,编译时也不能确定调用的是哪个类的 Print。当程序运行到该语句时,pa 指向的是哪个类的对象,被调用的就是哪个类的 Print。例如,程序执行到第 26 行时,pa 指向的是基类对象 a,因此被调用的就是类 A 的 Print 成员函数;执行到第 28 行时,pa 指向的是类 B 的对象,因此调用的是类 B 的 Print 成员函数;第 30 行同理:类 E 是类 A 的间接派生类,因此,执行到第 32 行时,多态规则仍然适用,此时 pa 指向派生类 E 的对象,故调用的是类 E 的 Print 成员函数。
需要强调的是,编译器不会分析程序的运行过程。编译器并没有通过分析程序上下文得出在第 28 行 pa 指向的是类 B 的对象,因此第 28 行应该调用类 B 的 Print 成员函数这样的结论。多态的语句调用哪个类的成员函数,是在运行时才能确定的,编译时不能确定(具体原理后面会解释)。因此多态的函数调用语句被称为是 “动态联编” 的,而普通的函数调用语句被称为是 “静态联编” 的。
6.1.2 通过基类引用实现多态
通过基类的引用调用虚函数的语句也是多态的,即通过基类的引用调用基类和派生类中的同名,同参数表的虚函数时,若其引用的是一个基类的对象,那么被调用是基类的虚函数;若其引用的是一个派生类的对象,那么被调用的是派生类的虚函数。
下面是一个通过基类的引用实现多态的例子。
#include <iostream>
using namespace std;
class A
{
public:
virtual void Print(){ cout << "A::Print" << endl;}
};
class B:public A
{
public:
virtual void Print(){ cout << "B::Print" << endl;}
};
void PrintInfo(A &r)
{
r.Print(); //多态,调用哪个Print,取决于工引用了哪个类的对象
}
int main()
{
A a; B b;
PrintInfo(a); //输出A::Print
PrintInfo(b); //输出B::Print
return 0;
}
程序的输出结果:
A::Print
B::Print
第 15 条语句就是通过基类的引用调用基类和派生类中都有的同名,同参数表的虚函数,因而符合多态的规则。第 20 行执行过程中。PrintInfo 的形参 r 引用的是基类对象 a,因此调用A::Print,第 21 行执行过程中,r 引用的是类 B 的对象 b,因此调用 B::Print。第 15 行的函数调用语句,每次执行时调用的可能是不同类的 Print 成员函数,因此这条语句编译时不可能确定它到底调用的是哪个类的 Print,即无法静态联编。
6.2 多态的作用
在面向对象的程序设计中使用多态,能够增强程序的可扩充性,即程序需要修改或增加功能的时候,需要改动和增加的代码较少。此外,使用多态也能起到精简代码的作用。下面通过两个实例来说明多态的作用。
6.2.1 游戏程序实例
游戏软件的开发是最能体现面向对象设计方法的优势的。游戏中的人物、道具、建筑物、场景都是很直观的对象,游戏运行的过程就是这些对象相互作用的过程。每个对象都有自己的属性和方法,不同对象又可能有共同的属性和方法,特别适合使用继承、多态等面向对象的机制。下面就以 “魔法门” 游戏为例来说明多态在增加程序可扩充性方面的作用。
“魔法门” 游戏中有各种各样的怪物,如骑士、天使、狼、鬼、等。每个怪物都有生命力、攻击力两种属性。怪物能够互相攻击,个怪物攻击另一个怪物时,会使被攻击者受伤;同时被攻击者会反击,使得攻击者也受伤。但是一个怪物反击的力量较弱,只是其自身攻击力的 1/2。
怪物主动攻击、被敌人攻击和实施反击时都有相应的动作。例如,骑士攻击时的动作就是挥舞宝剑,而火龙的攻击动作就是喷火;怪物受到攻击会嚎叫和受伤流血,如果受伤过重,生命力被减为0,则怪物就会倒地死去……
针对这个游戏,该如何编写程序,才能使得游戏版本升级,要增加新的怪物时,原有的程序改动尽可能少呢?换句话说,就是怎样才能使程序的可扩充性更好?
不论是否使用多态,均应使每种怪物都有一个类与之对应,每个怪物就是一个对象。而且怪物的攻击、反击和受伤等动作,都是通过对象的成员函数实现的,因此为每个类都需要编写 Attack、FightBack 和 Hurted 成员函数。
Attact函数表现攻击动作,攻击某个怪物,并调用被攻击怪物的 Hurted 函数,以减少被攻击怪物的生命值,同时也调用被攻击怪物的 FightBack 成员函数,受被攻击怪物反击。
Hurted 函数减少自身生命值,并表现受伤动作。
FightBack 函数表现反击动作,并调用被反击对象的 Hurted 成员函数,使被反击对象受伤。
下面对比使用多态和不使用多态两种写法,来看看多态在提高程序可扩充性方面的作用。
(1)不使用多态的写法。
假定用 CDragon 类表示火龙、用 CWolf 类表示狼,用 CGhost 类表示鬼,则 CDragon 类写法大致如下(其他类的写法也类似):
class CDragon
{
private:
int power; //攻击力
int lifevalue; //生命值
public:
void attack(CWolf* p); //攻击"狼"的成员函数
void Attack( CGhost * p); //攻击"鬼"的成员函数
//......其他 Attack 重载函数
//表现受伤的成员函数
void Hurted(int nPower);
void FightBack(CWolf* p); //反击"狼"的成员函数
void FightBack(CGhost* p); //反击"鬼"的成员函数
//......其他 FightBack 重载函数
};
接下来再看各成员函数的写法:
void CDragon::Attack(CWolf* p)
{
p->Hurted(power);
p->FightBack(this);
}
void CDragon::Attack(CGhost* p)
{
p->Hurted( power);
p->FightBack(this);
}
void CDragon::Hurted(int nPower)
{
lifevalue -= nPower;
}
void CDragon::FightBack(CWolf* p)
{
p->Hurted(power/2);
}
void CDragon::FightBack(CGhost* p)
{
p->Hurted(power/2);
}
第 1 行,Attack 函数的参数 p ,指向被攻击的 CWolf 对象。
第 3 行,在 p 所指向的对象上面执行 Hurted 成员函数,使被攻击的 “狼” 对象受伤。调用 Hurted 函数时,参数是攻击者 “龙” 对象的攻击力。
第 4 行,以指向攻击者自身的 this 指针为参数,调用被攻击者的 FightBack 成员函数,接受被攻击者的反击。
在真实的游戏程序中,CDragon 类的 Attack 成员函数中还应包含表现龙在攻击时的动作和声音的代码。
第 13 行,一个对象的 Hurted 成员函数被调用会导致该对象的生命值减少,减少的量等于攻击者的攻击力。当然,真实的程序中,Hurted 函数还应包含表现受伤时动作的代码,以及生命力如果减至小于等于零,则倒地死去的代码。
第17行,p 指向的是实施攻击者,对攻击者进行反击,实际上就是调用攻击者的 Hurted 成员函数使其受伤。其受到的伤害的大小等于实施反击者的攻击力的一半(反击的力量不如主动攻击大)。当然,FightBack 函数中其实也应包含表现反击动作的代码。
实际上,如果游戏中有 n 种怪物,CDragon 类中就会有 n 个 Attack 成员函数,用于攻击 n 种怪物。当然,也会有 n 个 FightBack 成员函数(这里假设两条龙也能互相攻击)。对于其他类,如 CWolf 等,也是这样。
以上为非多态的实现方法。如果游戏版本升级,增加了新的怪物雷鸟,假设其类名为CThunderBird,则程序需要做哪些改动呢?
除了新编写一个 CThunderBird 类外,所有的类都需要增加以下两个成员函数,用以对雷鸟实施攻击,以及在被雷鸟攻击时对其进行反击:
void Attack(CThunderBird* p);
void FightBack(CThunderBird* p);
这样,在怪物种类多的时候,工作量就较大。
实际上,非多态实现中,代码更精简的做法是将 CDragon,CWolf等类的共同特点抽取出来,形成一个 CCreature 类。然后从 CCreature 类派生出 CDragon ,CWolf 等类。但是由于每种怪物进行攻击、反击和受伤时的表现动作不同,CDragon,CWolf 类还是要实现各自的 Hurted 成员函数,以及一系列 Attack,FightBack 成员函数。所以只要没有利用多态机制,那么即便引入基类 CCreature,对程序的可扩充性也无帮助。
(2)使用多态的写法。
如果使用多态机制来编写这个程序,在要新增 CThunderBird 类时,程序改动就会小得多了。
设置一个基类 CCreature,概括了所有怪物的共同特点,然后所有具体的怪物类,如 CDragon、CWolf、CGhos t等,均从 CCreature 类派生而来。下面是 CCreature 类的写法:
class CCreature { //“怪物"类
protected:
int lifevalue, power;
public:
virtual void Attack(CCreature* p) {};
virtual void Hurted(int nPower){};
virtual void FightBack(CCreature* p){};
};
在基类 CCreature 类中,只有一个 Attack 成员函数和一个 FightBack 成员函数,它们都以基类指针作为参数。实际上,所有 CCreature 的派生类都只有一个 Attack 成员函数和个 FightBack成员函数。例如,CDragon 类的写法如下:
class CDragon:public cCreature
{
public:
virtual void Attack(CCreature* p){
p->Hurted(power);
p->FightBack(this);
}
virtual void Hurted(int nPower){
lifevalue- = nPower;
}
virtual void FightBack(CCreature* p){
p->Hurted(power/2);
}
};
在 CDragon 类的成员函数中,略去了表现动作和声音的那部分代码。其他类的写法和CDragon 类的写法类似。
在上述多态的写法中,当需要增加新怪物 “雷鸟” 的时候,只需要编写新类 CThunderBird 即可,不需要在已有的类中专门为新怪物增加 void Attack (CThunderBird* p) 和 voidFightBack(CThunderBird* p) 这两个成员函数,也就是说,其他类根本不用修改。这和前面非多态的实现方法相比,程序的可扩充性当然大大提高了。实际上,即便不考虑可扩充的问题,程序本身也比非多态的写法大大精简了。
为什么 CDragon 等类只需要一个 Attack 函数,就能够实现对所有怪物的攻击?假定有以下代码段:
1. CDragon dragon;
2. Cnolf wolf;
3.cGhost ghost;
4. CThunderBird bird;
5. Dragon.Attack(& wolf);
6. Dragon. Attack(& ghost) ;
7. Dragon.Attack(& bird);
根据赋值兼容规则,上面的 5、6、7 这 3 行中的参数,都与形参类型,即基类指针类型 CCreature* 匹配,所以编译没有问题。从 5、6、7 这 3 行进入到 CDragon::Attack 函数后,执行 p->Hurted(power) 语句时,p 分别指向的是 wolf,ghost 和 bird,根据多态的规则,分别调用的就是 CWolf::Hurted、CGhost::Hurted 和 CBird::Hurted了。
关于FightBack函数的情况和 Attack类似,不再赘述。
6.2.2 几何形体程序实例
例题:编写一个几何形体处理程序:输入几何形体的个数,以及每个几何形体的形状和参数,要求按面积从小到大依次输出每个几何形体的种类及面积。假设几何形体总数不会超过 100 个。
例如,输入:
4
R 3 5
C9
表示一共有 4 个几何形体,第一个是矩形(R代表矩形),宽和高分别是 3 和 5;第二个是圆形(C代表圆),半径是 9;第三个是三角形(T代表三角形),边分别是 3、4、5。第四个是矩形,宽和高都是 2。
应当输出:
Rectangle:4
Triangle:6
Rectangle:15
Circle:254.34
该程序可以如下运用多态机制编写,不但便于扩充(添加新的几何形体),还节省了代码量。程序如下:
#include <iostream>
#include <cmath>
using namespace std;
class CShape //基类:形体类
{
public:
virtual double Area() { }; //求面积
virtual void PrintInfo() { }; //显示信息
};
class CRectangle:public CShape //派生类:矩形类
{
public:
int w,h; //宽和高
virtual double Area();
virtual void PrintInfo();
};
class CCircle:public CShape //派生类:圆类
{
public:
int r; //半径
virtual double Area();
virtual void PrintInfo();
};
class CTriangle:public CShape //派生类:三角形类
{
public:
int a,b,c; //三边长
virtual double Area();
virtual void PrintInfo();
};
double CRectangle::Area() {
return w * h;
}
void CRectangle::PrintInfo() {
cout << "Rectangle:" << Area() << endl;
}
double CCircle::Area() {
return 3.14 * r * r ;
}
void CCircle::PrintInfo() {
cout << "Circle:" << Area() << endl;
}
double CTriangle::Area() { //根据海伦公式计算三角形面积
double p = ( a + b + c) / 2.0;
return sqrt(p * ( p - a)*(p- b)*(p - c));
}
void CTriangle::PrintInfo() {
cout << "Triangle:" << Area() << endl;
}
CShape *pShapes[100]; //用来存放各种几何形体,假设不超过100个
int MyCompare(const void *s1, const void *s2) //定义排序规则的函数
{
CShape **p1 = (CShape **)s1; //s1是指向指针的指针,其指向的指针为CShape* 类型
CShape **p2 = ( CShape **)s2;
double a1 = (*p1)->Area(); //p1指向几何形体对象的指针, *p1才指向几何形体对象
double a2 = (*p2)->Area();
if( a1 < a2 )
return -1; //面积小的排前面
else if (a2 < a1)
return 1;
else
return 0;
}
int main()
{
int i; int n;
CRectangle *pr; CCircle *pc; CTriangle *pt;
cin >> n;
for( i = 0;i < n;++i ) {
char c;
cin >> c;
switch(c) {
case 'R': //矩形
pr = new CRectangle();
cin >> pr->w >> pr->h;
pShapes[i] = pr;
break;
case 'C': //圆
pc = new CCircle();
cin >> pc->r;
pShapes[i] = pc;
break;
case 'T': //三角形
pt = new CTriangle();
cin >> pt->a >> pt->b >> pt->c;
pShapes[i] = pt;
break;
}
}
qsort(pShapes,n,sizeof(Cshape *),MyCompare);
for(i = 0;i <n;++i) {
pShapes[i]->PrintInfo();
delete pShapes[i]; //释放空间
}
return 0;
}
程序涉及 3 种几何形体。如果不使用多态,就需要用 3 个数组分别存放 3 种几何形体,不但编码麻烦,而且以后如果要增加新的几何形体,就要增加新的数组,扩充性不好。
本程序将所有几何形体的共同点抽象出来,形成一个基类 CShape。CRectangle,CCircle 等各种几何形体类都由 CShape 派生而来,每个类都有各自的计算面积函数 Area 和显示信息函数 PrintInfo,这两个函数在所有类中都有,而且都是虚函数。
第 50 行定义了一个 CShape* pShapes[100] 数组。由于基类指针也能指向派生类对象,因此,每输入一个几何形体,就动态分配一个该形体对应的类的对象(第74、79、84行),然后将该对象的指针存入 pShapes 数组(第76、81、86行)。总之, pShapes 数组中的元素,有的可能指向 CRectangle 对象,有的可能指向 CCircle 对象,有的可能指向 CTriangle 对象。
第 90 行对 pShapes 数组进行排序。排序的规则是按数组元素所指向的对象的面积,从小到大排。注意,待排序的数组元素是指针,而不是对象,因此调用 qsort 时的第三个参数是 sizeof(CShape*),而不是 sizeof(CShape)。在定义排序规则的函数 MyCompare 中,形参s1(s2与s1同)指向的是待排序的数组元素,数组元素是指针,因而 s1 是指向指针的指针,其指向的指针是 CShape* 类型。*s1是数组元素,即指向对象的指针,那么 ** s1 才是几何形体对象。由于s1是 void * 类型的,* s1 无定义,因此要先将 s1 转换到一个 CShape** 类型的指针 p1(第53行),此后,*pl 即是一个 CShape* 类型的指针,* pl 指向一个几何形体对象,“(*pl)->Area()” 就能求该对象的面积了(第55行)。“(* pl)->Area() ;” 这条语句是多态的,因为 *p1 是基类指针,Area 是虚函数。程序运行到此时,*pl 指向哪种对象,就会调用其相应类的求面积函数 Area,正确求得其面积。
如果不使用多态,就需要将不同形体的面积一 一求出来存到另外一个数组中,然后再排序,排序后还要维持面积值和其所属的几何形体的对应关系,这编写起来显然是比较麻烦的。
多态的作用还体现在第 91 行和第 92 行。只要用一个循环遍历排序好的 pShapes 数组,并通过数组元素调用 PrintInfo 虚函数,就能正确执行不同形体对象的 PrintInfo 成员函数,输出形体对象的信息。
上面这个使用了多态机制的程序,不但编码比较简单,可扩充性也较好。如果程序需要增加新的几何形体类,所要做的事情也只是从 CShape 类派生出新类,然后在第 72 行的 switch 语句中加一个分支即可。
第 93 行释放动态分配的对象。按照本程序的写法,这条语句是有一些问题的。具体什么问题,如何解决,在 6.5 节 “虚析构函数” 中将会解释。
6.3 多态的实现原理
“多态” 的关键在于通过基类指针或引用调用一个虚函数时,编译时不确定到底调用的是基类还是派生类的虚函数,运行时才确定。这到底是怎么实现的呢?请看下面的程序:
#include <iostream>
using namespace std;
class A
{
public:
int i;
virtual void func() {}
virtual void func2() {}
};
class B:public A
{
int j;
virtual void func() {}
};
int main()
{
cout << sizeof(A) << "," << sizeof(B);//输出8,12
return 0;
}
在 32 位计算机中的输出结果是:
8,12
如果将程序中的 “virtual” 关键字去掉,发现输出结果变为:
4,8
对比发现,有了虚函数以后,对象的体积比没有虚函数时多了4个字节。实际上,任何有虚函数的类及其派生类的对象,都包含这多出来的4个字节,这4个字节就是实现多态的关键,它位于对象存储空间的最前端,里面存放的是虚函数表的地址。
‘ ’每一个有虚函数的类(或有虚函数的类的派生类)都有一个虚函数表,该类的任何对象中都放着该虚函数表的指针(可以认为这是由编译器自动添加到构造函数中的指令完成的)。虚函数表是编译器生成的,程序运行时被载入内存。一个类的虚函数表中,列出了该类的全部虚函数地址。
例如,上面程序中类 A 对象的存储空间以及虚函数表,如图 6.1所示(假定类A还有其他虚函数)。
类B对象的存储空间以及虚函数表(假定类B还有其他虚函数)如 6.2 所示。
多态的函数调用语句被编译成根据基类指针所指向的(或基类引用所引用的)对象中存放的虚函数表的地址,在虚函数表中查找虚函数地址,并调用虚函数的一系列指令。
假设 pa 的类型是 A*,那么 “pa- >func()” 这条语句的执行过程如下。
(1) 取出 pa 指针所指位置的前 4 个字节,即对象所属的类的虚函数表的地址。如果 pa 指向的是 A 的对象。那么这个地址就是类 A 的虚函数表的地址;如果 pa 指向的是 B 的对象,那么这个地址就是类 B 的虚函数表的地址。
(2) 根据虚函数表的地址找到虚函数表,在其中查找要调用的虚函数的地址。不妨认为虚函数表是以函数名作为索引来查找的,虽然还会有更高效的查找方法。如果 pa 指向的是 A 的对象,自然就会在 A 的虚函数表中查出 A::func 的地址;如果 pa 指向的是 B 的对象,就会在 B 的虚函数表中查出 B::func 的地址。B 没有自己的 func2 函数,因此在 B 的虚函数表中照抄了 A::func2 的地址,这样,即便 pa 指向 B 对象,“pa- >func2();” 这条语句在执行过程中也能在B的虚函数表中找到 A: :func2 的地址。
(3) 根据找到的虚函数的地址,调用虚函数。
由以上可以看出,只要是通过基类指针或基类引用调用虚函数的语句,一定就是多态,一定就会执行上面的查表过程,哪怕这个虚函数仅在基类中有,在派生类中没有。
多态机制能够提高开发效率,但是也增加了程序运行时的开销。各个对象中包含的 4 个字节的虚函数表的地址,是空间上的额外开销,而查虚函数表的过程,就是时间上的额外开销了。在计算机发展的早期,计算机非常昂贵稀有,而且运行速度慢,计算机的运算时间和内存都是宝贵的,因此人们不惜多花人力编写运行速度更快、更节省内存的程序;而在今天,计算机的运算时间和内存往往没有人的时间宝贵,运算速度也很快,因此,在用户可以接受的前提下,降低程序运行的效率以提升人员的开发效率,就是值得的了。“多态” 的应用就是典型例子。
6.4 关于多态的注意事项
6.4.1 在成员函数中调用虚函数
类的成员函数之间可以互相调用,在成员函数(静态成员函数、构造函数和析构函数除外)中调用同类的虚函数的语句是多态的。请看下面程序:
#include <iostream>
using namespace std;
class CBase
{
public:
void func1()
{
func2();
}
virtual void func2() {cout << "CBase::func2()" << endl;}
};
class CDerived:public CBase
{
public:
virtual void func2() { cout << "CDerived:func2()" << endl; }
};
int main()
{
CDerived d;
d.func1();
return 0;
}
程序的输出结果:
CDerived :func2()
第 20 行调用 func1 成员函数。进人 func1 函数,执行到第 8 行,调用了 func2 函数。看上去应该调用的是 CBase 类的 func2,但输出结果证明了实际上调用的是 CDerived 类的 func2。这是因为在 func1 函数中,"func2();” 等价于 “this->func2();”,而 this 指针显然是 CBase* 类型的,即是一个基类指针,那么 “this- >func2();” 就是在通过基类指针调用虚函数,因此这条函数调用语句就是多态的。当本程序执行到第 8 行, this 指针指向的是一个 CDerived 类的对象,即 d,所以,被调用的就是 CDerived 类的 func2 了。
6.4.2 在构造函数和析构函数中调用虚函数
在构造函数和析构函数中调用虚函数不是多态的,编译时即可确定调用的是哪个函数。如果本类有该函数,调用的就是本类的;如果本类没有。调用的就是直接基类的;如果直接基类没有,调用的就是间接基类的,以此类推。请看下面的程序:
#include <iostream>
using namespace std;
class A
{
public:
virtual void hello(){cout << "A::hel1o" << endl;};
virtual void bye(){ cout <<"A::bye" << endl;};
};
class B:public A
{
public:
virtual void hello(){cout << "B::hello" << endl;};
B(){hello();}
~B(){bye();}
};
class C:public B
{
public:
virtual void hello(){cout << "C:: hello" << endl;}
};
int main()
{
C obj;
return 0;
}
程序的输出结果:
B::hello
A::bye
A 派生出 B,B 派生出 C。第 23 行 obj 对象生成时,会调用 B 类的构造函数。在 B 类的构造函数中调用了 hello成员函数。由于构造函数中调用虚函数不是多态,所以此时不会调用 C 类的hello成员函数,而是调用 B 类自己的hello成员函数。obj 对象消亡时,会引发 B 类析构函数的调用,在 B 类的析构函数中,调用了 bye 函数。B 类没有自己的 bye 函数,只有从基类 A 继承的 bye 函数,因此执行的就是 A 类的 bye。
将构造函数中调用虚函数实现为多态,是不合适的。以上面程序为例,obj对象生成时,要先调用基类构造函数初始化其中的基类部分。在基类构造函数执行过程中,派生类部分还未完成初始化。此时在基类 B 的构造函数中调用派生类 C 的 hello成员函数,那么很可能就是不安全的了。析构函数中调用虚函数不能是多态,原因也是类似的,因为执行到基类的析构函数时,派生类的析构函数已经执行,派生类对象中的成员变量的值可能已经不正确了。
6.4.3 注意区分多态和非多态的情况
初学者往往容易弄不清一条函数调用语句是否是多态。要注意,通过基类指针或引用调用成员函数的语句,只有当该成员函数是虚函数时才会是多态。如果该成员函数不是虚函数,那么这条函数调用语句就是静态联编的,编译时就能确定调用的是那个类的成员函数。
另外,C++语言规定,,要在基类中某个函数被声明为虚函数,那么,在派生类中,同名、同参数表的成员函数即使前面不写 virtual关键字,也自动成为虚函数。
请看下面的程序:
#include <iostream>
using namespace std;
class A
{
public:
void func1() { cout<<"A::func1"<<endl; };
virtual void func2() { cout<<"A::func2"<<endl; };
};
class B:public A
{
public:
virtual void func1() { cout << "B::func1" << endl; };
void func2() { cout << "B::func2" << endl; } //func2自动成为虚函数
};
class C:public B // C以A为间接基类
{
public:
void func1() { cout << "C::func1" << endl; }; //func1自动成为虚函数
void func2() { cout << "C::func2" << endl; }; //func2自动成为虚函数
};
int main()
{
C obj;
A *pa = &obj;
B *pb = &obj;
pa->func2(); //多态
pa->func1(); //不是多态
pb->func1(); //多态
return 0;
}
程序的输出结果:
C::func2
A::func1
C::func1
基类 A 中 func2 是虚函数,因此派生类 B,C 中的 func2 声明时虽然没有写 virtual 关键字,也都自动成为虚函数。所以第 26 行就是一个多态的函数调用语句,调用的是 C 类的 func2 。
基类 A 中的 func1 不是虚函数,因此第 27 行就不是多态。编译时根据 pa 的类型,就可以确定 func1 就是类 A 的 func1。
func1 在类 B 中成为虚函数,那么在 B 的直接和间接派生类中, func1 都自动成为虚函数。因此第 28 行,pb 是基类指针,func1 是基类 B 和派生类 C 中都有的同名、同参数表的虚函数,故这条函数调用语句就是多态。
6.5 虚析构函数
有时会让一个基类指针指向用 new 运算符动态生成的派生类对象,正如前面的 “多态的作用” 一节的几何形体程序中所做的那样。new 出来的对象都是通过 delete 指向它的指针来释放的。如果一个基类指针指向 new 出来派生类对象,而释放该对象的时候是通过 delete 该基类指针来完成,就如几何形体程序的第 93 行那样,就可能导致程序不正确。请看下面的程序:
#include <iostream>
using namespace std;
class CShape
{
public:
~CShape(){ cout << "CShape::destrutor" << endl;}
};
class CRectangle:public CShape
{
public:
int w, h; //宽和高
~CRectangle(){ cout <<"CRectangle:: destrutor"<< endl;}
};
int main()
{
CShape* p = new CRectangle;
delete p;
return 0;
}
程序的输出结果:
CShape::destrutor
输出的结果说明 “delete p;” 只引发了 CShape 类的析构函数被调用,没有引发 CRectangle 类的析构函数调用。这是因为该语句是静态联编的,编译器编译到此时,不可能知道此时 p 到底指向那个类型的对象,它只根据p的类型是 CShape * ,来决定应该调用 CShape 类的析构函数。但按理说,“delete p;” 导致一个 CRectangle 类的对象消亡,应该调用 CRectangle 类的析构函数才符合逻辑,否则有可能引发程序的 bug。例如,假设程序需要对 CRetangle 对象进行计数,那么此处不调用 CRetangle 类的析构函数,就会导致计数不正确。再如,假设 CRectangle 对象存续期间进行了动态内存分配,而释放内存的操作都是在析构函数中进行的,那么此处不调用 CRetangle类的析构函数,就会导致被 delete 的对象中的动态分配的内存以后再也没有机会回收。
综上所述,希望 "delete p;” 这样的语句,能够聪明地根据 p 所指向的对象,来执行相应的析构函数。实际上,这也是多态。为了在这种情况下实现多态,C++规定,需要将基类的析构函数声明为虚函数,即虚析构函数。将上面的程序中的 CShape 类改写,在析构函数前加 “virtual” 关键字。将其声明为虚函数:
class CShape
{
public:
virtual ~ CShape() { cout <<"CShape::destrutor" << endl;}
};
则程序的输出结果变成:
CRectangle::destrutor
CShape::destrutor
说明 CRetangle类的析构函数被调用了。实际上,派生类的析构函数,会自动调用基类的析构函数。
只要基类的析构函数是虚函数,那么派生类的析构函数不论是否用 “virtual” 关键字声明,都自动成为虚析构函数。
一般来说,一个类如果定义了虚函数,则最好将析构函数也定义成虚函数。
析构函数可以是虚函数,但是构造函数不能是虚函数。
6.6 纯虚函数和抽象类
纯虚函数就是没有函数体的虚函数。包含纯虚函数的类就称为抽象类。下面的类A就是一个抽象类:
class A {
private:
int a;
public:
virtual void Print()= 0; //纯虚函数
void fun1(){ cout << "fun1";}
};
Print 就是纯虚函数。纯虚函数的写法就是在函数声明后面加 “=0”,不写函数体。所以纯虚函数实际上是不存在的,引入纯虚函数是为了便于实现多态。
之所以把包含纯虚函数的类称为 “抽象类”,是因为这样的类不能生成独立的对象。例如定义了上面的 class A 后,下面的语句都是会编译出错的:
A a;
A*p = new A;
A a[ 2];
既然抽象类不能用来生成独立对象,那么抽象类有什么用呢?抽象类可以作为基类,用来派生新类。可以定义抽象类的指针或引用,并让它们指向或引用抽象类的派生类的对象,这就为多态的实现创造了条件。独立的抽象类的对象不存在,但是被包含在派生类对象中的抽象类的对象,是可以存在的。
抽象类的概念,是很符合逻辑的。回顾 6.2 节 “多态的作用" 中的 “魔法门” 游戏程序中CCreature类的写法:
class CCreature { // “怪物" 类
protected :
int lifevalue, power;
public:
virtual void Attack(CCreature* p) = 0;
virtual void Hurted(int nPower) = 0;
virtual void FightBack(CCreature* p) = 0;
};
在该程序中,实际上不需要独立的 CCreature 对象,因为一个怪物对象,它要么代表 “狼”,要么代表 “龙”,要么代表 “雷鸟”,总之是代表一种具体的怪物,而不会是一个抽象的什么都不是的 “怪物” 类的对象。所以,上面的 CCreature 类中的Attack 、HurtedFightBack 成员函数也都无事可做。既然如此,将上面 3 个成员函数声明为纯虚函数,从而把CCreature类变成一个抽象类,就是很恰当的了。因此,CCreature 类的改进写法如下:
class CCreature { //"怪物"类
protected :
int lifevalue, power;
public:
virtual void Attack(CCreature* p) = 0;
virtual void Hurted(int nPower) = 0;
virtual void FightBack(CCreature* p) = 0;
};
同理,6.2 节 “多态的作用” 中的几何形体程序中,几何形体对象要么是个圆,要么是个三角形,要么是个矩形,等等,也不存在抽象的 CShape 类的对象,因此 CShape 类应该如下改写为抽象类:
class CShape{ //基类:形体类
public:
virtual double Area() = 0; //求面积
virtual void PrintInfo() = 0; //显示信息
};
如果一个类从抽象类派生而来,那么当且仅当它对基类中的所有纯虚函数都进行覆盖并都写出了函数体(空的函数体 “{} ”也可以),它才能成为非抽象类。
思考题:在抽象类的成员函数内可以调用纯虚函数,但是在构造函数或析构函数内部不能调用纯虚函数。为什么?
答:首先明确,在成员函数内部调用虚函数时多态;在构造函数与析构函数内调用虚函数不是多态。以构造函数为例:如果是多态,则可能在基类构造函数中执行的函数实际上是派生类的函数,可是我们知道在执行派生类的构造函数之前会执行基类的构造函数,再执行自己的构造函数。如果在基类中调用的派生类的虚函数,此时派生类的成员变量的值可能是不对的(甚至可能引发程序错误,如空指针),得到的结果也就是不正确的。析构函数同理。那么如果在抽象类的构造函数或析构函数内调用纯虚函数,由于此时不是多态,就执行抽象类本身的函数,而这个函数体本事为空,又怎么能执行呢。
关于静态联编与动态联编相关知识,对于深度理解编译器工作原理有帮助,建议搞明白。