在面向对象编程的概念中,有封装、继承和多态三种机制,正是因为这些机制,提高了程序的可读性,可扩充性和可重用性。多态就是我们介绍面向对象编程的最后一个概念,所以这一部分就是【Cpp程序设计】面向对象编程所有内容的结尾了。“多态”(polymorphism)这个概念是指同一个名字的事物可以完成不同的功能。多态可以分为编译时多态和运行时多态,前者主要为函数的重载,在Cpp文件进行编译时,编译器就可以根据传入的实参形式确定调用的函数是哪一个;而后者则是一个全新的使用方法,我们本章就主要介绍这种方法的使用,他与继承和虚函数有着紧密的联系。

本章主要内容:多态的基本概念,运行时多态的两种实现方法,多态的实现原理,以及一些注意事项。

运行时多态的两种实现方法

通过基类指针实现多态

在第四章的介绍中我们知道,派生类对象的指针可以赋值给基类指针。对于通过基类指针调用的基类和派生类中都有的同名、同参数表的虚函数语句,编译时,编译器并不知道到底该运行哪个函数,只有程序运行到该语句时,如果基类指针指向的是一个基类对象,则基类的虚函数被调用;相反,如果基类指针指向一个派生类对象,则派生类中的虚函数被调用,这种机制就被称为多态。所谓的“虚函数”,就是在声明时前面加了virtual关键字的成员函数,注意在C++中只有声明virtual后才具有多态的性质(在java中,所有类方法默认为虚,除非使用static静态化),virtual这个关键字只在类的定义中的成员函数声明处使用,不能在类的外部协成员函数体时使用。静态成员函数不能是虚函数。包含虚函数的类就被称为“多态类”。我们通过以下示例来进一步理解多态的使用方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#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{
public:
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();
pa=pb;
pa->Print();
pa=&d;
pa->Print();
pb=&e;
pb->Print();
return 0;
}

输出结果应该为:

1
2
3
4
A::Print
B::Print
D::Print
E::Print

在上面的示例中,同名同参数表的虚函数就是Print()函数,所以就是一种典型的通过指针实现的运行时多态。多态的函数调用语句一般被称为是“动态联编”的,而普通的函数调用语句是”静态联编“的。

通过基类引用实现多态

通过基类的引用调用虚函数的语句也是多态的。通过基类的引用调用基类和派生类中同名、同参数表的虚函数时,若其引用的是一个基类的对象,则被调用的是基类的虚函数;若其引用的是一个派生类对象,则被调用的是派生类的虚函数。不妨看一个例子就能够明白上面所说的内容了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#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();
}
int main(){
A a;
B b;
PrintInfo(a);
PrintInfo(b);
return 0;
}

输出结果应该为:

1
2
A::Print
B::Print

这就是通过引用实现多态的方法。

多态的实现原理(虚函数表)

在使用指针或引用实现多态时,我们知道在编译时,编译器还不知道应该执行哪个函数,那这个语句是如何执行的呢。我们可以输出一个有虚函数的类和去掉虚函数声明后的类的大小比较,我们会发现,有虚函数的类总是比去掉虚函数声明的类多4个字节,这4个字节位于存储空间的最前端,其中存放的是虚函数表的地址。每一个有虚函数的类都有一个虚函数表,该类的任何对象中都放着该虚函数表的指针。

我们假设类A有一个虚函数func(),类B继承自类A也有这样的同名同参数表的函数func()。当pa的类型是A*时,则执行pa->func()的过程为:先取出pa的前四个字节,如果pa指向的时类A的对象,则这个地址就是类A的虚函数表的地址;如果时类B的对象,则地址为类B的虚函数表。然后根据虚函数表的地址找到虚函数表,在其中查找要调用的虚函数的地址,如果pa指向的是类A,则在其中找到A::func()的地址即可,若是指向类B,则在其中找到B::func()的地址,如果其中没有func()这个函数,编译器会在虚函数表中放一个A::func(),这样就是一个普通的继承过程。最后根据所得到的函数的地址调用对应的虚函数。

关于多态的注意事项

在成员函数中调用虚函数

