目录
一.多态的概念
字面意思,多种形态。即不同的对象完成同一件事,会产生不同的状态。
举个生活中的例子便于理解:买火车票行为,学生买票和成人买票。不同的对象:学生和成人;不同的状态:不同的火车票价格。
二.多态的实现
先来说说为什么要实现多态?
像买火车票一样,多态是生活中常见的现象,C++是面向对象语言,因此实现多态是必须的。
1.多态的实现条件:
背景:在继承体系中,需要用不同类的对象,调用同一个函数,却要实现不同的行为。
条件:
1.被调用的函数必须是虚函数,且派生类必须对基类函数重写
2.必须通过基类的指针或引用调用虚函数
2.虚函数
类中被virtual修饰的函数
3.虚函数重写,也叫覆盖
重写的定义:
定义:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的 返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
/*注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因
为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议
这样使用*/
};
void Func(Person& p)//这里必须是基类指针或者引用才是多态调用
{
//如果不是基类指针、引用,这里参数为派生类,那么基类无法合法传参调用该函数,因此不能实现多态
p.BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(ps);
Func(st);
return 0;
}
但是也有两个例外:
1.协变(基类和派生类中虚函数的返回值不同)
但是对返回值也是有要求,返回值必须得是有继承关系的指针或引用。
class A{}; class B : public A {}; class Person { public: virtual A* f() {return new A;} }; class Student : public Person { public: virtual B* f() {return new B;} }
第一个特殊重写几乎用不到,当作了解即可,但是第二个例外必须彻底理解,如下
2.析构函数的重写
我们知道,析构函数的函数名都不一样,于是C++规定,加virtual后,构成重写,为什么C++要规定这一点呢?
class Person { public: ~Person() { cout << "~Person" << endl; } }; class Student:public Person { public: ~Student() { cout << "~Student" << endl; } }; int main() { 在这种场景下使用是没有任何问题 //Student s; //Person p; //但是在这种场景下,基类指针没有释放派生类的空间,发生内存泄露 Person* p1 = new Person; delete p1; Person* p2 = new Student; delete p2; }
因此,为了防止这种合法的C++语法却造成的内存泄露问题,C++要求在继承体系中,最好将析构函数定义为虚函数,且基类析构函数是虚函数的情况下,派生类的析构函数均会重写,具体是因为编译器对析构函数做了统一处理,编译后析构函数的函数名均为destructor,重写后,在上述代码调用析构函数时,不再是普通调用,而是多态调用。
4.多态调用和普通调用
class Person {
public:
virtual ~Person()
{
cout << "~Person()" << endl;
}
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
// 重写实现
class Student : public Person {
public:
~Student()
{
cout << "~Student()" << endl;
}
void BuyTicket() { cout << "买票-半价" << endl; }
};
class Children : public Person
{
public:
void BuyTicket() { cout << "买票-免费" << endl; }
};
int main()
{
Person* p1 = new Person;
Person* p2 = new Student;
Person* p3 = new Children;
// 多态调用
p1->BuyTicket();
p2->BuyTicket();
p3->BuyTicket();
delete p1;
delete p2;
delete p3;
// 普通调用
Student s;
s.BuyTicket();
s.Person::BuyTicket();
return 0;
}
当满足构成多态条件时,发生多态调用,多态调用,调用哪一个,看的是“指针或者引用指向的对象”
而普通调用,调用哪个函数,取决于对象,指针或者引用自身对象的类型
5.关键字final和override
抛出一个问题:想让一个类不想被继承,要如何实现
方法1:
将这个类的构造函数实现为private属性。
基于这个条件,如果被继承,子类对象调用基类构造函数就是不被允许的。
方法2:
将类用final修饰
//final关键字 class A final { public: void func() { } };
final还可以用于修饰虚函数,表示这个函数不可以再被重写
比如在继承体系中,有A,B,C三个类,B中继承A的虚函数加上关键字final后,C中不会再继承虚函数。
class Car { public: virtual void Drive() final {} }; class Benz :public Car { public: //Benz中的Drive不是虚函数 virtual void Drive() {cout << "Benz-舒适" << endl;} };
override关键字用于检查这个函数是否重写了虚函数,如果没有则报错误
class Car{ public: virtual void Drive(){} }; class Benz :public Car { public: virtual void Drive() override {cout << "Benz-舒适" << endl} }
6.重写,重定义,重载对比
7.一道选择题的理解
下面程序输出结果是什么
class A { public: virtual void func(int val = 1){ std::cout<<"A->"<< val <<std::endl;} virtual void test(){ func();} }; class B : public A { public: void func(int val=0){ std::cout<<"B->"<< val <<std::endl; } }; int main(int argc ,char* argv[]) { B*p = new B; p->test(); return 0; }
答案是:
解释:
子类继承了虚函数,但是没有重写,p->test是普通调用。
到了test函数内部,调用func,func函数是虚函数且子类对其重写,满足多态条件1。
func(); //this->func()
this是A*还是B*?答案是A*,因为test()的第一个形参是默认的。
void test(A* x);
那么现在来看,满足了多态的两个条件,这里是多态调用,多态调用要看指针指向的对象,这里的this值其实是子类中切片出来的A,this指向的是B,因此这里调用子类中的func()
但是,val值应该是为0,为什么打印结果却是1呢?
这就要提到重写的一个鲜为人知的特点:
重写也叫覆盖的原因是,在底层原理上实现时,其实是“覆盖”的思想。重写其实是继承基类中虚函数的声明,只重写函数体部分。在底层来理解,就是先复制了一份,再覆盖掉。
因此,这里的虚函数func,函数声明始终是基类的那部分,val值为1
三.多态的原理
1.虚函数表
先来看看这样一个问题,求Base的大小?
class Base { public: virtual void Func1() { cout << "Func1()" << endl; } private: int _b = 1; };
答案是:8Byte
打开监视窗口观察:
通过观察,发现对象中多了一个指针__vfptr,这个指针是什么呢?
虚函数表指针,v->virtual,f->function,虚函数表指针指向的叫做虚函数表,也叫虚表。虚表中存放虚函数指针。
再来看看如果是派生类,会是什么样的?为了更好的解释,我们对基类和派生类添加一些函数,基类有虚函数Func1,Func2,普通函数Func3,派生类有属于自己的虚函数Func4
class Base { public: virtual void Func1() { cout << "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; } virtual int Func4() { cout << "Derive:Func4()" << endl; return 0; } protected: int _d =2; }; int main() { Base a; cout << sizeof(a) << endl; Derive d; return 0; }
结论:
1.有虚函数的类至少有一个虚函数表指针。
2. 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针是在这一部分的,另一部分是自己的成员。
3. 基类b对象和派生类d对象的虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
4. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。
5. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr,而g++编译器没有这样处理。Base的虚函数表大小是3,说明还存了一个nullptr。
6. 总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。这里Derive虚函数表的大小是4,说明在nullptr之前,存放了虚函数func4。
7. 还有一个问题:虚函数存在哪的?虚表存在哪的? 答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存放在代码段的。
我们对以上结论进行验证:
结论6.我们通过调试观察派生类自己的虚函数存放在哪里
通过vs2019提供的监视和内存窗口,我们发现Func3函数并没有被“存”下来,而当我们通过内存窗口来观察,发现连nullptr也没有存下来,这是什么情况呢
其实啊,vs2019的监视窗口确实观察不了这个现象,只能通过内存窗口来猜测,那nullptr都没有存放是因为编译器的种种因素影响,我们只需要重新编译再观察一次
ps:图片打错了,其实是Func4
我们也可以尝试把虚表打印出来
//打印虚表,本质是打印函数指针数组 typedef void(*VFPtr)(); void Print_VFTable(VFPtr* v) { for (size_t i = 0;v[i]!=nullptr;++i) { printf("[%d]:%p->", i,v[i]); VFPtr f = v[i]; f(); cout << endl; } } int main() { Derive d; Print_VFTable(((VFPtr*)*(int*)&d)); return 0; }
还有结论1:
如果是普通多继承,可能有两个虚表
可能你会疑问,派生类自己的虚函数存在哪里呢
其实存在了第一个继承时声明的类中,只不过vs2019的监视窗口无法显示,你可以用前面的打印函数将其打印出来
关于结论7的解释:虚函数存放在哪里,虚函数表存放在哪里
1.虚函数也是函数,存放在代码段。
在这里,再来了解一下内存的分区,相信你会有不一样的收获,
内存主要就是这些区域,你可能看到不同的博客对内存划分的描述不一样,但大致也就这些区域,根据我自己的理解,我们需要了解的有四个部分
1.栈,内存先划分一块区域给栈,然后栈向上生长
2.堆,堆是向下生长
3.数据段:全局数据区存全局变量,静态变量区存静态局部变量,静态全局变量。
还有常量区。
4.代码段:函数就存放在了这里
注意:
栈,堆,全局数据区这些区域都是可读可写的(RAM),而常量区和代码段是只读的(ROM)
常常有些文章的理解是把常量区划分在了代码段,因为常量和代码段都是只读的,但是内存无论怎样划分,本质都不会变。
2.虚函数表存放在常量区,可以通过打印地址观察,因此有些文献认为虚函数表存放在代码段,有些认为存放在数据段,其实只是划分理解不一样。
int main() { Base b; Derive d; int i = 0; static int j = 1; int* p1 = new int; const char* p2 = "xxxxxxxx"; printf("栈:%p\n", &i); printf("静态区:%p\n", &j); printf("堆:%p\n", p1); printf("常量区:%p\n", p2); Base* p3 = &b; Derive* p4 = &d; printf("Base虚表地址:%p\n", *(int*)p3); printf("Base虚表地址:%p\n", *(int*)p4); return 0; }
我们观察发现,虚表的地址更接近常量区。
2.多态的原理
用之前的买票来解释
1. 观察红色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚函数是Person::BuyTicket。
2. 观察蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中找到虚函数是Student::BuyTicket。
3. 这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
4. 再通过下面的汇编代码分析,看出满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中去找的。不满足多态的函数调用是编译时确认好的。
void Func(Person* p)
{
p->BuyTicket();
}
int main()
{
Person mike;
Func(&mike);
mike.BuyTicket();
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
001940EC cmp esi,esp
}
int main()
{
...
// 首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调
用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址
mike.BuyTicket();
00195182 lea ecx,[mike]
00195185 call Person::BuyTicket (01914F6h)
...
}
可能大家对这段汇编代码看不明白,其实也无关紧要,我们大致了解就可以了。
下面我通过具体的对比来观察一下:
代码如下:
class Car { public: virtual int AI_Drive() { int a = 2; cout << "Car:AI_Drive" << endl; return a; } }; class XiaomiSu7 :public Car { public: virtual int AI_Drive() { int i = 1; cout << "XiaomiSu7:AI_Drive" << endl; return i; } }; int main() { //多态调用 Car* p; p = new XiaomiSu7; p->AI_Drive(); //普通 XiaomiSu7 s; s.AI_Drive(); }
通过汇编观察,call指令要调用函数时,是调用eax中的内容,其实是一句jmp指令,由jmp跳转到具体函数。
下面观察普通调用的汇编代码
这里是直接call的函数地址,说明这句代码在编译时就已经确定好了。
3.动态和静态
基于以上知识,来介绍两个概念
1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。一般提到静态说明是编译时的行为,提到动态一般是运行时的行为。
四、抽象类
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
class Car { public: virtual void Drive() = 0; }; class Benz :public Car { public: virtual void Drive() { cout << "Benz-舒适" << endl; } }; class BMW :public Car { public: virtual void Drive() { cout << "BMW-操控" << endl; } }; void Test() { Car* pBenz = new Benz; pBenz->Drive(); Car* pBMW = new BMW; pBMW->Drive(); }
普通继承和接口继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
五、回顾
通过问答来回顾一下本篇文章的内容
1. 什么是多态?
字面意思来理解就是多种形态,其实是在写程序的过程中要让不同的对象通过同一个函数实现不同的行为。
2. 什么是重载、重写(覆盖)、重定义(隐藏)?
3. 多态的实现原理?在继承体系中,满足多态的构成条件后,在编译时会写成一张虚函数表,而在运行时通过基类的指针指向的不同对象在虚表中调用不同的虚函数,以此来实现多态。
4. inline函数可以是虚函数吗?答:可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为inline没有函数指针,而虚函数要放到虚表中去。
5. 静态成员可以是虚函数吗?答:不能,静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
6. 构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?答:可以,并且最好把基类的析构函数定义成虚函数。
8. 对象访问普通函数快还是虚函数更快?答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
9. 虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
11. 什么是抽象类?抽象类的作用?答:抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。