C++面向对象(三) —— 多态

本文最后更新于:2021年1月27日 晚上

概览:C++面向对象之多态,虚函数、动态联编(动态绑定)、虚析构函数、纯虚函数以及抽象类,以及多态的原理——虚函数指针和虚函数表。


多态是C++面向对象三大特性之一。

向不同的对象发送同一个消息(即调用函数),不同对象会产生不同的响应(函数实现)。通过多态性可以实现“一个接口,多种方法”。

形式有:

  • 函数重载
  • 运算符重载
  • 基于虚函数的多态性

需求——“千人千面”

需求:在函数void play(Animal *a);中传递不同的对象,传入什么对象,就调用其对应的函数。

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
class Animal {
public:
void talk() {
cout << "动物发出声音"<<endl;
}
};

class Cat:public Animal {
public:
void talk() {
cout << "小猫喵喵喵" << endl;
}
};

class Dog :public Animal {
public:
void talk() {
cout << "小狗汪汪汪" << endl;
}
};

void play(Animal *a) {
a->talk();
}

void test() {

Animal *a = new Animal();
play(a);
delete a;

Cat * c = new Cat();
play(c);
delete c;

Dog* d = new Dog();
play(d);
delete d;
}

输出结果为:

1
2
3
动物发出声音
动物发出声音
动物发出声音
  • 首先,无论在派生方式是public的方式下, 派生类的对象是可以赋值给基类指针的。所以上述函数play()的调用是没有问题的。
  • 然后从结果来看,函数无论传递的是基类还是派生类,最终调用的一定是基类方法。静态联编,(编译器默认做了一个安全处理,它认为,不管传递基类还是派生类对象,如果统一执行基类方法,一定可以成功)。

上述代码中,基类与派生类的方法是同名的方法,而我们的本意是根据传递对象的不同来显示不同的结果。最普通的方式那就是写三个同名函数,根据参数不同构成重载来解决这个问题,但是设想有1000个这样的不同类呢?

对于这种有继承关系的类,可以使用虚函数来解决这个问题。

定义虚函数

在基类的方法上添加关键字virtual,然后派生了对于同名方法进行各自的重写,然后再统一根据基类指针来作为参数传递时,就可以达到我们想要的效果,“千人千面”,这就是多态

  • 语法:基类中virtual 函数返回值 函数名(参数),这样就是一个虚函数。
  • 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
class Animal {
public:

//增加关键字virtual,表示这是一个虚函数
virtual void talk() {
cout << "动物发出声音"<<endl;
}
};

class Cat:public Animal {
public:
//派生类重写基类的虚函数
void talk() {
cout << "小猫喵喵喵" << endl;
}
};

class Dog :public Animal {
public:
void talk() {
cout << "小狗汪汪汪" << endl;
}
};

void play(Animal *a) {
a->talk();
}

void test() {

Animal *a = new Animal();
play(a);
delete a;

Cat * c = new Cat();
play(c);
delete c;

Dog* d = new Dog();
play(d);
delete d;
}

执行结果:

1
2
3
动物发出声音
小猫喵喵喵
小狗汪汪汪

静态联编与动态联编

联编:指一个程序模块、代码之间相互关联的过程。

静态联编就是在编译阶段就确定好了函数的地址,也称为早期匹配早绑定

对于第一块代码,它就属于静态联编。在编译时,编译器会自动地根据指针类型来判断指向的是一个什么样的对象,所以编译器认为基类指针指向地是基类对象。而程序并没有运行,所以不可能知道基类指针指向地具体是基类对象还是派生类对象。从程序安全地角度考虑,编译结果选择调用基类的成员函数,这种特性就是静态联编。

重载函数和运算符重载就是使用的静态联编的方式。

而虚函数可以使得程序在运行阶段具体决定调用哪个类的方法,而不是按编译阶段绑定的基类方法执行,这称为动态联编,又称为晚期绑定迟绑定动态绑定

当我们使用基类的引用或者指针时调用一个虚函数时,将会发生动态绑定。

多态发生的条件

基于虚函数的多态:

  1. 要有继承
  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
