目录
实现条件
虚函数重写的其他问题
1.协变
2.析构函数的重写也有点问题
两个关键字override,final
重载,重写,隐藏的对比--面试
抽象类
多态原理
虚函数表与虚函数表指针--面试
传参
还有两个问题
1.虚函数存在哪里?虚函数表中吗
2.虚函数表在哪里?
静态多态和动态多态
我们之前就接触过多态,比如函数重载,函数模板,在编译时通过传入不同的类型来实例化出从不同的函数,之所以称作静态多态,是因为在编译阶段完成,我们把编译时归为静态,运行时归为动态,动态多态是调用同一个函数,产生不同行为。
- 源码被编译器预处理,编译形成汇编语言,汇编形成二进制代码,也就是.o目标文件,最后链接形成可执行文件.exe。
- 编译期就是做翻译,检查语法,类型推导,模板实例化,此时程序没有运行,内存里没有真实变量数据
- 运行期,加载.exe到内存里,初始化变量的值,CPU执行代码,
- 静态多态,编译时多态,编译阶段就能确定要调用哪个函数,编译器查看func(a),a变量是int类型,进行类型推导,实例化一份int的普通函数,最后exe里没有模板函数,只有实例化的函数,编译器已经确定调用哪个函数
- 运行多态,运行期间才能知道要调用哪个函数,依赖对象成员变量中的序表指针vptr
比如对于买票这一函数,对于学生来讲,价格会减半;对于普通人来说,价格仍然不变。
这里我们着重介绍动态多态,
即运行确定的多态
实现条件
前提:多态是一个继承关系下的类对象,通过调用同一函数,产生了不同行为。比如,Student 继承了 Person,Person买票是全价,Student买票是半价。
要求:
1.通过基类的指针或者引用指向子类对象/基类对象,进而调用虚函数,来实现多态
说明,基类的指针可以指向基类对象,也可以指向子类对象,base* 指针变量里存的是基类或者子类在虚拟地址空间中的起始地址,它不像拷贝构造那样发生切片,而是内存中保留完整基类、子类对象,能调用到实际指向对象的虚函数,实现不同效果。
有个问题,同一个类,多个对象,它们只是成员变量的值不同,函数都是同一个,每一个对象都会实例化出同一份函数,还是多个对象,都指向同一个个函数?
1.普通成员函数,非virtual
class Person{
int age;
void show{...};
};
Person p1,p2;
内存布局:p1,p2内存只存age,函数放在代码段,不属于对象
为了区分不同对象调用同一个函数得到不同的结果,调用函数时候会隐式传入this指针,来表明当前是哪个对象调用的这个函数,参数传哪个对象的成员变量。
2.虚函数 virtual
class Person{
int age;
virtual void show{...};
};
Person p1,p2;
内存布局:p1,vptr虚表指针+age;p2,vptr虚表指针+age
同样,虚函数被多个对象调用,因此放到代码段,属于类,不会每个对象都复制一份
p1,p2都是同一个类,Person,因此它们的虚函数表存的函数地址都是相同的,因此虚函数表相同
但如果是Base p1,Person p1;
它们是两个类,它们有各自的虚函数,因此虚函数表不同
为了区分 不同类指向的不同虚函数表,或者可以说 不同对象指向的 类的虚表,在每个对象中加入一个成员变量,vptr,来区分当前对象属于哪个类,会调用哪个类对应的虚函数
p1.vptr和p2.vptr一个指向Base的虚表,一个指向Person的虚表,
而之前的则是p1.vptr和p2.vptr指向同一张Person的虚表
2.被调用的是virtual虚函数,父类中声明虚函数,子类中完成对父类虚函数的重写/覆盖
说明,子类必须对父类的虚函数进行重写/覆盖,这样才能使基类和派生类之间的函数不同,实现"多种形态"。
什么叫做虚函数?
在类成员前面加关键字virtual修饰(非成员函数不能加virtual)。
什么叫做虚函数的重写?
子类中定义一个与父类的虚函数完全相同的函数,这里的完全相同是指,函数名,参数列表,返回值类型完全相同,仅仅是函数体不同。
注意:子类进行重写虚函数时,即使子类的虚函数不加virtual关键字,也能构成重写(因为父类的虚函数被继承下来了,在子类中仍保留着虚函数的属性),但是不建议这么写,但父类的虚函数不能不加virtual。这点笔试常考,会故意挖坑,判断是否会构成多态。
一道经典的题
等会讲原理
总结:
1.继承时遵循查找,当B类型对象调用test()函数时,会先查找B中是否有该函数,没有的话会去继承的父类A中查找test(),若A中存在test(),则直接使用A中的test版本。而B中不会独立存储test(),调用的是A中定义的test()逻辑。
2.子类虚函数重写,本质上是父类A的声明+子类虚函数体的重写的组合。
Effective C++ 条款37 绝不重新定义继承而来的缺省值
虚函数重写的其他问题
1.协变
正常情况下,虚函数重写要满足两个条件,一是父类指针或引用,二是虚函数的完全相同(三同)
但协变打破了三同之中子类函数与基类函数返回值可以不同这一点, 即基类返回基类的指针/引用,派生类返回派生类的指针/引用
class Person { public: virtual Person* Buy() { cout << "正常价格" << endl; return new Person; } }; class Student :public Person { public: virtual Student* Buy() { cout << "打折" << endl; return new Student; } }; void func(Person* p) { p->Buy(); } int main() { Person p; Student s; func(&p); func(&s); return 0; }2.析构函数的重写也有点问题
如果基类的析构为虚函数,派生类析构只要定义了,不管加不加关键字virtual都构成重写,因为编译器对析构函数的函数名做了特殊处理,统一为destructor
进行这样的处理一定是为了解决什么问题,
基类的指针可以指向基类本身,也可以指向子类
我们看这样一个场景,当基类指针删除子类对象时,只会调用基类的析构函数,子类的析构函数被跳过,从而无法正确地释放子类资源
class A { public: ~A() { cout << "~A()" << endl; } }; class B:public A { public: ~B() { delete _b; cout << "~B()" << endl; } protected: int* _b = new int[0]; }; int main() { A* p = new B;//基类指向派生类对象B delete p;//仅调用A的析构函数(如果不构成多态,那就导致子类调用父类的析构) //结果:~A() return 0; }那这时我们就要解决一个问题,就是这个指向子类的父类指针,调用析构函数,期望先调用子类的析构,再调用父类的析构
这时候就期望通过多态来解决,就是调用的析构不和类型A*有关,而是期望和指针p指向的对象有关,delete 父类,调用父类的析构,delete子类,期望调用子类的析构
多态的两个条件,1.父类的指针/引用(已经达成,delete p(父类);通过父类的指针调用析构)2.虚函数的重写(加virtual,无返回值无参数,函数名被处理成distructor)
这就是要析构函数为什么要处理成相同函数名的原因----面试
class A { public: virtual ~A() { cout << "~A()" << endl; } }; class B:public A { public: ~B() { delete _b; cout << "~B()" << endl; //确保父类中的对象被销毁 //A::~A(); } protected: int* _b = new int[0]; }; int main() { A* p = new B; delete p; //结果:~B() ~A() return 0; }这里如何确保在子类对象调用析构后,父类对象也被销毁,需要手动在子类析构函数中调用父类析构吗?
不需要,编译器在子类析构函数执行完毕后,会自动插入父类析构函数的调用代码(保证先子后夫);而且不要自己显式调用析构,因为会破坏对象的生命周期,等到对象生命周期结束之后,编译器自己调用析构就好。
即派生类先析构子类那一部分 再析构父类--合成版本--自动调用析构
两个关键字override,final
override检查是不是完成重写(函数名写错/参数写错)
final,我们不想让派生类重写这个虚函数
重载,重写,隐藏的对比--面试
抽象类
抽象类?
是指包含纯虚函数的类
啥是纯虚函数?
仅定义接口,但不能实例化出对象。加 =0实现
比如这样一个场景,我们要定义一个形状的系统,包括"矩形","圆形"两个类,基类需要定义"计算面积"这样的函数,但是"计算面积"是一个抽象的概念,我们不知道这个函数具体要怎么实现,但此时我又必须得实现。
class Shape { public: virtual double Area() { return 0.0; //没有实际意义 } }; class Circle : public Shape { private: double _r; public: Circle(double r) : _r(r) {} // 子类中重写Area(),实现圆形面积 double Area() override { return 3.14 * _r * _r; } };这样的话调用基类s.Area()没有意义,因此采用纯虚函数的模式,只需要将抽象的函数进行声明,而不用定义,这样避免了基类的Area无意义;而且子类想要使用Area时,只需要对这个纯虚函数进行重写(否则子类也会成为抽象类,无法实例化)
class Shape { public: virtual double Area() = 0; }; class Circle : public Shape { private: double _r; public: Circle(double r) : _r(r) {} // 子类重写Area(),否则会无法实例化 double Area() override { return 3.14 * _r * _r; } }; int main() { //Shape s;//E0322,不允许是由抽象类类型的Shape对象 Circle c(1); cout << c.Area() << endl; return 0; }纯虚函数的继承是让子类继承其接口,然后子类通过这个接口来重写纯虚函数,才能实例化,保证了多态接口的一致。
多态原理
多态的原理是什么,为什么同一个接口,传入的对象类型不同,产生的结果也不同?
我们看下面一段代码,Base类类型的b对象的字节数是多少?
b中包含两个成员对象,_b和_ch,最大对齐数是它们的字节数是4+1,结构体的总大小就是结构体成员最大对齐数的整数倍,应该是8个字节,但实际得到的是16,说明b对象中还有别的成员。
class Base { public: virtual void Func1() { cout << "Func1()" << endl; } protected: int _b = 1; char _ch = 'x'; }; int main() { Base b; cout << sizeof(b) << endl; return 0; }虚函数表与虚函数表指针--面试
对象中的_vfptr指针叫做虚函数表指针(virtual function),它是一个指针,指向了一个虚函数表,虚函数表是一个函数指针数组。
编译器会为每个包含虚函数的类对象前面自动添加一个隐藏的成员变量_vfptr,_vfptr的作用是指向当前所属类的vtable虚函数表
啥是虚函数表呢?
它是储存虚函数地址的表,该表中存储了类中所有的虚函数的内存地址,
包括子类从父类中继承的未重写的虚函数,以及子类重写后的虚函数,
针对后者,若子类重写了虚函数,子类的虚函数表中会将重写后的虚函数地址覆盖父类对应的虚函数地址,(重写所以也称覆盖)
若子类新增了自己的虚函数,会在子类虚函数表的末尾加上这个新增虚函数的地址。
若子类没有重写继承的虚函数,子类的虚函数表中会保留父类对应的虚函数地址。
class Parent { public: virtual void func1() {} // 父类虚函数1 virtual void func2() {} // 父类虚函数2 }; class Child : public Parent { public: void func1() override {} // 子类重写func1 virtual void func3() {} // 子类新增虚函数func3 };我们看这段代码,父类的虚函数表[&Paretn::func1,&Parent::func2];
子类的虚函数表[&Child::func1,&Parent::func2,&Child::func3];
子类重写的虚函数func1覆盖父类地址,子类新增的虚函数func3新增到虚函数表,子类中没有重写的虚函数会保留对应父类的虚函数地址。
结果:我们可以看到子类虚函数func3也在子类的虚函数表中。
也可以看到子类和父类的虚函数表指针_vfptr中存放的虚函数表地址不同,因为表内的虚函数地址不同。可以理解为同类型的对象虚表共用,不同类型虚表各自独立。
传参
接下来我们分析为什么传入父类/子类对象的指针/引用,能通过相同的父类指针/引用的形参接收,来实现产生不同的结果
class Person { public: virtual void BuyTicket() { cout << "买票全价" << endl; } protected: int _num; }; class Student :public Person { public: virtual void BuyTicket() { cout << "买票半价" << endl; } protected: int _id; }; void Func(Person* ptr) { ptr->BuyTicket(); } int main() { Person p; Student s; Func(&p); Func(&s); return 0; }1.如何拿到_vfptr中存储的虚函数表地址?
通过提取对象s的头四个字节,来得到
Person p; Person* pp = &p; printf("Person虚表的地址%p\n", *(int*)pp);让一个Person类型的指针pp指向Person对象p,即pp得到的是p对象的地址,但是如果直接*pp解引用得到的是整个Person类型的p对象内容,我们现在将pp强转为int*类型,这样进行 *pp 解引用得到的就是 p 中头四个字节_vfptr 的内容,也就是指向的虚表地址了。
2.那ptr->BuyTicket()具体是怎么实现的?
与普通函数不同,虚函数先从Student s对象中取出隐藏的_vfptr指针,_vfptr指向Student对象的虚函数表,表中存储着Student::BuyTicket()的地址,接着调用该地址指向的函数。
主要区别是:在编译阶段,ptr的类型尽管是Person*,但编译器不能确定它所指向的对象是Person类型的对象还是Student类型的对象,不能直接绑定Student::ButTicket()的地址,而是在运行时,通过ptr指向的_vfptr查表,找到Student::Buyticket()的地址,call进行调用,从而实现不同的结果。
我们称在运行时进行函数地址的绑定为动态绑定。
还有两个问题
1.虚函数存在哪里?虚函数表中吗
虚函数和普通函数一样,都是存在于代码段的,只是将虚函数的地址复制到虚表中
2.虚函数表在哪里?
类似之前的取对象指针所指的头四个字节的内容,找到存储的虚表地址,来与栈区,堆区,静态区,常量区对比
存放在常量区
const char* p2 = "xxxxxxxx"; printf("常量区: % p\n", p2);、 Person * p3 = &b; printf("Person虚表地址: % p\n", *(int*)p3);