目录
前言:
1.多态的概念
2.多态的定义及实现
2.1多态的构成条件
2.2析构函数的重写(基类与派生类析构函数名字不同)
2.3虚函数重写
2.4C++ override 和final
2.5 重载、覆盖(重写)隐藏(重定义)的对比
3.多态的原理
3.1虚表与续表指针
3.2动态绑定与静态绑定
4单继承与多继承
4.1单继承中虚表
4.2多继承中虚表
4.2.1子类新增虚表归属问题
4.2.2多继承虚函数调用问题
4.3菱形继承多态与菱形虚拟继承多态
前言:
上一章节对面向对象三大特性的继承做了知识复盘,本章节对最后一个特性多态做一个知识梳理和总结。
1.多态的概念
通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会
产生出不同的状态。可以举个现实中车站买票的例子,同一个窗口,不同年龄多,不同职业的对象去买票价格是不同的(这就是多种形态也就是多态)
2.多态的定义及实现
2.1多态的构成条件
实现多态需要借助虚表,这里的虚表指的是虚函数表,虽然也是借助关键字:virtual, 这里需要和继承里面的虚拟继承区分开,两个概念不能搞混。有了虚函数表,就可以使用父类指针进行不同对象调用实现不同形态(接下来会仔细介绍)
继承中构成多态的两个条件:
1-必须通过基类的指针或者引用调用虚函数
我们通过一个买票的demo 理清楚多态的流程
class Person
{
public:virtual void BuyTicket() { cout << " 全价票 " << endl; }};
class Student :public Person
{
public:virtual void BuyTicket() { cout << " 半价票 " << endl; }};void Func(Person& person)
{person.BuyTicket();
}
int main()
{Person p;Student s;Func(p);Func(s);return 0;}
注意:除了上面提到的构成多态的两个必要条件,有两个例外是需要注意的
- 除父类外,其他子类中的函数不必使用 virtual 修饰,此时仍然能构成多态(注意三同,需要构成重写)
- 父子类中的虚函数返回值可以不相同,但此时需要返回对应的父类指针或子类指针,确保构成多态,这一现象称为 协变(了解)
class A{};
class B : public A {};
class Person {
public:virtual A* f() {return new A;}
};
class Student : public Person {
public:virtual B* f() {return new B;}
};
2.2析构函数的重写(基类与派生类析构函数名字不同)
1-析构函数可以是虚函数吗?为什么需要是虚函数?
2-析构函数加virtual,是不是虚函数重写?
答案是肯定的,如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。
为什么要这么处理呢?是要要让他们构成重写吗 那为什么要让他们构成重写呢?我们可以用一个demo 来解释为什么要这么做:
class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }~Person() { cout << "~Person()" << endl; }
};class Student : public Person {
public:virtual void BuyTicket() { cout << "买票-半价" << endl; }~Student() {cout << "~Student()" << endl;delete[] ptr;}protected:int* ptr = new int[10];
};int main()
{//Person p;//Student s;Person* p = new Person;p->BuyTicket();delete p;p = new Student;p->BuyTicket();delete p; // p->destructor() + operator delete(p)// 这里我们期望p->destructor()是一个多态调用,而不是普通调用return 0;
}
此时 只有BuyTicket函数和父类构成了虚函数重写,且都是由父类指针进行的调用,所以我们会看到买票的多态,但是析构函数并未构成虚函数重写(既不是虚函数也不是重写)再调用delete p的时候,他只是一个普通对象,当前类型为Person* 所以只会去调用父类的析构,从而造成内存泄漏。
虽然编译器对析构函数名称做了特殊处理,编译后嘻哈猴函数的名称统一处理成 destructor
我们还是希望p->destrctor()能够是一个多态调用,而不是普通调用,那就是构成虚函数重写,所以我们就能理解,为什么父类成员必须加上virtual。修改完成后就不会造成内存泄漏了,代码如下:
总结:
如何快速判断是否构成多态?
- 首先观察父类的函数中是否出现了 virtual 关键字
- 其次观察是否出现虚函数重写现象,三同:返回值、函数名、参数(协变例外)
- 最后再看调用虚函数时,是否为【父类指针】或【父类引用】
父类指针或引用调用函数时,如何判断函数调用关系?
- 若满足多态:看其指向对象的类型,调用这个类型的成员函数
- 不满足多态:看具体调用者的类型,进行对应的成员函数调用
2.3虚函数重写
通过上面的介绍,我们知道虚函数是构成多态的必要条件,我们也知道想要构成多态,还需要实现重写,可是重写具体怎么实现,我们好像一笔带过,下面我将用代码,虚函数表的具体演示,派生类如何实现覆盖,重写,以及重写了什么
#include <iostream>using namespace std;class A
{
public:virtual void func(int val = 1) { cout << "A: " << val << endl; }
};class B : public A
{
public:virtual void func(int val = 2) { cout << "B: " << val << endl; }
};int main()
{A* p = new B();p->func();return 0;
}
结果解析:初始化两个对象的时候,子类继承父类且都是是虚函数,会创建两张虚函数表,我们发现虚表指针的地址不一样,所以第一步,是两张虚表,当使用父类指针调用的时候,就会去完成重写,将父类的虚表复制,将自己的虚函数函数进行覆盖,但是重写的是实现方法,也就是外壳,内容是不会改变的,所以最后的结果就是 B:1
补充:
我们已知多态的条件之一就是父类的指针或者引用去调用,那为什么不能子类的指针或者引用去调用呢?为啥不能是父类对象呢?
答:1因为是复制父类的虚表进行重写,如果是父类调用父类就不用重写,父类调用子类就重写子类属于自己的那部分,如果用子类指针 永远无法调用到父类;
2 子类赋值给父类对象切片,不会拷贝虚表,如果拷贝虚表,那么父类对象虚表中是父类虚函数还是子列就不确定,会乱套。
2.4C++ override 和final
C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。
- final:修饰虚函数,表示该虚函数不能再被重写,对于父类的虚函数,如果加上final就不能被重写,也就无法实现多态
- override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错
2.5 重载、覆盖(重写)隐藏(重定义)的对比
截至目前为止,我们已经学习了三个 “重” 相关函数知识:重载、重写、重定义
这三兄弟不止名字很像,而是功能也都差不多,很多面试题中也喜欢考这三者的区别
重载:即函数重载,函数参数 不同而触发,不同的 函数参数 最终修饰结果不同,确保链接时不会出错,构成重载
重写(覆盖):发生在类中,当出现虚函数且符合重写的三同原则时,则会发生重写(覆盖)行为,具体表现为 父类虚函数接口 + 子类虚函数体,是实现多态的基础
重定义(隐藏):发生在类中,当子类中的函数名与父类中的函数名起冲突时,会隐藏父类同名函数,默认调用子类的函数,可以通过 :: 指定调用
重写和重定义比较容易记混,简言之 先看看是否为虚函数,如果是虚函数且三同,则为重写;若不是虚函数且函数名相同,则为重定义
3.多态的原理
之前提到过多态需要虚函数表,以及指向虚函数标的指针,我们可以写一个空类,通过测试大小验证一下:
class Parent
{virtual void func() {};
};int main()
{Parent p; cout << "Parent : " << sizeof(p) << endl;return 0;
}
通过验证我们发现,一个带有虚函数的类的大小在64位平台下是8,因此也就验证了我们猜想,虚函数的类中包含一个虚表指针 。虚表指针->虚表 实现多态。
3.1虚表与续表指针
虚函数表(虚表)即 virtual function table -> vft,指向虚表的指针称为 虚表指针 virtual function pointer -> vfptr,在 vs 的监视窗口中,可以看到涉及虚函数类的对象中都有属性 __vfptr(虚表指针),可以通过虚表指针所指向的地址,找到对应的虚表。虚函数表中存储的是虚函数地址,可以在调用函数时根据不同的地址调用不同的方法。
在下面这段代码中,父类 Person
有两个虚函数(func3
不是虚函数),子类 Student
重写了 func1
这个虚函数,同时新增了一个 func4
虚函数
#include <iostream>using namespace std;class Person
{
public:virtual void func1() { cout << "Person::fun1()" << endl; };virtual void func2() { cout << "Person::fun2()" << endl; };void func3() { cout << "Person::fun3()" << endl; }; //fun3 不是虚函数
};class Student : public Person
{
public:virtual void func1() { cout << "Student::fun1()" << endl; };virtual void func4() { cout << "Student::fun4()" << endl; };
};int main()
{Person p;Student s;return 0;
}
如何通过程序验证虚表的真实性?
- 虚表指针指向虚表,虚表中存储的是虚函数地址,而 64 位平台中指针大小为 8字节
- 因此可以先将虚表指针强转为 指向首个虚函数 的指针,然后遍历虚表打印各个虚函数地址验证即可。
- vs 中对虚表做了特殊处理:在虚表的结尾处放了一个 nullptr,因此下面这段代码可能在其他平台中跑不了。
typedef void (*VF_T)();//函数指针 为下面函数指针数组做铺垫class Person
{
public:virtual void func1() { cout << "Person::fun1()" << endl; };virtual void func2() { cout << "Person::fun2()" << endl; };void func3() { cout << "Person::fun3()" << endl; }; //fun3 不是虚函数
};class Student : public Person
{
public:virtual void func1() { cout << "Student::fun1()" << endl; };virtual void func4() { cout << "Student::fun4()" << endl; };
};
void test(VF_T table[])
{int i = 0;while(table[i]){printf(" [%d]:%p->", i, table[i]);//虚函数表里面存的是虚函数地址 直接解引用就是该虚函数VF_T f = table[i];f();i++;}cout << endl;
}
int main()
{Person p;Student s;test((VF_T*)(*(int*)&p));test((VF_T*)(*(int*)&s));return 0;
}
因为平台不同指针大小不同,因此上述传递参数的方式
(VF_T*)(*(int*)&p
具有一定的局限性
假设在64
位平台下,需要更改为(VF_T*)(*(long long*)&p
综上所述,虚表是真实存在的,只要当前类中涉及了虚函数,那么编译器就会为其构建相应的虚表体系
虚表相关知识补充:
- 虚表是在 编译 阶段生成的
- 虚表指针是在构造函数的 初始化列表 中初始化的
- 虚表一般存储在 常量区(代码段),有的平台中可能存储在 静态区(数据段)
int main()
{//验证虚表的存储位置Person p;Student s;int a = 10; //栈int* b = new int; //堆static int c = 0; //静态区(数据段)const char* d = "xxx"; //常量区(代码段)printf("a-栈地址:%p\n", &a);printf("b-堆地址:%p\n", b);printf("c-静态区地址:%p\n", &c);printf("d-常量区地址:%p\n", d);printf("p 对象虚表地址:%p\n", *(VF_T**)&p);printf("s 对象虚表地址:%p\n", *(VF_T**)&s);return 0;
}
显然,虚表地址与常量区的地址十分接近,因此可以推测 虚表位于常量区中,因为它需要被同一类中的不同对象共享,同时不能被修改(如同代码一样)
函数代码也是位于 常量区(代码段),可以在监视窗口中观察两者的差异
3.2动态绑定与静态绑定
静态绑定(前期绑定/早绑定)
- 在编译时确定程序的行为,也称为静态多态
动态绑定(后期绑定/晚绑定)
- 在程序运行期间调用具体的函数,也称为动态多态
p1->func1();
p2->func1();add(1, 2);
add(1.1, 2.2);
简单来说,静态绑定就像函数重载,在编译阶段就确定了不同函数的调用;而动态绑定是虚函数的调用过程,需要 虚表指针+虚表,在程序运行时,根据不同的对象调用不同的函数
4单继承与多继承
需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类 的虚表模型前面我们已经看过了,没什么需要特别研究的。
4.1单继承中虚表
我们上面研究的基本都是子类继承父类,对父类中的虚函数进行覆盖重写。
向父类中新增虚函数:父类的虚表中会新增,同时子类会继承,并纳入自己的虚表之中
向子类中新增虚函数:只有子类能看到,因此只会纳入子类的虚表中,父类是看不到并且无法调用的
向父类/子类中添加非虚函数时:不属于虚函数,不进入虚表,仅当作普通的类成员函数处理
4.2多继承中虚表
C++
中支持多继承,这也就意味着可能出现 多个虚函数重写 的情况,当父类指针面临 不同虚表中的相同虚函数重写 时,该如何处理呢?
#include <iostream>
using namespace std;//父类1
class Base1
{
public:virtual void func1() { cout << "Base1::func1()" << endl; }virtual void func2() { cout << "Base1::func2()" << endl; }
};//父类2
class Base2
{
public:virtual void func1() { cout << "Base2::func1()" << endl; }virtual void func2() { cout << "Base2::func2()" << endl; }
};//多继承子类
class Derive : public Base1, public Base2
{
public:virtual void func1() { cout << "Derive::func1()" << endl; }virtual void func3() { cout << "Derive::func3()" << endl; } //子类新增虚函数
};int main()
{Derive d;return 0;
}
此时,derive继承了base1和base2,所以derive有两张虚表,分别为 Base1 + Derive::func1
构成的虚表、Base2 + Derive::func1
构成的虚表
此时出现了两个问题:
- 子类
Derive
中新增的虚函数func3
位于哪张虚表中? - 为什么重写的同一个
func1
函数,在两张虚表中的地址不相同?
下面我们对这两个问题做一个深度解析。
4.2.1子类新增虚表归属问题
在单继承中,子类中新增的虚函数会放到子类的虚表中,因为只有一张表我们没有疑问,多继承中,子类中新增的虚函数默认添加至第一张虚表中,我们可以通过test打印进行验证,因为此时有两张虚表,所以需要分别打印;第一张虚表的地址和子类的首地址重合,只需要取地址+类型强转;第二张虚表就比较麻烦,需要在第一张虚表的起始地址处,跳过第一张虚表的大小,然后才能获取第二张虚表的起始地址。
//打印虚表
typedef void(*VF_T)();void test(VF_T table[])
{//vs中在虚表的结尾处添加了 nullptrint i = 0;while (table[i]){printf("[%d]:%p->", i, table[i]);VF_T f = table[i];f(); //调用函数,相当于 func()i++;}cout << endl;
}int main()
{Derive d;test(*(VF_T**)&d); //第一张虚表test(*(VF_T**)((char*)&d + sizeof(Base1))); //第二张虚表return 0;
}
可以看出新增的 func3
函数确实在第一张虚表中;可能有的人觉得取第二张虚表的起始地址很麻烦,那么可以试试利用 切片 机制,天然的取出第二张虚表的地址切片行为是天然的,可以完美取到目标地址.
Base2* table2 = &d; //切片
PrintVFTable(*(VF_T**)table2); //第二张虚表
4.2.2多继承虚函数调用问题
在上面的多继承多态代码中,子类分别重写了两个父类中的 func1
函数,但最终通过监视窗口发现:同一个函数在两张虚表中的地址不相同;因此可以推测:编译器在调用时,根据不同的地址寻找到同一函数,解决冗余虚函数的调用问题至于实际调用链路,还得通过汇编代码展现:
ptr2 在调用时的关键语句 sub ecx 4;sub 表示减法,ecx 通常存储 this 指针4 表示 Base1 的大小;这条语句表示将当前的 this 指针向前偏移 sizeof(Base1),后续再 jmp 时,调用的就是同一个 func1;这一过程称为 this 指针修正,用于解决冗余虚函数的调用问题
为什么是 Base2 修正?因为先继承了 Base1,后继承了 Base2,假设先继承的是 Base2,那么修正的就是 Base1这种设计很大胆也很巧妙,完美解决了多继承多态带来的问题因此回答问题二:两张虚表中同一个函数的地址不同,是因为调用方式不同,后继承类中的虚表需要通过 this 指针修正的方式调用虚函数。
4.3菱形继承多态与菱形虚拟继承多态
菱形继承问题是 C++ 多继承中的大坑,为了解决菱形继承问题,提出了 虚继承 + 虚基表 的相关概念,那么在多态的加持之下,菱形继承多态变得更加复杂:需要函数调用链路设计的更加复杂菱形虚拟继承多态就更不得了:需要同时考虑两张表:虚表、虚基表
- 虚基表中空余出来的那一行是用来存储偏移量的:表示当前虚基表距离虚表有多远