查看原文
其他

探索C++虚函数在内存中的表现形式及运行机制(二)

2017-10-06 KevinsBobo 看雪学院



实践:手动实现简单的虚表跳转功能


在了解虚表原理后我们通过不使用virtaul关键字的方法来实现和使用虚函数同样地效果。仅仅实现上面代码的效果:通过CSoldier指针调用不同兵种的攻击方法

  1. 定义一个函数指针类型vtFunPtr

  2. 在每个类(子类和父类)中都放一个静态函数指针数组m_vtArr

  3. 在父类数据成员中增加一个指向函数指针数组的指针m_vfPtr,子类不能再声明与父类同名的m_vfPtr数据成员,(因为这样虽然覆盖了父类的同名数据成员,但是父类的数据成员还是会出现在子类内存中,在使用时就会出现问题)

  4. 规定好成员函数在函数指针数组中的顺序

  5. 初始化每个静态指针数组m_vtArr

  6. 在每个类(子类和父类)的构造中将自己的静态指针数组m_vtArr地址赋给m_vfPtr

  7. 按照约定调用


代码:


#include <iostream>

 

class CSoldier;

// 定义成员函数指针

typedef void (CSoldier::*vtFunPtr)();

 

class CSoldier

{

public:

  CSoldier()

    : m_nBlood(0x20)

  {

    m_vfPtr = m_vtArr;

  }

 

  ~CSoldier()

  {

    std::cout << "~CSolder()" << std::endl;

  }

 

  void attack()

  {

    std::cout << "Soldier Attack!" << std::endl;

  }

 

  void run()

  {

    std::cout << "Soldier Run!" << std::endl;

  }

 

  // 虚表指针

  vtFunPtr* m_vfPtr;

  // 虚表

  static vtFunPtr m_vtArr[ 2 ];

 

protected:

  int m_nBlood;

};

 

class CGuner

  : public CSoldier

{

public:

  CGuner()

    : CSoldier() , m_nBlood(0x30) , m_nTest(0x10)

  {

    m_vfPtr = m_vtArr;

  }

 

  ~CGuner()

  {

    std::cout << "~CGuner()" << std::endl;

  }

 

  void attack()

  {

    std::cout << "Guner Attack!" << std::endl;

  }

 

  void run()

  {

    std::cout << "Guner Run!" << std::endl;

  }

 

  // 虚表

  static vtFunPtr m_vtArr[ 2 ];

 

protected:

  int m_nBlood;

  int m_nTest;

};

 

class CKnight

  : public CSoldier

{

  public:

  CKnight()

    : CSoldier() , m_nBlood(0x30) , m_nTest(0x10)

  {

    m_vfPtr = m_vtArr;

  }

 

  ~CKnight()

  {

    std::cout << "~CKnight()" << std::endl;

  }

 

  void attack()

  {

    std::cout << "Knight Attack!" << std::endl;

  }

 

  void run()

  {

    std::cout << "Knight Run!" << std::endl;

  }

 

  // 虚表

  static vtFunPtr m_vtArr[ 2 ];

 

protected:

  int m_nBlood;

  int m_nTest;

};

 

// 初始化虚表

vtFunPtr CSoldier::m_vtArr[] = { &CSoldier::attack , &CSoldier::run };

vtFunPtr CGuner::m_vtArr[] = { (vtFunPtr)&CGuner::attack , (vtFunPtr)&CGuner::run };

vtFunPtr CKnight::m_vtArr[] = { (vtFunPtr)&CKnight::attack , (vtFunPtr)&CKnight::run };

 

int main()

{

  // 创建多个不同种类的士兵

  CGuner*  pGunerA  = new CGuner;

  CGuner*  pGunerB  = new CGuner;

  CKnight* pKnightA = new CKnight;

  CKnight* pKnightB = new CKnight;

 

  // 将需要操作的士兵放进一个其父类指针数组里

  const int nSoldierNum = 4;

  CSoldier* const pAllSoldier[ nSoldierNum ] =

  { pGunerA, pGunerB, pKnightA, pKnightB };

 

  // 调用攻击方法

  for(int i = 0; i < nSoldierNum; ++i)

  {

    // 实现类似有虚函数时 pAllSoldier[i]->attack(); 同样地效果

    (pAllSoldier[i]->*(pAllSoldier[i]->m_vfPtr[0]))();

    (pAllSoldier[i]->*(pAllSoldier[i]->m_vfPtr[1]))();

 

    // 以上代码可以拆解为下面的方式,以便于理解:

    /*

     // 获取一个士兵对象

     CSoldier* pSoldier = pAllSoldier[ i ];

     // 获取这个士兵对象的攻击方法地址

     vtFunPtr pFun = pSol->m_vfPtr[ 0 ];

     // 通过下面变态的方式调用获取到的方法

     (pSoldier->*pFun)();

     */

  }

 

  return 0;

}


