深入剖析C++多态、VPTR指针、虚函数表

时间:2022-01-13 21:55:51

在讲多态之前,我们先来说说关于多态的一个基石------类型兼容性原则。

一、背景知识

  1.类型兼容性原则 

  类型兼容规则是指在需要基类对象的任何地方,都可以使用公有派生类的对象来替代。通过公有继承,派生类得到了基类中除构造函数、析构函数之外的所有成员。这样,公有派生类实际就具备了基类的所有功能,凡是基类能解决的问题,公有派生类都可以解决。类型兼容规则中所指的替代包括以下情况:

    子类对象可以当作父类对象使用

    子类对象可以直接赋值给父类对象

    子类对象可以直接初始化父类对象

    父类指针可以直接指向子类对象

    父类引用可以直接引用子类对象

  在替代之后,派生类对象就可以作为基类的对象使用,但是只能使用从基类继承的成员。类型兼容规则是多态性的重要基础之一。

#include <iostream>
using namespace std;

class Parent{
public:
void printP(){
cout
<<"Parent"<<endl;
}
Parent(
const Parent& obj){
cout
<<"拷贝构造函数"<<endl;
}
private:
int a;
};

class Son:public Parent
{
public:
void printC(){
cout
<<"Son"<<endl;
}
private:
int b;
};


void howtoPrint(Parent *base){
base->printP(); //只能执行父类的成员函数
}

void howtoPrint2(Parent &base){
base.printP(); //只能执行父类的成员函数
}

void main(int argc, char const *argv[])
{
Parent p1;
p1.printP();

Son s1;
s1.printC();
s1.printP();

//类型兼容性原则
//1-1.基类指针(引用)指向子类对象
Parent *p = NULL;
p1
= &s1;
p1
->printP();

//2.指针做函数参数
howtoPrint(&p1);//完全没问题
howtoPrint(&s1);//完全没问题,兼容性原则

//3.引用做函数参数
howtoPrint2(p1);
howtoPrint2(s1);

//第二层含义:可以让子类对象初始化父类对象
//子类就是一种特殊的父类
Parent p2 = c1;//调用拷贝构造函数

return 0;
}

  2.多态产生的背景探究

   我们先来看一个实例,然后慢慢引出为什么会有多态这一需求。

class Parent
{
public:
Parent(
int a){
this->a = a;
}
printP(){
cout
<<"Parent"<<endll;
}
private:
int a;

};

class Son:public Parent{
public:
Son(
int b):Parent(10){
this->b = b;
}
printP(){
cout
<<"Son"<<endll;
}
private:
int b;
};

void howtoPrint(Parent *base){
base->printP();
}

void howtoPrint2(Parent &base){
base.printP();
}

  上面定义了两个类,并且子类与父类都有一个同名函数printP(),现在,我们来看看测试案例:

    情形一:定义一个基类指针,让该指针分别指向基类和子类对象,然后调用printP():

void main(int argc, char const *argv[])
{
Parent
*p = NULL;
Parent p1(
20);
Son s1(
30);//

p
= &p1;//指针执行基类
p1
->printP();//

p
= &s1;//类型兼容性原则
p
->printP();
}

  对于第一个p1->printP(),我很快知道,执行的是父类的PrintP()函数;但是对于第二个,是执行子类还是父类?

  结果,我测试发现,也是执行的父类的printP()函数。

    情形二:定义基类别名和子类别名,然后使用别名来调用该函数:

void main(int argc, char const *argv[])
{


Parent
&base = p;//父类的别名
base.printP();//执行父类

Parent
&base2 = s1;//别名
base2.printP();//执行父类
}

    答案也是执行父类的printP()函数。

    情形三:定义一个函数,也就是上面写的howtoPrint(),函数参数为基类指针,然后定义一个指向基类指针,让该指针分别指向基类对象和子类对象:

