【C++要笑着学】虚函数表(VBTL) | 观察虚表指针 | 运行时决议与编译时决议 | 动态绑定与静态绑定 | 静态多态与动态多态 | 单继承与多继承关系的虚表(一)

简介: 虚表是编译器的实现,而非C++的语言标准。上一章我们学习了多态的概念,本章我们深入探讨一下多态的原理。文章开头先说虚表指针,观察编译器的查表行为。首次观察我们先从监视窗口观察美化后的虚表 _vfptr,再透过内存窗口观察真实的 _vfptr。我们还会探讨为什么对象也能切片却不能实现多态的问题。对于虚表到底存在哪?我们会带着大家通过一些打印虚表的方式进行比对!铺垫完虚表的知识后,会讲解运行时决议与编译时决议,穿插动静态的知识点。文章的最后我们会探讨单继承与多继承的虚表,多继承中的虚表神奇的切片指针偏移问题,这块难度较大,后续我们会考虑专门讲解一下,顺带着把钻石虚拟继承给讲了

💭 写在前面


虚表是编译器的实现,而非C++的语言标准。上一章我们学习了多态的概念,本章我们深入探讨一下多态的原理。文章开头先说虚表指针,观察编译器的查表行为。首次观察我们先从监视窗口观察美化后的虚表 _vfptr,再透过内存窗口观察真实的 _vfptr。我们还会探讨为什么对象也能切片却不能实现多态的问题。对于虚表到底存在哪?我们会带着大家通过一些打印虚表的方式进行比对!铺垫完虚表的知识后,会讲解运行时决议与编译时决议,穿插动静态的知识点。文章的最后我们会探讨单继承与多继承的虚表,多继承中的虚表神奇的切片指针偏移问题,这块难度较大,后续我们会考虑专门讲解一下,顺带着把钻石虚拟继承给讲了。


Ⅰ. 虚函数表(VTBL)


0x00 引入:发现虚表的存在

❓ 我们首先来做一道题:sizeof(Base) 是多少(32位下)?

class Base {
public:
  virtual void Func1() {
  cout << "Func1()" << endl;
  }
private:
  int _b = 1;
};
int main(void)
{
    Base b;
    return 0;
}

💡 答案:答案令人诧异,居然是 8。


通过监视窗口我们发现除了 _b 成员外还有了一个 _vfptr 在 b1 对象中:

b90b53af37035e91d72593ad4f2c1190_44cc04d0fb324873b887faab8e2d53ff.png


不监视不知道,一监视吓一跳。这个 _vfptr 是个什么 √8 玩意?


对象中的这个 _vfptr 我们称之为虚表指针(virtual function pointer),我们简称其为 虚表 。


一个含有虚函数的类中都至少有一个像这样的虚函数表指针,虚函数地址都会放到这个表里。


那么虚函数表中放了些什么呢?我们继续往下看。


💬 为了方便演示,我们再多整点函数:

class Base {
public:
  void Func1() {
  cout << "Func1()" << endl;
  }
  virtual void Func2() {
  cout << "Func2()" << endl;
  }
  virtual void Func3() {
  cout << "Func3()" << endl;;
  }
private:
  int _b = 1;
};

63422c45ccd464f5f0eff5513272faa0_9df03b81cf904734b2ff7633456cf682.png

通过监视窗口我们可以看到,虚函数 Func2 和 Func3 都被存进了 _vfptr 中。


虚表虚表,自然是存虚函数的表了,Func1 不是虚函数,自然也就不会存入表中。


0x01 观察虚表指针 _vfptr

❓ 思考:多态是怎么做到指向哪就调用哪的?对于父类的虚表又是什么样的呢?


💬 代码:我们用的是 VS2013 + 64位 环境去观测:


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;
};
int main(void)
{
  cout << sizeof(Base) << endl;
  Base b;
  return 0;
}


🔍 监视:我们还是先用监视窗口去做一个简单的观察:

306bcd82b681857ee2283c9f443d876b_19b7642a6759454498ae762b2070f4e2.png

监视窗口是为了方便我们观测优化过的,相当于是一种美化。


注意看,Func3 没有放在 _vfptr 中,又一次证明了这个表里只会存虚函数。


其实虚函数表也没搞什么特殊,也没什么了不起的,虚函数其实是和普通函数一样存在代码段的。


只是普通函数只会进符号表以方便链接,都是 "编译时决议",


而虚函数的地址会被放进虚表,是为了 "运行时决议" 做准备,这个我们后面会细说。


