C++ 虚函数表解析

一、何为多态

多态(polymorphism)指为不同数据类型的实体提供统一的接口,或使用单一的符号来表示多个不同的类型。比如我们熟悉的函数重载、模板技术,都属于多态。无论是模板还是函数重载,都是静态绑定的。也就是说,究竟该调用哪个重载函数或者说调用哪个模板类的实例化,在编译期就是确认的。虚函数也是多态的一种,它是运行时的多态。

下面的代码演示了通过虚函数实现的多态:

 1 #include<iostream>
 2 using namespace std;
 3 class Base 
 4 { 
 5 public: 
 6     virtual void f()
 7     {
 8         cout<<"Base::f()"<<endl;
 9     }
10 };
11 class Derived: public Base 
12 {
13 public:
14     void f()
15     {
16         cout<<"Derived::f()"<<endl;
17     }
18 };
19 int main() {
20     Derived x;
21     Base* p = &x;
22     p->f(); //输出Derived::f()。
23     Base &b = x;
24     b.f();  //输出Derived::f()。
25     Base b1 = x;
26     b1.f();//输出Base::f(),值语义,不能表现出多态
27     return 0;
28 }

 

运行结果:

[root@VM-16-4-opencloudos vtable]# ./main 
Derived::f()
Derived::f()
Base::f()

 

用法是:基类的指针或引用,用不同的子类赋值时,就表现不同的行为。而值语义是不能表现出多态的。

实现的机制是因编译器而异,但基本上使用虚函数表来实现的,这个后面再介绍。

这会我想谈的是:为什么说虚函数是运行时的多态,基类的指针指向的类型需要在运行期间才能确定?

其实单看上面的代码,也不需要在运行时才知道p->f()调用的是Devied中的函数呀,代码里已经明确了Base的指针p就是指向子类Derived的,我直接看代码都知道了,难道编译器是傻的吗,还要等到运行期时才去通过虚函数表找到p->f()实际调用的是Derived中的函数?其实不是的,对于编译期能确定调用目标的虚函数,最终生成的代码并不会傻乎乎的去查虚表,编译器会执行一些优化,进行静态绑定。具体的讨论可以参考下面这个回答:虚函数一定是运行期才绑定么?

那为什么都说是运行期绑定的?

其实上面这份代码看不出来,可以看下面这份:

 1 #include<iostream>
 2 using namespace std;
 3 class Base 
 4 { 
 5 public: 
 6     virtual void f()
 7     {
 8         cout<<"Base::f()"<<endl;
 9     }
10 };
11 class Derived: public Base 
12 {
13 public:
14     void f()
15     {
16         cout<<"Derived::f()"<<endl;
17     }
18 };
19 int main() {
20     char k = getchar();
21     Base *p = NULL;
22     if(k == 'a') {
23         p = new Base();
24     } else {
25         p = new Derived();
26     }
27     p->f(); //输出Base::f() or Derived::f() ?
28     return 0;
29 }

在编译期间无法分析出这个指针究竟指向什么类型的对象,只有等到程序运行时,用户从键盘输入字符之后才能确定。此时只能是通过运行期动态绑定了。

之所以要在运行期动态绑定来实现的原因就是运行期外部 IO。参考知乎的回答:为什么C++实现多态必须要虚函数表?

搞清楚了为什么需要运行期动态绑定了,那下面就来说说怎么实现的吧。

二、虚函数表

我们来看以下的代码。类 A 包含虚函数vfunc1vfunc2,由于类 A 包含虚函数,故类 A 拥有一个虚表。

1 class A {
2 public:
3     virtual void vfunc1();
4     virtual void vfunc2();
5     void func1();
6     void func2();
7 private:
8     int m_data1, m_data2;
9 };

类 A 的虚表如图 1 所示。

图 1:对象它的虚表示意图

虚表是一个指针数组,其元素是虚函数的指针,每个元素对应一个虚函数的函数指针。需要指出的是,普通的函数即非虚函数,其调用并不需要经过虚表,所以虚表的元素并不包括普通函数的函数指针。

虚表内的条目,即虚函数指针的赋值发生在编译器的编译阶段,也就是说在代码的编译阶段,虚表就可以构造出来了。

虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。

为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个指针,*__vptr,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。

为什么弄懂虚表的内存布局,我找了很多资料,认为这两个知乎的回答我能理解清楚:

一个回答是:单继承下的虚函数表的内存布局;

