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

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

Ⅱ. 多态的原理


0x00 运行时决议与编译时决议


我们刚才知道了,多态调用实现是靠运行时查表做到的,我们再看一段代码。


💬 在刚才代码基础上,让父类子类分别多调用一个 Func3,注意 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;
  }
private:
  int _d = 2;
};
int main(void)
{
  Base b;
  Derive d;
  Base* ptr = &b;  
  ptr->Func1();   // 调用的是父类的虚函数
  ptr->Func3();
  ptr = &d;
  ptr->Func1();   // 调用的是子类的虚函数
  ptr->Func3();
  return 0;
}

🚩 运行结果:

252016bc532cd2c63bfd23b9f0f65cac_2b9393b7ee0941a3905e60ff44c4918a.png


❓ 问题:这里 Func3 为什么不是 Derive 的?


💡 解答:因为 Func3 不是虚函数,它没有进入虚表。


如果我们从更深的角度 —— 汇编层面去看,就可以牵扯出编译时决议和运行时决议。


(这个我们前面一直再提,我们现在就来好好讲讲~ 乖♂乖♂站♂好 )


决议的意思就是如何去确定函数的地址,一个是在运行时确定,一个是在编译时确定。


📚 多态调用:运行时决议,即运行时确定调用函数的地址。【通过查虚函数表】


(编译完后通过指令,去对象中虚表里去找虚函数运行,是运行时去找,找到了才调用)


📚 普通调用:编译时决议,编译时确定调用函数的地址。【通过类型】


(所有的编译时确定都是看 ptr 是什么类型,跟对象没有关系,不看指向的对象,自己是什么类型,就去哪里找 Func1)

7a44f0c508a690d60e843f75eee85a38_bbf497ab2feb4d6eb92959ec5aa989d4.png

(查看反汇编)


这正是多态底层实现的原理,编译器去检查,如果满足多态的条件了,它就按运行时决议的方式。


0x01 动态绑定与静态绑定

静态库:指的是链接的那个阶段链接的库。


动态库:程序运行起来后才加载,去动态库里找。


静态绑定:又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态。比如函数重载。


动态绑定:又称后期绑定(晚绑定),在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也成为动态多态。


0x02 静态的多态和动态的多态

多态在有些书上还细分了静态的多态和动态的多态。


静态的多态(编译时):指的是函数重载。

int x = 0, y = 1;
double a = 0.0, b = 1.1;
swap(x, y);
swap(a, b);
这两个 swap 让人感觉是同一个函数,
但实际不是。实际编译链接根据函数名修饰规则找到不同的函数


动态的多态(运行时):指的是本节内容讲的这个。

void Func(Person& p) {
    p.BuyTicket();
}
Person Mike;
Func(Mike);
Student Jack;
Func(Jack);

Ⅲ. 单继承与多继承关系的虚函数表


0x00 单继承中的虚函数表

(需要注意的是,在单继承和多继承关系中,下面我们去关注的是子类对象的虚表模型,因为父类的虚表模型我们前面已经看过了,没什么需要特别研究的地方)


💬 代码:单继承中的虚函数表:

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; }
  void func3() { cout << "Derive::func3" << endl; }
  virtual void func4() { cout << "Derive::func4" << endl; }
private:
  int b;
};


我们还是用刚才介绍的方法打印虚表:

typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[]) {
  // 依次取虚表中的虚函数指针打印并调用,调用就可以看出存的是哪个函数
  cout << " 虚表地址>" << vTable << endl;
  for (int i = 0; vTable[i] != nullptr; ++i) {
  printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
  VFPTR f = vTable[i];
  f();
  }
  cout << endl;
}
int main()
{
  Base b;
  Derive d;
}

ad75b732f805401130a971d68afcb9e7_a89a46859f0447c79d369b1ecdced70d.png

💬 代码:我们在把虚函数表打印出来看看(32位取头上4个字节,64位需要取头上8个字节):

int main()
{
  Base b;
  Derive d;
  PrintVTable((VFPTR*)(*(int*)&d));
  return 0;
}

🚩 运行结果:

c73fdaa15ad83f9b46c9216b2c4f2904_ea21bcccda8a4352b3a8015220bfe5d9.png

0x01 多继承中的虚函数表

刚才我们看的是单继承,我们现在再看复杂一点的多继承。


💬 代码:Base1 和 Base2 都进行了重写

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;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[]) {
  cout << " 虚表地址>" << vTable << endl;
  for (int i = 0; vTable[i] != nullptr; ++i)
  {
  printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
  VFPTR f = vTable[i];
  f();
  }
  cout << endl;
}
int main()
{
  Derive d;
  VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
  PrintVTable(vTableb1);
  VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
  PrintVTable(vTableb2);
  return 0;
}

这里 Derive 明显会有两张虚表,我们先透过监视简单看一下:

826f5f7651480de9d2244f7a59986e4d_3c28960f71b3480682203148d605db00.png