所以这里我们可以这么理解:

735ede6b893c0b74cf4caa529c90fe76_9b288b06e6ac49caa1591a0ea476c8e7.png

📚 虚表的本质:虚表是一个 "存虚函数指针的指针数组" ,一般情况这个数组最后面会放一个空指针。


0x02 虚函数的重写与覆盖

回忆一下,上一章我们介绍重写的时候还说过,"重写" 还可以称为 "覆盖",


这是为什么呢?叫重写似乎更好去理解,覆盖好像很难去理解啊。


💬 代码:现在我们增加一个子类 Derive 去继承 Base:

// 父类 Base
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;
};
// 子类 Derive
class Derive : public Base {
public:
  virtual void Func1() {
  cout << "Derive::Func1()" << endl;
  }
private:
  int _d = 2;
};
int main(void)
{
  cout << sizeof(Derive) << endl;
  Derive d;
  return 0;
}

🚩 运行结果:

cd764583e60eab0e1e419889a640e2cc_c0731df4dd324d83a3214067848ba65e.png

(如果没有虚表这里会是8)


🔍 监视:我们再通过监视窗口观察

8b75c8ff3559fb02f8e9dbddea329678_ab71e308a1414be3b66ff94343570e14.png

和父类的相对比,冷静分析后不难发现:

44cd33854167c80a9b3263a02b34a1fb_535147a7579846868967514f000bb234.png


父类 b 对象和子类 d 对象虚表是不一样的,这里看我们发现 Func1 完成了重写,


所以 d 的虚表中存的是重写的 Derive::Func1,所以虚函数的重写也叫做覆盖。


就可以理解为:子类的虚表拷贝了父类的虚表,子类的 Func1 覆盖掉了父类上的 Func1。


(覆盖指的是虚表中虚函数的覆盖)


虚函数重写:语法层的概念,子类对继承父类虚函数实现进行了重写。

虚函数覆盖:原理层的概念,子类的虚表,拷贝父类虚表进行了修改,覆盖重写那个虚函数。

🔺 总结:虚函数的重写与覆盖,重写是语法层的叫法,覆盖是原理层的叫法。


0x03 编译器的查表行为

❓ 思考: 是如何做到指针指向谁就调用谁的虚函数的?好像非常的听♂话:

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;
  }
private:
  int _d = 2;
};
int main(void)
{
  Base b;
  Derive d;
  Base* ptr = &b;  
  ptr->Func1();   // 调用的是父类的虚函数
  ptr = &d;
  ptr->Func1();   // 调用的是子类的虚函数
  return 0;
}

🚩 运行结果:

f22a2de0416a1967c988e6ef222a200b_0db7f1679e0b4ce09ae9dfd8a32557cc.png

能不能猜到是跟虚表有关系?它到底要调用哪个函数不是按类型去定的,


如果是按类型去定的那这里调的应该都是父类,结果会都是 Base::Func1() ,所以显然不是。


这里会去 ptr 指针指向的对象里去查表,其实对它自己而言它自己都也不知道调用的是谁,


因为子类切个片,它自己也只能看到父类对象,它根本就没法知道,但是他会查表!


📚 具体行为如下:


编译器会从指向的对象里去找,先在父类对象里找到了 Base::Func1,

Base* ptr = &b; // 指向是b,是父类Base的
  ptr->Func1();   // 调用的是父类的虚函数

然后指向变为 &d,它就从子类对象里找,从而找到了 Derive::Func1。

ptr = &d;       // 指向变成d了,是子类Derive的
ptr->Func1();   // 这时调用的就是子类的虚函数了

所以,多态调用实现是依靠运行时去指向对象的虚表中查,调用函数地址。


0x04 探讨:对象也能切片,为什么不能实现多态?

既然指针和引用可以实现多态,那父类赋值给子类对象也可以切片,


为什么实现不了多态?搞歧视?

Base* ptr = &d;    ✅
Base& ref = d;     ✅ 
Base b = d;    ❓ 为什么不行?都是支持切片的,为什么对象就不行?

从编译器的角度,编译器实现时会判断构不构成多态,不满足规则不构成多态就找到地址,call。


至于为什么实现不了多态,因为实现出来会出现混乱状态。


"即使你是一门语言的设计者,遇到这种问题也很难解决 "


根本原因是:对象切片时,子类对象只会拷贝成员给父类对象,并不会拷贝虚表指针。


因为拷贝了就混乱了,父类对象中到底是父类的虚表指针?还是子类的虚表指针?


