什么是虚表?
- 虚表全称为虚拟函数表
- 在C++语言中,每个有虚函数的类或者虚继承的子类,编译器都会为它生成一个虚表
虚表存储在哪里?
- 对象头8B(如果是32位操作系统是对象头4B,如果是64位操作系统是对象头8B)
什么是虚函数?
虚函数就是函数前面用virtual来修饰它,用法格式为:
virtual 函数返回类型 函数名(参数表){函数体};
例如:
class Heap {
public:
virtual void* allocate(size_t size) {
return nullptr;
};
};
什么是纯虚函数?
纯虚函数的用法格式为:
virtual 函数返回类型 函数名(参数表)= 0;
class Heap {
public:
virtual void* allocate(size_t size) = 0;
};
但是如果使用纯虚函数,我们不能创建 Heap 对象。
new Heap
编译报错:不允许使用抽象类类型“Heap”的对象,因为函数“Heap::allocate”是纯虚函数。
C++类对象内存布局
以下结论摘自 《C++中一个class类对象占用多少内字节》,本文不做深入讨论
- 一个空的类对象在内存中占1个字节;
这是为何呢?我想对于这个问题,不仅是刚入行不久的开发新手,就算有过几年以上C++开发经验的开发人员也未必能说清楚这个。
编译器在执行Heap* p = new Heap;
这行代码后需要,作出一个 class Heap 的对象。并且这个对象的地址还是独一无二的,于是编译器就会给空类创建一个隐含的一个字节的空间。
- static 静态成员变量 不 占用类对象内存空间;
- 成员函数是 不 占用类对象内存空间的;
- 每个有虚函数的类或者虚继承的子类(即父类中包含虚函数),类对象内存空间的头部将增加一个“虚表指针”(32位操作系统占4B,64位操作系统占8B);
带有“抽象”的“纯虚函数”的类,不能实例化。(因为,“不能实例化抽象类”)
所以,它也不存在类对象内存空间。
GDB观察栈&对象&虚表的方法
先讲一下我的 GDB 观察方法,为了缩减篇幅,之后只会给图和结论。
运行环境 | 版本 |
---|---|
操作系统 | Ubuntu 64位 |
IDE | Clion |
首先 Base 的测试代码如下图所示:
#include <cstdio>
class Base {
public:
virtual void f() {};
virtual void g() {};
virtual void h() {};
};
int main() {
Base* base = new Base;
long* vtable = (long*)*(long*)base;
printf("%p\n", vtable);
printf("%lx\n",*vtable);
printf("%lx\n",*(vtable+1));
printf("%lx\n",*(vtable+2));
return 0;
}
把断点打在 main 函数 return 0 的位置,并且运行C++程序。
然后执行以下命令查看当前“栈帧”的局部变量及其内存地址
(gdb) info locals
base = 0x55555556aeb0
vtable = 0x555555557d88 <vtable for Base+16>
(gdb) info locals vtable
vtable = 0x555555557d88 <vtable for Base+16>
查看局部变量对应的Base对象的头8B,g表示显示8个字节,x表示以十六进制数显示:
(gdb) x/1gx 0x55555556aeb0
0x55555556aeb0: 0x0000555555557d88
Base对象头8B刚好存储的是虚表地址
查看虚表中存储的字节:
(gdb) x/3gx 0x0000555555557d88
0x555555557d88 <vtable for Base+16>: 0x0000555555555218 0x0000555555555228
0x555555557d98 <vtable for Base+32>: 0x0000555555555238
查看以指令格式 i(instruction) 查看虚表中的字节对应的含义:
(gdb) x/i 0x0000555555555218
0x555555555218 <Base::f()>: endbr64
(gdb) x/i 0x0000555555555228
0x555555555228 <Base::g()>: endbr64
(gdb) x/i 0x0000555555555238
0x555555555238 <Base::h()>: endbr64
下图是我用 Excel 画的一个简单的示意图:
★ 如果创建多个 Base 对象实例,他们的头部“虚表指针”相同,共用同一个“虚表”。
不同继承情况:
然后,我们多种不同的继承情况来研究子类的内存对象结构。
子类不覆写父类虚函数时
当子类不覆写父类虚函数时,在子类的虚函数表中,先存放基类的虚函数,在存放子类自己的虚函数。
//子类1,无虚函数重载
class Child1 : public Base {
public:
virtual void f1() { }
virtual void g1() { }
virtual void h1() { }
};
子类覆写父类其中一个虚函数时
当子类重写了父类的虚函数,编译器会将子类虚函数表中对应的父类的虚函数替换成子类的函数。
//子类2,覆写父类的虚函数f
class Child2 : public Base {
public:
virtual void f() override { };
virtual void g1() { };
virtual void h1() { };
};
子类覆写父类全部虚函数时
// 覆写全部父类虚函数
class Child3 : public Base {
public:
virtual void f() override { };
virtual void g() override { };
virtual void h() override { };
};
多重继承
对于多重继承,子类对象先存放第一个父类的数据拷贝,在存放第二个父类的数据拷贝,依次类推,最后存放自己的数据成员。
其中,每一个父类拷贝都包含一个虚函数表指针。如果子类重载了某个父类的某个虚函数,那么该将该父类虚函数表的函数覆盖。
另外,子类自己的虚函数,存储于第一个父类的虚函数表后边部分。
来看一个例子:
#include <cstdio>
class Base {
public:
virtual void f() {};
virtual void g() {};
virtual void h() {};
};
class Parent {
public:
virtual void x() {};
virtual void y() {};
virtual void z() {};
};
class Child4 : public Base, public Parent {
public:
void f() override { };
void y() override { };
virtual void f4() { };
virtual void g4() { };
virtual void h4() { };
};
int main() {
Base* base = new Base;
Parent* parent = new Parent;
Base* child4 = new Child4;
long* vtable = (long*)*(long*)base;
printf("%p\n", vtable);
printf("%lx\n",*vtable);
printf("%lx\n",*(vtable+1));
printf("%lx\n",*(vtable+2));
return 0;
}
Child4 继承了 Base 和 Parent 两个父类,每一个父类拷贝都包含一个虚函数表指针,所有 Child4 对象有两个虚表指针。
如果父类 Base 有数据成员的话,这两个“虚表指针”中间将间隔 Base类的数据成员拷贝。
如果 Parent 和 Child4 也有自己的数据成员,那要放在 Parent 的数据成员拷贝之后。
Child4 自己的虚函数保存在第一个父类 Base 的虚函数之后。
Child4 在第一个父类 Base 的虚函数之后可以找到 Child4::y() 方法,同时,在第二个父类 Parent 对应虚表中 Parent::y() 被 Child4::y() 覆盖和取代了。
虚继承
虚继承的用法格式为:
class 子类 : virtual 父类;
class Child5 : virtual public Base {
public:
virtual void f5() {};
virtual void g5() {};
virtual void h5() {};
};
我在 Linux 上试验时,并未发现 C++类对象的内存结构 上的(7)单一虚继承 提到的现象,
考虑到 Windows 使用是 Visual C++ 编译器,而 Linux 使用的是 g++ 编译器,可能存在差异。
g++ 在处理虚继承和普通继承时,是一样的规则。同上 子类不覆写父类虚函数时 的结果。