我们的 func3 是放哪一个虚表里?是两张都放一份,还是选择一份放呢?

9cdbc09e14364c9d4f3775d238cf722d_5a96e41d68e448d9ae17158fb7d6b4a1.png


func1 的两个地址好像不一样,0X0911ae 和 0X901249,因为它们都不是真正的函数的地址。


我们来看看 Derive 中的 func1 真正的地址:


printf("%p\n", &Derive::func1);


这里可能就是多套了一层,是一种保护机制。虽然不一样但是最后都跳到了函数上面去。

ca072430c2628e4ec13ef3455d3884e2_f58072d0b8c64517953fdf1ecd488392.png


🔺 结论:Derive 对象 Base2 虚表中 func1 时,是 Base2 指针 ptr2 取调用,但是这时 ptr2 发生切片指针偏移,需要修正。中途就需要修正存储 this 指针 ecx 的值。


❓ 问题:这里还有一个指针偏移的问题,在多继承中这三个指针的值是一样的吗?

Base1* ptr1 = &d;
Base2* ptr2 = &d;
Derive* ptr3 = &d;
cout << ptr1 << endl;
cout << ptr2 << endl;
cout << ptr3 << endl;

🚩 运行结果:0073FBA0   0073FBA8   0073FBA0


💡 答案:不一样。给人第一感觉好像是一样的,因为赋过去的值都是 &d,但实际上并不一样。


因为这里要发生切片,切片后赋值兼容,所以它们的地址就不一样了。

c0a04c580a54c47360934f65ce168ad2_cb638f1f141c469c8b69696befd776a0.png

0x02 多态的一些题目

1. 什么是多态?
2. 什么是重载、重写(覆盖)、重定义(隐藏)?
3. 多态的实现原理?答:参考本节课件内容
4. inline函数可以是虚函数吗?答:可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为 虚函数要放到虚表中去。
5. 静态成员可以是虚函数吗?答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式 无法访问虚函数表,所以静态成员函数无法放进虚函数表。
6. 构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?答:可以,并且最好把基类的析构函数定义 成虚函数。参考本节课件内容
8. 对象访问普通函数快还是虚函数更快?答:首先如果是普通对象,是一样快的。如果是指针对象或者是 引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
9. 虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况下存在代码 段(常量区)的。
10. C++菱形继承的问题?虚继承的原理?
相关文章
|
1月前
|
自然语言处理 编译器 Linux
|
9天前
|
存储 程序员 C++
深入解析C++中的函数指针与`typedef`的妙用
本文深入解析了C++中的函数指针及其与`typedef`的结合使用。通过图示和代码示例,详细介绍了函数指针的基本概念、声明和使用方法,并展示了如何利用`typedef`简化复杂的函数指针声明,提升代码的可读性和可维护性。
35 0
|
1月前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
108 4
|
1月前
|
自然语言处理 编译器 Linux
告别头文件,编译效率提升 42%!C++ Modules 实战解析 | 干货推荐
本文中,阿里云智能集团开发工程师李泽政以 Alinux 为操作环境,讲解模块相比传统头文件有哪些优势,并通过若干个例子,学习如何组织一个 C++ 模块工程并使用模块封装第三方库或是改造现有的项目。
|
2月前
|
存储 安全 编译器
在 C++中,引用和指针的区别
在C++中,引用和指针都是用于间接访问对象的工具,但它们有显著区别。引用是对象的别名,必须在定义时初始化且不可重新绑定;指针是一个变量,可以指向不同对象,也可为空。引用更安全,指针更灵活。
|
2月前
|
存储 程序员 编译器
简述 C、C++程序编译的内存分配情况
在C和C++程序编译过程中,内存被划分为几个区域进行分配:代码区存储常量和执行指令;全局/静态变量区存放全局变量及静态变量;栈区管理函数参数、局部变量等;堆区则用于动态分配内存,由程序员控制释放,共同支撑着程序运行时的数据存储与处理需求。
160 21
|
2月前
|
Linux 编译器 C语言
Linux c/c++之多文档编译
这篇文章介绍了在Linux操作系统下使用gcc编译器进行C/C++多文件编译的方法和步骤。
49 0
Linux c/c++之多文档编译
|
2月前
|
存储 C++
c++的指针完整教程
本文提供了一个全面的C++指针教程,包括指针的声明与初始化、访问指针指向的值、指针运算、指针与函数的关系、动态内存分配,以及不同类型指针(如一级指针、二级指针、整型指针、字符指针、数组指针、函数指针、成员指针、void指针)的介绍,还提到了不同位数机器上指针大小的差异。
63 1
|
2月前
|
存储 编译器 C语言
C++入门2——类与对象1(类的定义和this指针)
C++入门2——类与对象1(类的定义和this指针)
51 2
|
2月前
|
算法 编译器 C++
【C++篇】领略模板编程的进阶之美:参数巧思与编译的智慧
【C++篇】领略模板编程的进阶之美:参数巧思与编译的智慧
96 2