C++程序设计:多态与虚函数

在面向对象程序设计思想中,多态就是不同的对象对同一信息产生不同的行为。面向对象的多态特性使得软件开发更科学、更符合人类的思维习惯,能有效地提高软件开发效率,缩短开发周期,提高软件可靠性。

一、多态概述

C++中的多态分为静态多态和动态多态。其中,静态多态是函数重载,在编译阶段就能确定调用哪个函数。动态多态由继承产生,是指同一个属性或行为,在基类及其各派生类中具有不同的语义,不同的对象根据所接收的消息做出不同的响应。例如,动物都能发出叫声,但不同的动物叫声不同,猫会“喵喵”、狗会“汪汪”,这就是多态的体现。

面向对象程序设计中所说的多态通常指的是动态多态。在C++中,“消息”是指调用类的成员函数,不同的行为代表函数的不同实现方式,因此,多态的本质是函数的多种实现形态。实现多态需要满足以下3个条件:

  • 基类声明虚函数。
  • 派生类重写基类的虚函数。
  • 基类指针指向派生类对象,通过基类指针访问虚函数。

二、虚函数实现多态

如果基类与派生类中有同名成员函数,根据类型兼容规则,当使用基类指针或基类引用,操作派生类对象时,只能调用基类的同名函数。如果想要使用基类指针或基类引用,调用派生类中的成员函数,需要通过虚函数实现,虚函数是实现多态的基础。

虚函数的作用是,让派生类对虚函数进行重新定义,它只能存在于类的继承层次结构中。若基类中声明了虚函数,并且派生类重新定义了虚函数,当使用基类指针或基类引用,操作派生类对象调用虚函数时,系统会自动调用派生类中的虚函数。

接下来,将针对虚函数实现多态进行详细讲解。

2.1、实现多态

虚函数的声明方式,是在成员函数的返回值类型前,添加virtual关键字,格式如下所示:

1
2
3
4
5
6
7
class 类名
{
权限控制符:
virtual 函数返回值类型 函数名(参数列表);
// 其他成员
...
};

声明虚函数时,有以下几点需要注意。

  • 虚函数不能是静态成员函数。
  • 构造函数不能声明为虚函数,但析构函数可以声明为虚函数。
  • 友元函数不能声明为虚函数,但虚函数可以作为另一个类的友元函数。
  • 虚函数只能是类的成员函数,不能将类外的普通函数声明为虚函数,即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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include<iostream>
using namespace std;

// 动物类Animal
class Animal
{
public:
// 声明虚函数speak()
virtual void speak();
};

// 类外实现虚函数speak()
void Animal::speak()
{
cout<<"动物叫声"<<endl;
}


// 猫类Cat,公有继承Animal类
class Cat:public Animal
{
public:
// 声明虚函数speak()
virtual void speak();
};

// 类外实现虚函数speak()
void Cat::speak()
{
cout<<"猫的叫声:喵喵"<<endl;
}


// 猫类Dog,公有继承Animal类
class Dog:public Animal
{
public:
// 声明虚函数speak()
virtual void speak();
};

// 类外实现虚函数speak()
void Dog::speak()
{
cout<<"狗的叫声:汪汪"<<endl;
}


int main()
{
// 创建Cat类对象cat
Cat cat;
// 定义Animal类指针pA指向对象cat
Animal *pA=&cat;
// 通过pA调用speak()函数
pA->speak();

// 创建Dog类对象dog
Dog dog;
// 定义Animal类指针pB指向对象dog
Animal *pB=&dog;
// 通过pB调用speak()函数
pB->speak();

return 0;
}

上述代码,第5~10行代码定义了动物类Animal,该类声明了虚函数speak()。第13~16行代码在类外实现虚函数speak()。需要注意的是,在类外实现虚函数时,返回值类型前不能添加virtual关键字。

第20~25行代码定义了猫类Cat,公有继承Animal。Cat类也声明了虚函数speak()。第35~40行代码定义了狗类Dog,公有继承Animal类,Dog类也声明了虚函数speak()。

第52~56行代码,在main()函数中创建Cat类对象cat,定义Animal类指针pA指向对象cat,然后通过pA调用speak()函数。第59~63行代码创建Dog类对象dog,定义Animal类指针pB指向对象dog,然后通过pB调用speak()函数。

在上述代码中,pA指针调用的是Cat类的speak()函数,会输出猫的叫声;pB调用的是Dog类的speak()函数,会输出狗的叫声。基类指针调用的永远都是派生类重写的虚函数,不同的派生类对象都有自己的表现形态。

需要注意的是,派生类在重写基类虚函数时,必须与基类中虚函数的原型完全一致,派生类中重写的虚函数前,即使不添加virtual关键字,也会被视为虚函数。

2.2、实现机制

在编写程序时,需要根据函数名、函数返回值类型、函数参数等信息正确调用函数,这个匹配过程通常称为绑定。

C++提供了两种函数绑定机制:静态绑定和动态绑定。静态绑定也称为静态联编、早绑定,它是指编译器在编译时期就能确定要调用的函数。动态绑定也称为动态联编、迟绑定,它是指编译器在运行时期才能确定要调用的函数。