输出:

 

增加了这么多代码只模拟了最简单的虚函数调用,能够想像到编译器在背后做了多么的处理!


虚函数与继承需要注意的问题


这里只是强调一些使用的概念,可以跳过 ^_^


需要明确的虚函数使用问题


➡️ 虚函数的间接调用


编译器在编译时对于调用没有加virtual关键字的对象方法是直接通过函数地址来调用的,也就是说普通方法的调用是直接将地址写在调用位置的,称作直接调用 ;那在有了virtual关键字之后再通过指针或引用调用 时,编译器在编译时肯定不会直接写,因为它需要查表才能知道要调用哪个方法,所以称作间接调用 ,需要注意,只有通过指针或引用来调用才会发生间接调用!

 

那么证实上面说的直接调用和间接调用呢?看反汇编调用代码:

 

测试代码:



反汇编:

 

虚表:

 

 

 


  • 第一条调用语句对应的反汇编call指令后直接写了一个地址,这个地址是CGuner类attack()方法的地址,所以这里是直接调用


  • 第二条调用语句对应的汇编代码比较多,我们只注意蓝线框部分,发现它寄存器中取出一个值并偏移4字节后再调用,所以这么多汇编代码就是找到虚表指针,然后从找到虚表之后再调用对应的函数。偏移4字节是因为attack()方法在虚表中处于第二个位置,一个位置4字节,所以从表头偏移4字节


  • 第三条调用语句和第二条类似,不同的地方是偏移了8字节,因为run()方法在虚表中处于第三个位置


以上就是间接调用了,总结一下:只有对于通过指针或引用的方式调用虚函数才是间接调用,有个特例pGuner->CGuner::attack(); 或 pGuner->CSoldier::attack()这样的调用是直接调用。

 

这时有个疑问:通过普通函数调用虚函数是直接调用还是间接调用?

 

我们在CSoldier类中增加一个普通成员方法funTest()来调用攻击方法:


测试代码:


输出结果: 

 

结果竟然是正确的,所以来看一下反汇编:

 

我们看到是间接调用的,得到结论:在普通成员函数中调用虚函数也是间接调用。


➡️ 函数的重载、覆盖和隐藏在继承的虚函数中的表现


先用张图说下C++编译器函数匹配的过程:

 

 

再上张表来说明这三者的区别:


函数名
作用域参数返回值

重载

相同

相同

不同

不影响

覆盖

子类和父类

相同

相同

相同

隐藏

重叠(包括子类和父类)

相同

不同

不影响

 

现在根据一图一表应该就能够想到函数重载、覆盖和隐藏在继承的虚函数中的表现了

 

简单总结一下关键点:子类有个和父类同名同参的虚函数,会覆盖;子类有个和父类同名但不同参的虚函数,会隐藏(此时无论是对于子类来说无论是直接调用还是间接调用都调不到父类同名函数)。


➡️ 数据成员在继承中的表现


结合之前对于继承后的类内存的分析再强调一下:父类的所有数据成员都会出现在子类的内存中,无论有没有被覆盖。

 

2017-05-12补充:但是要注意,在通过父类指针调用子类覆盖父类的非虚函数(无多态性)时,这个非虚函数获取到的数据成员还是父类的!具体例子可以看文章后面补充的那个问题里面数据成员的表现。



在复杂情况下编译器对虚函数(虚表指针、虚表)的实现



构造和析构时编译器对多态性的约束


通过前面一系列的实验,我们知道,在子类构造和析构时,虚表指针指向的都是子类的虚表,所以在子类的析构和构造中调用虚函数不会出现错误调用的情况

 