void main(int argc, char const *argv[])
{
Parent
*p = NULL;
Parent p1(
20);
Son s1(
30);

p
= &p1;
howtoPrint(
&p1);
p
= &s1;
howtoPrint(
&s1);
}

   答案都是执行父类的printP()函数。

    情形四:定义一个函数,也就是上面写的howtoPrint2(),函数参数为基类对象的引用,然后分别传入基类对象引用和子类对象引用:

void main(int argc, char const *argv[])
{
Parent
*p = NULL;
Parent p1(
20);
Son s1(
30);

howtoPrint2(p1);
howtoPrint2(s1);
}

   答案依然是调用基类的printP()函数。

  上面四种情形,不管我们怎么改变调用方式,始终都是调用的基类的函数,那么问题就来了,怎么样才能解决,传入子类对象调用子类函数,传入基类对象调用基类函数呢?

  因此,C++给我们提供了多态这一个解决方案。下面,我们来看看,多态是如何解决我们的需求的。

  2.多态

  要理解多态,首先要搞明白,什么是多态?

  多态:就是根据实际的对象类型决定函数调用语句的具体调用目标。

  总结一句话就是,同样的调用语句有多种不同的表现形态。

              深入剖析C++多态、VPTR指针、虚函数表

   在了解什么什么事多态以后,那么问题就来了,C++编译器是如何实现上述需求的呢?各位看官,接着往下看。 

二、虚函数

  1.定义:类成员函数前面添加virtual关键字以后,该函数被称为虚函数。

  2.声明

class Parent
{
public:
Parent(
int a){
this->a = a;
}
virtual print(){
cout
<<"Parent"<<endll;
}
private:
int a;

};

  3.多态的实现

  上面我们说道,如果解决C++中,根据传入对象的不同来调用同样的函数呢?

  这就用到了C++提供的虚函数!关于虚函数实现机制,在下面我会慢慢剖析。

  下面,我们来看看多态的效果:

class Parent
{
public:
Parent(
int a){
this->a = a;
}
virtual printP(){
cout
<<"Parent"<<endll;
}
private:
int a;

};

class Son:public Parent{
public:
Son(
int b):Parent(10){
this->b = b;
}
printP(){
//子类的virtual写可不写,只需要父类写就可以了
cout<<"Son"<<endll;
}
private:
int b;
};

  上面的继承关系很清晰,这里要注意一点:基类与子类的同名函数,要想实现多态,基类同名函数必须声明为virtual函数

  在定义了虚函数后,我在来看看上面出现的问题解决掉了没?我们写出测试案例:

  测试案例一:成功解决了问题

void main(int argc, char const *argv[])
{
Parent
*p = NULL;
Parent p1(
20);
Son s1(
30);

p
= &p1;
p1
->printP();//执行父类的打印函数


p
= &s1;
p
->printP();//执行子类的打印函数
}

  测试案例二:也成功解决问题

void main(int argc, char const *argv[])
{
Parent p1(
20);
Son s1(
30);

Parent
&base = p1;//父类的别名
base.printP();//执行父类

Parent
&base2 = s1;//别名
base2.printP();//执行子类
}

  测试案例三:

void howtoPrint(Parent *base){ //一个调用语句执行不同的函数
base->printP();
}

void main(int argc, char const *argv[])
{
Parent p1(
20);
Son s1(
30);
howtoPrint(
&p1);//父类
howtoPrint(&s1);//子类
}

  在测试案例三种,我们定义了一个函数howtoprint(),函数参数为基类指针,在主函数中,我们分别传入了子类对象和基类对象,输出结果显示:当传入基类对象,调用基类的printP()语句,当传入子类对象的时候,调用的是子类的printP()函数,结果也正好是我们想要的。也就是说,在执行howtoPrint()函数的时候,发生了多态。

  最后,我们在看看测试案例四:

void howtoPrint2(Parent &base){//一个调用语句执行不同的函数
base.printP();
}