另一个回答是我不太明白的地方:对象在其起始地址处存放了虚表指针(vptr),vptr指向虚表,虚表中存储了实际的函数地址。原来我以为虚表中存储的只有虚函数的地址,但其实不是的,vptr指向的并不是虚表的表头,而是直接指向虚函数的位置。实际上虚表中虚函数的位置之前,还有两个槽位,每个槽位占8个字节,这篇回答说明了这两个槽位的作用:多继承下的虚函数表的内存布局。

另外之前我有个疑惑:派生类中会有几个vptr呢?比如B继承A,然后C继承了B,C类中会有几个vptr呢?是有3个吗?(A、B、C各一个),还是只有1个?

答案是:在单链继承中,每一个派生类型都包含了其基类型的数据以及虚函数,这些虚函数可以按照继承顺序,依次排列在同一张虚表之中,因此只需要一个虚指针即可。并且由于每一个派生类都包含它的直接基类,且没有第二个直接基类,因此其数据在内存中也是线性排布的,这意味着实际类型与它所有的基类型都有着相同的起始地址。

而对于多继承而言,假设类型C同时继承了两个独立的基类A和B,比如:

 1 struct A
 2 {
 3     int ax;
 4     virtual void f0() {}
 5 };
 6 
 7 struct B
 8 {
 9     int bx;
10     virtual void f1() {}
11 };
12 
13 struct C : public A, public B
14 {
15     int cx;
16     void f0() override {}
17     void f1() override {}
18 };

与单链继承不同,由于AB完全独立,它们的虚函数没有顺序关系,即f0f1有着相同对虚表起始位置的偏移量,不可以顺序排布。 并且AB中的成员变量也是无关的,因此基类间也不具有包含关系。这使得ABC中必须要处于两个不相交的区域中,同时需要有两个虚指针分别对它们虚函数进行索引。 其内存布局如下所示:

                                               C Vtable (7 entities)+--------------------+
struct C                                        | offset_to_top (0)  |
object                                          +--------------------+0 - struct A (primary base)                 |     RTTI for C     |0 -   vptr_A -----------------------------> +--------------------+       8 -   int ax                                |       C::f0()      |16 - struct B                                +--------------------+16 -   vptr_B ----------------------+        |       C::f1()      |24 -   int bx                       |        +--------------------+28 - int cx                         |        | offset_to_top (-16)|
sizeof(C): 32    align: 8              |        +--------------------+|        |     RTTI for C     |+------> +--------------------+|    Thunk C::f1()   |+--------------------+

在上面的内存布局中,C将A作为主基类,也就是将它虚函数“并入”A的虚表之中,并将A的vptr作为C的内存起始地址。

上面的内存布局中,offset_to_top(-16)用于确保如果将类C实例的地址赋给类B的指针p,调用p->f0()时,能找到对应的虚函数,其内部会将指针偏移offset_to_top个字节,找到类C的虚表指针(vptr_A),然后找到对应的虚函数来调用。至于为什么是16个字节,是因为vptr本身占8个字节,另外还有int ax; 虽然int是4字节的,但因为内存对齐,所以总共占16个字节。

 

假设类型C同时继承了两个独立的基类AB

C++的编译器保证虚函数表的指针存在于对象实例中最前面的位置, 这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。

下面的程序用于验证包含虚函数的类对象内存的首地址是vptr,并演示如何通过vptr找到虚函数表并访问虚函数:

#include <iostream>
using namespace std;class Base {public:virtual void f() { cout << "Base::f" << endl; }virtual void g() { cout << "Base::g" << endl; }virtual void h() { cout << "Base::h" << endl; }};
typedef void(*Fun)(void);
int main(int argc, char *argv[])
{Base b;Fun pFun = NULL;cout << "虚函数表地址:" << (int*)(&b) << endl;  //vptrcout << "虚函数表 — 函数指针数组的首地址:" << (int*)*(int*)(&b) << endl;// Invoke the first virtual functionpFun = (Fun)*((int*)*(int*)(&b)+0);  // Base::f()
    pFun();return 0;
}

运行结果:

[root@VM-16-4-opencloudos vtable]# ./main 
虚函数表地址:0x7ffdb2560880
虚函数表 — 第一个函数地址:0x400b78
Base::f

&b取到了虚函数表的地址(vptr),*(int*)(&b)是对虚函数表地址的解引用,得到的是虚函数表。虚函数表中存放了一个函数指针数组,即数组中的每个元素都是一个函数指针,指向每一个虚函数。直接访问(int*)*(int*)(&b)得到的是函数指针数组的首地址。对其进行解引用,即*(int*)*(int*)(&b),则可以得到函数指针数组的首地址指向的元素,该元素是一个函数指针,因为虚函数按照其声明顺序放于虚函数表中,所以*(int*)*(int*)(&b)对应的是Base::f()函数的地址。

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/796774.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