那下面的调用是调用父类的虚函数还是子类的虚函数?就不确定了:

ptr = &b;
ptr->func1();    //  ?????????? 父类的func1,还是子类的func1?

对象实现多态又不得不去拷贝虚表,因为它肯定是需要去对象里的虚表里找,


问题是拷贝虚表后就乱了套了。最大问题是 —— 这时候到底调用的是谁的问题。


如果一个父类对象切片拷贝给子类后,切片前指向子类,没切片前指向父类。


"这让人头大"


所以对象不能实现多态,想实现也不行,实现了就乱了套了!


🔺 总结:


一个类对象中的 __vptr 是恒定的,它永远都会指向其所属类的虚表。

而当使用指针或引用时,__vptr 会指向继承类的虚表(从而达成多态的效果)

0x05 透过内存窗口仔细观察 _vfptr

💬 打开监视窗口观察下列代码的虚表:

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;
  }
  void Func3() {
  cout << "Derive::Func3()" << endl;
  }
  virtual void Func4() {
  cout << "Derive::Func4()" << endl;
  }
private:
  int _d = 2;
};

从监视窗口观察,有时候会发现,好像有些虚函数再监视窗口显示的虚表里不存在。


这时候千万不能动摇 "只要是虚函数都会存入虚表" 这个事实。


这是监视窗口的锅,我们前面就说了 —— 监视窗口是美化过的!


想要看到真实的样子,我们可以打开内存去查看:

d7671ee18093377f4b335bceeed2228d_8f15dc5c80184fde8954df3b01349102.png

但是这内存看的很让人迷糊,这谁看得懂,知道谁是谁?有什么办法可以把虚表打印出来?


💬 只要取到虚表指针,想打印虚表就很简单了:


虚表是个函数指针数组,该数组里的每个元素存放的是一个函数指针。

typedef void(*V_FUNC)();
/* 打印虚表 */
void Print_VFTable(V_FUNC* arr) {
  printf("vfptr:%p\n", arr);
  for (size_t i = 0; arr[i] != nullptr; i++) {
  printf("[%d]: %p\n", i, arr[i]);
  V_FUNC foo = arr[i];
  foo();
  }
}
int main(void)
{
  Derive d;
  Print_VFTable(
  (V_FUNC*)(*((int*)&d))   // 指针之间是可以互相转换的
  );
    /* 
        语法有规定:完全没有关系的类型强转也不行。
        至少得有一点关系:比如指针和int
        因为指针虽然是地址但是也是表示地址的编号,第几个编号的地址
        指针之间可以随意转换,我想取4个字节,&d 是个 Derive*,
        接引用后就是 Derive,此时强转成 int* 解引用就是取4个字节了。
        由于是 int* 要调用 Print_VFTable 函数传不过去,所以我们最外层又
        强转回 V_FUNC* 了,这是一个函数指针数组的地址指针。
        “内线转外线再转内线”
    */
  return 0;
}

🚩 运行结果:

74859f156a11b9935d826f675b3ef521_fcbd0f8b8e61494889e68e1b7268da3d.png

🔺 结论:VS 监视窗口看到的虚函数表不一定是真实的,可能被处理过。


0x06 虚表的存储位置

虚表,一个类型共用一个类型虚表吗?虚表到底存在哪?

int main(void)
{
  Base b1;
  Base b2;
  Base b3;
  Base b4;
  Print_VFTable((V_FUNC*)(*((int*)&b1)));
  Print_VFTable((V_FUNC*)(*((int*)&b2)));
  Print_VFTable((V_FUNC*)(*((int*)&b3)));
  Print_VFTable((V_FUNC*)(*((int*)&b4)));
  return 0;
}

🚩 运行结果:

53262aacdd90d928f5ad32b2b7728446_e246b49dd25e4033ab8492d37260f6c9.png

🔺 结论:同一个类型它们的虚表内存地址都是一样的,同一类型的对象共用一份虚表。


现在我们知道了同一类型的对象公用一张虚表了,我们再来思考虚表存在哪里的问题。


❓ 思考:虚表到底存在哪里?


虚表放在栈上合理吗?显然不合理,放在栈上虚表跟着这个对象走跟着那个对象走,太不稳定了。


虚表最好能够永久存储,我们希望虚表稳稳地存着。


我们说的对象在构造的时候初始化虚表,实际上不是建立虚表,


按理来说编译的时候就已经把虚表建立好了,会在构造函数的初始化列表阶段把地址存进虚表。

