《深入C++多态机制:从虚函数表到运行时类型识别》
前引:当基类指针调用虚函数时,编译器生成的
call [eax+4]
指令背后,隐藏着由VTable和VPTR构建的运行时类型迷宫。本文通过反汇编验证GCC/Clang的Itanium ABI实现,量化虚函数调用相较于CRTP静态多态的性能差异,并探讨不同场景下使用final
关键字的指令缓存优化策略!
目录
【一】多态的概念
【二】多态的定义
【三】多态的实现
(1)虚函数的写法
(2)虚函数的重写
(3)调用虚函数
总结
【四】虚函数重写的三个例外
(1)派生类的重写函数可不加 virtual
(2)协变
(3)析构函数的重写
【五】C++11:override 和 final
(1)final
(2)override
【六】重载、重写、重定义区别
【七】抽象类
【八】虚函数表
总结:
【九】两个常见的大坑
(1)
(2)
【一】多态的概念
多态:多种形态
多态的运行:运行时多态是动态绑定,也叫晚期绑定
例如:
现在买火车票,不同的人买的价格是不同的;学生可能是5折,成年人原价,甚至其他类人群享受不同的优惠,同样是火车票但是有不同的购买场景——多态
【二】多态的定义
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为
那么在继承中要构成多态还有两个条件:
1. 必须通过基类的指针或者引用调用虚函数
2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
【三】多态的实现
上面我们通过定义看到:
(1)必须有继承关系
(2)通过基类的指针/引用来调用虚函数
(3)调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
(1)虚函数的写法
成员函数中:被virtual修饰的类成员函数称为虚函数(virtual 不能和 static 一起使用)
例如:
virtual void Func1() {cout << "class A" << endl; }
(2)虚函数的重写
虚函数的重写(覆盖):
派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的 返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数
·
例如:
//基类 class A { public:virtual void Func1(){cout << "class A" << endl;} }; //派生类 class B :public A { public:virtual void Func1(){cout << "class B" << endl;} };
注意:虚函数的重写必须保证与基类函数的:返回值、函数名、参数完全相同
(3)调用虚函数
调用虚函数有两个点:
(1)参数类型必须是基类对象
(2)参数必须是引用或者指针
例如:
//必须是基类对象类型+引用/指针 void text(A& ptr) {ptr.Func1(); }int main() {A V1;B V2;//引用调用虚函数text(V1);text(V2);return 0; }
这样也是可以的,只是传的参数没有实例化,需要用到const修饰,只可读不可改:
总结
我们发现多态的调用一共有两个条件:
(1)基类的 virtual 修饰成员函数+派生类的函数重写
(2)必须由基类的类类型的指针/引用调用
【四】虚函数重写的三个例外
(1)派生类的重写函数可不加 virtual
例如(虽然可以这样,但是建议还是加上!):
//基类 class A { public:virtual void Func1(){cout << "class A" << endl;} }; //派生类 class B :public A { public:void Func1(){cout << "class B" << endl;} };
(2)协变
派生类重写基类虚函数时,与基类虚函数返回值类型不同
即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用
注意:这里的返回值只要满足父子关系的类即可,可以是其它类
(3)析构函数的重写
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字, 都与基类的析构函数构成重写(这里其实就是我们上面的第一个例外!)
·
虽然函数名不相同, 看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处 理,编译后析构函数的名称统一处理成destructor
析构函数的重写一般适用于以下场景(指针指向谁就调用哪个的析构):
例如下面是经过重写了的,就符合虚函数的调用情况:
【五】C++11:override 和 final
(1)final
final:修饰虚函数,表示该虚函数不能再被重写
例如:
问:如果不行基类被继承,可以如何操作?
(1)基类的构造、析构私有化
(2)使用 final 修饰
(2)override
override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错
例如:
【六】重载、重写、重定义区别
重载:
在同一个类中,函数名相同、参数不同构成重载
重写:
在基类和派生类中,两个类域的函数具有相同的函数名、参数、返回值(协变除外),且都是虚函数,由基类的指针/引用调用
重定义:
在基类和派生类中,两个类域的函数具有相同的函数名
【七】抽象类
概念:在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类)
特点:抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承
接口继承和实现继承:
(1)普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现
(2)虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数
例如:
【八】虚函数表
(1)派生类对象V2和基类对象V1都有一个虚表指针
(2)虚函数表本质是一个存虚函数指针的指针数组
一般情况这个数组最后面放了一个nullptr
(3)派生类的虚表生成:
a.先将基类中的虚表内容拷贝一份到派生类虚表中
b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后
(4)虚函数存在哪的?虚表存在哪的?
虚表存的是虚函数指针,不是虚函数
虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中
总结:
虚函数的生成:
派生类先将基类的虚函数拷贝一份,派生类如果有虚函数,先重写,再用自己的虚函数覆盖虚表中刚才拷贝的基类的虚函数,再把自己新增的虚函数放在派生类虚表的最后
虚函数的本质:
虚函数是一个指针数组,指针数组里存的是虚函数指针,虚函数指针指向的是虚函数
【九】两个常见的大坑
(1)
首先这是一个多继承,现在创建了一个派生类对象 d
·
对于p1:它的类型是基类类型,右边是一个派生类对象,这其实是一种多态(下一题提到)
换个角度理解:就是p1指向了d里面Base1那一部分(成员变量)
对于p2:它的类型是基类类型,右边是一个派生类对象,同理
对于p3:属于正常的指向
继承的顺序是:Base1->Base2,那么指向应该是:
(2)
首先 p 指向派生类实例化对象,调用派生类对象里面的 test()函数
(1)test()函数不满足多态,所以正常调用Func()函数
(2)坑1:此时 this指针 应该是A类型的,因为基类其实相当于是派生类的一个成员变量,例如
(3)坑2:this指针是基类的,那么我们来看Func()是否符合多态:
函数的参数和形参还是有区别的,例如函数参数相同,不包含形参,例如这里的
int val = 1 和 int val = 0其实参数是相同的,0和1属于形参,那么这里就符合多态 调用
(4)外面是派生类调用基类的指针,所以调用派生类里面的虚函数,打印 B->0
注意:在派生类中的这个Func()函数只写 “int val” 也是可以的,因为派生类的虚函 数是拷贝基类的虚函数,重写的是实现