目录
- 多态:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会 产生出不同的状态。
一、多态的定义及实现
1.1多态的定义
- 多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了 Person。Person对象买票全价,Student对象买票半价。
- 多态的条件
1.调用虚函数必须是基类的指针或者引用
2.调用的函数必须是虚函数,并且派生类要对虚函数进行重写
1.2虚函数
- 被virtual修饰的类成员函数就叫虚函数
- 虚函数重写(覆盖)的条件
1.函数名,参数,返回值类型必须相同
2.必须是被virtual修饰的类成员函数
- 虚函数重写的两个例外
1.协变:返回值的类型可以不同,但必须是父子关系的指针或者引用
2.派生类可以不加virtual修饰,但建议加上
- 析构函数的重写
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字, 都与基类的析构函数构成重写,虽然基类与派生类析构函数名字看起来不同,但编译器统一处理成destruct()
为什么析构函数要进行虚函数的重写?
看一种特殊情况:
基类AA和派生类BB的析构函数不是虚函数的重写,可能会引发内存泄漏
程序运行的结果如下:
可以看到,不使用虚函数的重写,编译器不会去调用派生类的析构函数,此时如果派生类中有动态申请资源就会导致空间没有被释放,导致内存泄露。
为什么不使用虚函数的重写就不会去调用派生类的析构函数呢?当delete一个对象的时候,首先会去调用该对象的析构函数,a->destruct(),不是虚函数,就会按照普通函数的方式去调用,此时a的类型为基类类型,那么它就会去调用基类的析构函数,而不是调用派生类的析构函数。
如果对基类的析构函数进行虚函数的重写,那么它就去调用a指向的对象的析构函数,也就是派生类析构函数,那么就不会有内存泄露的问题了。
1.3override和final
- C++11提供了override和final两个关键字,可以帮 助用户检测是否重写。
1. final:修饰虚函数,表示该虚函数不能再被重写
2. override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
1.4重载、重写(覆盖)、隐藏(重定义)的区别
-
重载:必须在同一作用域,函数名相同,参数不同
-
重写(覆盖):发生在基类和派生的作用域中,函数名、参数、返回值类型(协变例外)相同,必须为虚函数
-
隐藏(重定义):发生在基类和派生类的作用域中,只要函数名相同就构成隐藏,函数名相同发生在基类和派生类中,如果不是重写就是重定义
二、抽象类
- 在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口 类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
//抽象类--包含纯虚函数 --> 间接强制派生类对纯虚函数进行重写
class Car
{
public:
virtual void Drive() = 0;
};
class Benz :public Car
{
public:
virtual void Drive()
{
cout << "Benz-舒适" << endl;
}
};
class BMW :public Car
{
public:
virtual void Drive()
{
cout << "BMW-操控" << endl;
}
};
void cardisplay(Car* p)
{
p->Drive();
}
void test4()
{
cardisplay(new Benz);
cardisplay(new BMW);
}
抽象类强制了派生类对虚函数的重写,从另外一个角度来说,抽象类限制了用基类去构造对象,只能用派生类去构造体现基类功能的对象,更好展现了接口继承
- 接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实 现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成 多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
三、多态的原理--虚函数表
3.1虚函数表
基类中的虚函数:
派生类的虚函数(派生类只重写了基类的一个虚函数):
以下通过监视窗口来观察一下虚函数表:
观察监视窗口可以发现,基类和派生类都有一个_vfptr,也叫虚函数表指针,它实质上是一个用来存放虚函数指针的数组指针。
- 派生类中有两部分,一部分是自己的成员,一部分是从基类继承下来的成员,只有虚函数才会放进虚函数表里。
- 基类对象pp和派生类对象s虚表是不一样的,这里print完成了重写,所以派生类对象s的虚表中存的是重写的Student::print()。只要是虚函数就会被放进虚表里。
- 虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
- 每个虚函数表数组的最后一般有一个nullptr垫底,至于派生类自己的新增虚函数,就会按其在派生类中的声明次序增加到派生类虚表的最后。
- 同一类型的对象共用一张虚表
- 虚函数表的生成:
- 先将基类中的虚表内容拷贝一份到派生类虚表中
- 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
- 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后
- 如果证明第三点?我们可以通过打印虚函数表来验证:
需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。只需要点目录栏的-生成-清理解决方案,再编译就好了。
//打印虚函数表--函数指针数组
typedef void(*FUN_PTR)();
void display(FUN_PTR* table)
{
for (int i = 0; table[i] != nullptr; i++)
{
printf("[%d]:%p->", i, table[i]);
FUN_PTR f = table[i];
f();
}
printf("\n");
}
再与内存窗口进行对比:
基类的虚函数表:
派生类的虚函数表:
可以看到,内存窗口中显示的虚函数的地址和打印结果一样,也可以验证另外一点:虚函数表中最后都会有一个nullptr。
- 虚函数存在哪里?虚表存在哪里?虚表里面存的是虚函数的指针,而不是虚函数,虚函数实际上是存在代码段的。虚表存在哪里?对象中存的是虚表的指针,而不是虚表,接下来我们可以通过打印地址来验证一下虚表存在哪里
通过直接打印虚表的地址,可以对比到它存的位置与代码段靠近,我们可以暂且认为虚表是存在代码段的
3.2多态的原理
- 首先,当指向的对象去调用虚函数时,他会去该对象的虚表里面找到对应的虚函数地址,再找到对应的虚函数,这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
- 多态有两个条件:虚函数的重写和调用者必须是基类的指针或者引用,为什么?
- 为什么必须是虚函数的重写?如果不进行虚函数的重写,那么派生类继承下来的都是和基类相同的同一个函数,就无法实现多态;进行虚函数的重写,派生类继承基类的虚函数表,并对虚函数表中的某个虚函数进行重写,调用时就去找派生类对象中的虚表指针,再找到重写后的虚函数,而不是和基类调用同一个虚函数,就可以实现多态。
- 为什么必须是基类的指针或者引用?如果基类是对派生类切割后的拷贝,并不会拷贝到虚表。如果拷贝了虚表,那么该拷贝得到的基类对象中存的是派生类的虚表,这就与我们自己创建得到的基类对象中存的虚表产生矛盾,就乱套了。
观察监视窗口就可以验证该结论:派生类切割拷贝给基类时,不会拷贝虚表,并且同一类型的对象共用一张虚表
- 满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取去找的。不满足多态的函数调用时编译时确认好的。通过观察汇编代码来验证:
ebp+8中存的是p对象的指针,把p的指针放到eax中,再把eax移动到edx,就相当于把p对象的头4个指针(虚表指针)放到edx中,再把edx中的头4个指针(虚函数的指针)放回eax中,call eax就相当于call该虚函数的地址,就能调用到该虚函数了。可以看出多态中虚函数的调用是在运行时确定的,是运行起来再到对象中去找的。
3.3动态绑定与静态绑定
- 静态类型:对象在声明时采用的类型,在编译时确定,不可改变
- 动态类型:指向的对象的类型,在运行时确定,可以改变
- 静态绑定绑定的是静态类型,又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态, 比如:函数重载
- 动态绑定绑定的是动态类型,又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体 行为,调用具体的函数,也称为动态多态
四、多继承关系中的虚函数表
4.1多继承
先上一份代码
//多继承中的多态
class Base1
{
public:
virtual void func1()
{
cout << "Base1::func1()" << endl;
}
virtual void func2()
{
cout << "Base1::func2()" << endl;
}
protected:
int _b1;
};
class Base2
{
public:
virtual void func1()
{
cout << "Base2::func1()" << endl;
}
virtual void func2()
{
cout << "Base2::func2()" << endl;
}
protected:
int _b2;
};
class Derive :public Base1, public Base2
{
public:
virtual void func1()
{
cout << "Derive::func1()" << endl;
}
virtual void func3()
{
cout << "Derive::func3()" << endl;
}
protected:
int _d1;
};
打印虚表来看看多继承中虚表的情况有没有什么不同:
可以看到,派生类中有两张虚表,具体的模型如下图:
- 多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中
- 为什么重写了func1,调用时都调用的是Derive虚函数表里的fun1,但是Base1和Base2虚表中fun1的地址不一样呢?
通过汇编代码分析情况:
通过汇编代码,我们可以看到,其实不管是去调用哪个虚表里的func1,最终都是去调用同一个func1函数。分析派生类的对象模型,Base1的位置刚好对应Derive对象的起始位置,当类型为Base1的指针指向Derive时,this指针的位置刚好直接就是Derive对象的起始位置;但如果是Base2的指针指向Derive,该this指针的位置是在Base2的位置,this指针去调用Derive::print()应该指向的是Driver的起始位置,就需要向上偏移8个字节,通过sub操作来实现,再通过两次jump来找到同一个func1函数(this指针是Derive类型对象的指针)
也就说表面上两个基类的虚表里存的func1地址不同,但底层都指向同一个func1。
4.2菱形继承和菱形虚拟继承
//多态中的菱形继承和菱形虚拟继承
class A
{
public:
virtual void func1()
{
cout << "A::func1" << endl;
}
public:
int _a;
};
//class B : public A
class B : virtual public A
{
public:
virtual void func1()
{
cout << "B::func1" << endl;
}
virtual void func2()
{
cout << "B::func2" << endl;
}
public:
int _b;
};
//class C : public A
class C : virtual public A
{
public:
virtual void func1()
{
cout << "C::func1" << endl;
}
virtual void func2()
{
cout << "C::func2" << endl;
}
public:
int _c;
};
class D : public B, public C
{
public:
virtual void func1()
{
cout << "D::func1" << endl;
}
virtual void func3()
{
cout << "D::func3" << endl;
}
public:
int _d;
};
1.菱形继承的对象模型
通过观察内存窗口和监视窗口可以发现,D中存有两份的A数据,分别在B和C中各存一份。D中有两个虚表,一个来自B,一个来自C,里面存的是重写的虚函数func1。
2.菱形虚拟继承的对象模型
通过观察内存窗口和监视窗口可以发现,D中只有一份的A数据,在对象模型的底部。D中有两个三表,一个来自A,一个来自B,一个来自C,里面存的是重写的虚函数D::func1。B和C通过各自的虚基表中存的相对偏移量来找到A
B对象中的虚基表和虚函数表
C对象中的虚基表和虚函数表
可以看到,B和C中各有一份虚基表和虚函数表,派生类D的自增虚函数会放在第一个虚函数表,也就是B的虚函数表中,A中也单独有一份虚函数表,存的也是重写的虚函数D::func1 。虚基表中还存有一份数据,这是用来找到虚函数表的相对偏移量。
4.3笔试题
- 以下代码的运行结果:
- inline可以是虚函数吗?可以,不过编译器就忽略inline属性,这个函数就不再是 inline,因为虚函数要放到虚表中去。
- 静态成员可以是虚函数吗?答:不能,因为静态成员函数没有this指针,使用类型::成员函数 的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
- 构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表 阶段才初始化的。有了具体实例化的对象才会有虚表
- 对象访问普通函数快还是虚函数更快?答:首先如果是普通对象,是一样快的。如果是指针 对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函 数表中去查找。
- 虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况 下存在代码段(常量区)的。