//普通的计算器类实现
class Calcutor {

public:

int getResult(string op) {
if (op == "+") {
return m_numA + m_numB;
}
else if (op == "-") {
return m_numA - m_numB;
}
else if (op == "*") {
return m_numA * m_numB;
}
else return INFINITY;
}

int m_numA;
int m_numB;

};

void test2() {

//普通的计算器类的实现

Calcutor c;
c.m_numA = 10;
c.m_numB = -10;

cout << c.m_numA << " + " << c.m_numB << " = " << c.getResult("+") << endl;
cout << c.m_numA << " - " << c.m_numB << " = " << c.getResult("-") << endl;
cout << c.m_numA << " * " << c.m_numB << " = " << c.getResult("*") << endl;

}
  • 当这个计算器要增加新的功能时,则需要对其源代码进行修改。而代码维护的原则是尽量不修改原有代码。
  • 而且这个代码后期维护起来并不方便。
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
//抽象计算器类
class AbstractCalculator {
public:

virtual int getResult() {
return 0;
}

int m_Num1;
int m_Num2;
};

//实现加法运算器
class AddCalculator:public AbstractCalculator {
public:
int getResult() {
return m_Num1 + m_Num2;
}
};

//实现减法计算器
class SubCalculator :public AbstractCalculator {
public:
int getResult() {
return m_Num1 - m_Num2;
}
};

//实现乘法计算器
class MulCalculator :public AbstractCalculator {
public:
int getResult() {
return m_Num1 * m_Num2;
}
};

void test1() {
//测试三段代码

cout << "加法测试:" << endl;

AbstractCalculator * cal = new AddCalculator;
cal->m_Num1 = 10;
cal->m_Num2 = 20;
cout << cal->m_Num1 << " + " << cal->m_Num2 << " = " << cal->getResult() << endl;
delete cal; //手动释放

cout << "减法测试:" << endl;

cal = new SubCalculator;
cal->m_Num1 = 10;
cal->m_Num2 = 20;
cout << cal->m_Num1 << " - " << cal->m_Num2 << " = " << cal->getResult() << endl;
delete cal;

cout << "乘法测试:" << endl;

cal = new MulCalculator;
cal->m_Num1 = 10;
cal->m_Num2 = 20;
cout << cal->m_Num1 << " * " << cal->m_Num2 << " = " << cal->getResult() << endl;
delete cal;

}
  • 而通过多态实现的计算器,前面构造一个抽象的计算器类,定义好虚函数。
  • 增加新的功能只需要在原有基础上构建一个新的派生类即可。

优点:

  • 代码组织结构清晰
  • 可读性强
  • 利于前期和后期的扩展以及维护

一个更易懂的例子:LOL英雄联盟游戏:https://mp.weixin.qq.com/s/CeCuXuCjYROgNLmUiLtlHA

在普通成员函数中的多态

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
class Base {
public:
void func1() {
//调用虚函数
this->func2();
}

virtual void func2() {
cout << "Base::func2虚函数" << endl;
}

};

class Derived :public Base {
public:
virtual void func2() {
cout << "Derived::func2函数" << endl;
}
};

void test1() {

//在类中的非构造、非析构函数中调用虚函数,依旧是多态。

Base * pBase = new Derived();
pBase->func1(); //Derived::func2函数
delete pBase;

}

pBase指针实际指向了派生类的对象,而派生类中并没有显式的func1()函数,但是它有继承自基类的函数,在Base::func1()之中执行this->func2(),this基类指针指向了派生类对象,调用了虚函数,发生了多态。

  • 结论:在类中的非构造、非析构函数中调用虚函数,依旧是多态。

构造函数与析构函数能否实现多态?

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
class Father {
public:

Father() {
cout << "Father构造函数" << endl;
this->hello();
}

virtual ~Father() {
cout << "Father析构函数" << endl;
this->bye();
}

virtual void hello() {
cout << "hello in Father" << endl;
}

virtual void bye() {
cout << "bye in Father" << endl;
}
};

