文章目录
多态的概念
多态的概念:
通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
如购买车票:当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。
多态的定义及实现
多态的构成条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价。
那么在继承中要构成多态还有两个条件:
- 必须通过基类的指针或者引用调用虚函数。
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。
虚函数
虚函数:
即被virtual修饰的类成员函数称为虚函数。
如:
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }//virtual加在函数名前面
};
虚函数的重写
虚函数的重写(覆盖):
派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
- 注意,继承中,父子类有同名函数即构成隐藏(重定义)。
- 多态重写中,返回值类型,函数名,参数都需要相同才能构成重写。
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
//继承中构成多态还有两个条件
//1. 必须通过基类的指针或者引用调用虚函数
//2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
//也构成虚函数的重写
/*void BuyTicket() { cout << "买票-半价" << endl; }*/
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(ps);
Func(st);
return 0;
}
- 注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很范,不建议这样使用。
虚函数重写的两个例外:
协变(基类与派生类虚函数返回值类型不同):
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指
针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。(了解)
析构函数的重写(基类与派生类析构函数的名字不同):
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,
都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,
看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处
理,编译后析构函数的名称统一处理成destructor。
通过以下例子说明该机制的作用:
析构函数为虚函数时:
普通对象
普通对象的析构没有问题。当使用父类指针或引用去管理new出来(动态申请)的父子类对象时:
也没有问题。
析构函数不是虚函数时:
普通对象
普通对象也没有问题。使用父类指针或引用去管理new出来(动态申请)的父子类对象时:
这时候就发现析构出现问题了。
所以在使用父类指针或引用去管理new出来(动态申请)的父子类对象时,父类的析构函数一定要写成虚函数。
class Person {
public:
//virtual ~Person() { cout << "~Person()" << endl; }
~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
//virtual ~Student() { cout << "~Student()" << endl; }
~Student() { cout << "~Student()" << endl; }
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函
//数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
普通对象析构顺序不会有问题,无论是否构成多态
//Person p;
//Student s;
//父类的指针调用,不重写析构函数则会发生错误
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}
了解这些后再来回顾多态构成的条件。
- 必须通过基类的指针或者引用调用虚函数。
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。
重载、覆盖(重写)、隐藏(重定义)的对比
C++11 override 和 final
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数
名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有
得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮
助用户检测是否重写。
- final:修饰虚函数,表示该虚函数不能再被重写
- final加在函数上则函数无法被重写。
- override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
//final和override
//class Person final//加在类上则该类无法被继承
class Person
{
public:
//virtual void BuyTicket()final//该函数无法被重写
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
class Student : public Person
{
public:
//virtual void BuyTicket(int i) //不会报错,但不构成多态
//virtual void BuyTicket(int i) override //加了override则会报错
//{
// cout << "买票-半价" << endl;
//}
virtual void BuyTicket() override
{
cout << "买票-半价" << endl;
}
//virtual void BuyTicket() override
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person p;
Student s;
Func(p);
Func(s);
return 0;
}
接口继承
这算得上是多态的小坑吧;
- 多态是接口继承,子类直接将对象的接口继承下来了,只实现重写。
父类的a为1;子类的a为-1;当多态调用时,a都为1,说明多态为接口继承。
抽象类
概念:
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
未重写纯虚函数:
重写纯虚函数:
多态底层原理
开始前我们计算一下对象的大小:Base类只有一个int成员_b,Func1在静态区,所以Base类对象大小应该为:4
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
int main()
{
Base b;
cout << sizeof(b) << endl;
return 0;
}
但计算得出Base类的大小为8这是为何呢?通过调试窗口可以看到,b里面不仅只有_b,还有一个_vfptr,这个_vfptr其实是一个虚函数表指针,指向一张虚函数表,这张表里面放的是该类虚函数的地址。
- 这里的虚函数表指针和虚函数表不是上篇继承中的提到的虚基表指针和虚基表。
- 这里的_vfptr虚表指针大小为4是在32位平台下得出的,若64位下则为8。
虚函数表
虚函数表指针(virtual function table porinter):
- 本质是一个函数数组指针,一个指向函数数组的指针。
虚函数表(virtual function table):
- 本质是一个函数指针数组,一个用来存放函数指针的数组。
通过下面例子来继续探索一下虚函数表
- 我们增加一个派生类Derive去继承Base
- Derive中重写Func1
- Base再增加一个虚函数Func2和一个普通函数Func3
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 Func3()
{
cout << "Derive::Func3()" << endl;
}
private:
int _d = 2;
};
int main()
{
Base b;
Derive d;
return 0;
}
可以通过调试窗口去观察并得出:
派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。
基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表
中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数
的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函
数,所以不会放进虚表。虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
总结一下派生类的虚表生成:
-
- 先将基类中的虚表内容拷贝一份到派生类虚表中 。
-
- 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
-
- 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
- 这里还有一个很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。但是很多人都是这样深以为然的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?等会我们去验证一下会发现VS下是存在代码段的
多态原理
要想理解多态的原理,就必须先搞清楚上面的虚函数表,所以下面才正式开始多态原理的讲解。
还是以开头介绍多态的买票例子进行讲解。
观察下图的红色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚
函数是Person::BuyTicket。
观察下图的蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中
找到虚函数是Student::BuyTicket。
这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
- 多态的本质也是在执行指令,只是它的指令是在运行时确定的。
- 反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调
用虚函数。反思一下为什么?通过观察下列调试。
- 多态的条件之一为:基类的指针或者引用调用虚函数,当我们尝试用传值调用时,是无法构成多态的。因为传值调用就会调用拷贝构造,而编译器是不会将派生类的虚函数表指针拷贝给基类的虚函数表指针的,所以形参只会是基类的虚函数表指针,就无法构成多态了。
- 再通过下面的汇编代码分析,看出满足多态以后的函数调用,不是在编译时确定的,是运行
起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。
- 可以看到同样是函数调用,多态的汇编就比普通的函数调用要复杂,这就是因为他是运行
起来以后到对象的中取找的。
单继承和多继承关系的虚函数表
需要注意的是在单继承和多继承关系中,下面我们重点去关注的是派生类对象的虚表模型。
单继承中的虚函数表
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:
//重写func1,新增func3,func4
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
virtual void func4() { cout << "Derive::func4" << endl; }
private:
int b;
};
- 子类中重写了func1,新增func3,func4。
在子类的虚函数表中理应有:func1 func2 func3 func4四个函数的地址,但是通过监视窗口观察却只有两个,难道之前讲的都是错的?所以我们只好通过内存窗口进行再次验证,因为内存是不会骗人的,如果有就一定会存在。
- 通过内存窗口发现虚函数表里头两个确实是对应的虚函数地址,与虚函数表结尾的nullptr中间确实还隔着两个函数地址。这时我们可以猜测是监视窗口出了问题,没有展示后两个函数地址,但也不能完全肯定,除非我们将虚函数表里的地址打印出来一一比对。
打印虚函数表
虚函数表指针(virtual function table porinter):
- 本质是一个函数数组指针,一个指向函数数组的指针。
虚函数表(virtual function table):
- 本质是一个函数指针数组,一个用来存放函数指针的数组。
上面已经对虚函数表进行了介绍,本质是个函数指针数组,存放着虚函数的地址,那我们能不能将数组里面是内容打印出来查看呢?当然可以,只需要一个函数指针不就可以了。这时用到typedef来对这个类型重命名一下方便我们观看。
- 函数指针格式
函数指针类型的重命名⽐如,将 void(*)(int) 类型重命名为 pfun_t ,就可以这样写:
typedef void(*pfun_t)(int)//新的类型名必须在*的右边
对于重写的虚函数,可以将其类型重命名为VFTPTR(Virtual Function Table Porinter):
typedef void(*VFTPTR)();
- 重写的虚函数返回值为void,无参数。
所以打印虚函数地址的函数可以写成这样
typedef void(*VFTPTR)();
void PrintVFT(VFTPTR* _vftp)//虚函数表类型指针
{
cout << "虚表地址:" << *_vftp << endl;
cout << "---------------------" << endl;
//本质是个数组,按照数组的方式打印
for (int i = 0; _vftp[i] != 0; i++)
{
printf("[%d]:%p->", i+1, _vftp[i]);
VFTPTR f = _vftp[i];//获取虚函数
f();//调用虚函数
}
cout << endl;
}
- VFTPTR为虚函数表的类型
打印的函数写好了,接下来就是获取虚函数表
- 虚函数表是通过虚函数指针找到的,而虚函数指针是在对象里的,而且通过内存窗口发现虚函数表指针是放在对象最前面的。
那么就可以利用这一特点去获取虚函数表指针。
int main()
{
Base b;
Derive d;
VFTPTR* p = (VFTPTR*)(*(int*)&d);
PrintVFT(p);
return 0;
}
思路:取出b、d对象的头4bytes(32位平台下为4,64位下指针大小为8,需要跟着改),就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr
1.先取b的地址,强转成一个int*的指针。
2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针。
3.再强转成VFTPTR*,因为虚表就是一个存VFTPTR类型(虚函数指针类型)的数组。
4.虚表指针传递给PrintVFT进行打印虚表。
5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最
后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,再编译就好了。
打印的地址与内存窗口的比对。
通过观察打印的虚函数地址和内存窗口里的地址一一比,结果是吻合的,这时候就可以证实是VS的调试窗口有问题,没有完全显示虚函数表里的地址。
而实际上虚函数表的存放在代码段的,跟常量,代码一起。
多继承中的虚函数表
在多继承中Derive 继承Base1,Base2;其中Base类中有虚函数func1,func2;派生类Derive 重写了func1,并新增虚函数func3。
class Base1 {
public:
virtual void func1() { cout << "Base1::func1" << endl; }
virtual void func2() { cout << "Base1::func2" << endl; }
private:
int b1=1;
};
class Base2 {
public:
virtual void func1() { cout << "Base2::func1" << endl; }
virtual void func2() { cout << "Base2::func2" << endl; }
private: int b2=2;
};
class Derive : public Base1, public Base2 {
public:
virtual void func1() { cout << "Derive::func1" << endl; }
//继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d1=3;
};
//打印虚函数地址
typedef void(*VFTPTR)();
void PrintVFT(VFTPTR* vftp)//虚函数类型指针
{
cout << "虚表地址:" << *vftp << endl;
cout << "---------------------" << endl;
//本质是个数组,按照数组的方式打印
for (int i = 0; vftp[i] != 0; i++)
{
printf("[%d]:%p->", i+1, vftp[i]);
VFTPTR f = vftp[i];//获取虚函数
f();//调用虚函数
}
cout << endl;
}
int main()
{
Base1 b1;
Base2 b2;
Derive d;
VFTPTR* p1 = (VFTPTR*)(*(int*)&d);
PrintVFT(p1);
VFTPTR* p2 = (VFTPTR*)(*(int*)((char*)&d + sizeof(Base1)));
PrintVFT(p2);
//cout << sizeof(d) << endl;
//Base1* p1 = &d;
//p1->func1();
//Base1* p2 = &d;
//p2->func1();
return 0;
}
- 通过打印地址发现Derive 类中有两张虚函数表,一张是Base1的,另一张是Base2的。对于新增加的虚函数func3会添加到优先继承的虚表中。
眼细的可能已经发现了两张表中重写了的func1确实是调用了Derive 的func1,但是他们的地址却不一样。
调用的是同一个函数,但地址却不一样。
这其实和指针的偏移有关,粗略观察一下这两者调用的汇编指令便能发现其中的猫腻
Base1* 去调用,也就是第一张虚表
VS中,函数调用一般先是通过寄存器(此处为eax)跳转到jump指令(jmp),jmp指令才去真正调用函数。Base2*调用,第二张虚表
调用第二张虚表的func1(虚表存的是地址)发现比调用第一张虚表的func1多跳转了很多次。这是因为两个指针指向的位置不一样,在调用第二张虚表时编译器会先回到该类最开始的位置。也就是汇编时下图的操作
但是最终发现都会去调用Derive::func1。