一、多态简介
面向对象程序设计语言有 封装、继承 和 多态 三种机制,这三种机制能够有效提高程序的可读性、可扩充性和可重用性。
多态(polymorphism) 指的是同一名字的事物可以完成不同的功能。多态(polymorphism) 可以分为 编译时的多态 和 运行时的多态。前者主要是指函数的重载(包括运算符的重载)、对重载函数的调用,在编译时就能根据实参确定应该调用哪个函数,因此叫 编译时的多态;而后者则和继承、虚函数等概念有关,是本章要讲述的内容。本教程后面提及的多态都是指运行时的多态。
二、C++多态和虚函数
2.1 多态和虚函数简介
在《C++继承与派生》一章的 “C++将派生类对象赋值给基类对象(向上转型)” 一节中讲到,基类的指针也可以指向派生类对象,当基类指针 指向派生类的对象时,虽然使用了派生类的成员变量,但是却没有使用它的成员函数,导致输出结果不伦不类,不符合我们的预期。
换句话说,通过基类指针只能访问派生类的成员变量,但是不能访问派生类的成员函数。
Tips: 直观上认为,如果指针指向了派生类对象,那么就应该使用派生类的成员变量和成员函数,这符合人们的思维习惯。
为了消除这种现象,让基类指针能够访问派生类的成员函数,C++ 增加了 虚函数(Virtual Function)。使用虚函数非常简单,只需要在函数声明前面增加 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
|
#include <iostream>
using namespace std;
//基类People
class People{
public:
People(char *name, int age);
virtual void display(); //声明为虚函数
protected:
char *m_name;
int m_age;
};
People::People(char *name, int age): m_name(name), m_age(age){}
void People::display(){
cout<<m_name<<"今年"<<m_age<<"岁了,是个无业游民。"<<endl;
}
//派生类Teacher
class Teacher: public People{
public:
Teacher(char *name, int age, int salary);
virtual void display(); //声明为虚函数
private:
int m_salary;
};
Teacher::Teacher(char *name, int age, int salary): People(name, age), m_salary(salary){}
void Teacher::display(){
cout<<m_name<<"今年"<<m_age<<"岁了,是一名教师,每月有"<<m_salary<<"元的收入。"<<endl;
}
int main(){
People *p = new People("王志刚", 23);
p -> display();
p = new Teacher("赵宏佳", 45, 8200);
p -> display();
return 0;
}
|
运行结果:
1
2
|
王志刚今年23岁了,是个无业游民。
赵宏佳今年45岁了,是一名教师,每月有8200元的收入。
|
本例仅仅是在 display() 函数声明前加了一个virtual关键字,将成员函数声明为了 虚函数(Virtual Function),这样就可以通过基础People 的 p 指针调用 Teacher 类的成员函数了,运行结果也证明了这一点(赵宏佳已经是一名老师了,不再是无业游民了)。
有了虚函数,基类指针指向基类对象时就使用基类的成员(包括成员函数和成员变量),指向派生类对象时就使用派生类的成员。换句话说,基类指针可以按照基类的方式来做事,也可以按照派生类的方式来做事,它有多种形态,或者说有多种表现方式,我们将这种现象称为 多态(Polymorphism)。
上面的代码中,同样是p->display();这条语句,当 p 指向不同的对象时,它执行的操作是不一样的。同一条语句可以执行不同的操作,看起来有不同表现方式,这就是 多态(Polymorphism)。
多态(Polymorphism) 是面向对象编程的主要特征之一,C++中虚函数的唯一用处就是构成 多态(Polymorphism)。
C++提供多态的目的是:可以通过基类指针对所有派生类(包括直接派生和间接派生)的成员变量和成员函数进行“全方位”的访问,尤其是成员函数。如果没有多态,我们只能访问成员变量。
前面我们说过,通过指针调用普通的成员函数时会根据 指针的类型(通过哪个类定义的指针)来判断调用哪个类的成员函数,但是通过本节的分析可以发现,这种说法并不适用于虚函数,虚函数是根据指针的指向来调用的,指针指向哪个类的对象就调用哪个类的虚函数。
2.2 借助引用也可以实现多态
引用在本质上是通过指针的方式实现的,既然借助指针可以实现多态,那么我们就有理由推断:借助引用也可以实现多态。
修改上例中 main() 函数内部的代码,用引用取代指针:
1
2
3
4
5
6
7
8
9
10
11
|
int main(){
People p("王志刚", 23);
Teacher t("赵宏佳", 45, 8200);
People &rp = p;
People &rt = t;
rp.display();
rt.display();
return 0;
}
|
运行结果:
1
2
|
王志刚今年23岁了,是个无业游民。
赵宏佳今年45岁了,是一名教师,每月有8200元的收入。
|
由于引用类似于常量,只能在定义的同时初始化,并且以后也要从一而终,不能再引用其他数据,所以本例中必须要定义两个引用变量,一个用来引用基类对象,一个用来引用派生类对象。从运行结果可以看出,当基类的引用指代基类对象时,调用的是基类的成员,而指代派生类对象时,调用的是派生类的成员。
不过引用不像指针灵活,指针可以随时改变指向,而引用只能指代固定的对象,在多态性方面缺乏表现力,所以以后我们再谈及多态时一般是说指针。本例的主要目的是让读者知道,除了指针,引用也可以实现多态。
2.3 多态的用途
通过上面的例子我们可能还未发现多态的用途,不过确实也是,多态在小项目中鲜有有用武之地。
接下来的例子中,我们假设你正在玩一款军事游戏,敌人突然发动了地面战争,于是你命令陆军、空军及其所有现役装备进入作战状态。具体的代码如下所示:
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
|
#include <iostream>
using namespace std;
//军队
class Troops{
public:
virtual void fight(){ cout<<"Strike back!"<<endl; }
};
//陆军
class Army: public Troops{
public:
void fight(){ cout<<"--Army is fighting!"<<endl; }
};
//99A主战坦克
class _99A: public Army{
public:
void fight(){ cout<<"----99A(Tank) is fighting!"<<endl; }
};
//武直10武装直升机
class WZ_10: public Army{
public:
void fight(){ cout<<"----WZ-10(Helicopter) is fighting!"<<endl; }
};
//长剑10巡航导弹
class CJ_10: public Army{
public:
void fight(){ cout<<"----CJ-10(Missile) is fighting!"<<endl; }
};
//空军
class AirForce: public Troops{
public:
void fight(){ cout<<"--AirForce is fighting!"<<endl; }
};
//J-20隐形歼击机
class J_20: public AirForce{
public:
void fight(){ cout<<"----J-20(Fighter Plane) is fighting!"<<endl; }
};
//CH5无人机
class CH_5: public AirForce{
public:
void fight(){ cout<<"----CH-5(UAV) is fighting!"<<endl; }
};
//轰6K轰炸机
class H_6K: public AirForce{
public:
void fight(){ cout<<"----H-6K(Bomber) is fighting!"<<endl; }
};
int main(){
Troops *p = new Troops;
p ->fight();
//陆军
p = new Army;
p ->fight();
p = new _99A;
p -> fight();
p = new WZ_10;
p -> fight();
p = new CJ_10;
p -> fight();
//空军
p = new AirForce;
p -> fight();
p = new J_20;
p -> fight();
p = new CH_5;
p -> fight();
p = new H_6K;
p -> fight();
return 0;
}
|
运行结果:
1
2
3
4
5
6
7
8
9
|
Strike back!
--Army is fighting!
----99A(Tank) is fighting!
----WZ-10(Helicopter) is fighting!
----CJ-10(Missile) is fighting!
--AirForce is fighting!
----J-20(Fighter Plane) is fighting!
----CH-5(UAV) is fighting!
----H-6K(Bomber) is fighting!
|
这个例子中的派生类比较多,如果不使用多态,那么就需要定义多个指针变量,很容易造成混乱;而有了多态,只需要一个指针变量 p 就可以调用所有派生类的虚函数。
从这个例子中也可以发现,对于具有复杂继承关系的大中型程序,多态可以增加其灵活性,让代码更具有表现力。
2.4 虚函数注意事项以
C++ 虚函数对于多态具有决定性的作用,有虚函数才能构成 运行时多态。
虚函数的注意事项:
- 只需要在虚函数的声明处加上 virtual 关键字,函数定义处可以加也可以不加。
- 为了方便,你可以只将基类中的函数声明为虚函数,这样所有派生类中具有遮蔽关系的同名函数都将自动成为虚函数(关于名字遮蔽已在《C++继承时的名字遮蔽》一节中进行了讲解)。
- 当在基类中定义了虚函数时,如果派生类没有定义新的函数来遮蔽此函数,那么将使用基类的虚函数。
- 只有派生类的虚函数覆盖基类的虚函数(函数原型相同[参数相同])才能构成多态(通过基类指针访问派生类函数)。例如基类虚函数的原型为virtual void func();,派生类虚函数的原型为virtual void func(int);,那么当基类指针 p 指向派生类对象时,语句p -> func(100);将会出错,而语句p -> func();将调用基类的函数。
- 构造函数不能是虚函数,对于基类的构造函数,它仅仅是在派生类构造函数中被调用,这种机制不同于继承。也就是说,派生类不继承基类的构造函数,将构造函数声明为虚函数没有什么意义。
- 析构函数可以声明为虚函数,而且有时候必须要声明为虚函数,这点我们将在下节中讲解。
2.5 构成多态的条件
多态是指通过基类的指针既可以访问基类的成员,也可以访问派生类的成员。
下面是构成多态的条件:
- 必须存在继承关系;
- 继承关系中必须有同名的虚函数,并且它们是覆盖关系(函数原型相同);
- 存在基类的指针,通过该指针调用虚函数;
下面的例子对各种情形进行了演示:
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
|
#include <iostream>
using namespace std;
//基类Base
class Base{
public:
virtual void func();
virtual void func(int);
};
void Base::func(){
cout<<"void Base::func()"<<endl;
}
void Base::func(int n){
cout<<"void Base::func(int)"<<endl;
}
//派生类Derived
class Derived: public Base{
public:
void func();
void func(char *);
};
void Derived::func(){
cout<<"void Derived::func()"<<endl;
}
void Derived::func(char *str){
cout<<"void Derived::func(char *)"<<endl;
}
int main(){
Base *p = new Derived();
p -> func(); //输出void Derived::func()
p -> func(10); //输出void Base::func(int)
p -> func("http://c.biancheng.net"); //compile error
return 0;
}
|
在基类 Base 中我们将void func()声明为虚函数,这样派生类 Derived 中的void func()就会自动成为虚函数。p 是基类 Base 的指针,但是指向了派生类 Derived 的对象。
- 语句p -> func();调用的是派生类的虚函数,构成了多态。
- 语句p -> func(10);调用的是基类的虚函数,因为派生类中没有函数覆盖它。
- 语句p -> func(“http://c.biancheng.net”);出现编译错误,因为通过基类的指针只能访问从基类继承过去的成员,不能访问派生类新增的成员。
2.5 什么时候声明虚函数
- 首先看成员函数所在的类是否会作为基类。
- 然后看成员函数在类的继承后有无可能被更改功能,如果希望更改其功能的,一般应该将它声明为虚函数。如果成员函数在类被继承后功能不需修改,或派生类用不到该函数,则不要把它声明为虚函数。
三、C++虚析构函数的必要性
上节讲到,构造函数不能是虚函数,因为派生类不能继承基类的构造函数,将构造函数声明为虚函数没有意义。
这是原因之一,另外还有一个原因:C++ 中的构造函数用于在创建对象时进行初始化工作,在执行构造函数之前对象尚未创建完成,虚函数表尚不存在,也没有指向虚函数表的指针,所以此时无法查询虚函数表,也就不知道要调用哪一个构造函数。下节将会讲解虚函数表的概念。
析构函数 用于在销毁对象时进行清理工作,可以声明为虚函数,而且有时候必须要声明为虚函数。
为了说明虚析构函数的必要性,请大家先看下面一个例子:
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
|
#include <iostream>
using namespace std;
//基类
class Base
{
public:
Base();
~Base();
protected:
char *str;
};
Base::Base()
{
str = new char[100];
cout<<"Base constructor"<<endl;
}
Base::~Base()
{
delete[] str;
cout<<"Base destructor"<<endl;
}
//派生类
class Derived: public Base
{
public:
Derived();
~Derived();
private:
char *name;
};
Derived::Derived()
{
name = new char[100];
cout<<"Derived constructor"<<endl;
}
Derived::~Derived()
{
delete[] name;
cout<<"Derived destructor"<<endl;
}
int main()
{
Base *pb = new Derived();
delete pb;
cout<<"-------------------"<<endl;
Derived *pd = new Derived();
delete pd;
return 0;
}
|
运行结果:
1
2
3
4
5
6
7
8
|
Base constructor
Derived constructor
Base destructor
-------------------
Base constructor
Derived constructor
Derived destructor
Base destructor
|
本例中定义了两个类,基类 Base 和派生类 Derived,它们都有自己的构造函数和析构函数。在构造函数中,会分配 100 个 char 类型的内存空间;在析构函数中,会把这些内存释放掉。
pb、pd 分别是基类指针和派生类指针,它们都指向派生类对象,最后使用 delete 销毁 pb、pd 所指向的对象。
从运行结果可以看出,语句 delete pb;
只调用了基类的析构函数,没有调用派生类的析构函数;而语句 delete pd;
同时调用了派生类和基类的析构函数。
在本例中,不调用派生类的析构函数会导致 name 指向的 100 个 char 类型的内存空间得不到释放;除非程序运行结束由操作系统回收,否则就再也没有机会释放这些内存。这是典型的内存泄露。
- 为什么
delete pb;
不会调用派生类的析构函数呢?
因为这里的析构函数是非虚函数,通过指针访问非虚函数时,编译器会根据指针的类型来确定要调用的函数;也就是说,指针指向哪个类就调用哪个类的函数,这在前面的章节中已经多次强调过。pb 是基类的指针,所以不管它指向基类的对象还是派生类的对象,始终都是调用基类的析构函数。
- 为什么
delete pd;
会同时调用派生类和基类的析构函数呢?
pd 是派生类的指针,编译器会根据它的类型匹配到派生类的析构函数,在执行派生类的析构函数的过程中,又会调用基类的析构函数。派生类析构函数始终会调用基类的析构函数,并且这个过程是隐式完成的,继承和派生 章节的析构函数中已经介绍。
更改上面的代码,将基类的析构函数声明为虚函数:
1
2
3
4
5
6
7
8
|
class Base
{
public:
Base();
virtual ~Base();
protected:
char *str;
};
|
运行结果:
1
2
3
4
5
6
7
8
9
|
Base constructor
Derived constructor
Derived destructor
Base destructor
-------------------
Base constructor
Derived constructor
Derived destructor
Base destructor
|
将基类的析构函数声明为虚函数后,派生类的析构函数也会自动成为虚函数。这个时候编译器会忽略指针的类型,而根据指针的指向来选择函数;也就是说,指针指向哪个类的对象就调用哪个类的函数。pb、pd 都指向了派生类的对象,所以会调用派生类的析构函数,继而再调用基类的析构函数。如此一来也就解决了内存泄露的问题。
注意,这里强调的是基类,如果一个类是最终的类,那就没必要再声明为虚函数了。
四、C++纯虚函数和抽象类详解
4.1 纯虚函数和抽象类的概念
在C++中,可以将虚函数声明为 纯虚函数,语法格式为:
1
|
virtual 返回值类型 函数名 (函数参数) = 0;
|
纯虚函数 没有函数体,只有函数声明,在虚函数声明的结尾加上=0,表明此函数为 纯虚函数。
Tips: 最后的=0并不表示函数返回值为0,它只起形式上的作用,告诉编译系统“这是纯虚函数”。
包含纯虚函数的类称为 抽象类(Abstract Class)。之所以说它抽象,是因为它 无法实例化,也就是无法创建对象。原因很明显,纯虚函数没有函数体,不是完整的函数,无法调用,也无法为其分配内存空间。
抽象类通常是作为基类,让派生类去实现纯虚函数。派生类必须实现纯虚函数才能被实例化。
纯虚函数使用举例:
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
|
#include <iostream>
using namespace std;
//线
class Line{
public:
Line(float len);
virtual float area() = 0;
virtual float volume() = 0;
protected:
float m_len;
};
Line::Line(float len): m_len(len){ }
//矩形
class Rec: public Line{
public:
Rec(float len, float width);
float area();
protected:
float m_width;
};
Rec::Rec(float len, float width): Line(len), m_width(width){ }
float Rec::area(){ return m_len * m_width; }
//长方体
class Cuboid: public Rec{
public:
Cuboid(float len, float width, float height);
float area();
float volume();
protected:
float m_height;
};
Cuboid::Cuboid(float len, float width, float height): Rec(len, width), m_height(height){ }
float Cuboid::area(){ return 2 * ( m_len*m_width + m_len*m_height + m_width*m_height); }
float Cuboid::volume(){ return m_len * m_width * m_height; }
//正方体
class Cube: public Cuboid{
public:
Cube(float len);
float area();
float volume();
};
Cube::Cube(float len): Cuboid(len, len, len){ }
float Cube::area(){ return 6 * m_len * m_len; }
float Cube::volume(){ return m_len * m_len * m_len; }
int main(){
Line *p = new Cuboid(10, 20, 30);
cout<<"The area of Cuboid is "<<p->area()<<endl;
cout<<"The volume of Cuboid is "<<p->volume()<<endl;
p = new Cube(15);
cout<<"The area of Cube is "<<p->area()<<endl;
cout<<"The volume of Cube is "<<p->volume()<<endl;
return 0;
}
|
运行结果:
1
2
3
4
|
The area of Cuboid is 2200
The volume of Cuboid is 6000
The area of Cube is 1350
The volume of Cube is 3375
|
本例中定义了四个类,它们的继承关系为:Line –> Rec –> Cuboid –> Cube。
Line 是一个抽象类,也是最顶层的基类,在 Line 类中定义了两个纯虚函数 area() 和 volume()。
在 Rec 类中,实现了 area() 函数;所谓实现,就是定义了纯虚函数的函数体。但这时 Rec 仍不能被实例化,因为它没有实现继承来的 volume() 函数,volume() 仍然是纯虚函数,所以 Rec 也仍然是抽象类。
直到 Cuboid 类,才实现了 volume() 函数,才是 一个完整的类,才可以被实例化。
可以发现,Line 类表示“线”,没有面积和体积,但它仍然定义了 area() 和 volume() 两个纯虚函数。这样的用意很明显:Line 类不需要被实例化,但是它为派生类提供了“约束条件”,派生类必须要实现这两个函数,完成计算面积和体积的功能,否则就不能实例化。
在实际开发中,你可以定义一个抽象基类,只完成部分功能,未完成的功能交给派生类去实现(谁派生谁实现)。这部分未完成的功能,往往是基类不需要的,或者在基类中无法实现的。虽然抽象基类没有完成,但是却 强制要求派生类完成,这就是抽象基类的“霸王条款”。
抽象基类除了约束派生类的功能,还可以实现多态。请注意 Line *p = new Cuboid(10, 20, 30);
行代码,指针 p 的类型是 Line,但是它却可以访问派生类中的 area() 和 volume() 函数,正是由于在 Line 类中将这两个函数定义为纯虚函数;如果不这样做,该行后面的代码都是错误的, 这或许才是C++提供纯虚函数的主要目的。
关于纯虚函数的几点说明:
- 一个纯虚函数就可以使类成为抽象基类,但是抽象基类中除了包含纯虚函数外,还可以包含其它的成员函数(虚函数或普通函数)和成员变量;
- 只有类中的虚函数才能被声明为纯虚函数,普通成员函数和顶层函数均不能声明为纯虚函数;
五、C++多态的实现机制——虚函数表详解
5.1 虚函数表简介
前面讲到,当通过指针访问类的成员函数时:
- 如果该函数是非虚函数,那么编译器会根据指针的类型找到该函数;也就是说,指针是哪个类的类型就调用哪个类的函数;
- 如果该函数是虚函数,并且派生类有同名的函数遮蔽它,那么编译器会根据指针的指向找到该函数;也就是说,指针指向的对象属于哪个类就调用哪个类的函数。这就是多态。
编译器之所以能通过指针指向的对象找到虚函数,是因为在创建对象时额外地增加了虚函数表。
如果一个类包含了虚函数,那么在创建该类的对象时就会额外地增加一个数组,数组中的每一个元素都是虚函数的入口地址。不过数组和对象是分开存储的,为了将对象和数组关联起来,编译器还要在对象中安插一个指针,指向数组的起始位置。这里的数组就是 虚函数表(Virtual function table),简写为vtable
。
C++中,64位CPU架构 的寻址指针的大小分别是 int64_t
, 32位CPU架构 的寻址指针的大小分别是 int
。
虚函数表示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
/**
*64位机器测试例子
*/
#include <iostream>
class Base {
public:
virtual void a() { std::cout << " a()" << std::endl; }
virtual void b() { std::cout << " b()" << std::endl; }
virtual void c() { std::cout << " c()" << std::endl; }
};
int main()
{
Base base;
( ( ( void(*)() ) *( (std::int64_t*) ( *( (std::int64_t*)&base ) ) + 0) ) ) ();
( ( ( void(*)() ) *( (std::int64_t*) ( *( (std::int64_t*)&base ) ) + 1) ) ) ();
( ( ( void(*)() ) *( (std::int64_t*) ( *( (std::int64_t*)&base ) ) + 2) ) ) ();
return 0;
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
/**
*32位机器测试例子
*/
class Base {
public:
virtual void a() { std::cout << "a()" << std::endl; }
virtual void b() { std::cout << "b()" << std::endl; }
virtual void c() { std::cout << "c()" << std::endl; }
};
int main()
{
Base base;
( ( ( void(*)() ) *( (int*) ( *( (int*)&base ) ) + 0) ) ) ();
( ( ( void(*)() ) *( (int*) ( *( (int*)&base ) ) + 1) ) ) ();
( ( ( void(*)() ) *( (int*) ( *( (int*)&base ) ) + 2) ) ) ();
return 0;
}
|
输出结果:
下图为class Base 的内存模型:
从上图中我们能对虚函数表有一个直观的认知。
指向虚函数表的指针是作为数据成员(隐式/用户不感知)存在于所有对象中。当调用虚函数时,是通过对象中指向虚函数表的指针查找到对象的虚函数表(存放对象的虚函数地址的表,虚函数表是由虚函数指针组成的数组),再在虚函数表中找到对应函数的地址后进行调用的。
5.2 单继承下的虚函数表介绍
以下面的继承关系为例:
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
|
#include <iostream>
#include <string>
using namespace std;
//People类
class People
{
public:
People(string name, int age);
public:
virtual void display();
virtual void eating();
protected:
string m_name;
int m_age;
};
People::People(string name, int age): m_name(name), m_age(age){ }
void People::display()
{
cout<<"Class People:"<<m_name<<"今年"<<m_age<<"岁了。"<<endl;
}
void People::eating()
{
cout<<"Class People:我正在吃饭,请不要跟我说话..."<<endl;
}
//Student类
class Student: public People
{
public:
Student(string name, int age, float score);
public:
virtual void display();
virtual void examing();
protected:
float m_score;
};
Student::Student(string name, int age, float score):
People(name, age), m_score(score){ }
void Student::display()
{
cout<<"Class Student:"<<m_name<<"今年"<<m_age<<"岁了,考了"<<m_score<<"分。"<<endl;
}
void Student::examing()
{
cout<<"Class Student:"<<m_name<<"正在考试,请不要打扰T啊!"<<endl;
}
//Senior类
class Senior: public Student
{
public:
Senior(string name, int age, float score, bool hasJob);
public:
virtual void display();
virtual void partying();
private:
bool m_hasJob;
};
Senior::Senior(string name, int age, float score, bool hasJob):
Student(name, age, score), m_hasJob(hasJob){ }
void Senior::display()
{
if(m_hasJob)
{
cout<<"Class Senior:"<<m_name<<"以"<<m_score<<"的成绩从大学毕业了,并且顺利找到了工作,Ta今年"<<m_age<<"岁。"<<endl;
}
else
{
cout<<"Class Senior:"<<m_name<<"以"<<m_score<<"的成绩从大学毕业了,不过找工作不顺利,Ta今年"<<m_age<<"岁。"<<endl;
}
}
void Senior::partying()
{
cout<<"Class Senior:快毕业了,大家都在吃散伙饭..."<<endl;
}
int main()
{
People *p = new People("赵红", 29);
p -> display();
p = new Student("王刚", 16, 84.5);
p -> display();
p = new Senior("李智", 22, 92.0, true);
p -> display();
return 0;
}
|
运行结果:
1
2
3
|
Class People:赵红今年29岁了。
Class Student:王刚今年16岁了,考了84.5分。
Class Senior:李智以92的成绩从大学毕业了,并且顺利找到了工作,Ta今年22岁。
|
各个类的对象内存模型如下所示:
图中左半部分是对象占用的内存,右半部分是虚函数表 vtable。在对象的开头位置有一个指针 vfptr,指向虚函数表,并且这个指针始终位于对象的开头位置。
仔细观察虚函数表,可以发现基类的虚函数在 vtable 中的索引(下标)是固定的,不会随着继承层次的增加而改变,派生类新增的虚函数放在 vtable 的最后。如果派生类有同名的虚函数遮蔽(覆盖)了基类的虚函数,那么将使用派生类的虚函数替换基类的虚函数,这样具有遮蔽关系的虚函数在 vtable 中只会出现一次。
当通过指针调用虚函数时,先根据指针找到 vfptr,再根据 vfptr 找到虚函数的入口地址。以虚函数 display() 为例,它在 vtable 中的索引为 0,通过 p 调用时:
编译器内部会发生类似下面的转换:
1
|
( *( *(p+0) + 0 ) )(p);
|
下面我们一步一步来分析这个表达式:
0
是 vfptr 在对象中的偏移,p+0
是 vfptr 的地址;
*(p+0)
是 vfptr 的值,而 vfptr 是指向 vtable 的指针,所以 *(p+0)
也就是 vtable 的地址;
- display() 在 vtable 中的索引(下标)是 0,所以
( *(p+0) + 0 )
也就是 display() 的地址;
- 知道了 display() 的地址,
( *( *(p+0) + 0 ) )(p)
也就是对 display() 的调用了,这里的 p 就是传递的实参,它会赋值给 this 指针。
可以看到,转换后的表达式是固定的,只要调用 display() 函数,不管它是哪个类的,都会使用这个表达式。换句话说,编译器不管 p 指向哪里,一律转换为相同的表达式。
转换后的表达式没有用到与 p 的类型有关的信息,只要知道 p 的指向就可以调用函数,这跟 名字编码(Name Mangling)算法 有着本质上的区别。
再来看一下 eating() 函数,它在 vtable 中的索引为 1,通过 p 调用时:
编译器内部会发生类似下面的转换:
1
|
( *( *(p+0) + 1 ) )(p);
|
对于不同的虚函数,仅仅改变索引(下标)即可。
派生类是不会单独产生虚表的,派生类都是继承了父类的虚表,使用派生类中的父类的虚表即可。
Tips: 派生类中的虚表相当于拷贝了一份父类的虚表,然后派生类中将重写的虚函数进行覆盖。如果派生类中也有定定义新的虚函数,但是并没有与父类构成重写,那么这个虚函数也是在虚表中的。
以上是针对单继承进行的讲解。当存在多继承时,虚函数表的结构就会变得复杂,尤其是有虚继承时,还会增加虚基类表,更加让人抓狂,这里我们就不分析了,有兴趣的读者可以自行研究。
1) 子类继承自Base,未覆盖父类的虚函数的虚函数表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
#include <iostream>
class Base {
public:
virtual void a() { std::cout << " a()" << std::endl; }
virtual void b() { std::cout << " b()" << std::endl; }
virtual void c() { std::cout << " c()" << std::endl; }
};
class SubClass : public Base {
public:
virtual void d() { std::cout << " d()" << std::endl; }
};
int main()
{
SubClass sub;
( ( ( void(*)() ) *( (std::int64_t*) (* ( (std::int64_t*)&sub ) ) + 0) ) ) ();
( ( ( void(*)() ) *( (std::int64_t*) (* ( (std::int64_t*)&sub ) ) + 1) ) ) ();
( ( ( void(*)() ) *( (std::int64_t*) (* ( (std::int64_t*)&sub ) ) + 2) ) ) ();
( ( ( void(*)() ) *( (std::int64_t*) (* ( (std::int64_t*)&sub ) ) + 3) ) ) ();
return 0;
}
|
运行结果:
结论:
- 虚函数按照其声明顺序放于表中;
- 父类的虚函数在子类的虚函数前面;
2)子类继承自Base,覆盖父类虚函数的虚函数表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
#include <iostream>
class Base {
public:
virtual void a() { std::cout << " a()" << std::endl; }
virtual void b() { std::cout << " b()" << std::endl; }
virtual void c() { std::cout << " c()" << std::endl; }
};
class SubClass : public Base {
public:
virtual void a() { std::cout << " subclass a()" << std::endl; }
virtual void d() { std::cout << " d()" << std::endl; }
};
int main()
{
SubClass sub;
( ( (void(*)() ) *( (std::int64_t*) (* ( (std::int64_t*)&sub ) ) + 0) ) ) ();
( ( (void(*)() ) *( (std::int64_t*) (* ( (std::int64_t*)&sub ) ) + 1) ) ) ();
( ( (void(*)() ) *( (std::int64_t*) (* ( (std::int64_t*)&sub ) ) + 2) ) ) ();
( ( (void(*)() ) *( (std::int64_t*) (* ( (std::int64_t*)&sub ) ) + 3) ) ) ();
return 0;
}
|
运行结果:
1
2
3
4
|
subclass a()
b()
c()
d()
|
结论:
- 虚表中派生类覆盖的虚函数的地址被放在了基类相应的函数原来的位置(SubClass的a()函数替换了Base的a());
- 派生类没有覆盖的虚函数延用父类的;
5.3 多继承下的虚函数表介绍
1)无虚函数的覆盖的虚函数表
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
|
class Base {
public:
virtual void vFunc1() { std::cout << " Base::vFunc1" << std::endl; }
virtual void vFunc2() { std::cout << " Base::vFunc2" << std::endl; }
virtual void vFunc3() { std::cout << " Base::vFunc3" << std::endl; }
};
class Base2 {
public:
virtual void vBase2Func1() { std::cout << " Base2::vFunc1" << std::endl; }
virtual void vBase2Func2() { std::cout << " Base2::vFunc2" << std::endl; }
};
class Base3 {
public:
virtual void vBase3Func1() { std::cout << " Base3::vFunc1" << std::endl; }
virtual void vBase3Func2() { std::cout << " Base3::vFunc2" << std::endl; }
};
class SubClass : public Base , public Base2, public Base3 {
virtual void vFunc() { std::cout << " SubClass::vFunc" << std::endl; }
};
int main()
{
SubClass sub;
printf("size of sub object: %d \n", sizeof(sub));
typedef void(*FUNCTION)();
/**
*Base有三个函数 vFunc1 & vFunc2 & vFunc3
*Base2有两个函数 vFunc1 & vFunc2
*Base3有两个函数 vFunc1 & vFunc2
*从继承顺序知道虚函数表顺序是 vTablePtr1--->vTablePtr2--->vTablePtr3
*/
/**
*对象地址的前三个地址内容分别是指向vTable1的指针vTablePtr1 & 指向vTable2的指针vTablePtr2
* & 指向vTable3的指针vTablePtr3
*/
/**
*虚函数表一 Base vTablePtr1 (地址)
*/
std::int64_t *subAddress = (std::int64_t*)⊂
std::int64_t *vTablePtr1 = (std::int64_t*)*(subAddress);
/**
*虚函数表二 Base2 vTablePtr2(地址)
*/
std::int64_t *subAddress2 = (std::int64_t*)&sub + 1;
std::int64_t *vTablePtr2 = (std::int64_t*)*(subAddress2);
/**
*虚函数表三 Base3 vTablePtr3 (地址)
*/
std::int64_t *subAddress3 = (std::int64_t*)&sub + 2;
std::int64_t *vTablePtr3 = (std::int64_t*)*(subAddress3);
/**
*定义测试函数
*/
//Base
std::int64_t *pBaseFunc1 = (std::int64_t *)*(vTablePtr1 + 0);
std::int64_t *pBaseFunc2 = (std::int64_t *)*(vTablePtr1 + 1);
std::int64_t *pBaseFunc3 = (std::int64_t *)*(vTablePtr1 + 2);
// SubClass 的虚函数按序追加到第一个父类(Base)虚函数表的后面
std::int64_t *pSubVFunc = (std::int64_t *)*(vTablePtr1 + 3);
//Base2
std::int64_t *pBase2Func1 = (std::int64_t *)*(vTablePtr2 + 0);
std::int64_t *pBase2Func2 = (std::int64_t *)*(vTablePtr2 + 1);
//Base3
std::int64_t *pBase3Func1 = (std::int64_t *)*(vTablePtr3 + 0);
std::int64_t *pBase3Func2 = (std::int64_t *)*(vTablePtr3 + 1);
/**
*调用测试函数
*/
//Base
(FUNCTION(pBaseFunc1))();
(FUNCTION(pBaseFunc2))();
(FUNCTION(pBaseFunc3))();
(FUNCTION(pSubVFunc))();
//Base2
(FUNCTION(pBase2Func1))();
(FUNCTION(pBase2Func2))();
//Base3
(FUNCTION(pBase3Func1))();
(FUNCTION(pBase3Func2))();
return 0;
}
|
运行结果:
1
2
3
4
5
6
7
8
9
|
size of sub object: 24
Base::vFunc1
Base::vFunc2
Base::vFunc3
SubClass::vFunc
Base2::vFunc1
Base2::vFunc2
Base3::vFunc1
Base3::vFunc2
|
结论:
- 每个基类都有自己的虚函数表
- 派生类的虚函数地址依照继承的声明顺序放在第一个基类的虚表最后(这点和单继承无虚函数覆盖相同)
2)有虚函数的覆盖的虚函数表
SubClass的代码修改为如下:
1
2
3
4
5
6
7
8
9
10
11
12
|
class SubClass : public Base , public Base2, public Base3 {
/**
*覆盖父类Base的vFunc1
*/
virtual void vFunc1() { std::cout << " SubClass::vFunc1" << std::endl; }
virtual void vFunc() { std::cout << " SubClass::vFunc" << std::endl; }
/**
*覆盖父类Base2的vBase2Func1
*/
virtual void vBase2Func1() { std::cout << " SubClass::vFunc1" << std::endl; }
};
|
运行结果:
1
2
3
4
5
6
7
8
9
|
size of sub object: 24
SubClass::vFunc1
Base::vFunc2
Base::vFunc3
SubClass::vFunc
SubClass::vFunc1
Base2::vFunc2
Base3::vFunc1
Base3::vFunc2
|
结论:
- 虚表中派生类覆盖的虚函数的地址被放在了基类相应的函数原来的位置(相对于单继承,多继承依然是根据对应重写的函数来覆盖);
- 派生类没有覆盖的虚函数延用基类的;
3)菱形继承的虚函数表
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
|
class Base {
public:
virtual void vBaseFunc1() { std::cout << " Base::vFunc1" << std::endl; }
virtual void vBaseFunc2() { std::cout << " Base::vFunc2" << std::endl; }
virtual void vBaseFunc3() { std::cout << " Base::vFunc3" << std::endl; }
};
class SubClass1 : public Base {
public:
/**
*继承自Base的vBaseFunc1
*/
virtual void vBaseFunc1() { std::cout << " SubClass1::Base::vFunc1" << std::endl; }
virtual void vSubClass1Func() { std::cout << " SubClass1::vFunc" << std::endl; }
};
class SubClass2 : public Base {
public:
/**
*继承自Base的vBaseFunc2
*/
virtual void vBaseFunc2() { std::cout << " SubClass2::Base::vFunc2" << std::endl; }
virtual void vSubClass2Func() { std::cout << " SubClass2::vFunc" << std::endl; }
};
class SubSubClass : public SubClass1, public SubClass2 {
public:
/**
*继承自SubClass1& SubClass2 的vBaseFunc1 :所以两个虚函数表都应该被修改
*/
virtual void vBaseFunc1() { std::cout << " SubSubClass::vFunc1" << std::endl; }
/**
*继承自SubClass2 & SubClass1 的vBaseFunc2 :所以两个虚函数表都应该被修改
*/
virtual void vBaseFunc2() { std::cout << " SubSubClass::vFunc2" << std::endl; }
virtual void vSubSubClassFunc() { std::cout << " SubSubClass::vFunc" << std::endl; }
};
int main()
{
SubSubClass subsub;
printf("size of sub object: %d \n", sizeof(subsub));
typedef void(*FUNCTION)();
/**
*虚函数表一 Base vTablePtr1 (地址)
*/
std::int64_t *subAddress = (std::int64_t*)⫕
std::int64_t *vTablePtr1 = (std::int64_t*)*(subAddress);
/**
*虚函数表二 Base2 vTablePtr2(地址)
*/
std::int64_t *subAddress2 = (std::int64_t*)&subsub + 1;
std::int64_t *vTablePtr2 = (std::int64_t*)*(subAddress2);
/**
*定义测试函数
*/
//SubClass1
std::int64_t *pBaseFunc1 = (std::int64_t *)*(vTablePtr1 + 0);
std::int64_t *pBaseFunc2 = (std::int64_t *)*(vTablePtr1 + 1);
std::int64_t *pBaseFunc3 = (std::int64_t *)*(vTablePtr1 + 2);
std::int64_t *pBaseFunc4 = (std::int64_t *)*(vTablePtr1 + 3);
//SubClass2
std::int64_t *pBase2Func1 = (std::int64_t *)*(vTablePtr2 + 0);
std::int64_t *pBase2Func2 = (std::int64_t *)*(vTablePtr2 + 1);
std::int64_t *pBase2Func3 = (std::int64_t *)*(vTablePtr2 + 2);
std::int64_t *pBase2Func4 = (std::int64_t *)*(vTablePtr2 + 3);
/**
*SubClass1继承自Base有三个虚函数&自身定义了一个虚函数
*/
(FUNCTION(pBaseFunc1))();
(FUNCTION(pBaseFunc2))();
(FUNCTION(pBaseFunc3))();
(FUNCTION(pBaseFunc4))();
/**
*SubClass2 继承自Base有三个虚函数&自身定义了一个虚函数
*/
(FUNCTION(pBase2Func1))();
(FUNCTION(pBase2Func2))();
(FUNCTION(pBase2Func3))();
(FUNCTION(pBase2Func4))();
return 0;
}
|
运行结果:
六、C++类成员函数的静态绑定 和 动态绑定
6.1 符号(Symbol)和 符号绑定
C/C++ 用变量(对象)来存储数据,用函数来定义一段可以重复使用的代码,它们最终都要放到内存中才能供 CPU 使用。CPU 通过地址来取得内存中的代码和数据,程序在执行过程中会告知 CPU 要执行的代码以及要读写的数据的地址。
CPU 访问内存时需要的是地址,而不是变量名和函数名!变量名和函数名只是地址的一种助记符,当源文件被编译和链接成可执行程序后,它们都会被替换成地址。编译和链接过程的一项重要任务就是找到这些名称所对应的地址。
假设变量 a、b、c 在内存中的地址分别是 0X1000、0X2000、0X3000,那么加法运算 c = a + b;
将会被转换成类似下面的形式:
1
|
0X3000 = (0X1000) + (0X2000);
|
表示取值操作,整个表达式的意思是,取出地址 0X1000 和 0X2000 上的值,将它们相加,把相加的结果赋值给地址为 0X3000 的内存。
变量名和函数名为我们提供了方便,让我们在编写代码的过程中可以使用易于阅读和理解的英文字符串,不用直接面对二进制地址,那场景简直让人崩溃。
我们不妨将变量名和函数名统称为 符号(Symbol),找到符号对应的地址的过程叫做 符号绑定。
Tips: 本节只讨论函数名和地址的绑定,变量名也是类似的道理;
6.2 函数的 静态绑定 与 动态绑定
我们知道,函数调用实际上是执行函数体中的代码。函数体是内存中的一个代码段,函数名就表示该代码段的首地址,函数执行时就从这里开始。说得简单一点,就是必须要知道函数的入口地址,才能成功调用函数。
找到函数名对应的地址,然后将函数调用处用该地址替换,这称为 函数绑定。
一般情况下,在编译期间(包括链接期间)就能找到函数名对应的地址,完成函数的绑定,程序运行后直接使用这个地址即可,这称为 静态绑定(Static binding)。
但是有时候在编译期间想尽所有办法都不能确定使用哪个函数,必须要等到程序运行后根据具体的环境或者用户操作才能决定,这称为 动态绑定(dynamic binding)。
Tips: C++ 是一门静态性的语言,会尽力在编译期间找到函数的地址,以提高程序的运行效率,但是有时候实在没办法,只能等到程序运行后再执行一段代码(很少的代码)才能找到函数的地址。
函数动态绑定示例:
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
|
#include <iostream>
#include <string>
using namespace std;
//People类
class People
{
public:
People(string name, int age);
public:
virtual void display();
virtual void eating();
protected:
string m_name;
int m_age;
};
People::People(string name, int age): m_name(name), m_age(age){ }
void People::display()
{
cout<<"Class People:"<<m_name<<"今年"<<m_age<<"岁了。"<<endl;
}
void People::eating()
{
cout<<"Class People:我正在吃饭,请不要跟我说话..."<<endl;
}
//Student类
class Student: public People
{
public:
Student(string name, int age, float score);
public:
virtual void display();
virtual void examing();
protected:
float m_score;
};
Student::Student(string name, int age, float score):
People(name, age), m_score(score){ }
void Student::display()
{
cout<<"Class Student:"<<m_name<<"今年"<<m_age<<"岁了,考了"<<m_score<<"分。"<<endl;
}
void Student::examing()
{
cout<<"Class Student:"<<m_name<<"正在考试,请不要打扰T啊!"<<endl;
}
//Senior类
class Senior: public Student
{
public:
Senior(string name, int age, float score, bool hasJob);
public:
virtual void display();
virtual void partying();
private:
bool m_hasJob;
};
Senior::Senior(string name, int age, float score, bool hasJob):
Student(name, age, score), m_hasJob(hasJob){ }
void Senior::display()
{
if(m_hasJob)
{
cout<<"Class Senior:"<<m_name<<"以"<<m_score<<"的成绩从大学毕业了,并且顺利找到了工作,Ta今年"<<m_age<<"岁。"<<endl;
}
else
{
cout<<"Class Senior:"<<m_name<<"以"<<m_score<<"的成绩从大学毕业了,不过找工作不顺利,Ta今年"<<m_age<<"岁。"<<endl;
}
}
void Senior::partying()
{
cout<<"Class Senior:快毕业了,大家都在吃散伙饭..."<<endl;
}
int main()
{
People *p = new People("赵红", 29);
p -> display();
p = new Student("王刚", 16, 84.5);
p -> display();
p = new Senior("李智", 22, 92.0, true);
p -> display();
return 0;
}
|
运行结果:
1
2
3
|
Class People:赵红今年29岁了。
Class Student:王刚今年16岁了,考了84.5分。
Class Senior:李智以92的成绩从大学毕业了,并且顺利找到了工作,Ta今年22岁。
|
示例中, 通过 p -> display();
语句调用 display() 函数时会转换为下面的表达式:
1
|
( *( *(p+0) + 0 ) )(p);
|
这里的 p 有可能指向 People 类的对象,也可能指向 Student 或 Senior 类的对象,编译器不能提前假设 p 指向哪个对象,也就不能确定调用哪个函数,所以编译器干脆不管了,p 爱指向哪个对象就指向哪个对象,等到程序运行后执行一下这个表达式自然就知道了。
有读者可能会问,对于下面的语句:
1
2
|
p = new Senior("李智", 22, 92.0, true);
p -> display();
|
p 不是已经确定了指向 Senior 类的对象吗,难道编译器不知道吗?对,编译器编译到第二条语句的时候如果向前逆推一下,确实能够知道 p 指向 Senior 类的对象。但是,如果是下面的情况呢?
1
2
3
4
5
6
7
8
9
10
11
|
int n;
cin>>n;
if(n > 100)
{
p = new Student("王刚", 16, 84.5);
}
else
{
p = new Senior("李智", 22, 92.0, true);
}
p -> display();
|
如果用户输入的数字大于 100,那么 p 指向 Student 类的对象,否则就指向 Senior 类的对象,这种情况编译器如何逆推呢?我们不知道用户输入什么数字!所以编译器干脆不会向前逆推,因为编译器不知道前方是什么情况,可能会很复杂,它也无能为力。
这就是动态绑定的本质:编译器在编译期间不能确定指针指向哪个对象,只能等到程序运行后根据具体的情况(指向的实际对象)再决定。
函数绑定总结:
- 对于类的普通成员函数在编译链接时就能确定绑定的对象,使用的是 静态绑定,发生在编译期。
- 对于类的虚函数在编译链接时不能确定绑定的对象,只有在运行期才能确定, 使用的是 动态绑定,发生在运行期。
- 虽然虚函数的调用是在运行期才确定,但是虚函数表的创建是在编译阶段就完成构建。