class Son :public Father {
public:

Son() {
cout << "Son构造函数" << endl;
this->hello();
}

~Son() {
cout << "Son析构函数" << endl;
this->bye();
}

void hello() {
cout << "hello in Son" << endl;
}

void bye() {
cout << "bye in Son" << endl;
}

};

void test2() {

//在构造函数、析构函数中调用虚函数,不是多态

Father * father = new Son();

cout << "----" << endl;
father->hello();//这里才有多态
cout << "----" << endl;

delete father;
}

执行结果为:

1
2
3
4
5
6
7
8
9
10
11
Father构造函数
hello in Father
Son构造函数
hello in Son
----
hello in Son
----
Son析构函数
bye in Son
Father析构函数
bye in Father

从结果可见,在构造和析构函数中调用虚函数时,不会发生多态。

在编译时就会确定,调用的是自己类的函数还是基类的函数,不会发生动态联编。

原因:触发派生类的构造函数必然先触发基类的构造函数,而这时派生类的部分还没有构造,怎么可能能用虚函数实现动态绑定派生生类对象呢,所以构造B基类部分的时候,调用的基类的函数bar;同样的道理,当调用继承层次中某一层次的类的析构函数时,往往意味着其派生类部分已经析构掉,所以也不会呈现出多态。

https://blog.csdn.net/yesyes120/article/details/79627028

虚析构函数——引例

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
class Base {
public:
Base() {
cout << "Base构造函数" << endl;
}
~Base() {
cout << "Base析构函数"<<endl;
}
//纯虚函数
virtual void func() = 0;
};

class Son :public Base {
public:
Son() {
cout << "Son构造函数" << endl;
}
~Son() {
cout << "Son析构函数" << endl;
}
virtual void func() {
cout << "Son func函数" << endl;
}
};

void dosome(Base* b) {
b->func();
delete b; //释放开辟的空间
}

void test3() {
dosome(new Son());
}

调用test3()函数,输出结果为:

1
2
3
4
Base构造函数
Son构造函数
Son func函数
Base析构函数
  1. 首先,派生类的构造函数在构造对象时一定会调用基类的构造函数。
  2. 但是在代码28行,delete释放空间时,仅仅调用了基类的析构函数,并没有调用派生类的构造函数

危害:如果派生类中比基类中额外有一些存放于堆中的数据,由于无法调用派生类的析构函数,会存留一些数据无法释放,从而可能造成内存泄漏等问题。

解决方式:将基类中的析构函数修改为虚析构函数或者纯虚析构函数

纯虚析构函数见纯虚函数那一小节。

解决方案——虚析构函数

把基类的析构函数声明为virtual即可。

1
2
3
4
5
6
7
8
9
10
11
class Base {
public:
Base() {
cout << "Base构造函数" << endl;
}
virtual ~Base() {
cout << "Base析构函数"<<endl;
}
//纯虚函数
virtual void func() = 0;
};

再次调用test3()可得输出结果:

1
2
3
4
5
Base构造函数
Son构造函数
Son func函数
Son析构函数
Base析构函数

通过这样的方式,当通过基类指针释放派生类的对象时,首先调用派生类的析构函数,然后再调用基类的析构函数,依旧遵循继承中的「先构造,后虚构」的规则。

虚析构函数的使用习惯

  • 如果一个类定义了虚函数,则应当将析构函数也定义成为虚函数。
  • 一个类打算作为基类使用时,也应该将析构函数定义成虚函数。
  • 构造函数不能定义为虚函数。

纯虚函数与抽象类

在多态中,通常基类中虚函数的实现是毫无意义的,主要都是调用派生类重写的内容,因此我们可以将基类中的虚函数修改成为纯虚函数

纯虚函数语法:virtual 返回值类型 函数名(参数) = 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
class Animal {
public:
//纯虚函数
virtual void talk() = 0;
};

class Cat :public Animal {
public:
//Cat没有重写基类Animal的纯虚函数
void sleep() {
cout << "猫咪睡觉" << endl;
}
};

void test1() {
//Animal a; //不允许使用抽象类对象
//Cat c; //不允许使用抽象类对象
//Animal *a = new Cat();//不允许使用抽象类类型Cat的对象
}