void main(int argc, char const *argv[])
{
Parent
*p = NULL;
Parent p1(
20);
Son s1(
30);

howtoPrint2(p1);
//父类
howtoPrint2(s1);//子类

}

  正如我们想要的,一个调用语句执行不同的函数。

  哦,最后我成功的得到了我们想要的结果,但我们应该至此为止么?显然不能,多态的强大之处,我们还没真正的感受到,而且对于多态的实现机制,难道你就不想挖一挖吗?为什么会出现這种情况,难道你就没有疑惑吗?各位,别着急,继续往下看!

三、从一个案例中体会多态的强大之处

  场景:现在我们有三辆战机,两辆英雄战机和一辆敌机,其关系图如下:

                深入剖析C++多态、VPTR指针、虚函数表

  功能:当我们传入初级战机对象和敌机对象进行战斗时,判断输赢;当我们传入高级战机和敌机战斗,判断输赢;

  我们用两种方式来做做看,看看多态的强大之处:

class HeroFighter{
public:
virtual int power(){
return 10;
}
};
class AdvHeroFighter:public HeroFighter
{
public:
int power(){
return 20;
}
};
class EnemyFighter
{
public:
int attack(){
return 15;
}
};

  上面定义了三个战机类,我们来看看测试代码:

  非多态使用方式:

void main(int argc, char const *argv[])
{
HeroFighter hf;
AdvHeroFighter ahf;
EnemyFighter ef;

if (hf.power() > ef.attack())
{
cout
<<"英雄胜"<<endl;
}
else{
cout
<<"英雄负"<<endl;
}
if (ahf.power() > ef.attack())
{
cout
<<"英雄胜"<<endl;
}
else{
cout
<<"英雄负"<<endl;
}
}

  上面这种方式,代码虽然结构很清晰,但是代码相似度很高,而且,一旦需求变化,又要新添许多逻辑判断。下面,我们看看多态是如何实现的。

void play(HeroFighter *hf,EnemyFighter *ef){
if (hf->power() > ef->attack())//hf->power()调用会发生多态
cout<<"英雄胜"<<endl;
else
cout
<<"英雄负"<<endl;
}

void main(int argc, char const *argv[])
{
HeroFighter hf;
AdvHeroFighter ahf;
EnemyFighter ef;
play(
&hf,&ef);
play(
&ahf,&ef);
}

  有没有发现,使用多态来实现,发现代码简洁多!我们在play()中定义了两个参数:一个是英雄基类指针,一个是敌机指针。当我们传入不同英雄战机对象时,在hf->power()这里会发生多态,根据对象的不同,调用不同类的同名函数。我们可以把play()函数看成一个框架,不管创建多少英雄战机,我们都能使用hf->power()来调用属于对象自己的函数。而且play()這个函数,我们并不需要做任何的改动!有没有发现多态很强大,其实在设计模式中,基本上都是依靠多态来的,可以说多态是设计模式的基石,这一点都不为过。

  有了上面的描述,我们很容易总结出多态实现的基础:

    1.要有继承

    2.要有虚函数重写

    3.父类指针(引用)指向子类对象

 四、多态理论基础

  1.静态联编与动态联编

   联编:是指一个程序模块、代码之间相互关联的过程

    静态联编(static binding):是程序的匹配、连接在编译阶段实现,重载函数使用静态联编

     动态联编:是指程序联编推迟到运行时进行,又称为迟联编。switch和if语句是动态联编的例子

    关于静态联编和动态联编,我们下面拿上面的例子进行说明一下:

 

深入剖析C++多态、VPTR指针、虚函数表深入剖析C++多态、VPTR指针、虚函数表
class HeroFighter{
public:
virtual int power(){
return 10;
}
};

class AdvHeroFighter:public HeroFighter
{
public:
int power(){
return 20;
}
};