类的成员函数之间可以互相调用。在成员函数(静态、构造、析构除外)中调用其他虚成员函数的语句是多态的。看一个示例来理解上面这句话:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include<iostream>
using namespace std;
class CBase{
public:
void func1(){
func2();
}
virtual void func2(){
cout<<"CBase::func2()"<<endl;
}
};
class CDrived:public CBase{
public:
virtual void func2(){
cout<<"CDrived::func2()"<<endl;
}
};
int main(){
CDrived d;
d.func1();
return 0;
}

输出结果为:

1
CDrived::func2()

在构造函数和析构函数中调用虚函数

注意在构造函数和析构函数中调用虚函数不是虚函数,如果本类有这个函数,调用的就是本类的函数;如果本类没有,调用的就是直接基类的函数;如果直接基类还是没有,那么就调用简介基类的函数,以此类推。仍然以一个例子来理解这句话。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include<iostream>
using namespace std;
class A{
public:
virtual void hello(){
cout<<"A::hello"<<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;
}

输出结果应该为:

1
2
B::hello
A::bye

遵守这个规则是为了安全性考虑,不过放在应用端或者为了应对考试,记住这个规则就是最好的了。

区分多态和非多态的情况

C++不如java简洁的原因就是很多时候需要判断一个函数是否为虚函数(java中除了静态函数意外全部为虚函数)。在C++中,要注意通过基类引用或指针调用的函数语句,只有当给成员函数为虚函数时才表现为多态,如果不是虚函数,那么这个函数就是静态联编的,不会表现出多态的特性。

特别注意的时,有时一个函数没有virtual关键字也会表现出虚函数特性,因为C++中规定只要基类中的某个函数被声明为虚函数,则派生类中的同名、同参数表的成员函数即使前面不写virtual关键字,也自动成为虚函数。例如以下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#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;
}
};
class C:public B{
public:
void func1(){
cout<<"C::func1"<<endl;
}
void func2(){
cout<<"C::func2"<<endl;
}
};
int main(){
C obj;
A* pa=&obj;
B* pb=&obj;
pa->func2();
pa->func1();
pb->func1();
return 0;
}

不难看出上述代码中,func1()在类B、C中是虚函数,func2()始终是一个虚函数。所以输出结果应该为:

1
2
3
C::func2
A::func1
C::func1

虚析构函数

有时我们会让一个基类指针指向一个用new动态生成的派生类对象,在使用delete删除这个对象时,我们如果调用的是基类的析构函数,会有一部分内容没有被删除,也会产生一些逻辑问题,例如统计图形数量时,删除一个三角形的计数减在了整个图形基类上。所以我们有时会将析构函数声明为虚函数,来保证对象消亡时调用正确的析构函数。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include<iostream>
using namespace std;
class CShape{
public:
virtual ~CShape(){
cout<<"CShape::destructor"<<endl;
}
};
class CRectangle:public CShape{
public:
int w,h;
~CRectangle(){
cout<<"CRectangle::destructor"<<endl;
}
};
int main(){
CShape* p=new CRectangle;
delete p;
return 0;
}

输出为:

1
2
CRectangle::destructor
CShape::destructor

可以看出,通过虚析构函数,使对象正确的析构函数被调用。只要基类的析构函数是虚函数,那么派生类的析构函数自动成为虚函数。一般来说一个类如果定义了虚函数,则最好把析构函数也定义为虚函数

纯虚函数和抽象类

类似于java中的接口,抽象类是包含纯虚函数的类,而纯虚函数就是没有函数体的虚函数。纯虚函数的定义使用原先的函数后加=0声明完成的,例如:

1
2
3
4
5
6
7
class A{
private:
int a;
public:
virtual void Print()=0;//纯虚函数
void fun1(){cout<<"fun1"<<endl;}
};

Print()就是纯虚函数,我们不去写它的函数体,实际上这样的函数时不存在,引入这个函数就是为了便于多态的实现。抽象类之所以比较特殊,就是因为抽象类不能形成独立的对象,即上述代码中不能使用A aA* p=new AA a[2]这类语句。抽象类是用来作为基类进行扩展派生新的类的。可以定义抽象类的指针或引用并让他们指向派生类的对象,这样为多态提供了便利。独立的抽象类对象不存在,但是被包含在派生类对象中的抽象类对象是可以存在的。

如果一个类是从抽象类中派生出来的,那么当且仅当它对基类中所有纯虚函数都进行覆盖并写出函数体,他才能成为非抽象类。

到此为止,你已经掌握了基本的面向对象编程的思想。