class Dog :public Animal {
public:
//重写了基类的纯虚函数
void talk() {
cout << "小狗发出了汪汪汪的声音" << endl;
}
};

void test2() {
Dog d; //可以使用
d.talk();

Animal* animal = new Dog();//可以使用
animal->talk();
}

抽象类特点

一旦拥有了纯虚函数,那这个类就属于抽象类,抽象类负责定义接口,而其余类负责覆盖接口。

  1. 抽象类不能够实例化对象
  2. 派生类继承抽象类之后,如果不重写基类的纯虚函数,那这个派生类依旧是抽象类。
  3. 抽象类的指针和引用可以指向由抽象类派生出去的类的对象。
  4. 拥有了纯虚析构函数的类也属于抽象类,也不能够实例化对象。

纯虚析构函数

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
class Animal {
public:
Animal() {
m_id = nullptr;
cout << "Animal无参构造函数" << endl;
}
Animal(int *id) :m_id(id) {
cout << "Animal有参构造函数" << endl;
}

//纯虚析构函数
virtual ~Animal() = 0;

//纯虚函数
virtual void func() = 0;

int* m_id;//存放于堆区的数据
};

Animal::~Animal() {
//纯虚析构函数,类内声明,类外实现
if (m_id != nullptr) {
delete m_id;
m_id = nullptr;
}
cout << "Animal析构函数" << endl;
}

class Cat :public Animal {
public:
Cat() {
cout << "Cat无参构造函数" << endl;
}

Cat(int *id) :Animal(id) {
cout << "Cat有参构造函数" << endl;
}

~Cat() {
cout << "Cat析构函数" << endl;
}

//重写基类的func()虚函数
virtual void func() {
if(m_id != nullptr)
cout << "m_id: "<<*m_id << endl;
}

};

void test() {
int *id = new int(20);
Animal *animal = new Cat(id);
animal->func();
delete animal;

cout << "--------------" << endl;

animal = new Cat();
animal->func();
delete animal;
}

输出结果为:

1
2
3
4
5
6
7
8
9
10
Animal有参构造函数
Cat有参构造函数
m_id: 20
Cat析构函数
Animal析构函数
--------------
Animal无参构造函数
Cat无参构造函数
Cat析构函数
Animal析构函数

注意:

  1. 对于纯虚析构函数,类内按照普通的方式书写,但是类外需要写其实现,尤其是基类对象中含有指针时。
  2. 对于派生类中要给基类中的某些数据赋值时,调用基类的有参构造要在初始化列表的位置

多态的实现原理

  1. 当类中声明虚函数时,编译器会在类中生成一个虚函数表。
  2. 虚函数表是一个存储类成员函数指针的数据结构。
  3. 虚函数表时由编译器自动生成和维护的。
  4. virtual成员函数会被编译器放入虚函数表中。
  5. 当存在虚函数时,每个对象中都有一个指向虚函数表的指针。
  6. 通过虚函数表指针调用重写函数是在程序运行时进行的,需要通过寻址操作才能确定真正确定应当调用的函数。在效率上是要低于普通的成员函数的。

当类含有虚函数时其类的大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Cat {
public:
int age;
void func() {}
};

class Dog {
public:
int age;
virtual void func() {}
};

class Fox {
public:
virtual void func() {}
};

void test() { //x86 x64
cout << "Cat sizeof = " << sizeof(Cat) << endl; //4 4
cout << "Dog sizeof = " << sizeof(Dog) << endl; //8 16
cout << "Fox sizeof = " << sizeof(Fox) << endl; //4 8
}

编译器:VS2017

在编译器中选择x86的按钮,即可选择位数,x86就是32位,其对应的指针就是32位,4字节。

x64就是64位,其对应的指针就是64位,8字节。

  • Dog在64位下,sizeof为16的原因是,4字节int,8字节虚函数指针,以及4字节用于对齐

以下的代码都基于x86模式下来使用。

虚函数指针与虚函数表

每一个有虚函数的类或者是「含有虚函数类的」派生类都有一个虚函数表,该类的任何对象中都会存放着虚函数表的指针,而寻函数表中会有该类的虚函数的地址。

