目录
1.概念
2多态定义与实现
2.1特殊情况
2.2.c++11override+final
2.3. 重载、覆盖(重写)、隐藏(重定义)
2.4例题
3.抽象类
4.多态原理
4.1动态绑定和静态绑定
5.单继承和多继承关系的虚函数表
5.1.单继承
5.2多继承
5.3菱形继承和菱形虚拟继承
6.小结
1.概念
形象的说,多种形态,当完成某个行为的时候,不同的对象去完成时会有不同的状态。
比如vip买物品价格和普通用户买同样物品的价格会不一样。
2多态定义与实现
构成多态有2个条件
1.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
2.必须通过基类的指针或引用调用虚函数
#include<iostream> using namespace std;class A { public:virtual int name(int a) {cout << "111" << endl;return 1;} }; class B :public A { public:virtual int name(int b) {cout << "222";return 2;} };void func(A&p) {p.name(2); } int main() {A a;B b;func(a);func(b);//111//222return 0; }
virtual修饰的是类成员函数是虚函数。
虚函数的重写,就是派生类与基类有一个完全相同的虚函数(返回值类型,函数名字,参数列表(参数名可以不同,但类型和顺序要相同))。
重写虚函数的时候,派生类的虚函数virtual可以不写,基类的虚函数被继承下来后,虚函数的属性也保留,派生类少了virtual的虚函数(伪)也可以跟基类的虚函数构成重写。但这个点很离谱,是为下面的析构函数准备的。一般不建议这么写,不规范。
2.1特殊情况
1.协变,即虚函数重写中,基类和派生类的虚函数返回值类型可以不同,可以是父子类关系的指针或引用
#include<iostream> using namespace std; class c { }; class d:public c{}; class A { public:virtual c* name(int a) {cout << "111" << endl;return new c;} }; class B :public A { public:virtual d* name(int b) {cout << "222";return new d;} };void func(A&p) {p.name(2); } int main() {A a;B b;func(a);func(b);//111//222return 0; }
2.析构函数的重写
因为此时父类和子类的析构函数不构成重写,也就不是多态。
delete是调用了类的析构函数,是普通调用,看的是指针或引用或对象的类型,也就是说,此时只会调用A类的析构函数。
这样就可能会造成内存泄漏,因为子类的内容只清理了父类的,但是子类自身独有的没有被清理。
因此我们需要多态调用这里。
析构函数被编译器统一命名为destructor,这就符合的虚函数重写的一个条件,因此
结合前面说的一个不规范写法,这个不规范写法主要是为了析构函数准备的,因为只要父类的析构函数加了virtual,那子类就可以不加,但也可以满足多态调用。
2.2.c++11override+final
注意,如果想让类不能被继承,方法1:可以让父类的构造函数放在私有成员里
这样子类就无法直接构造了
方法2:final修饰的类,无法被继承
class c final{ };
final可以修饰虚函数,让虚函数无法被重写
virtual ~A() final{cout << "~A" << endl;}
override可以检查是否成功重写,不成功编译器报错
2.3. 重载、覆盖(重写)、隐藏(重定义)
重载:两个函数在同一作用域,函数名和参数相同。
重写:两个函数必须是虚函数,且分别在派生类和基类的作用域里,函数名、参数(参数类型,参数顺序。即参数列表)、返回值必须相同(协变例外)。
重定义:两个函数分别在派生类和基类的作用域里,且函数名相同。注意,基类和派生类的同名函数不构成重写就是重定义。
2.4例题
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;}A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
答案是B。首先func构成了重写(上面不规范写法里的内容),其次func()调用时this->func();
this是什么,我一开始认为是B*,实际上是A*,因为成员函数的继承并不是写了一个新的函数在B类里,而是B类也有了A类成员函数的访问权,test(*this)里的this是A*,此时B类是通过切片的方式传递过来。因此此地的this是A*。
根据多态的条件,必须通过基类的指针或引用调用虚函数。此时this->func()满足了多态调用,因为p指针传递过去给test,test的this是A*,p是B*,此时是切片,切片是指向子类的一部分父类成员,也就是说指向的范围还是子类,那么此时func,是以B类身份调用,那根据多态,此时就是调用了B类成员里的func。因此是B->
为什么是1?
这里要明白,重写是实现重写,其实就是说,重写的只是函数体,函数头依旧是沿用父类的
,因此val此时缺省值是1。导致最后答案是B->1
3.抽象类
虚函数后面加上=0,这个函数被称为纯虚函数。包含纯虚函数的类叫抽象类(接口类),不能实例化对象。派生类继承后也不能实例化对象,只有重写纯虚函数,派生类才能实例化对象。注意,纯虚函数规定了派生类必须重写,并且纯虚函数体现了接口继承
\
注意,纯虚函数在父类里不能写函数体(本质就是为了让派生类依据需要必须重写纯虚函数)
抽象类如其名,可以理解一个概念性的类,比如面包就是一个抽象父类,而法棍面包就可以理解为面包这个父类的派生类。抽象类是没有实体的,只有派生类经过重写纯虚函数,才能实例化。
普通函数的继承是实现继承,派生类继承了基类的函数,可以使用函数,继承的是函数的实现(还有接口)。虚函数的继承是一种接口继承,派生类主要继承的是基类虚函数的接口,目的是为了重写,达成多态,当然实现也是有继承的,可以通过指定作用域访问父类的虚函数实现。如果不实现多态,就不要把函数定义成虚函数。
4.多态原理
class x { public:virtual void aaaa() {cout << 1;} private:int y = 1; }; sizeof x等于多少? 一般来说是4,但是,实际测试出来是8(x86环境,x64是16,我找了半天)
我们会发现多了个vfptr指针,v是virtual,f是function。这个指针叫虚函数表指针,指向的是一块虚函数表(虚函数的地址放在虚函数表里),每个有虚函数的类都至少有一个虚函数表指针,一般放在最前面,有些编译器可能不同。虚函数表本质上就一个函数指针数组。虚函数表末尾默认存个nullptr指针。g++下没放
#include<iostream> using namespace std;class x { public:virtual void aaaa() {cout << 1;}virtual void bbbb() {cout << 1;} private:int y = 1; }; class k :public x {virtual void aaaa() {cout << 2 << endl;} }; int main() {x x1;k k1;return 0; }
可以发现,派生类的父类的虚函数表里有2个虚函数地址,但子类重写了其中一个虚函数,子类的虚函数表,第一个虚函数地址变了(这也说明了重写在原理层是覆盖,重写是语法层的概念,实现重写)。注意只是多态调用的时候,函数头是看父类的,函数体才是看是子类还是父类。
重申一遍,有虚函数的类里有虚函数表指针,虚函数表指针指向的是虚函数表,虚函数表里存的是指针(虚函数地址)。而虚函数和普通函数,都是放在代码段的,不在任何栈堆里。
注意,派生类的虚函数表指针就是在派生类的父类成员区域里。
#include<iostream> using namespace std;class x { public:virtual void aaaa() {cout << 1 << endl;}virtual void bbbb() {cout << 1;} private:int y = 1; }; class k :public x {virtual void aaaa() {cout << 2 << endl;} }; void f(x& x2) {x2.aaaa(); } int main() {x x1;k k1;f(x1);f(k1);return 0; }关于多态如何实现,我们可以发现父类的指针或引用可能接受到的一个是原生的父类,一个是子类的切片 而在具体调用中,如果是原生的父类,那么会直接去调用原生父类的虚函数表指针,找到虚函数。 而如果是子类,且实现了虚函数重写。因为切片本质是子类的一部分, 且虚函数表会被继承在子类的父类成员范围内,那么编译器通过切片的虚函数表指针 ,找到相应的虚函数,而因为重写,所以同样的虚函数表内位置, 地址存的是不同的虚函数。这就实现了多态调用
普通调用是根据调用者的类型确定函数地址。
普通调用是在编译时通过符号表找到函数地址。
多态调用时在运行时通过不同类的虚函数表找到虚函数地址
补充:
派生类的虚函数表生成:先将基类虚函数表复制到派生类,如果派生类有重写基类的某个虚函数,那就替换相应虚函数表里的地址,最后如果派生类自己有新的非重写虚函数,就按在派生类的声明顺序添加到派生类的虚函数表里。
同类型的对象有共同的虚函数表。
4.1动态绑定和静态绑定
静态绑定(前期绑定、早绑定),是在程序编译期间确定了程序的行为,也称为静态多态,比如函数重载
动态绑定(后期绑定、晚绑定),是在运行时,根据拿到的类型确定程序的具体行为,调用具体的函数,即动态多态。
5.单继承和多继承关系的虚函数表
5.1.单继承
我们会发现,虽然通过监视窗口,我们也看到了派生类的虚函数表,但是明明派生类自己也有个非重写的虚函数的,但是没有出现
我们找到内存中的虚函数表,可以看到,实际上B类是有2个虚函数的,只是vs的监视窗口有点毛病。
虚函数表在什么位置?
#include<iostream> using namespace std; class A { public:virtual void aaa() {cout << 1 << endl;} }; class B :public A { public:virtual void aaa() {cout << 2 << endl;}virtual void bbb() {cout << 3333 << endl;} }; int main() {B b1;A a1;B* b2 = &b1;A* a2 = &a1;int a;static int b=1;int* c = new int;const char* d = "dwdawd";printf("栈区地址:%p\n", &a);printf("静态区地址:%p\n", &b);printf("堆区地址:%p\n", c);printf("常量区地址:%p\n", d);//注意,虚函数表地址怎么打印?//首先,类对象不能强转,所以我们要用指针来强转,因为指针的类型//只是决定了其解引用的范围。printf("A虚函数表地址:%p\n", *(int*)a2);printf("B虚函数表地址:%p\n", *(int*)b2); //注意,x86环境是强转成int*,x64要long long*return 0; }
我们可以看到,是接近于常量区的(window环境,linux比较新的,也是在常量区)。
虚函数表是在编译时产生的,对象里的虚函数表指针,则是在构造函数的初始化列表,第一个初始化的(赋予虚函数表的地址)
如何打印虚函数表
#include<iostream> using namespace std; class A { public:virtual void aaa() {cout << 1 << endl;} }; class B :public A { public:virtual void aaa() {cout << 2 << endl;}virtual void bbb() {cout << 3 << endl;} }; typedef void(*VFPTR)(); //重命名函数指针void PrintVFPTR(VFPTR* vfr) {//vs下可以用nullptr判断,linux要灵活用别的//比如直接传几个虚函数//另外,我这边虚函数类型都是固定void函数,方便打印for (int i = 0; vfr[i] != nullptr; i++){//vfr[i]printf("[第%d个虚函数]:%p\n", i + 1, *(vfr+i));VFPTR f = *(vfr + i);//vfr[i]f(); //(*f)()也可以} } int main() {A a1;B b1;cout << "A虚函数表:" << endl;//注意,最外面还要强转成函数指针,不强转还是int*类型,传参传不过去PrintVFPTR((VFPTR*)(*((int*)&a1)));cout << "B虚函数表:" << endl;PrintVFPTR((VFPTR*)(*((int*)&b1)));return 0; }
5.2多继承
#include<iostream> using namespace std; class A { public:virtual void aaa() {cout << 1 << endl;}virtual void aaaa() { cout << 11 << endl; };int _a = 1; }; class B { public:virtual void aaa() {cout << 2 << endl;}virtual void aaaa() {cout << 22 << endl;}int _b = 1; }; class C :public A, public B { public:virtual void aaa() {cout << 3 << endl;};virtual void bbbb() { cout << 33 << endl; };int _c = 1; };int main() {cout << sizeof C << endl;return 0; }
注意,多继承下的虚函数表,是都会被继承下来的。
int main() {cout << sizeof C << endl;C c1;A* a1 = &c1;B* b1 = &c1;return 0; } 为了节省篇幅,同样的部分就不复制进来了
可以看到,b1和a1存的地址是不同的,两者的差距,正好是一个对象的大小,即8byte
#include<iostream> using namespace std; class A { public:virtual void aaa() {cout << 1 << endl;}virtual void aaaa() { cout << 11 << endl; };int _a = 1; }; class B { public:virtual void aaa() {cout << 2 << endl;}virtual void aaaa() {cout << 22 << endl;}int _b = 1; }; class C :public A, public B { public:virtual void aaa() {cout << 3 << endl;};virtual void bbbb() { cout << 33 << endl; };int _c = 1; }; typedef void(*VFPTR)(); //重命名函数指针void PrintVFPTR(VFPTR* vfr) {//vs下可以用nullptr判断,linux要灵活用别的//比如直接传几个虚函数//另外,我这边虚函数类型都是固定void函数,方便打印for (int i = 0; vfr[i] != nullptr; i++){//vfr[i]printf("[第%d个虚函数]:%p\n", i + 1, *(vfr+i));VFPTR f = *(vfr + i);//vfr[i]f(); //(*f)()也可以} } int main() {C c1;A* a1 = &c1;B* b1 = &c1;cout << "C类中的A类虚函数表:\n";PrintVFPTR((VFPTR*)*((int*)a1));cout << "C类中的B类虚函数表:\n";PrintVFPTR((VFPTR*)*((int*)b1));return 0; }
由程序运行结果可以见到,C类的非重写虚函数是被添加到了A类的虚函数表上,而我试过改变C类继承的顺序,可以得出,这个添加的虚函数表是根据继承顺序觉得的。
class C:public A,public B是加到A类虚函数表上,class C:public B,public A是添加到B类虚函数表上。
我们会发现,明明是同一个函数,但是在虚函数表中的地址不同,因为D对象中A对象成员正好在前面,a1存的地址,即是整个D对象的开头地址,也是其中A类对象开头地址,因为aaa函数是派生类重写的,且切片本身还是指向D对象,所以调用aaa函数也是以D对象身份来调用,a1存的函数地址,是仅有的一个中间层的地址(通过汇编可以看到,D类call的是一个jump指令的地址,jump指令中再跳到函数地址),但是b1不同,b1虽然也是D对象的切片,但是,指向的地址不是D对象起始地址,而是后面一些,这样多态调用,就不能以D类对象调用了,所以在汇编视角,call了一次jump,但这个jump是跳到一个sub指令,正好减到了a1对象的jump,再调用这个jump,再跳到函数中。
5.3菱形继承和菱形虚拟继承
这部分不多描述,可以去看下专门的关于菱形继承和多态的文章。
class A { public:virtual void aaa() {cout << 1 << endl;}int _a = 1; }; class B:public A { public:int _b = 2; }; class C :public A { public:int _c = 3; }; class D :public B, public C { public:int _d = 4; };
就存储模型来说,菱形继承和多继承的模型类似。
而对于菱形虚拟继承,有重写冲突的问题。
菱形虚拟继承之后,A类的内容只会保留一份,而B类和C类如果有重写A类的虚函数,那么这时候,覆盖的虚函数表是同一个,那么就会发生争议,编译器不知道到底覆盖谁的。
解决方法是直接在D类上重写即可。
如果出现下列情况:BC继承A,D继承BC。A类有虚函数,B、C有非重写的虚函数,D类有重写A类虚函数。那么D会变得更大。除了D类的虚函数表指针会依旧放在公共位置(里面存着重写了A类虚函数的新的虚函数地址),B类和C类都会有一个单独的虚函数表指针指向各自单独的虚函数表,跟公共位置的D类虚函数表指针指向的虚函数表不一样。
6.小结
1. inline函数可以是虚函数吗,可以。编译器在普通调用的时候,会遵循inline的属性,直接展开。但是如果是多态调用,会忽视inline属性,该虚函数会有地址,并且地址存在虚函数表中,走多态调用。
2.什么是多态。参考上文,静态多态和动态多态。
3.重载、重写、重定义。参考上文。
4.多态实现原因。参考上文
5.静态成员可以是虚函数吗?不能。因为静态成员函数没有this指针,而多态的条件之一就是由基类的指针或引用调用虚函数(而静态成员函数没有this指针意味着,使用类型::成员函数的方式调用静态成员函数无法满足多态的条件。),因此静态成员函数无法构成多态。
6.构造函数可以是虚函数吗?不能。因为虚函数表指针是在构造函数的初始化列表中才初始化的,而虚函数又必须在虚函数表中,由编译器在运行时通过虚函数表指针找到虚函数使用,初始化虚函数表指针又是要通过构造函数,这就死循环了。因此不能。
7.析构函数可以是虚函数吗?可以,建议基类析构函数都定义成虚函数。其他参考上文。
8.对象访问普通函数快还是虚函数快?如果虚函数是普通调用,则一样快。如果是多态调用,则普通函数快。因为多态调用,需要编译器在运行的时候通过虚函数表指针访问虚函数表,再找到虚函数地址,再调用虚函数,而普通函数,在编译的时候,已经通过类成员函数表,直接找到普通函数地址,运行的时候再调用普通函数。
9。虚函数表在什么阶段生成,存在哪?虚函数表在编译阶段就生成了,一般存在代码段(常量区)
10.C++菱形继承的问题?虚继承的原理。参考本文和继承文章。
11.什么是抽象类?抽象类的作用?参考上文。抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系