1. 多态的概念
多态的概念,通俗来讲,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态
- 举个栗子: 比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。
- 再举个栗子: 最近为了争夺在线支付市场,支付宝年底经常会做诱人的扫红包-支付-给奖励金的活动。那么大家想想为什么有人扫的红包又大又新鲜8块、10块...,而有人扫的红包都是1毛,5毛....。其实这背后也是一个多态行为。支付宝首先会分析你的账户数据,比如你是新用户、比如你没有经常支付宝支付等等,那么你需要被鼓励使用支付宝,那么就你扫码金额 = random()%99;比如你经常使用支付宝支付或者支付宝账户中常年没钱,那么就不需要太鼓励你去使用支付宝,那么就你扫码金额 = random()%1;总结一下:同样是扫码动作,不同的用户扫得到的不一样的红包,这也是一种多态行为。ps:支付宝红包问题纯属瞎编,大家仅供娱乐。
2. 多态的定义及实现
2.1 多态的构成条件
多态是==在不同继承关系的类对象,去调用同一函数,产生了不同的行为==。比如,Student继承了Person。Person对象买票全价,Student对象买票半价。
那么在继承中要构成多态还有两个条件
:
- 必须==通过基类的指针或者引用调用虚函数。==
- ==被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。==
我们先来举一个例子来看一种多态的场景:
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);
return 0;
}
这里我们来考虑一个问题:如果是父类对象类型呢,还能构成多态吗?
答案显然是否定的,既然都不满足多态发生的条件了,我们来看一下结果。
2.2 虚函数
上面我们提到了构成多态的条件之一是通过基类的指针或引用来调用虚函数,那么这里的虚函数又是什么呢?
虚函数: 即被virtual
修饰的类成员函数称为虚函数
cpp 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; }
};
虚函数的重写的几个例外:
💕 子类虚函数中的virtual可以省略
因为虚函数的继承是接口继承,因此,子类中继承得到的虚函数和父类虚函数的函数接口时完全相同的,子类对虚函数进行重写,重写的也不过是虚函数的实现罢了。所以即使我们不加virtual,子类虚函数的接口类型和父类也时相同的。所以可以不写,不过我们一般都建议写上,因为如果不写的话会降低程序的可读性。
💕 协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。==通俗点讲就是子类虚函数和父类虚函数的返回值可以不同,但必须是子类类型或父类类型的指针或者引用。==
class Person
{
public:
virtual Person* BuyTicket() {
cout << "买票-全价" << endl;
return this;
}
};
class Student : public Person
{
public:
// 重写/覆盖
virtual Student* BuyTicket() {
cout << "买票-半价" << endl;
return this;
}
};
当然了,==这里的子类和父类不一定非要是当前的子类和父类,只要有继承关系的父子类作为函数返回值也是可以的。==
class A{
};
class B:public A{
};
class Person
{
public:
virtual A* BuyTicket() {
cout << "买票-全价" << endl;
return nullptr;
}
};
class Student : public Person
{
public:
// 重写/覆盖
virtual B* BuyTicket() {
cout << "买票-半价" << endl;
return nullptr;
}
};
这里我们还需要注意的是,如果子类函数和父类函数不满足这四个条件中的任意一个: 虚函数、返回值相同、参数类型相同、函数名相同、同时也不属于这几个例外的时候
,那么==子类函数和父类函数一般会构成隐藏==,隐藏只要求函数名相同。
💕 析构函数的重写(基类与派生类析构函数的名字不同)
如果基类的析构函数为虚函数,==此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写==,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,==这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor
==。所以只要父类析构函数定义为虚函数,子类虚函数就一定可以构成重写。
==如果父类析构函数不加virtual关键字,析构函数没有形成多态,相当于普通函数调用,如果什么类型的指针就调用什么类型的析构函数==,所以父类的析构函数建议加上virtual。才能保证ptr1和ptr2指向的对象正确的调用析构函数。
class Person {
public:
virtual ~Person() {
cout << "~Person()" << endl; }
};
class Student : public Person {
public:
virtual ~Student() {
cout << "~Student()" << endl; }
};
int main()
{
Person* ptr1 = new Person;
Person* ptr2 = new Student;
delete ptr1;
delete ptr2;
return 0;
}
2.3 C++11 override 和 final
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override
和final
两个关键字,可以帮助用户检测是否重写。
💕 final
:修饰虚函数,表示该虚函数不能再被重写
class Car
{
public:
virtual void Drive() final {
}
};
class Benz :public Car
{
public:
virtual void Drive() {
cout << "Benz-舒适" << endl;}
};
💕 override
: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错
class Car {
public:
virtual void Drive() {
}
};
class Benz :public Car {
public:
virtual void Drive() override {
cout << "Benz-舒适" << endl; }
};
重载、覆盖(重写)、隐藏(重定义)的对比
3. 抽象类
3.1 概念
==在虚函数的后面写上== =0
,则这个函数为 纯虚函数
。==包含纯虚函数的类叫做抽象类(也叫接口类)==,抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承
。
class Car {
public:
virtual void Drive() = 0;
};
class Benz :public Car {
};
int main()
{
Car c;
Benz b;
return 0;
}
这里我们看到,抽象类不能实例化出对象。派生类继承后也不能实例化出对象。
class Car {
public:
virtual void Drive() = 0;
};
class Benz :public Car {
public:
virtual void Drive() {
cout << "Benz-舒适" << endl; }
};
int main()
{
Car c;
Benz b;
return 0;
}
重写纯虚函数后,派生类才能实例化出对象,但是基类依然不能实例化出对象。
3.2 接口继承和实现继承
普通函数的继承是一种实现继承,==派生类继承了基类函数,可以使用函数,继承的是函数的实现。== 虚函数的继承是一种接口继承,==派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。== 所以如果不实现多态,不要把函数定义成虚函数
。
函数接口指的是函数控制块{}
上面的部分,即函数声明,函数实现指的是函数控制块 {} 中的部分。这其实也解释了为什么子类函数不加 virtual 修饰也是虚函数。
3.3 一道重要的笔试/面试题
以下程序输出什么?
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
。
> 首先new了一个B对象,然后用p指针指向它,使用p调用test函数,由于B中的test函数是继承于A中的test函数。在这里我们需要注意的是,继承过来的函数他的接口是不变的,也就是B所继承的test函数中的参数列表和A中的是一摸一样的,包括this指针的类型依然是`A
,但是调用者的类型却是
B
`所以这里发生了A指针引用B
的对象,发生了切片操作,所以构成了多态的条件。> 在test函数内部再调用func函数,由于func函数是虚函数,而且在子类中重写了这个虚函数,所以调用的B中的func函数,而且由于虚函数的继承是接口继承,重写虚函数重写的是他的实现,所以B中func函数的val和A中是一样的,依旧是
1
,所以答案选B:
B->1
。
根据上面的题目,我们可以稍微修改一下,这里有两道他的变形题目:
变形1
class A
{
public:
virtual void func(int val = 1){
std::cout << "A->" << val << std::endl; }
};
class B : public A
{
public:
void func(int val = 0){
std::cout << "B->" << val << std::endl; }
virtual void test() {
func(); }
};
int main(int argc, char* argv[])
{
B* p = new B;
p->test();
return 0;
}
答案: B->0
。
> 这道题目和上面的很像,唯一不同的就是将test函数写到了子类中了,这里我们看到test函数也使用了virtual来修饰,变成了虚函数,但是由于父类中并不存在相同名字的虚函数,并没有发生虚函数的重写,所以在它这里只能看做一个普通函数。
> 当我们使用p去调用test函数的时候,调用的是B类中的test函数,所以==并没有发生多态的条件==,这里在test函数中调用func函数时,调用的就是普通的B类中的func函数,这里的val是0,所以答案是:
B->0
。
变形2
cpp 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[]) { A* p = new B; p->test(); return 0; }
答案:
B->1
。
解析:
> 这里我们虽然使用了一个
A*
类型的指针,但是new出来的对象确是一个B类型的,所以这里发生了切片操作,本质上还是一个B类型的指针,到这里就和我们第一道题目一样了,同样发生了多态,而且我们要注意虚函数的接口继承问题。所以答案依旧是
B->1
。
**
# 4. 多态的原理
## 4.1 虚函数表
这里我们先来看一道题目:
cpp class Base { public: virtual void Func1() { cout << "Func1()" << endl; } private: int _a = 1; char _b; }; int main() { cout << sizeof(Base) << endl; return 0; }
这里我们如果要是按照C语言的内存对齐规则来计算的话,那答案不应该是8吗,但结果为什么是12呢?多出来的4个字节来自于哪里呢?
当我们实例化一个对象后,通过监视窗口发现Base类中还存在一个vfptr的虚函数表指针。 这里我们需要注意一点:
虚表
==是多态中的概念,该表本质上是一个函数指针数组,里面存放的是虚函数的地址==;而虚基表
==是菱形虚拟继承中的概念,该表本质上是一个整形数组,里面存放的是当前类与虚基类的偏移量。==cpp class Base { public: virtual void Func1() { cout << "Func1()" << endl; } virtual void Func2() { cout << "Func2()" << endl; } private: int _a = 1; char _b = 'b'; }; class Derive :public Base { public: virtual void Func1() { cout << "virtual void Func1()" << endl; } private: int _d = 3; }; int main() { Base b; Derive d; return 0; }
这里我们还可以发现,func1在子类虚表中的地址和父类虚表中的地址是不同的。子类没有对func2进行重写,但是但是虚表中却也继承了父类的func2,而且他们两个的地址是一样的。所以子类虚表是通过拷贝父类虚表,将子类进行重写得到新的虚函数指针,然后覆盖掉原来虚表中该函数的地址。因此,==重写指的是重写虚函数,覆盖指的是使用虚表中重写的虚函数的地址覆盖掉原来虚函数的地址。==
因此,
子类虚表生成的原理
==就是先拷贝一份父类的虚表中的内容,然后将重写后的虚函数覆盖掉拷贝过来的虚函数的地址。派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。== 判断虚表的存储位置?
cpp int main() { Base b; Derive d; int x = 0; static int y = 0; int* z = new int; const char* p = "xxxxxxxxxxxxxxxxxx"; printf("栈对象:%p\n", &x); printf("堆对象:%p\n", z); printf("静态区对象:%p\n", &y); printf("常量区对象:%p\n", p); printf("b对象虚表:%p\n", *((int*)&b)); printf("d对象虚表:%p\n", *((int*)&d)); return 0; }
因为虚表指针是一个指针,一个指针在32平台下占4个字节,所以当我们将类对象的地址强制转换成int,然后对其解引用就能拿到vfptr了,当我们使用%p打印输出时就能得到虚表的地址了,最后将虚表的地址和区域中各个位置的地址相比较时,发现虚表的地址和代码段的地址非常接近,所以我们大概可以判断虚表是存在代码段的,因此,同一类型的虚表是共享的。
*
## 4.2 多态的原理
cpp class Base { public: virtual void Func1() { cout << "Base::Func1()" << endl; } virtual void Func2() { cout << "Base::Func2()" << endl; } void Func3() { cout << "Base::Func3()" << endl; } private: int _b = 1; }; class Derive : public Base { public: virtual void Func1() { cout << "Derive::Func1()" << endl; } virtual void Func4() { cout << "Derive::Func4()" << endl; } private: int _d = 2; }; int main() { Derive d; Base* ptr = &d; d.Func1(); ptr->Func1(); return 0; }
cpp //这里虽然func1是虚函数,但是d是对象,不满足多态的条件,所以这里是普通函数的调 //用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址 d.Func1(); 0018298D lea ecx,[d] 00182990 call Base::Func1 (0181096h) ptr->Func1(); //ptr中存的是d对象的指针,将ptr移到eax中 00182995 mov eax,dword ptr [ptr] //[eax]就是取eax值指向的内容,这里相当于把d对象头四个字节(虚表指针)移动到了edx 00182998 mov edx,dword ptr [eax] //[edx]就是取edx指向的内容,这里相当于把虚表中的的头4个字节存的虚函数指针移动到了eax 0018299A mov esi,esp 0018299C mov ecx,dword ptr [ptr] 0018299F mov eax,dword ptr [edx] //call eax中存的虚函数的指针,这里可以看出满足多态的调用,不是在编译时确定的,是运行起来 //以后到对象的中取找的。 001829A1 call eax 001829A3 cmp esi,esp 001829A5 call __RTC_CheckEsp (01812FDh)
在进行多态调用的时候,
父类指针如果指向的是父类对象
,就去父类对象的虚表里面取被调函数的地址,此时取出来的地址是父类的虚函数表地址。
如果指向的是子类对象中父类的那一部分
,则去子类对象中属于父类对象那一部分中找虚表,然后从虚表里面取出被调函数的地址,由于子类对象对虚表进行了覆盖,所以取出的是子类重写后虚函数的地址。这样就实现了多态,所以多态的原理是,依靠虚表实现指向谁就调用谁。
**
## 4.3 动态绑定和静态绑定
1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态 ,
比如:
函数重载
2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体
行为,调用具体的函数,也称为动态多态。
像多态这种的就叫做动态绑定
为什么父类对象不能实现多态,必须是父类的指针或者引用?
> 子类对象在赋值给父类对象时不会将子类对象的虚表拷贝给父类,因此不能实现动态绑定。那么为什么拷贝的时候不能将虚表拷贝给父类对象呢?因为如果要是虚表能进行拷贝的话,父类将无法区分此时父类对象的虚表是父类本身的虚表还是从子类拷贝而来的虚表。所以只能实现静态绑定,而无法实现动态绑定。
虚函数表指针是什么时候初始化的,虚函数表又是什么时候初始化的?**
class Base
{
public:
Base()
:_b(0)
{
}
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
private:
int _b = 1;
};
int main()
{
Base b;
return 0;
}
5. 单继承和多继承关系的虚函数表
5.1 单继承中的虚函数表
class Base {
public:
virtual void func1() {
cout << "Base::func1" << endl; }
virtual void func2() {
cout << "Base::func2" << endl; }
private:
int a;
};
class Derive :public Base {
public:
virtual void func1() {
cout << "Derive::func1" << endl; }
virtual void func3() {
cout << "Derive::func3" << endl; }
virtual void func4() {
cout << "Derive::func4" << endl; }
private:
int b;
};
int main()
{
Base b;
Derive d;
return 0;
}
观察上图中的监视窗口中我们发现看不见func3和func4。这里是编译器的监视窗口故意隐藏了这
两个函数,也可以认为是他的一个小bug。那么我们如何查看d的虚表呢?
其实在这里我们是可以通过内存窗口来看到func3和func4:
💕 使用代码打印出虚表中的函数。
typedef void(*VF_PTR)();
void PrintVFTable(VF_PTR* table)
{
for (int i = 0; table[i] != nullptr; ++i)
{
printf("[%d]:%p->", i, table[i]);
VF_PTR f = table[i];
f();
}
cout << endl;
}
5.2 多继承中的虚函数表
class Base1 {
public:
virtual void func1() {
cout << "Base1::func1" << endl; }
virtual void func2() {
cout << "Base1::func2" << endl; }
private:
int b1;
};
class Base2 {
public:
virtual void func1() {
cout << "Base2::func1" << endl; }
virtual void func2() {
cout << "Base2::func2" << endl; }
private:
int b2;
};
class Derive : public Base1, public Base2 {
public:
virtual void func1() {
cout << "Derive::func1" << endl; }
virtual void func3() {
cout << "Derive::func3" << endl; }
private:
int d1;
};
这里我们可以看到,因为Base1有两张虚表,Base2也有两张虚表,子类同时继承了Base1和Base2后,子类也将有两张虚表。这里我们在打印子类中的虚表时,需要分别打印Base1的虚表和Base2的虚表,在这里我们还能发现子类新增加的虚表放在了Base1类虚表的后面。
==因此,在多继承中,父类一共有多少张虚表,那么子类就会拷贝多少张虚表,然后进行重写,如果子类有自己特有的虚表,那自己的虚表将添加到最先继承的父类的虚表的后面。==
5.3 菱形继承和菱形虚拟继承
我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不做深入讲解,一般我们也不需要研究清楚,因为实际中很少用。如果好奇心比较强的宝宝,可以去看下面的两篇链接文章。
6. 继承和多态常见的面试问题
- 什么是多态?
多态就是指多种形态,指的是不同的对象做同一件事情,其结果是不一样的。多态分为静态多态和动态多态,静态多态也称为编译时的多态;在编译时期就已经确定要执行了的函数地址了;主要有函数重载和函数模板。动态多态在运行时才去确定对象类型和正确选择需要调用的函数,用父类的指针或引用去调用重写的虚函数。指向谁就调用谁。- inline可以是虚函数吗?
从语义上来讲,如果是多态调用,虚函数不可以是内联函数,因为多态要进行动态绑定,如果是普通调用,可以是内联函数,但是内联函数不会进符号表,没有函数地址,而虚函数的地址是要放进虚函数表中的;从语法上来讲,不管是普通调用还是多态调用,将虚函数定义为内联函数都不会编译报错,因为 inline 只是一个建议关键字,具体是否为内联由编译器决定。- 静态成员可以是虚函数吗?
不能,因为静态成员没有 this 指针,而虚函数要通过虚表调用,虚表要通过对象中的虚表指针来查找,无法使用类型::成员函数的调用方式直接访问虚表。
- 构造函数可以是虚函数吗?
不能,因为如果构造函数是虚函数的话,实例化对象时需要先从虚表中取出构造函数的地址,虽然虚表在编译时就生成好了,但是虚表指针在构造函数的初始化列表完成初始化的,所以再对象完成构造之前,我们无法通过虚表指针来访问虚表。- 析构函数可以是虚函数吗?
可以,并且建议将父类的析构函数都定义为虚函数,为了能够正确的调用析构函数,编译器会将虚函数统一处理成destructor(),如果基类的析构函数不加上virtual,此时指向子类的父类指针也无法正确调用析构函数。- 拷贝构造函数和赋值运算符重载可以是虚函数吗?
拷贝构造函数不可以是虚函数,因为拷贝构造函数也是构造函数,也要在初始化列表中初始化对象的虚表指针。赋值运算符重载可以是虚函数,但是没有任何的实际价值,因为虚函数的作用是为了实现多态。- 对象访问普通函数快还是虚函数更快?
如果虚函数不是多态调用,则一样块,如果是多态调用,则普通函数更快,因为虚函数多态调用需要运行时到虚表中去查找虚函数的地址。- 虚函数表是在什么地方生成的?存在哪里?
虚函数表在编译阶段生成,存在于代码段/常量区,并且同一类型共享一张虚表。构造函数初始化列表阶段初始化的是虚函数表的指针,对象中存的也是虚函数表指针。- 友元函数可以是虚函数吗?
友元函数不属于成员函数,不能成为虚函数,虚函数是针对成员函数的。