故上面类的大小中,比普通函数多出来的那些字节就是用来存放「虚函数表的地址」。

看一段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Base {
public:
int i;
virtual void Print() {}//虚函数
};

class Derived :public Base {
public:
int n;
virtual void Print() {}//重写了虚函数
};

class DerOther :public Base {
//只继承
};

然后使用VS开发人员命令工具,cl /d1 reportSingleClassLayoutBase "17 虚函数表.cpp"来查看类的构造。

使用链接:http://www.colourso.top/vs-use/

类Base的结构与虚函数表

1
2
3
4
5
6
7
8
9
10
11
12
class Base      size(8):
+---
0 | {vfptr}
4 | i
+---

Base::$vftable@:
| &Base_meta
| 0
0 | &Base::Print

Base::Print this adjustor: 0

类Derived的结构与虚函数表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Derived   size(12):
+---
0 | +--- (base class Base)
0 | | {vfptr}
4 | | i
| +---
8 | n
+---

Derived::$vftable@:
| &Derived_meta
| 0
0 | &Derived::Print

Derived::Print this adjustor: 0

类DerOther的结构与虚函数表

1
2
3
4
5
6
7
8
9
10
11
12
class DerOther  size(8):
+---
0 | +--- (base class Base)
0 | | {vfptr}
4 | | i
| +---
+---

DerOther::$vftable@:
| &DerOther_meta
| 0
0 | &Base::Print
  • vfptr —— virtual function pointer,即虚函数指针。指向了虚函数表
  • vftable —— virtual function table,即虚函数表。表内会记录虚函数的地址
  • &Base::Print —— 前面的取地址符加上Base::Print表示该函数的地址。

看上述例子,类DerOther原样继承了类BaseDerOther的虚函数表是原样地继承了Base的虚函数表,故表内的虚函数地址是Base的虚函数的地址。

而看类Derived,它继承了类Base,并且重写了它的虚函数,这时子类中的虚函数内部的虚函数地址会替换为子类的虚函数地址

案例与图片来自:https://mp.weixin.qq.com/s/CeCuXuCjYROgNLmUiLtlHA【公众号:小林coding】

当父类的指针或者引用指向了子类对象时候,会发生多态。当调用Print()函数时,会通过虚函数表来确定其对应的虚函数地址,从而会有不同的对象不同的行为。

B站视频链接:https://www.bilibili.com/video/BV1et411b73Z?p=136【黑马】

证明虚函数指针的作用

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
class Parent {
public:
virtual void func() {
cout << "Parent::func" << endl;
}
};

class Son :public Parent {
public:
virtual void func() {
cout << "Son::func" << endl;
}
};

void test() {
Parent * pptr = new Son();
pptr->func(); //多态 Son::func

Parent parent;

int *p1 = (int*)&parent;//Parent的地址
int *p2 = (int*)pptr; //Son——pptr的地址

*p2 = *p1; //让Parent的地址赋给Son——pptr的地址
pptr->func(); //Parent::func

}

最终的输出结果为

1
2
Son::func
Parent::func

类Parent的结构与虚函数表

1
2
3
4
5
6
7
8
9
class Parent    size(4):
+---
0 | {vfptr}
+---

Parent::$vftable@:
| &Parent_meta
| 0
0 | &Parent::func

类Son的结构与虚函数表

1
2
3
4
5
6
7
8
9
10
11
class Son       size(4):
+---
0 | +--- (base class Parent)
0 | | {vfptr}
| +---
+---

Son::$vftable@:
| &Son_meta
| 0
0 | &Son::func

int *p1 = (int*)&parent;是将类Parent的头4个字节 也就是「虚函数表指针」存储到了p1指针中。

x86下,指针4字节

int *p2 = (int*)pptr;是将类Son的头4个字节「虚函数表指针」存储到了p2指针中。

*p2 = *p1;就是将Parent的虚函数表的地址赋值给Son的虚函数表的地址。

再次调用函数,依据虚函数表里的函数最终访问到了基类的函数。

参考链接:掌握了多态的特性,写英雄联盟的代码更少啦!【小林coding】


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!