那如果实在子类构造之前的父类的构造时调用虚函数呢?


  • 通过前面的实验,我们也知道在进入父类构造是虚表指针指向的是父类的构造,所以在这里调用虚函数也是不会出错的,所以直接看一下在CSodier构造中调用attack()的反汇编:


  • 没看错,就是是直接调用!想一想,在构造函数中绝对是调用当前类自己的方法,所以根本不需要间接调用。


  • 那么要是在父类构造函数中通过普通函数调用虚函数会不会是直接调用?还是使用funTest()方法来测试:


    这时就是间接调用了

  • 那在父类的析构中调用虚方法呢?因为在进析构前虚表指针指向的是子类虚表,这时编译器会怎样操作才保证正确调用呢?


  • 就不上图了,直接说编译器的操作吧:编译器在每进一个析构时都会将虚表指针指向当前类的虚表 比如:析构顺序是先子类再父类,在进子类析构时会将虚表指针指向子类虚表(虽然本来指向的就是子类虚表,但还是会再为虚表指针进行一次赋值操作),出子类析构、进父类析构时就会将父类虚表指针指向父类虚表。并且在析构中调用虚方法也是直接调用。


多重继承下的虚函数与虚表


实现代码


多重继承可理解为一个子类继承了多个父类。

 

用沙发、床作为父类,用可在两者之间相互转换沙发床作为子类,以下是代码描述:

#include <iostream>

 

class CSofa

{

public:

  CSofa()

    : m_nColor(0x01)

  {

    /* Nothing todo */

  }

 

  virtual ~CSofa()

  {

    std::cout << "~CSofa()" << std::endl;

  }

 

  virtual void site()

  {

    std::cout << "CSofa: Site" << std::endl;

  }

 

private:

  int m_nColor;

};

 

class CBed

{

public:

  CBed()

    : m_nColor(0x02)

  {

    /* Nothing todo */

  }

 

  virtual ~CBed()

  {

    std::cout << "~CBed()" << std::endl;

  }

 

  virtual void sleep()

  {

    std::cout << "CBed: Sleep" << std::endl;

  }

 

private:

  int m_nColor;

};

 

class CSofaBed

  : public CSofa,

    public CBed

{

public:

  CSofaBed()

    : CBed() , CSofa() , m_nColor(0x03)

  {

    /* Nothing todo */

  }

 

  virtual ~CSofaBed()

  {

    std::cout << "~CSofaBed()" << std::endl;

  }

 

private:

  int m_nColor;

};


子类内存表现


无虚函数时

 

 

我们看到两个父类的数据成员都出现在了子类的内存中,并且是按照子类继承列表的顺序排列的。


有虚函数时

 

 

我们看到此时子类内存中多了两个地址数据,不需要进去查看里面是什么就能够猜到肯定是两个虚表指针。里面存的是什么呢?

 

 

 

 

 

 

 

通过内存分析发现,两张表中的第一项都存放了子类的析构(两张虚表中子类析构的地址是不同的,因为这不是真的析构,而是经过一层包装的析构,在这里将其理解为析构),第二项存放的是子类继承父类的虚方法的地址

 

所以可以推测出如果子类重写了对应父类的虚方法,那么对应的虚表中的内容也就变化了。


对多重继承下虚表指针和虚表问题的探索