class EnemyFighter
{
public:
int attack(){
return 15;
}
};
三个战斗机类
void play(HeroFighter *hf,EnemyFighter *ef){ 

if (hf->power() > ef->attack())//hf->power()调用会发生多态
cout<<"英雄胜"<<endl;
else
cout
<<"英雄负"<<endl;
}

  我们知道在if判断里面会发生多态,如果power函数没有定义为virtual函数,那么这里称为的是静态联编,也就是说,hf->power()这里的调用关系,会在编译阶段根据函数参数会绑定到HeroFighter对象身上,这也就是为什么,不管以何种方式传入子类对象,都只能调用到基类的power()函数,因为这种调用关系,在编译的时候就已经确定了!

  如果power()函数被定义为虚函数,那么就称为动态联编

 

   即这种绑定关系并不会依靠函数参数来确定,而是在程序运行期间进行绑定。这也就是为什么当我们传入对象不同,会调用不同的函数。

  2.重载、重写、重定义 

       函数重载

    • 函数重载必须在同一个类中进行
    • 子类无法重载父类函数,父类同名函数将被名称覆盖
    • 重载是在编译器期间根据参数类型和个数决定函数调用(静态联编)

    函数重写

    • 函数重写必须发生在父类与子类之间
    • 父类与子类的函数原型完全一样
    • 使用virtual声明之后能够产生多态(如果不写virtual关键字,称为重定义)
    • 多态是在运行期间根据具体对象的类型来决定函数调用

          --------------非虚函数重写 --->重定义
      重写
          --------------虚函数重写----->重写(会发生多态)

class Parent
{
public:
//以下三个函数在同一个类中表现为重载
virtual void fun(){
cout
<<"func1()"<<endl;
}
virtual void fun(int i){
cout
<<"func2()"<<endl;
}
virtual void fun(int i,int j){
cout
<<"func3()"<<endl;
}
int abc(){
cout
<<"abc"<<endl;
}
virtual void fun(int i,int j,int k,int r){
cout
<<"fun(i,j,k,r)"
}
};
class Parent
{
public:
int abc(){
return 0;
}
};

class Son:public Parent
{
public:
int abc(){//非虚函数重写--->重定义
cout<<"abc"<<endl;
return 1
}
};
class Parent
{
public:
virtual void fun(int i,int j){
cout
<<"func3()"<<endl;
}
};

class Son:public Parent
{
public:
virtual void fun(int i,int j){//与父类相同,這是虚函数重写
cout<<"fun(int i,int j)"<<endl;
}
}

  注意:以下问题

class Parent
{
public:

virtual void fun(){
cout
<<"func1()"<<endl;}
virtual void fun(int i,int j,int k,int r){
cout
<<"fun(i,j,k,r)"}
};

class Son:public Parent
{
public:
virtual void fun(int i,int j,int k){
cout
<<"func(int i,int j,int k)"<<endl;}
};

void main(int argc, char const *argv[])
{
Son s;
s.fun();
//这会调用哪个?
s.fun(1,2,3,5);//编译会咋样?
return 0;
}

  分析:上面执行编译都不能通过。我们慢慢分析,s.fun(),这里会报错,为什么呢?原因很简单,编译器首先会在子类的名称空间找,看自己有没有叫fun()的函数,如果有名字没fun()函数,就会执行自己的,至于参数个数问题,编译器可不管。它只管有没有叫這个名字,所以,在编译的时候,父类同名函数将被名称覆盖。如果想调用父类的函数,我们只有通过域作用符来调用s.parent::fun();

     同理s.fun(1,2,3,5);也不会执行父类的!

五、多态原理探究

  1.VPTR指针与虚函数表

  这里我们主要来探究一下,编译器在什么地方动了手脚,从而支持了多态?

  从一段代码来分析:

  下面代码,我把C++编译器可能动手脚地方标注出来,看看编译器到底是在什么时候就实现不同对象能调用同名函数绑定关系。

class Parent{
public:
Parent(
int a=0){
this->a = a;}
virtual void print(){ //地方1
cout<<"parent"<<endl;}
private:
int a;
};

class Son:public Parent{
public:
Son(
int a=0,int b=0):Parent(a){
this->b = b;}
void print(){
cout
<<"Son"<<endl;}
private:
int b;
};

