目录
多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。????????简单理解一下,用买票的例子来说,普通人买的都是成人票,但是要是学生买票就可以买学生票。
????????多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买成人票,Student对象买学生票。在继承中要构成多态还有两个条件:
被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。 必须通过基类的指针或者引用调用虚函数。
虚函数就是被virtual修饰的类成员函数称为虚函数。
class Person { public: virtual void Buyticket() { cout << "成人票" << endl; } };
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。class Person { public: virtual void Buyticket() { cout << "成人票" << endl; } }; class Student : public Person { public: virtual void Buyticket() { cout << "学生票" << endl; } };
所以,虚函数重写条件:定义成虚函数+返回类型相同+函数名相同+参数相同,不满足就不会构成重写,而是隐藏。
?
完成上面的步骤就可以实现多态
class Person { public: virtual void Buyticket() { cout << "成人票" << endl; } }; class Student : public Person { public: virtual void Buyticket() // 够成派生类重写和虚函数 { cout << "学生票" << endl; } }; void Func(Person& p) // 参数为父类的引用或指针,分别接受基类和派生类,够成多态 { p.Buyticket(); } int main() { Person ps; Student st; Func(ps); Func(st); }
特例:子类相应的虚函数不写virtual依然构成重写,但是最好加上。
1、协变(基类与派生类函数返回值类型不同)(了解)
? ? ? ? 派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变,简单来说,函数返回的类型必须是有父子关系的指针或引用,父类返回父类的,子类返回子类的,就算不是这个类的指针或引用也是可以构成协变的,使用场景不多,了解一下。
先来做一道小题,看一看输出结果是什么
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() { B* p = new B; // 子类的指针 p->test(); // 子类没有虚函数的重写,不构成多态 return 0; }
所以这道题就是为了说一下接口继承。看构不构成多态就看是不是父类的指针或引用调用虚函数和虚函数构不构成重写。这道题还是要看清指向的对象是谁。
2、析构函数的重写(基类与派生类析构函数的名字不同)????????如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。? ? ? ? 普通场景是没有问题的,不管是不是虚函数,先创建一个父类对象再创建一个子类对象,析构子类对象的时候先调用子类的析构函数,再调用父类的析构函数,析构父类对象的时候调用父类的析构函数。? ? ? ? 但是下面这个场景:????????第一个new一个父类对象,delete时候调用父类析构函数没有问题;但是第二个new的是一个子类对象,调用的却是父类析构函数。我们想要满足多态,父类调用父类,子类先调子类再调父类,原因是什么呢接下来说一下。????????想要解决这个问题就要把父类的析构函数定义成虚函数,当然子类不加virtual也是构成多态的,但是还是加上比较好。
final:修饰虚函数,表示该虚函数不能被重写
????????上一篇已将说过了,如果一个父类不想被继承,在类名后加上final关键字就可以了,这个关键字也可以修饰虚函数。
但是用的比较少,定义虚函数就是想要重写,要不然定义成虚函数干嘛。
override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错
这个关键字是放在子类中的,来检查子类虚函数有没有完成重写,可以帮助我们检查。
到现在我们已经知道了这几种函数关系,那么接下来就来对比一下,以免搞混。
- 重载
- 两个函数必须在同一作用域
- 要求函数函数名相同,参数不同(参数的类型、顺序、个数)
- 重写
- 构成重写的两个函数分别在基类和派生类
- 函数名/参数/返回值 必须相同,协变的返回值可以是父子类关系的指针或引用
- 两个函数必须是虚函数
- 重定义
- 构成隐藏的两个函数分别在基类和派生类
- 函数名相同
- 只要不构成重写就是隐藏
抽象类的概念:
????????在虚函数的后面写上 = 0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。class A { public: virtual void func() = 0; };
接口继承和实现继承????????普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
看一道小题,算一下A占多少字节。
class A { public: virtual void Func1() { cout << "Func1()" << endl; } private: int _a = 1; }; int main() { A a; cout << sizeof(a) << endl; return 0; }
????????结果是8,A类中有一个成员变量,int类型占4字节,对齐一下还是4字节,但这就太简单了,上一篇也讲到过,虚继承会有一块空间来存放偏移量,类中要有一个指针指向这块空间,32位下指针就是4字节,所以加上这4字节就是8,而这里存放的就是一个虚表指针。
????????而这个虚表是一个函数指针数组。
class A { public: virtual void func(int val) { std::cout << "A->" << val << std::endl; } void test() {} private: int _a = 1; }; class B : public A { public: virtual void func(int val) { std::cout << "B->" << val << std::endl; } private: int _b = 1; }; void Func(A& p) { p.func(1); } int main() { A a; Func(a); B b; Func(b); return 0; }
多态的本质原理:符合多态的两个条件时,程序运行时去指向对象的虚表,虚表就是虚函数表,在虚表中找到对应的虚函数地址进行调用。
如果不符合多态就是普通的函数调用,在编译链接的时候就已经确定了函数的地址,运行时直接调用它就可以了。
? ? ? ? 这个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。
通过监视窗口也可以看到,虽然是两个对象,但他们是同一个类型共用了一个虚表。
通过上面两张图片,不管子类是否重写虚函数,子类和父类的虚表都是不同的。
????????如果子类中我写了一个虚函数,它是不是也要进虚表呢,那肯定是要进的,接下来就来写一个函数来打印一下虚表。通过监视窗口和内存窗口可以看出,每个对象的前四个地址就是虚表,可以通过这4字节来找到虚表,而虚表就是一个函数指针数组。
class A { public: virtual void Func() { cout << "A::Func()" << endl; } virtual void func1() { cout << "A::func1()" << endl; } }; class B : public A { public: virtual void Func() { cout << "B::Func()" << endl; } virtual void func2() { cout << "B::func2()" << endl; } }; typedef void(*VFPTR)(); // 把函数指针类型typedef一下 void PrintVFtable(VFPTR table[]) { for (size_t i = 0; table[i] != nullptr; i++) // 在vs编译器下,会把虚表末尾设为nullptr { printf("vftable[%d]:%p ", i, table[i]); table[i](); // 函数指针数组中的每个类型都是函数指针,加上()就可以直接调用 } } int main() { A a1; A a2; B b1; B b2; // 取a1的地址,强转为int*再解引用就是4字节(强转为int*是把对象的地址变成了int*,必须要解引用才能拿到4字节的地址),这样就可以拿到虚表的地址 // 这个打印函数的参数是函数指针数组类型的,int是传不进去的,所以再强转一下就可以了 PrintVFtable((VFPTR*)*(int*)&a1); cout << endl; PrintVFtable((VFPTR*)*(int*)&b1); return 0; }
把A和B中的虚表都打印了一下,就可以看到B中的虚函数也在虚表中。
总结一下虚表:
- 先将父类中的虚表内容拷贝一份到子类虚表中。
- 如果子类重写了父类中某个虚函数,用子类自己的虚函数覆盖虚表中父类的虚函数。
- 子类自己新增加的虚函数按其在子类中的声明顺序增加到子类虚表的最后。
虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。虚表在vs下是存在代码段的。
单继承在上面已经看到了,接下来看看多继承是怎样处理的。
class A { public: virtual void func1() { cout << "A::func1" << endl; } virtual void func2() { cout << "A::func2" << endl; } private: int _a; }; class B { public: virtual void func1() { cout << "B::func1" << endl; } virtual void func2() { cout << "B::func2" << endl; } private: int _b; }; class C : public A, public B { public: virtual void func1() { cout << "C::func1" << endl; } virtual void func3() { cout << "C::func3" << endl; } private: int _c; }; typedef void(*VFPTR)(); void PrintVFtable(VFPTR table[]) { for (size_t i = 0; table[i] != nullptr; i++) // 在vs编译器下,会把虚表末尾设为nullptr { printf("vftable[%d]:%p ", i, table[i]); table[i](); // 函数指针数组中的每个类型都是函数指针,加上()就可以直接调用 } } int main() { C c; // 上面也有算字节大小的题,这里要加上两个继承的虚表和成员 // 这里算出的结果是20,继承的两个类是16,子类有一个变量是4,这是20 // 但是子类的虚函数放在了哪里呢 cout << sizeof(c) << endl; PrintVFtable((VFPTR*)(*(int*)&c)); // 和之前一样,取前4个字节就是A的虚表 cout << endl; PrintVFtable((VFPTR*)(*(int*)((char*)&c + sizeof(A)))); // 想要取到B的就要跳过A,一定要先强转成char*再加,这样才能跳过,int*加1是加4字节 cout << endl; // 还有一种方式就是切片,用B的指针接收C,这样会自动偏移到B的虚表的地址 B* ptr = &c; PrintVFtable((VFPTR*)(*(int*)(ptr))); return 0; }
????????通过运行结果就可以看到,把C中的虚函数放到了A的虚函数表的最后一个位置。通过这张图片还可以发现一个问题,C中的func1实现了多态,但是他们在A和B中的地址不一样,最后调用的是同一个函数,这是为什么呢?
? ? ? ? 这个问题需要通过反汇编来解决。
但是第二个又有些不同。
????????他们的不同点就是第二种多跳了几次,sub这行,ecx中存放的是this指针,成员函数的调用是通过对象的地址。
不管他怎样跳转,最后都是要调用这个函数。
????????关于多态的知识大部分都已经讲解完了,剩下都是些很复杂还不常用的知识,最后来说一下:
? ? ? ? inline函数是否可以是虚函数,回忆一下inline函数,它没有地址,直接在调用的地方展开,但是一个函数只要定义成虚函数,他就会进入虚函数表,表中存放函数地址;这就要说到inline只是一个建议,多态调用中,inline就失效了,所以它可以是虚函数。
? ? ? ? static函数是否可以是虚函数,那是肯定不行,它没有this指针,可以直接使用访问限定符访问函数,但是虚函数是为了实现多态的,多态是运行时去虚表中找,static是在编译时决议,他是虚函数没有价值。
? ? ? ? 构造函数是否可以是虚函数,也是不可以的,搞清楚这个问题就要看看虚表指针是什么时候创建的,可以通过编译器看一下,虚表指针是在初始化列表的时候生成的,所以构造函数不能是虚函数。
? ? ? ? 拷贝构造函数是否可以是虚函数,结论和构造函数一样。
????????赋值运算符重载是否可以是虚函数,首先可以尝试写一下,写完就会发现,函数参数不同,尽管返回值不同,但是可以构成协变,参数不同就不构成重写,也就不构成多态,想要构成多态就要把参数变成一样的,但是这样就没有什么意义。
? ? ? ? 对象访问普通函数快还是虚函数快,虚函数不构成多态,那就一样快;构成多态就是普通函数快,多态调用还要运行时去虚表中找虚函数的地址。
? ? ? ? 虚函数表是在什么阶段生成的?存在哪呢?前面也说过,注意虚表指针是在构造函数初始化列表初始化的,但虚表不是,想解决这个问题可以把每个区的地址打印一下,看看是在哪个区,实际上虚表可能是在代码段(常量区),因为它是只读的,并且在编译阶段就生成好的。