懒得上图了,直接文字描述吧 ^_^

  1. 两张虚表中都存了子类的析构地址,那会不会析构两次?

    答案是肯定不会的,不管谁是编译器的作者,他都不会让这样的事情发生的

  2. 在子类中新增一个两个父类都没有的虚函数,它会出现在哪张虚表的哪个位置?

    出现在内存中第一个对象的虚表指针指向的虚表的尾部,不会出现在第二个对象的虚表指针指向的虚表里

  3. 子类继承的两个父类中有一个同名函数,通过不同的父类指针调用子类对象的该同名虚函数时会不会出问题?


    不会出问题,且正确调用,根据函数匹配规则,只在一个父类的函数中寻找匹配。那编译器是如何实现这一点的呢?


    通过变量窗口发现子类对象经过转换后的两个父类指针的竟然指向的不是同一个位置(第一个父类CSofa指针指向的是子类内存的首地址,第二个父类CBed指针指向的是内存中第二张虚表的首地址,还是看下图吧,理解更清晰一点),所以两个父类虚表指针都指向的是自己的虚表,说明在这里编译器为了获取两个父类各自的虚表指针而进行了偏移操作,而不是直接拿出内存中的第一个虚表指针来使用。

    但在子类没有覆盖两个父类的同名方法的前提下,通过子类直接调用或间接调用该方法编译器会报二义性错误(就算两个父类方法同名不同参也不行,根据前面的函数匹配过程和重载、覆盖、隐藏的概念来理解)


  4. 接上个问题,如果子类对象是new出来的,然后通过两个不同的父类指针分别delete该对象是否会正常调用析构?(分两次尝试,而不是同时delete两个父类指针)


  5. 首先,通过第一个父类对象指针delete是不会有问题的,第一:还回去的空间首地址还是申请时的空间首地址,第二:一定会通过虚表正常调用析构。


    那如果是通过第二个父类对象指针delete时,还回去的空间首地址就不是申请是的空间首地址了,但是实际中还是正常调用析构和释放空间,看来是编译器在delete时又帮我们把指针偏移回去了 ^_^



在菱形继承与虚继承下的表现


普通实现代码


套用上面的例子:沙发和床都是家具,所以应该再创建一个家具类,让这两个类继承家具类,然后沙发床类再继承这两个类,就构成了菱形继承

 

代码描述:

#include <iostream>

 

class CFurnitrue

{

public:

  CFurnitrue()

    : m_nColor(0x00) , m_nTest(0xFF)

  {

    /* Nothing todo */

  }

 

  virtual ~CFurnitrue()

  {

    std::cout << "~CFurnitrue()" << std::endl;

  }

 

  virtual int getColor()

  {

    return m_nColor;

  }

 

protected:

  int m_nColor;

private:

  int m_nTest;

};

 

class CSofa

  : public CFurnitrue

{

public:

  CSofa()

    : CFurnitrue() , m_nTest(0x01)

  {

    /* Nothing todo */

  }

 

  virtual ~CSofa()

  {

    std::cout << "~CSofa()" << std::endl;

  }

 

  virtual void site()

  {

    std::cout << "CSofa: Site" << std::endl;

  }

 

private:

  int m_nTest;

};

 

class CBed

  : public CFurnitrue

{

public:

  CBed()

    : CFurnitrue() , m_nTest(0x02)

  {

    /* Nothing todo */

  }

 

  virtual ~CBed()

  {

    std::cout << "~CBed()" << std::endl;

  }

 

  virtual void sleep()

  {

    std::cout << "CBed: Sleep" << std::endl;

  }

 

private:

  int m_nTest;

};

 

class CSofaBed

  : public CSofa,

    public CBed

{

public:

  CSofaBed()

    : CBed() , CSofa() , m_nTest(0x03)

  {

    /* Nothing todo */

  }

 

  virtual ~CSofaBed()

  {

    std::cout << "~CSofaBed()" << std::endl;

  }

 

  virtual void lie()

  {

    std::cout << "CSofaBed: Lie" << std::endl;

  }

 

private:

  int m_nTest;

};


继承逻辑图:

 


内存结构

CSofaBed test;


首先来看没有虚函数时的CSofaBed内存情况:

 


 

通过内存发现,CFurnitrue的数据成员在CFofaBed的内存中出现了两次,这就有一些疑问了,CFofaBed在使用它可以访问的CFurnitrue数据成员时到底去访问哪个?这个疑问留到下一节来探索

 

有虚函数时CSofaBed内存情况:

 

 

 

内存中多了两张虚表,毫无疑问,这和多重继承的情况一样


  • 第一张虚表:

仔细观察就会发现,这其实就是一个三层继承的虚表


  • 第二张虚表:


将这两张虚表和多重继承的一对比就会发现就是多重继承的虚表中多了一个CFurnitrue类的虚函数。


问题分析


接上边的疑问,首先子类内存中有两个m_nColor的数据,两个虚表中还各有一个getColor()虚函数地址,那分别访问这两个会出现什么问题?

  • 测试:

  • 报getColor();二义性错误

  • 测试:

    在CSofaBed中覆盖getColor(),再次调用:

    报return m_nColor;二义性错误

  • 测试:

    在CSofaBed中覆盖m_nColor和getColor(),再次调用:

    正常


