多态:(附高频面试题)虚函数重写覆盖,基类析构重写,重载重写隐藏对比,多态原理,虚表探究一文大全
🎬 胖咕噜的稞达鸭:个人主页
学完本章你将会了解:
-
基类的析构函数为什么建议写成虚函数?(面试考试常用)1
-
函数重载,函数重写(覆盖),函数隐藏的区别(面试经常问),重写就是重定义?还是重定义就是重写?2
-
所有成员函数(包括静态成员函数)可以被设置成虚函数吗?1
-
虚函数存在哪里?虚表存在哪里?。1
-
抽象类可否实例化对象?可否定义指针?。2
多态
多态的概念
多态就是多种形态。分为编译时多态(静态多态)和运行时多态(动态多态)。
编译时多态主要是函数重载和函数模板,传不同的参数就调用不同的函数。
运行时多态,就是完成某个行为时(函数),可以传不同的对象会有不同的行为,比如说,动物的叫声可以是一个行为(函数),猫咪的叫声是“喵喵”,狗的叫声是“汪汪”。
虚函数:
类成员函数前面加virtual修饰,那么这个成员函数被称为虚函数。
实现多态的条件:
1.必须指针或者引用(基类的)调用虚函数;
2.被调用的函数必须是虚函数。
说明:要实现多态效果,
第一必须是基类的指针或者引用,因为只有基类的指针或引用才能指向派生类对象;
第二派生类必须对基类的虚函数重写/覆盖,重写或者覆盖了,派生类才会有不同的函数,多态的不同形态效果才可以达到。
虚函数的重写/覆盖:派生类中有一个跟基类完全相等的虚函数(即派生类虚函数与基类虚函数的返回值类型,函数名字,参数列表完全相同),这时候就是派生类的虚函数重写了基类的虚函数。
#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>
using namespace std;class Person
{
public:virtual void buyTicket() { cout << "买票-全价" << endl; }//这个地方的virtual不可少
};
class Student :public Person
{
public:virtual void buyTicket() { cout << "买票-打折" << endl; }//虚函数重写,三同
};void Func(Person* ptr)//虽然是Person指针ptr在调用buyTicket,但是最终打印出来的结果是由ptr指向的对象决定的。//基类的指针或引用
{ptr->buyTicket();
}
int main()
{Person ps;Student st;Func(&ps);//买票-全价Func(&st);//买票-半价return 0;
}
//引用
void Func(Person& ptr)
{ptr.buyTicket();
}
int main()
{Person ps;Student st;Func(ps);//买票-全价Func(st);//买票-半价return 0;
}
注意:在重写基类虚函数时,基类的virtual不可以去掉,派生类的虚函数在不加virtual关键字的时,虽然也可以构成重写(继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是这个写法不规范,不建议这样写,考试会多出,判断其是否构成多态。
多态高难度面试题:
下面来一道题我们来深入理解:(腾讯面试题!!!)
- 为什么会打印出B->1?
来分析:
派生类对象B 在堆上创建一个B类的对象,然后把这个对象的地址赋值给指针p,使得指针p指向这个新创建的B类对象。然后用指针p来操作这个b对象,p->test(),
通过指针p调用B对象的test
方法。
- 为什么test()明明在A对象中,B可以调得到吗?
可以的,构成多态吗?有关键字virtual在基类后面,而且函数名,返回值类型,参数列表是一样的,满足继承;其次用p去调用test(),由于p是派生类B对象的指针,所以调用的时候先去派生类中寻找test()
,没有找到,但是B继承了A,接下来会去A基类中找test()
,这里的this指针是基类的指针,构成多态。且B会隐藏A。
- 这个test()是A的this,还是B 的this指针呢?**
答案是A的。由于p是派生类B对象的指针,所以调用的时候先去派生类中寻找test()
,没有找到,但是B继承了A,接下来会去A基类中找test()
,因为B会隐藏A。虽然继承了A,但是调用的时候还是在A类中,所以test()
中的this
指针是A的。所以会构成多态,满足基类中的指针或者引用去指向派生类对象,this->func()。
指向谁调用谁,所以先打印出B->
- 到底是1还是0?
用父类函数的说明部分加上派生类的实现部分,本质上是重写虚函数的实现。为什么在void func(int val=0)
之前不加virtual
,因为继承下来的实际上是virtual void func(int val=1) {std::cout<<"B->"<<val<<std::endl;}
绝不重新定义继承而来的函数列表中的值,缺省值。
所以打印出B->1
。
- 拓展改编:假如将主函数中的p->test()改成p->func(),此时会打印出什么?
B->0,因为p是派生类B的指针,用p去调用B中的函数,打印出B->0
,此时不构成多态的条件:虽然函数名相同,返回值相同,参数列表相同,构成虚函数条件。但是基类的指针引用没有找到。所以这里是普通函数的调用。
虚函数重写的一些其他问题:
协变:派生类在重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>
using namespace std;class A{};
class B:public A{};//协变
class Person
{
public:virtual A* buyTicket(){cout << " 贵" << endl;return nullptr;}
};class Student :public Person
{
public:virtual B* buyTicket(){cout << " 便宜点" << endl;return nullptr;}
};void func(Person* cur)
{cur->buyTicket();
}
int main()
{Person ps;Student st;func(&ps);//贵func(&st);//便宜点return 0;
}
析构函数的重写:
为什么基类的析构函数建议设计成虚函数?
基类的析构函数为虚函数,此时派生类析构函数只要定义,无论派生类是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类的析构函数看起来不重名,实际上编译器对析构函数的名称做了特殊处理,编译之后基类和派生类的析构函数统一被处理为destructor
,所以基类的析构函数加了virtual修饰,派生类的析构函数就会构成重写。
这里给个代码块我们看一下:
delete
在调用的时候会先调用析构函数,然后会调用operator
去析构。
如果说我们不将基类的析构函数设计成虚函数会怎么样?
下面结合代码来看,子类branCh()
在构造的时候向内存动态申请了10个字节的空间用来储存_p,但是析构函数~branCh()
从来没有被调用,导致_p内占用的内存永远无法被释放,形成内存泄漏。
所以这里的解决方法就是在基类foundaTion
析构函数之前要加上虚函数virtual
,改成virtual ~ foundaTion()
,运行时会新增调用派生类的branCh
的析构函数,就会去释放子类当中的动态数组,内存泄漏的问题就可以解决。
class foundaTion
{
public:virtual ~foundaTion(){ cout << "析构~foundaTion()" << endl;}
};class branCh :public foundaTion
{
public:virtual ~branCh(){cout << "析构~branCh()->delete:" << _p << endl;delete _p;}
protected:int* _p = new int[10];
};int main()
{foundaTion* p1 = new foundaTion;//这里已经满足了父类的指针或引用去调用foundaTion* p2 = new branCh;delete p1;//调用foundation()的析构函数delete p2;//调用派生类的析构函数:会先调用branCh()的析构析构函数,其次会调用foundation()的析构函数
}
只有满足多态才可以完成基类和派生类的析构。
override和final关键字
C++对于函数重写的要求非常严格,但是在有些情况下会由于疏忽写错,比如函数名写错,参数写错等会构成无法重载,但是编译器在编译的时候不会报错,只有在运行的时候无法得到预期结果,所以哪里写错就在哪里加上override
,可以帮助用户重写这个函数,如果不想让派生类去重写虚函数,最好加上final
这个修饰。也就是不想让这个类被派生类继承。
了解即可~
现在来对比重载/重写/隐藏的对比
都是函数之间的关系。
函数重写/覆盖:函数名字,返回类型和参数列表完全相同。
两个函数分别在基类和派生类两个不同的类域
两个函数都是虚函数
还是上面的图拿下来看。
函数重载:(重写)
两个函数在同一个作用域;
函数相同,参数不同,参数的类型或者个数不同,返回值可同,可以不同
函数隐藏:(重定义)
函数名相同
两个函数只要不构成重写就是隐藏,基类派生类的成员变量相同也叫隐藏
两个函数分别在基类和派生类的不同类域
纯虚函数和抽象类
在虚函数的后面加上=0,则这个函数就是纯虚函数,纯虚函数所在的类就是抽象类。注意:纯虚函数不能实例化出对象。
继承抽象类的派生类能否实例化出对象呢?
不可以,因为继承下来继承了基类中的所有元素,除非在派生类中重新写虚函数,不要再后面加=0,其实也就是让我们再写一遍。
抽象类可以定义指针,而且经常这样做,其目的就是用父类指针指向子类从而实现多态。
给个代码来理解。
class car
{
public:virtual void Drive()=0;//纯虚函数
}
class Benz:public car
{
public:virtual void Drive(){cout<<"Benz-舒适"<<endl;}
};int main()
{car s;//这里就会报错,因为纯虚函数不能实例化出对象(重写就不会报错了)Benz b;//这里会报错,继承抽象类的派生类不能实例化出对象car* pBenz=new Benz;car* pBmw=new Bmw;pBenz->Drive();pBmw->Drive();return 0;
}
多态的原理:
先来做一道题(要求在32位程序下):
class Base
{
public:virtual void func1(){cout << "func1()" << endl;}
protected:int _a = 1;char _ch = 'x';
};int main()
{Base a;cout << sizeof(a) << endl;//12return 0;
}
打印结果是12,在32位程序下,int 类型的_a占4字节,char类型的_b占1个字节,考虑内存对齐最终会占到8个字节,但是为什么打印出12?
除了_a和_ch成员,还有一个_vfptr放在对象的前面(注意有些平台会放在对象额最后面),对象中这个指针就叫做虚函数表指针(virtual function table)。一个含有虚函数的类中都有至少一个虚函数表指针,因为一个类中所有的虚函数的地址都要放到这个类对象的虚函数表中,虚函数也就是虚表。也就是虚函数指针的数组。
多态的原理:
指向谁就调用谁,指向哪个对象,运行的时候,到指向对象的虚函数表中找到对应虚函数的地址,进行调用。
#include<iostream>
using namespace std;class Person
{
public:virtual void buyTicket(){cout << " 全价" << endl;}
protected:string _name;
};class Student :public Person
{
public:virtual void buyTicket(){cout << " 半价" << endl;}
protected:int _id;
};class Soider :public Person
{
public:virtual void buyTicket(){cout << " 优先买票" << endl;}
protected:string _codename;
};void func(Person* ptr)
{ptr->buyTicket();
}
int main()
{Person ps;Student st;Soider sr;func(&ps);//全价func(&st);//半价func(&sr);//优先买票return 0;
}
多态不仅会发生在派生类对象之间,多个派生类继承基类,重写虚函数之后。
多态还会发生在多个派生类之间。
动态绑定和静态绑定
对于不满足多态条件(指针或引用+调用虚函数)的函数调用是指在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定;
满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就叫做动态绑定。
可见:
静态绑定的运行效率要高一点,因为调用时指令很少,但是动态绑定就需要调很多的指令。
虚函数表深入探究
-
基类对象的虚函数表中存放基类所有虚函数的地址。也就是说同类型的对象他们的虚函数表是一样的。(3个Base基类对象都存在一个虚函数表中)。不同类型的对象虚表各自独立。
-
派生类由两部分给构成(继承下来的基类和自己的成员),一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。
但这里继承下来的基类部分虚函数表指针(在子类中有一份父类的部分)和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也各自独立一样。 -
派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
-
派生类的虚函数表中包含:
基类的虚函数地址
派生类重写的虚函数地址
派生类自己的虚函数地址
怎么理解3.4条:
简单来说:就是派生类用自己重写的虚函数”覆盖了“基类虚函数在虚函数表中的位置。这样通过派生类对象调用该虚函数时,就会执行派生类重写后的版本。
把父类的虚函数表拷贝过来构成第一个部分,如果子类中重写了func1
,virtual void func1(){ cout << "Base::func1" << endl; }
,就会用重写的func1
的地址去覆盖继承父类的虚函数地址。Base
的 func2没有被Derive
子类重写,就不管;派生类中还有一部分func3是自己的,这一部分会存在派生类自己的虚函数地址中。这里func4是普通非虚函数,不存在虚函数表中。
看图理解:
- 虚函数存在哪里?
存在代码段中,虚函数和普通函数一样的,编译好之后是一段指令,都是存在代码段中,只是说虚函数的地址又存到了虚表中。
- 虚函数表存在哪里呢?
来一段代码理解一下:
class Base
{
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }
protected:int a = 1;
};
class Derive :public Base
{
public:virtual void func1(){ cout << "Base::func1" << endl; }virtual void func3() { cout << "Base::func3" << endl; }void func4() { cout << "Derive::func4" << endl; }
protected:int b = 2;
};int main()
{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 b;Derive d;Base* p3 = &b;Derive* p4 = &d;printf("Person虚表地址:%p\n", *(int*)p3);printf("Student虚表地址:%p\n", *(int*)p4);printf("虚函数地址:%p\n", &Base::func1);//printf("普通函数地址:%p\n", &Base::func4);return 0;
}
i是局部变量,存在栈区;
j是静态区变量存在静态区;
p1是局部变量,但是由于动态开辟new了一块空间,给int 类型的指针,所以p1应该是在堆上;
p2局部变量在栈上,但是它指向的值是一段字符串常量,所以p2在常量区。
要想拿到虚函数表的地址,要先拿到对象的前4个字节,就是虚函数表的地址。
将p3强制类型转换成int 类型,然后再解引用拿到指向虚表地址的前四个字节。
看运行结果,虚函数表的地址跟常量区的地址非常接近,所以虚函数表的地址是存在代码段(常量区)当中的。普通函数地址,虚函数地址都存在常量区当中。
总结回顾:
反向过来思考,如果基类没有被设置成虚函数,有一个派生类继承了基类,现在派生类中有继承下来的数据,同时还自己向动态内存申请了一个空间,当在调用析构函数的时候,基类没有写virtual关键字,不是虚函数,这里析构的时候没有调用派生类,所以析构的时候动态申请的内存得不到释放,造成内存泄漏。相反如果设置了虚函数,在析构基类之前加上关键字:virtual ,就会析构掉派生类中的所有元素。很好的解决了内存泄漏的问题。
虽然基类与派生类的析构函数看起来不重名,实际上编译器对析构函数的名称做了特殊处理,编译之后基类和派生类的析构函数统一被处理为destructor
,所以基类的析构函数加了virtual
修饰,派生类的析构函数就会构成重写。 ↩︎ ↩︎ ↩︎ ↩︎重写和重定义是两码是事,重写即覆盖,针对多态, 重定义才是函数隐藏。
函数名都相同:相同点
1: 静态成员函数不能设置为虚函数。核心关键是没有隐藏的this指针,可以通过类名::成员函数名 直接调用,此时没有this无法拿到虚表,就无法实现多态,因此不能设置为虚函数。
4.存在代码段中,虚函数和普通函数一样的,编译好之后是一段指令,都是存在代码段中,只是说虚函数的地址又存到了虚表中。
虚函数表的地址是存在代码段(常量区)当中的。普通函数地址,虚函数地址都存在常量区当中。
5.纯虚函数不能实例化出对象。
继承抽象类的派生类不能实例化出对象,因为继承下来继承了基类中的所有元素,除非在派生类中重新写虚函数,不要再后面加=0,其实也就是让我们再写一遍。
抽象类可以定义指针,而且经常这样做,其目的就是用父类指针指向子类从而实现多态。 ↩︎ ↩︎