8-回溯算法

参考代码随想录题目分类大纲如下:一、回溯算法理论基础 什么是回溯法 回溯法也可以叫做回溯搜索法,它是一种搜索的方式。回溯是递归的副产品,只要有递归就会有回溯。回溯法其实就是暴力查找,并不是什么高效的算法 回溯法的效率虽然回溯法很难,很不好理解,但是回溯法并不是…

九月

arc131 C考虑奇数情况,只有一个时先手必胜,设当前异或和为 \(S\),必输的情况是 \(\forall S \oplus a_i \in a\),这些数是一一对应的,但一共有奇数,此时先手必胜。偶数是,若第一回合无法结束游戏则变为后手,同上。 E若一个点所有边颜色相同,包含该点的环便不可能三边颜…

微信小程序开发系列10----页面配置--事件冒泡和阻止

下图点击里面,外面的事件也触发 场景:广告 点击先看广告,之后跳转到功能页面 会冒泡的事件源码获取方式(免费):(1)登录-注册:http://resources.kittytiger.cn/(2)签到获取积分(3)搜索:8-wxmleventMp事件冒泡和阻止

椭圆的第二定义

平面内到定点 \(F(c,0)\)的距离和到定直线 :\(\displaystyle l:x=\frac{a^{2}}{c}\)( 点\(F\)不在\(l\)上)的距离之比为常数\(\displaystyle \frac{a}{c}\)(即离心率\(e\),\(0<e<1\))的点的轨迹是椭圆。(即点\(P\)轨迹) 其中定点\(F\)为椭圆的焦点,定直线\(l\)称…

微信小程序开发系列9----页面配置--事件-参数传递

图点击里面,外面的事件也触发 场景:广告 点击先看广告,之后跳转到功能页面 会冒泡的事件 源码获取方式(免费):(1)登录-注册:http://resources.kittytiger.cn/(2)签到获取积分(3)搜索:7-wxmleventparameter事件-参数传递

NSSM使用方法/使用NSSM把.Net Core部署至 Windows 服务

使用NSSM把.Net Core部署至 Windows 服务 NSSM使用简介1、官网http://www.nssm.cc/,下载地址http://www.nssm.cc/download 2、下载后解压到自己喜欢的目录如:F:\work\nssm-2.24\win643、以管理员权限打开命令行工具,切换到nssm.exe所在路径,运行 nssm install,打开程序配置…

使用NSSM把.Net Core部署至 Windows 服务

使用NSSM把.Net Core部署至 Windows 服务 NSSM使用简介1、官网http://www.nssm.cc/,下载地址http://www.nssm.cc/download 2、下载后解压到自己喜欢的目录如:F:\work\nssm-2.24\win643、以管理员权限打开命令行工具,切换到nssm.exe所在路径,运行 nssm install,打开程序配置…

PC算法详解

基于约束的方法(PC(Peter-Clark)算法) 基于约束的方法大多数是在经验联合分布上测试条件独立性,来构造一张反映这些条件独立性的图。通常会有多个满足一组给定的条件独立性的图,所以基于约束的方法通常输出一个表示某个 MEC(边缘计算) 的图(例如,一个 PAG)。 最有名的…

第十九讲:幻读是什么,幻读有什么问题?

第十九讲:幻读是什么,幻读有什么问题? 简概:引入 ​ 在上一篇文章最后,我给你留了一个关于加锁规则的问题。 ​ 今天,我们就从这个问题说起吧。为了便于说明问题,这一篇文章,我们就先使用一个小一点儿的表。 ​ 建表和初始化语句如下(为了便于本期的例子说明,我把上篇…

网络计划技术——关键路线法精解

网络计划技术最早由美国杜邦公司于1957年开发的“关键路径法(CPM)”和美国海军在1958年发明的“计划评审技术(PERT)”推动。网络计划技术(Network Planning Techniques)是一种用于项目管理和调度的科学方法,其核心思想是通过构建网络图来描述项目中各个任务之间的逻辑关…

Rust使用Actix-web和SeaORM开发WebAPI通过Swagger UI查看接口文档

本文将介绍Rust语言使用Actix-web和SeaORM库,数据库使用PostgreSQL,开发增删改查项目,同时可以通过Swagger UI查看接口文档和查看标准Rust文档 开始项目 首先创建新项目,名称为rusty_crab_api cargo new rusty_crab_apiCargo.toml [dependencies] sea-orm = { version = &q…