三次测试,前两次错误,最后一次正确,但就算最后一次正确了,结果也不是我们想要的,因为在内存中CSofaBed的m_nColor和CFurnitrue的m_nColor不是同一个(回顾前面描述的数据成员在继承中的表现)

 

另外,就算通过一些方法避免了上面的错误,但是内存中出现了很多冗余,这是不希望发生地事情。


虚继承


声明方法:


为了解决上面遇到的问题C++提供了一个虚继承的方法:在继承父类的声明前加virtual关键字:

为什么要加在CSofa和CBed继承CFurnitrue声明前呢?

 

我们回顾一下前面菱形继承的内存情况,冲突是发生在这两个类继承了CFurnitrue时,并不是CSofaBed继承这两个类时。


内存表现


我们来看下这样声明后CSofaBed的内存:

  • 无虚函数时:


在此时的内存中发现CFurnitrue的数据成员出现在了整个内存的最后面,并且只有一份。


另外还发现内存中多了两个指针数据,分别查看它们指向的内存发现,指向的内存前4字节都是0(可以不用考虑这4字节的内容),后4字节是一个16进制数字,这是一个偏移值:将这两个值分别与指向偏移值指针地址相加0x0012FF0C + 0x14 == 0x0012ff20; 0x0012FF14 + 0x0C == 0x0012FF20;得到的都是CFurnitrue数据成员在CSofaBed内存中的首地址


所以编译器一定是在背后通过地址的偏移来获得菱形虚继承最顶端类的数据成员地址,然后再进行相应的操作,从而避免了数据冗余。


为什么有两个偏移值呢?因为时两个类继承了CFurnitrue然后CSofaBed再继承这两个类,回顾多重继承时,将子类指针转换为不同父类指针后发现有一个父类指针是经过偏移的地址,说明在菱形继承中将子类指针转换为中间两个父类指针时其中一个父类指针也会进行编译操作,因此这里写了两个偏移值,以保证通过中间两个父类指针操作子类对象不会出错


  • 有虚函数时:


这个内存结构就比较奇怪了,相比上面没有虚函数的内存结构,我们发现,除了每个指向偏移值地址的指针上面分别多了一个地址外,父类数据成员前面也多了一个指针。


首先我们看偏移地址:(偏移地址前面4字节的0变成了0xFFFFFFC,还是看不出这4字节代表什么,我们暂且认为它代表有没有虚函数)将两个偏移值分别和指向偏移值指针地址相加0x0012FF04 + 0x18 == 0x0012FF1C; 0x0012FF10 + 0x0C == 0x0012FF1C;得到的结果是父类数据成员前面哪个地址值的首地址(间接说明这个新出现的地址值属于CFurnitrue类的内容)


这时来看指向偏移值地址的指针上面的那个指针,发现它们分别指向一块儿存函数地址的空间,所以这个指针就是虚表指针了,指向的地址的内容就是虚表了(再看一眼父类数据成员前面的指针,它也是虚表指针)。但是我们看到前两个虚表指针指向的虚表的形态除了没有虚析构的地址,其他内容都和多重继

承中两个虚表的内容一样。那虚析构去哪儿了呢?


再看第三张虚表,发现,虚析构在了这里!并且CFurnitrue的虚函数地址也在这里!说明这里代表的是CFurnitrue的虚表了,所以经过偏移值偏移到这里就一点都不奇怪了。另外,CSofaBed的析构存在这里也说明整个类在构建过程中(进入不同的构造、析构时)这里的虚表指针都会指向当时操作对应的类的虚表(这张虚表主要存储的是对应类的虚析构地址,和CFurnitrue的虚函数地址,不要和前两张虚表搞混了)(这里要跟的内存太多了,一个个截图有些吃不消就不放截图了,感兴趣的话自己跟一下内存 ^_^)


如果CSofaBed覆盖了CFurnitrue的方法会出现在哪张虚表中?看内存图揭晓答案:



  • 只有虚析构时:


首先我们看到这时的内存那情况仅仅比没有一个虚函数的情况多了一个指针数据,而且偏移值上面4字节的内容也是0。这个指针数据肯定是用来存虚析构的虚表了,结合上面有虚函数的内存分析就清楚这里的确只需要一张虚表就够了