void play(Parent *p){ //地方2
p->print();}

void main(int argc, char const *argv[])
{
Parent p;
//地方3
Son s;
play(
&s)
return 0;
}

  在地方1处,我们知道,既然函数被声明了virtual,编译器会做特殊处理,会不会是这里确定了绑定关系呢?

  在地方2处,我们知道,当传来子类对象, 执行子类函数, 传来父类对象,执行父类对象,多态就是在这里发生的,那会不会在这里呢?

  但是,恰恰我们最容易忽视就是在地方3处,真正确定绑定关系的地方,就是创建对象的时候!!这时候C++编译器会偷偷的给对象添加一个vptr指针。

  只要我们在类中定义了virtual函数,那么我们在定义对象的时候,C++编译器会在对象中存储一个vptr指针,类中创建的虚函数的地址会存放在一个

虚函数表中,vptr指针就是指针這个表的首地址。

       深入剖析C++多态、VPTR指针、虚函数表

      深入剖析C++多态、VPTR指针、虚函数表

 

void play(Parent *p){ 
p
->print();
}

  在发生多态的地方,也就上面的,编译器根本不会去区分,传进来的是子类对象还是父类对象。而是关心print()是否为虚函数,如果是虚函数,就根据不同对象的vptr指针找属于自己的函数。而且父类对象和子类对象都会有vptr指针,传入对象不同,编译器会根据vptr指针,到属于自己虚函数表中找自己的函数。即:vptr--->虚函数表------>函数的入口地址,从而实现了迟绑定(在运行的时候,才会去判断)。

  如果不是虚函数,那么这种绑定关系在编译的时候就已经确定的,也就是静态联编!

  这里,关于虚函数表要说明两点:

  说明1:通过虚函数表指针VPTR调用重写函数是在程序运行时进行的,因此需要通过寻址操作才能确定真正应该调用的函数。而普通成员函数是在编译时就确定了调用的函数。在效率上,虚函数的效率要低很多。

  说明2:出于效率考虑,没有必要将所有成员函数都声明为虚函数

  说明3 :C++编译器,执行play函数,不需要区分是子类对象还是父类对象

  最后,我们来总结一下多态的实现原理   

  • 当类中声明虚函数时,编译器会在类中生成一个虚函数表
  • 虚函数表是一个存储类成员函数指针的数据结构
  • 虚函数表是由编译器自动生成与维护的,virtual成员函数会被编译器放入虚函数表中

 

   2.如何证明VPTR指针的存在

class Parent1{
public:
Parent1(
int a=0){
this->a = a;}
void print(){
cout
<<"parent"<<endl;}
private:
int a;};
class Parent2{
public:
Parent2(
int a=0){
this->a = a;}
virtual void print(){
cout
<<"parent"<<endl;}
private:
int a;};

void main(int argc, char const *argv[]){
cout
<<"Parent1"<<sizeof(Parent1)<<endl; //4
cout<<"Parent2"<<sizeof(Parent2)<<endl; //8
return 0;
}

六、深入剖析VPTR指针

  问题:构造函数中能调用虚函数,实现多态么?

  等价于:对象中的vptr指针什么时候初始化?

  我们看一段代码:

class Parent{
public:
Parent(
int a=0){
this->a = a;
print();}
virtual void print(){cout<<"Parent"<<endl;}
private:
int a;
};
class Son:public Parent{
Son(
int a=0,int b=0):Parent(a){
this->b = b;
print();}
virtual void print(){cout<<"Son"<<endl;}
};
void main(int argc, char const *argv[]){
Son s;
return 0;
}

  当我们定义对象的时候,会执行构造函数,但是,在构造函数里面,我们调用了虚函数print(),那么这里的print()会执行哪个?会发生多态么??

  测试发现:两个类中构造函数中,都只会调用自己类中的print()函数。

  为什么会这样?为什么没有发生多态?

  深入剖析C++多态、VPTR指针、虚函数表