d1e00159f958c711c29e428ae920b4a0_8a734ba72cc7443c8711d7b77f8de260.png

此外,不仅要将虚表放置到永久的区域,不能因为某个对象销毁了这个虚表就没了,


那其他对象住哪?他们可是要共用同一张虚表的!!!所以这个虚表要保证一直都在。


并且还要很容易就能找到,那存在堆上可以吗?


不太行!堆要动态申请,虽然让第一个实例化的对象申请似乎也是可以的,但是堆释放啊!


谁去释放?让最后一个走的对象释放?那不还得加引用计数,所以虚表放堆上也不太可能。


那现在栈也不能存,堆也不能存,就只剩下常量区和数据段了。


静态区和常量区存放好像也很合理,当你实在不确定它到底在哪里的时,


这时候就需要一种 "验证问题的逆向精神",就比如刚才打印虚表指针,正是这种精神。


当然,这很依赖丰富的基础知识,是需要大量练习和实际锻炼的。


💬 比对:


int c = 2;     // 全局变量
int main(void)
{
  Base b1;
  Base b2;
  Base b3;
  Base b4;
  Print_VFTable((V_FUNC*)(*((int*)&b1)));
  Print_VFTable((V_FUNC*)(*((int*)&b2)));
  Print_VFTable((V_FUNC*)(*((int*)&b3)));
  Print_VFTable((V_FUNC*)(*((int*)&b4)));
  int a = 0;
  static int b = 1;   // 静态区
  const char* str = "Hello,World!\n";   // str在栈上,但指向的空间在常量区
  int* p = new int[10];   // p在栈上,但p指向的空间在堆上
  printf("栈: %p\n", &a);
  printf("静态区/数据段: %p\n", &b);
  printf("静态区/数据段: %p\n", &c);
  printf("常量区代码段: %p\n", str);
  printf("代码段: %p\n", str);
  printf("堆: %p\n", p);
  printf("虚表: %p\n", (*((int*)&b4)));
    printf("函数:%p\n", Derive::Func3);
    printf("函数:%p\n", Derive::Func2);
    printf("函数:%p\n", Derive::Func1);
  return 0;
}


🚩 运行结果:

00d30f0f8f27ec74668968e6b5df6ff2_87021655f9b640da9fb452225112b813.png

最合适的地方似乎就是数据段了。


想一想一下虚表是什么,是一个函数指针数组,放到数据段上是再合适不过的了。


🔺 总结:虚表存储在数据段上。

相关文章
|
4天前
|
C++ 数据格式
LabVIEW传递接收C/C++DLL指针
LabVIEW传递接收C/C++DLL指针
14 1
|
4天前
|
C++
【C++】从零开始认识多态(二)
面向对象技术(oop)的核心思想就是封装,继承和多态。通过之前的学习,我们了解了什么是封装,什么是继承。 封装就是对将一些属性装载到一个类对象中,不受外界的影响,比如:洗衣机就是对洗衣服功能,甩干功能,漂洗功能等的封装,其功能不会受到外界的微波炉影响。 继承就是可以将类对象进行继承,派生类会继承基类的功能与属性,类似父与子的关系。比如水果和苹果,苹果就有水果的特性。
24 1
|
4天前
|
C++
【C++】从零开始认识多态(一)
面向对象技术(oop)的核心思想就是封装,继承和多态。通过之前的学习,我们了解了什么是封装,什么是继承。 封装就是对将一些属性装载到一个类对象中,不受外界的影响,比如:洗衣机就是对洗衣服功能,甩干功能,漂洗功能等的封装,其功能不会受到外界的微波炉影响。 继承就是可以将类对象进行继承,派生类会继承基类的功能与属性,类似父与子的关系。比如水果和苹果,苹果就有水果的特性。
26 4
|
2天前
|
存储 安全 C语言
C++|多态性与虚函数(1)功能绑定|向上转换类型|虚函数
C++|多态性与虚函数(1)功能绑定|向上转换类型|虚函数
|
4天前
|
编译器 C++
C/C++杂谈——指针常量、常量指针
C/C++杂谈——指针常量、常量指针
9 0
|
4天前
|
C++ 编译器 存储
|
4天前
|
C++ 编译器
|
4天前
|
存储 C++
C++中的多态
C++中的多态
8 0
|
4天前
|
存储 安全 程序员
C++:智能指针
C++:智能指针
21 5
|
4天前
|
存储 安全 C++
深入理解C++中的指针与引用
深入理解C++中的指针与引用
12 0