本文最后更新于: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; }
|
输出结果为:
- 首先,无论在派生方式是
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 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 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(); 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析构函数
|
- 首先,派生类的构造函数在构造对象时一定会调用基类的构造函数。
- 但是在代码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: void sleep() { cout << "猫咪睡觉" << endl; } };
void test1() { }
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 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; }
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析构函数
|
注意:
- 对于纯虚析构函数,类内按照普通的方式书写,但是类外需要写其实现,尤其是基类对象中含有指针时。
- 对于派生类中要给基类中的某些数据赋值时,调用基类的有参构造要在初始化列表的位置。
多态的实现原理
- 当类中声明虚函数时,编译器会在类中生成一个虚函数表。
- 虚函数表是一个存储类成员函数指针的数据结构。
- 虚函数表时由编译器自动生成和维护的。
- virtual成员函数会被编译器放入虚函数表中。
- 当存在虚函数时,每个对象中都有一个指向虚函数表的指针。
- 通过虚函数表指针调用重写函数是在程序运行时进行的,需要通过寻址操作才能确定真正确定应当调用的函数。在效率上是要低于普通的成员函数的。
当类含有虚函数时其类的大小
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() { cout << "Cat sizeof = " << sizeof(Cat) << endl; cout << "Dog sizeof = " << sizeof(Dog) << endl; cout << "Fox sizeof = " << sizeof(Fox) << endl; }
|
编译器: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
原样继承了类Base
,DerOther
的虚函数表是原样地继承了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();
Parent parent;
int *p1 = (int*)&parent; int *p2 = (int*)pptr;
*p2 = *p1; pptr->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】