虚函数是通过动态绑定实现多态的,当编译器在编译过程中遇到virtual关键字时,它不会对函数调用进行绑定,而是为含有虚函数的类建立一张虚函数表Vtable。在虚函数表中,编译器按照虚函数的声明顺序依次保存虚函数地址。同时,编译器会在类中添加一个隐藏的虚函数指针VPTR,指向虚函数表。在创建对象时,将虚函数指针VPTR放置在对象的起始位置,为其分配空间,并调用构造函数将其初始化为虚函数表的地址。需要注意的是,虚函数表不占用对象空间。

派生类继承基类时,也继承了基类的虚函数指针。当创建派生类对象时,派生类对象中的虚函数指针指向自己的虚函数表。在派生类的虚函数表中,派生类虚函数会覆盖基类的同名虚函数。当通过基类指针或基类引用操作派生类对象时,会以被操作对象的内存为准,从中获取虚函数指针,由虚函数指针找到虚函数表,调用对应的虚函数。

下面结合代码,分析虚函数实现多态的机制,示例代码如下:

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
// 定义基类Base1
class Base1
{
public:
// 声明虚函数func()
virtual void func();
// 声明虚函数base1()
virtual void base1();
// 声明虚函数show1()
virtual void show1();
};

// 定义基类Base2
class Base2
{
public:
// 声明虚函数func()
virtual void func();
// 声明虚函数base2()
virtual void base2();
// 声明虚函数show2()
virtual void show2();
};

// 定义Derive类,公有继承Base1和Base2
class Derive:public Base1, public base2
{
public:
// 声明虚函数func()
virtual void func();
// 声明虚函数base1()
virtual void base1();
// 声明虚函数show2()
virtual void show2();
};

在上述代码中,基类Base1有func()、base1()和show1()三个虚函数;基类Base2有func()、base2()和show2()三个虚函数;派生类Derive公有继承Base1和Base2,Derive类声明了func()、base1()和show2()三个虚函数。Derive类与Base1类和Base2类的继承关系,如下图所示。

img

在编译时,编译器发现Base1类与Base2类有虚函数,就为两个类创建各自的虚函数表,并在两个类中添加虚函数指针。如果创建Base1类对象(如base1)和Base2类对象(如base2),则对象中的虚函数指针会被初始化为虚函数表的地址,即虚函数指针指向虚函数表。对象base1与对象base2的内存逻辑示意图,如下图所示。

img

Derive类继承自Base1类与Base2类,也会继承两个基类的虚函数指针。Derive类的虚函数func()、base1()和show2()会覆盖基类的同名虚函数。如果创建Derive类对象(如derive),则对象derive的内存逻辑示意图,如下图所示。

img

通过基类Base1、基类Base2的指针或引用操作Derive类对象,在程序运行时,编译器从Derive类对象的内存中获取虚函数指针,通过指针找到虚函数表,调用相应的虚函数。不同的类,其函数实现都不一样,在调用时就实现了多态。

三、虚析构函数

在C++中,不能声明虚构造函数,因为构造函数执行时,对象还没有创建,不能按照虚函数方式调用。但是,在C++中可以声明虚析构函数,其方法是在“~”符号前添加virtual关键字,格式如下所示:

1
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
40
#include<iostream>
using namespace std;

// 基类Base
class Base
{
public:
// 虚析构函数
virtual ~Base();
};

Base::~Base()
{
cout<<"Base类析构函数"<<endl;
}


// 派生类Derive,公有继承Base类
class Derive:public Base
{
public:
// 虚析构函数
~Derive();
};

Derive::~Derive()
{
cout<<"Derive类析构函数"<<endl;
}


int main()
{
// 基类指针指向派生类对象
Base *pb=new Derive;
// 释放基类指针
delete pb;

return 0;
}

上述代码,第5~10行代码定义了Base类,该类声明了虚析构函数。第19~24行代码定义了Derive类公有继承Base类。Derive类中定义了析构函数,虽然析构函数前面没有添加关键字virtual,但它仍然是虚析构函数。第35~37行代码,定义了Base类指针pb指向一个Derive类对象,然后使用delete运算符释放pb指向的空间。

虚析构函数的定义与用法很简单,但在C++程序中却是非常重要的一个编程技巧。在编写C++程序时,最好把基类的析构函数声明为虚析构函数,即使基类不需要析构函数,也要显式定义一个函数体为空的虚析构函数,这样所有派生类的析构函数都会自动成为虚析构函数。如果程序中通过基类指针释放派生类对象,编译器能够调用派生类的析构函数完成派生类对象的释放。

四、纯虚函数与抽象类

有时候,在基类中声明函数并不是基类本身的需要,而是考虑到派生类的需求,在基类中声明一个函数,函数的具体实现由派生类根据本类的需求定义。例如,动物都有叫声,但不同的动物叫声不同,因此基类(动物类)并不需要实现描述动物叫声的函数,只需要声明即可,函数的具体实现在各派生类中完成。在基类中,这样的函数可以声明为纯虚函数。