总结


由于篇幅已经够长了(读者不能耐心看下来就说明我的描述有问题 :<)所以就没有再探索上面提到的各种情况的各种变态组合了,但是不管怎样组合都离不开已经总结出来的规律,比如菱形继承中虚表的很多规律就和多重继承的一样。所以就写到这里了 ^_^

 

看了这么多内存,第一感觉就是编译器对虚函数的实现真复杂(编译器作者真伟大)!既然这么复杂,又要这么多的指针偏移、跳转、寻址的才实现想要的功能,那是不是要考虑速度问题了?


  • 在LeetCode或牛客网刷过题的朋友肯定有感受,在需要大量输入输出时如果使用了std::cout; std::cin;就会导致程序运行超时,一部分原因是iostream就是一个菱形继承的例子,来看整个输出输入流的继承关系图:

不过在开发中,程序写的不合理所带来的开销远远大于使用虚函数时的开销。但是如果可以不需要虚函数就可以实现的功能(比如说通过组合来实现上面沙发床的功能),就尽量不要使用继承了,多写些代码就能提高效率,又避免了继承的问题,也是不错的。



补一个有趣的小坑


2017-05-12补充: 听说在子类的构造中主动调自己的析构,虚表指针会抽风。。。


问题描述

直接上代码:

#include <iostream>

 

class CBase

{

public:

  CBase()

    : m_nData(0)

  {

    std::cout << "CBase()" << std::endl;

  }

 

  virtual ~CBase()

  {

    std::cout << "~CBase()" << std::endl;

  }

 

  virtual void echoData()

  {

    std::cout << "CBase::m_nData = " << m_nData << std::endl;

  }

 

protected:

  int m_nData;

};

 

class CDerive

  : public CBase

{

public:

  CDerive()

    : CBase()

  {

    m_nData = 10;

    std::cout << "CDerive()" << std::endl;

    CDerive::~CDerive(); // 在构造中主动调用析构

    echoData(); // 在构造中尝试调用一次自己的函数

    /*

     * 正常输出应是:CDerive::m_nData = 10

     */

  }

 

  virtual ~CDerive()

  {

    std::cout << "~CDerive()" << std::endl;

  }

 

  virtual void echoData()

  {

    std::cout << "CDerive::m_nData = " << m_nData << std::endl;

  }

 

private:

  int m_nData; // 子类隐藏父类数据成员

};

 

int main()

{

  CDerive obj;

  CBase* pBase = &obj;

 

  pBase->echoData();

  /*

   * 正常输出应是:CDerive::m_nData = 10

   */

 

  obj.echoData();

  /*

   * 正常输出应是:CDerive::m_nData = 10

   */

 

  return 0;

}


注意,在子类构造中主动调用了一次自己的析构(调用析构函数并不会对对象的数据成员产生影响)

 

实际输出:

 

看到输出的异常了吧,通过父类指针调用的虚函数竟然还是父类的函数(数据成员的输出也是错误的),所以在这里并没有多态性!

原因分析

通过上面的例子我们发现,在子类的构造中主动调用了一次自己的析构出现了问题,那么我们来看看这个过程中虚表发生的变化:

 

根据前面的分析我们知道虚表指针会在进入析构的时候刷新一次,并且调用子类的析构后还会调用其父类的析构

 

现在在子类构造中调用自己的析构,单步分析:

  • 单步进入子类析构,查看内存:

    虚表表指针是正确的

  • 然后单步进入父类析构,查看内存:

    也是正常的

  • 离开父类析构,回到子类构造时,再查看内存(和上图结果一模一样!)


现在问题清楚了,原来是在子类构造中调用自已的析构,虚表指针会在对应的析构中动态变化,而到了最后一层父类析构中刷新为该父类的虚表指针后再离开该析构函数后虚表指针并没有变回子类的(这是可以理解的,因为需要用到析构时肯定是对象要销毁时,所以没必要在离开最后一层父类析构时再将虚表指针刷回父类)

 

ps: 正常情况下是不会发生上面的情况的,因为谁会没事儿调自己的析构呢,哈哈





本文由看雪论坛 KevinsBobo 原创

转载请注明来自看雪社区

热门阅读


点击阅读原文/read,

更多干货等着你~


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存