目录
1. 多态的概念
多态就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
举个栗子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。
2. 多态的定义及实现
2.1 多态的构成条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价,Soldier对象优先买票。
那么在继承中要构成多态还有两个条件:
- 必须通过基类的指针或者引用调用虚函数。
- 被调用的函数必须是虚函数(virtual),且派生类必须对基类的虚函数进行重写(函数名、返回值、参数均相同 才为虚函数)。
图示:
2.2 虚函数及虚函数的重写
- 虚函数:即被virtual修饰的类成员函数称为虚函数。
class Person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } };
- 注意:虚函数这里的virtual是为了实现多态,而虚继承的virtual是为了解决菱形继承的数据冗余和二义性。
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
class Person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } }; class Student : public Person { public: virtual void BuyTicket() { cout << "买票-半价" << endl; } /*注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因 为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议 这样使用*/ /*void BuyTicket() { cout << "买票-半价" << endl; }*/ }; void Func(Person& p) { //通过父类的引用调用虚函数 p.BuyTicket(); } void Func(Person* p) { //通过父类的指针调用虚函数 p->BuyTicket(); } int main() { Person ps; Student st; //传对象 Func(ps);//买票-全价 Func(st);//买票-半价 //传地址 Func(&ps);//买票-全价 Func(&st);//买票-半价 return 0; }
注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。
2.3 虚函数重写例外
1.协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。通俗的说是虚函数返回值类型为父子关系指针和引用。
如下基类Person类虚函数f的返回值类型为基类A对象的指针,Student类虚函数f的返回值为派生类B对象的指针,且B是A的子类,这俩虚函数称为协变。
//A是B的父类 class A {}; class B : public A {}; //父类 class Person { public: //父类虚函数返回值为父类的指针 virtual A* f() { cout << "virtual A* Person::f()" << endl; return nullptr; } }; //子类 class Student : public Person { public: virtual B* f()//返回值B是A的子类,此时发生协变(协变的类型必须是父子关系) { cout << "virtual B* Student::f()" << endl; return nullptr; } }; int main() { Person p;//父类的指针 Student s; Person* ptr = &p; //父类的指针指向父类对象,调用父类的虚函数 ptr->f();//virtual A* Person::f() ptr = &s; //父类的指针指向子类对象,调用子类的虚函数 ptr->f();//virtual B* Student::f() }
2.析构函数的重写(基类与派生类析构函数的名字不同)
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。
class Person { public: ~Person() { cout << "~Person()" << endl; } }; class Student : public Person { public: ~Student() { cout << "~Student()" << endl; } }; int main() { Person* ptr = new Person; delete ptr;//~Person() ptr = new Student; delete ptr;//~Person() return 0; }
此段代码中,我创建了一个父类指针,先指向父类Person,然后delete释放,再指向子类Student,再delete释放,期望的结果应该是指向父类的调用父类的析构函数,指向子类的调用子类的析构函数。可是结果却都是调用父类的析构函数,造成内存泄漏。可期望的delete函数是一个多态调用。
当把父类的析构函数变成虚函数,其它不变的时候
class Person { public: virtual ~Person() { cout << "~Person()" << endl; } }; class Student : public Person { public: //Person析构函数加了virtual,关系就变了 //重定义(隐藏)关系 -> 重写(覆盖)关系 //对于普通对象是没有影响的 ~Person() { cout << "~Student()" << endl; } }; // 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函\ 数,才能构成多态,才能保证ptr指向的对象正确的调用析构函数。
这里很明显结果是我们所预期的,先释放指向父类的对象,再释放指向子类的对象(继承后的析构为先子后父),正是由于基类的析构函数加上了virtual变成虚函数,才得以让父类和子类的析构函数构成重写,完成多态调用,才能析构正确。
结论:如果设计一个类,可能会作为基类,将基类的析构函数最好定义为虚函数
2.4 C++11 override 和 final
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。
1.final:修饰虚函数,表示该虚函数不能再被重写,修饰类表示不能被继承。这里我父类的虚函数Drive不想被其它人重写,在其后面加上final即可,此时子类就无法对Drive进行重写了,如下:
final修饰一个类,让其不能被继承,如下:
2.override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
2.5 重载、覆盖(重写)、隐藏(重定义)的对比
3. 抽象类
概念:在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
//抽象类 class Car { public: //纯虚函数 virtual void Drive() = 0;//声明,也可以定义出来,一般只给声明即可 }; class BMW :public Car {}; int main() { //Car c; error 抽象类不能实例化对象 //BMW b; error 派生类继承后也不能实例化出对象 }
此例说明抽象类Car不能实例化出对象。派生类BMW继承后也不能实例化出对象,因为派生类继承了Car,纯虚函数跟着继承下来了,所以BMW也是抽象类。但是我重写纯虚函数,派生类就可以实例化出对象了。
//抽象类 class Car { public: //纯虚函数 virtual void Drive() = 0;//声明,也可以定义出来,一般只给声明即可 }; class BMW :public Car { public: //重写纯虚函数 virtual void Drive() { cout << "BMW-操控" << endl; } }; class Benz :public Car { public: //重写纯虚函数 virtual void Drive() { cout << "Benz-舒适" << endl; } }; int main() { //Car c;//抽象类不能实例化对象 //重写纯虚函数,派生类就可以实例化出对象 BMW b1; Benz b2; //不同对象使用基类指针完成多态的行为 Car* pBenz = new Benz; Car* pBMW = new BMW; pBenz->Drive();//Benz-舒适 pBMW->Drive();//BMW-操控 }
由此可见纯虚函数的间接功能:要求子类需要重写,才能实例化出对象。
接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
4. 多态原理
虚函数表
下面sizeof(Base)是多少?
class Base { public: virtual void Func1() { cout << "Func1()" << endl; } private: int _b = 1; };
通过观察测试我们发现b对象在32位是8bytes,64位是16bytes。
int main() { Base b; cout << sizeof(b) << endl;//8/16 }
通过监视窗口看出:除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关)。
对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。那么派生类中这个表放了些什么呢?我们接着往下分析。
- 1、我们增加一个派生类Derive去继承Base
- 2、Derive中重写Func1
- 3、Base再增加一个虚函数Func2和一个普通函数Func3
class Base { public: virtual void Func1() { cout << "Base::Func1()" << endl; } virtual void Func2() { cout << "Base::Func2()" << endl; } void Func3() { cout << "Base::Func3()" << endl; } private: int _b = 1; }; class Derive : public Base { public: virtual void Func1() { cout << "Derive::Func1()" << endl; } private: int _d = 2; }; int main() { Base b; Derive d; return 0; }
通过监视窗口来看看父类对象b和子类对象d的内部组成结构:
通过观察和测试,我们发现了以下几点问题:
- 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。
- 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法(派生类对继承基类虚函数实现进行了重写),覆盖是原理层的叫法(子类的虚表,拷贝父类的虚表进行了修改,覆盖重写了那个虚函数)。
- 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。
- 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
- 总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
- 这里还有一个很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。但是很多人都是这样深以为然的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在代码段的,Linux g++下大家自己去验证。
多态的原理
多态是如何实现当父类指针指向父类对象时,调用父类的虚函数,而父类指针指向子类对象时,调用的就是子类的虚函数呢?
class Person { public: virtual void BuyTicket() { cout << "Person : 买票-全价" << endl; } }; class Student : public Person { public: virtual void BuyTicket() { cout << "Student : 买票-半价" << endl; } }; void Func(Person* p) { p->BuyTicket(); } int main() { Person Mike; //指向父类对象调用父类虚函数 Func(&Mike);//Person : 买票-全价 Student Johnson; //指向子类对象调用子类虚函数 Func(&Johnson);//Student : 买票-半价 return 0; }
根据图示,我们可以观察到如下:
1、观察上图的红色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚函数是Person::BuyTicket。
2、观察上图的蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中找到虚函数是Student::BuyTicket。这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调用虚函数。反思一下为什么?再通过下面的汇编代码分析,看出满足多态以后的函数调用,不是在编译时确定的,是依靠运行时,去指向对象的虚表中查调用函数地址。不满足多态的函数调用时编译时确认好的。
而如果是下列调用方式呢?
void Func(Person* p) { p->BuyTicket(); } int main() { Person mike; Func(&mike);//Person : 买票-全价 mike.BuyTicket();//Person : 买票-全价 return 0; }
为什么这里的结果都是调用父类的虚函数呢?首先要区分多态调用和普通调用:
- 多态调用:运行时决议 -- 运行时(查虚函数表)确定调用函数的地址;
- 普通调用:编译时决议 -- 编译时(符号表)确定调用函数的地址。
接下来我从汇编代码中截取部分做解释:
// 以下汇编代码中跟你这个问题不相关的都被去掉了 void Func(Person* p) { ... p->BuyTicket(); // p中存的是mike对象的指针,将p移动到eax中 001940DE mov eax,dword ptr [p] // [eax]就是取eax值指向的内容,这里相当于把mike对象头4个字节(虚表指针)移动到了edx 001940E1 mov edx,dword ptr [eax] // [edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax 00B823EE mov eax,dword ptr [edx] // call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来 以后到对象的中取找的。 001940EA call eax 00头1940EC cmp esi,esp } int main() { ... // 首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调 用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址 mike.BuyTicket(); 00195182 lea ecx,[mike] 00195185 call Person::BuyTicket (01914F6h) ... }
对象切片的时候,子类只会拷贝成员给父类对象,不会拷贝虚表指针,如果拷贝就乱套了,且父类对象中到底是父类的虚表指针还是子类的虚表指针都有可能?因此子类对象赋给父类不能实现多态。
动态绑定和静态绑定
- 静态绑定:又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
- 动态绑定:又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
5. 单继承和多继承关系
在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类的虚表模型前面我们已经看过了,没什么需要特别研究的,接下来看看派生类的虚表。
5.1 单继承中的虚函数表
class Base { public: virtual void func1() { cout << "Base::func1" << endl; } virtual void func2() { cout << "Base::func2" << endl; } private: int a; }; class Derive :public Base { public: virtual void func1() { cout << "Derive::func1" << endl; } virtual void func3() { cout << "Derive::func3" << endl; } virtual void func4() { cout << "Derive::func4" << endl; } private: int b; }; int main() { Base b; Derive d; }
首先子类d的虚表是拷贝了父类b的虚表,并且对子类自己重写的虚函数func1进行了覆盖,其次观察上图中的子类监视窗口中我们发现看不见func3和func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小bug。是真的没有这两个函数吗?这里我们给出两种方法来观察这两个函数。
- 通过内存监视窗口
2. 使用代码打印虚表内容
typedef void(*VFPTR) (); void PrintVTable(VFPTR vTable[]) { // 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数 cout << " 虚表地址>" << vTable << endl; for (int i = 0; vTable[i] != nullptr; ++i) { printf(" 第%d个虚函数地址 :%p,->", i, vTable[i]); VFPTR f = vTable[i]; f(); } cout << endl; } int main() { Base b; Derive d; PrintVTable((VFPTR*)(*(int*)&b));//取对象头4byte下虚表指针,64位下用double PrintVTable((VFPTR*)(*(int*)&d)); }
与监视、内存窗口对比如下:
结论:VS的监视窗口看到虚函数表不一定是真实的,可能被处理过。
虚表存在哪个区域?(栈?、堆?、静态区?、常量区?……?)
同一个类型的对象共用一个虚表,所以需要一个长期存储的区域,放常量区更为合理,可以通过如下代码验证:
int c = 2; int main() { Base b4; int a = 0; static int b = 1; const char* str = "hello world"; int* p = new int[10]; printf("栈:%p\n", &a); printf("静态区/数据段:%p\n", &b); printf("静态区/数据段:%p\n", &c); printf("常量区/代码段:%p\n", str); printf("堆:%p\n", p); printf("虚表:%p\n", (*((int*)&b4))); printf("函数地址:%p\n", &Derive::func3); printf("函数地址:%p\n", &Derive::func2); printf("函数地址:%p\n", &Derive::func1); }
通过观察,虚表的地址和常量区的地址更为接近。且虚表不可以修改,初始化的时候就有了,对象存在虚函数表就存在。
5.2 多继承中的虚函数表
class Base1 { public: virtual void func1() { cout << "Base1::func1" << endl; } virtual void func2() { cout << "Base1::func2" << endl; } private: int b1; }; class Base2 { public: virtual void func1() { cout << "Base2::func1" << endl; } virtual void func2() { cout << "Base2::func2" << endl; } private: int b2; }; class Derive : public Base1, public Base2 { public: virtual void func1() { cout << "Derive::func1" << endl; } virtual void func3() { cout << "Derive::func3" << endl; } private: int d1; }; int main() { Derive d; return 0; }
这里Derive就是一个多继承,它继承了Base1和Base2,我们先通过监视窗口看看其虚表的结构如何:
这里依旧是拷贝了父类Base1和Base2的虚表,进行修改,覆盖重写了那个虚函数。这里子类Derive的虚函数fun3依旧是没有在监视窗口中看到,很明显这又是监视窗口的一个美化。
理想中的状态如下图示:
和单继承的虚表一样,我们有两种方法观察内部结构:
- 1、通过内存窗口
- 2、通过打印虚表地址的方法来看
typedef void(*VFPTR) (); void PrintVTable(VFPTR vTable[]) { cout << " 虚表地址>" << vTable << endl; for (int i = 0; vTable[i] != nullptr; ++i) { printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]); VFPTR f = vTable[i]; f(); } cout << endl; } int main() { Derive d; PrintVTable((VFPTR*)(*(int*)&d)); PrintVTable((VFPTR*)(*(int*)((char*)&d + sizeof(Base1)))); return 0; }
总结:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中。
- 问:下列这三个指针的值是一样的吗?
int main() { Base1* ptr1 = &d; Base2* ptr2 = &d; Derive* ptr3 = &d; cout << ptr1 << endl;//004FFA1C cout << ptr2 << endl;//004FFA24 cout << ptr3 << endl;//004FFA1C }
5.3 菱形继承与菱形虚拟继承
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的
模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看
了,一般我们也不需要研究清楚,因为实际中很少用。具体内容还请看陈皓大佬的博文:1、C++虚函数表解析
2、C++对象的内存布局
6. 继承和多态常见的面试问题
- 什么是多态
1、多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。
2、必须通过基类的指针或者引用调用虚函数。
3、被调用的函数必须是虚函数(virtual),且派生类必须对基类的虚函数进行重写。
- 什么是重载、重写(覆盖)、重定义(隐藏)?
1. 重载是指两个函数在同一作用域,这两个函数的函数名相同,参数不同。
2.重定义(隐藏)是指两个函数分别在基类和派生类的作用域,这两个函数的函数名相同。若两个基类和派生类的同名函数不构成重写就是重定义。
3.重写(覆盖)是指两个函数分别在基类和派生类的作用域,这两个函数的函数名、参数、返回值都必须相同(协变例外),且这两个函数都是虚函数。
- 多态的实现原理?
父类对象和子类对象的成员中都包含一个虚表指针,这个虚表指针指向一个虚表,虚表当中存储的是该类对应的虚函数地址。因此父类指针指向父类对象时,会通过父类的虚表找到父类的虚函数地址,即可调用;当父类指针指向子类对象时,会通过子类的虚表中找到子类的虚函数地址进行调用。
- inline函数可以是虚函数吗?
可以,不过内联函数是没有地址的,不会进入符号表。如果是普通调用倒没啥影响,如果是多态调用,编译器就忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去。
- 静态成员可以是虚函数吗?
不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
- 构造函数可以是虚函数吗?
不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。而虚函数的意义是多态,多态调用时到虚函数表中去找,可构造函数之前还没初始化,如何去找?
- 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
可以,并且最好把基类的析构函数定义成虚函数。
- 对象访问普通函数快还是虚函数更快?
首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
- 虚函数表是在什么阶段生成的,存在哪的?
虚函数表是在就编译阶段生成的,一般情况下存在代码段(常量区)的。
- C++菱形继承的问题?虚继承的原理?
问题:菱形继承会导致子类存放2份的父类成员,导致数据冗余和二义性。
原理:而虚继承对于相同的虚基类在对象中只会存储一份,若要访问虚基类的成员则要通过虚机表指针访问到虚机表,而虚机表中存放的时偏移量,通过偏移量来找到公共父类成员的位置并对其进行操作。
注意:
- 继承里的虚基表存储的是偏移量,是为了解决数据冗余和二义性
- 多态里的虚函数表存的是虚函数的地址,为了的是实现多态
- 什么是抽象类?抽象类的作用?
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。