纯虚函数也通过virtual关键字声明,但是纯虚函数没有函数体。纯虚函数在声明时,需要在后面加上“=0”,格式如下所示:

1
virtual 函数返回值类型 函数名(参数列表)=0;

上述格式中,纯虚函数后面“=0”并不是函数的返回值为0,它只是告诉编译器这是一个纯虚函数,在派生类中会完成具体的实现。

纯虚函数的作用是,在基类中为派生类保留一个接口,方便派生类根据需要完成定义,实现多态。派生类都应该实现基类的纯虚函数,如果派生类没有实现基类的纯虚函数,则该函数在派生类中仍然是纯虚函数。

如果一个类中包含纯虚函数,这样的类称为抽象类。抽象类的主要作用是,通过它为一个类群建立一个公共接口(纯虚函数),使它们能够更有效地发挥多态性。抽象类声明了公共接口,而接口的完整实现由派生类定义。

抽象类只能作为基类派生新类,不能创建抽象类的对象,但可以定义抽象类的指针或引用,通过指针或引用操作派生类对象。抽象类可以有多个纯虚函数,如果派生类需要实例化对象,则在派生类中需要全部实现基类的纯虚函数。如果派生类没有全部实现基类的纯虚函数,未实现的纯虚函数在派生类中仍然是纯虚函数,则派生类也是抽象类。

下面通过案例,演示纯虚函数和抽象类的应用,示例如下。

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
#include<iostream>
using namespace std;

// 动物类Animal
class Animal
{
public:
// 纯虚函数speak()
virtual void speak()=0;
// 纯虚函数eat()
virtual void eat()=0;
// 虚析构函数
virtual ~Animal();
};

Animal::~Animal()
{
cout<<"调用Animal析构函数"<<endl;
}


// 猫类Cat,公有继承Animal类
class Cat:public Animal
{
public:
// 声明speak()函数
void speak();
// 声明eat()函数
void eat();
// 声明析构函数
~Cat();
};

// 类外实现猫类speak()函数
void Cat::speak()
{
cout<<"小猫喵喵叫"<<endl;
}

// 类外实现猫类eat()函数
void Cat::eat()
{
cout<<"小猫吃鱼"<<endl;
}

// 类外实现猫类析构函数
Cat::~Cat()
{
cout<<"调用Cat析构函数"<<endl;
}


// 兔子类Rabbit,公有继承Animal类
class Rabbit:public Animal
{
public:
// 声明speak()函数
void speak();
// 声明eat()函数
void eat();
// 声明析构函数
~Rabbit();
};

// 类外实现兔子类speak()函数
void Rabbit::speak()
{
cout<<"小兔子咕咕叫"<<endl;
}

// 类外实现兔子类eat()函数
void Rabbit::eat()
{
cout<<"小兔子吃白菜"<<endl;
}

// 类外实现兔子类析构函数
Rabbit::~Rabbit()
{
cout<<"调用Rabbit析构函数"<<endl;
}


int main()
{
// 定义基类指针pC指向Cat类对象
Animal* pC=new Cat;
// 通过pC指针调用Cat类的speak()函数
pC->speak();
// 通过pC指针调用Cat类的eat()函数
pC->eat();
// 释放pC指针指向的空间
delete pC;

// 定义基类指针pR指向Rabbit类对象
Animal* pR=new Rabbit;
// 通过pR指针调用Rabbit类的speak()函数
pR->speak();
// 通过pR指针调用Rabbit类的eat()函数
pR->eat();
// 释放pR指针指向的空间
delete pR;

return 0;
}

上述代码,第5~14行代码定义了动物类Animal,该类提供了两个纯虚函数speak()和eat()。第23~32行代码定义了猫类Cat公有继承Animal类,Cat类实现了Animal类的全部纯虚函数。第54~63行代码定义了兔子类Rabbit公有继承Animal类,Rabbit类实现了Animal类的全部纯虚函数。

第87~93行代码,在main()函数中定义了Animal类指针pC指向一个Cat类对象,并通过pC指针调用speak()函数和eat()函数,之后使用delete运算符释放pC指针指向的空间。第96~102行代码,定义Animal类指针pR指向一个Rabbit类对象,并通过pR指针调用speak()函数和eat()函数,之后使用delete运算符释放pR指针指向的空间。

在上述代码中,Animal类是抽象类,如果创建Animal类对象,编译器会报错。例如,在main()函数中,添加如下代码:

1
Animal animal;

此时,再运行程序,编译器会报错。这里需要主要的是,如果Animal类的某个派生类没有全部实现纯虚函数,则派生类也是抽象类,不能创建该派生类的对象。

五、参考

[《C++程序设计教程》](《C++程序设计教程(第2版)》电子书在线阅读-黑马程序员 编著-得到APP (dedao.cn))


C++程序设计:多态与虚函数
https://kuberxy.github.io/2024/09/17/C++程序设计5:多态与虚函数/
作者
Mr.x
发布于
2024年9月17日
许可协议