【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++菱形继承的问题?虚继承的原理?
相关文章
|
3月前
|
C++
C++ 根据程序运行的时间和cpu频率来计算在另外的cpu上运行所花的时间
C++ 根据程序运行的时间和cpu频率来计算在另外的cpu上运行所花的时间
44 0
|
15天前
|
自然语言处理 编译器 Linux
|
4月前
|
消息中间件 Java C语言
消息队列 MQ使用问题之在使用C++客户端和GBase的ESQL进行编译时出现core dump,该怎么办
消息队列(MQ)是一种用于异步通信和解耦的应用程序间消息传递的服务,广泛应用于分布式系统中。针对不同的MQ产品,如阿里云的RocketMQ、RabbitMQ等,它们在实现上述场景时可能会有不同的特性和优势,比如RocketMQ强调高吞吐量、低延迟和高可用性,适合大规模分布式系统;而RabbitMQ则以其灵活的路由规则和丰富的协议支持受到青睐。下面是一些常见的消息队列MQ产品的使用场景合集,这些场景涵盖了多种行业和业务需求。
|
21天前
|
自然语言处理 编译器 Linux
告别头文件,编译效率提升 42%!C++ Modules 实战解析 | 干货推荐
本文中,阿里云智能集团开发工程师李泽政以 Alinux 为操作环境,讲解模块相比传统头文件有哪些优势,并通过若干个例子,学习如何组织一个 C++ 模块工程并使用模块封装第三方库或是改造现有的项目。
|
1月前
|
存储 程序员 编译器
简述 C、C++程序编译的内存分配情况
在C和C++程序编译过程中,内存被划分为几个区域进行分配:代码区存储常量和执行指令;全局/静态变量区存放全局变量及静态变量;栈区管理函数参数、局部变量等;堆区则用于动态分配内存,由程序员控制释放,共同支撑着程序运行时的数据存储与处理需求。
101 21
|
1月前
|
Linux 编译器 C语言
Linux c/c++之多文档编译
这篇文章介绍了在Linux操作系统下使用gcc编译器进行C/C++多文件编译的方法和步骤。
39 0
Linux c/c++之多文档编译
|
1月前
|
算法 编译器 C++
【C++篇】领略模板编程的进阶之美:参数巧思与编译的智慧
【C++篇】领略模板编程的进阶之美:参数巧思与编译的智慧
77 2
|
26天前
|
C++
C++ 20新特性之结构化绑定
在C++ 20出现之前,当我们需要访问一个结构体或类的多个成员时,通常使用.或->操作符。对于复杂的数据结构,这种访问方式往往会显得冗长,也难以理解。C++ 20中引入的结构化绑定允许我们直接从一个聚合类型(比如:tuple、struct、class等)中提取出多个成员,并为它们分别命名。这一特性大大简化了对复杂数据结构的访问方式,使代码更加清晰、易读。
32 0
|
3月前
|
C语言 C++
vscode——如何在vscode中运行C/C++
vscode——如何在vscode中运行C/C++
53 1
|
4月前
|
C++ 运维
开发与运维编译问题之在C++中在使用std::mutex后能自动释放锁如何解决
开发与运维编译问题之在C++中在使用std::mutex后能自动释放锁如何解决
68 2