虚函数和虚表
C++中的虚函数(virtual function)是指在基类中使用virtual关键字声明的成员函数,允许在派生类中重写(override),并且通过基类指针或引用调用时能够实现动态多态(运行时多态)。
1. 简介
1.1 虚函数的作用与意义
- 实现多态:虚函数是C++实现运行时多态的基础。通过基类指针或引用调用虚函数时,能够根据实际对象类型调用对应的重写函数。
- 接口设计:虚函数常用于抽象基类(接口类),让派生类实现具体行为,提升代码的可扩展性和灵活性。
- 解耦与扩展:利用虚函数,可以将接口与实现分离,便于后续扩展和维护。
1.2 虚函数的使用注意事项
- 构造函数不能为虚函数,但析构函数可以且建议声明为虚函数,以保证通过基类指针删除派生类对象时能正确析构。虚函数可以有默认实现,派生类可选择重写或直接继承。
- 纯虚函数(abstract function):声明为
virtual void foo() = 0;,表示该函数没有实现,类也变为抽象类,不能实例化,只能被继承。
1.3 示例代码
class Animal {
public:
virtual void speak() = 0; // 纯虚函数
virtual ~Animal() {} // 虚析构函数
};
class Dog : public Animal {
public:
void speak() override { std::cout << "汪汪!" << std::endl; }
};
void makeSound(Animal* animal) {
animal->speak(); // 动态多态
}1.4 虚函数的性能影响
- 虚函数调用需要通过虚表指针间接寻址,略有性能损耗,但带来的灵活性远大于这点开销。
- 现代编译器对虚函数的优化已较为成熟,除非在极端性能场景下,一般无需担心。
2. 虚函数与 inline
virtual用于声明虚函数,实现运行时多态。inline建议编译器将函数体插入调用处,减少调用开销。- 虚函数加
inline实际作用有限,头文件内实现虚函数时会自动视为 inline。
3. 虚函数表(vtable)及其内存结构
3.1 虚表的存储
- 每个类只有一份虚表(静态存储,放在程序的数据段(
data segment)或者只读数据段(.rodata)中,不是每个对象一份; - 每个对象有一个虚表指针(
vptr),指向所属类的虚表,它是编译器为每个含有虚函数的类自动添加的一隐藏指针成员,每个对象实例都有自己的vptr,指向该对象所属类的虚表(vtable); - 当你通过基类指针/引用调用虚函数时,实际就是通过对象的
vptr查找虚表,找到正确的函数地址并调用;
假设有如下代码:
class Base {
public:
int a;
virtual void foo();
virtual void bar();
};
class Derived : public Base {
public:
int b;
void foo() override;
virtual void baz();
};Base 对象内存布局:虚表指针放在这个对象的头部位置。
+-------------------+
| vptr (虚表指针) | ---> 指向 Base 的虚表
+-------------------+
| a |
+-------------------+Base 的虚表(vtable):虚表里面存放的是各个虚方法的函数地址。
Base vtable:
+-------------------+
| &Base::foo |
+-------------------+
| &Base::bar |
+-------------------+Derived 对象内存布局:首先是一个指向虚表的指针,其次是继承于父类的数据成员,再后面是自定义的一些数据成员。
+-------------------+
| vptr (虚表指针) | ---> 指向 Derived 的虚表
+-------------------+
| a | // 继承自 Base
+-------------------+
| b | // Derived 自己的成员
+-------------------+Derived 的虚表(vtable):子类有自己单独的静态虚表,编译期完成内容的设置,规则是,如果是没有重写的父类虚方法,对应位置就是父类的函数地址,如果是重写之后了的,参考下面的内存结构示意
Derived vtable:
+-------------------+
| &Derived::foo | // 覆盖了 Base::foo
+-------------------+
| &Base::bar | // 没有重写,还是指向 Base::bar
+-------------------+
| &Derived::baz | // 新增的虚函数
+-------------------+4. 菱形继承的问题
4.1 它有什么问题
示例:
class A { public: int a; };
class B : public A {};
class C : public A {};
class D : public B, public C {};class D 的内存结构如下:
+-------------------+
| B::A::a | // 第一份A
+-------------------+
| B 的其他成员 |
+-------------------+
| C::A::a | // 第二份A
+-------------------+
| C 的其他成员 |
+-------------------+
| D 的成员 |
+-------------------+存在的问题:
二义性:
D对象有两份A的成员,访问a时有二义性;int main() { D d; d.a = 10; // 错误,二义性 // 需要指定路径 d.B::a = 10; d.C::a = 20; }资源浪费:内存中会有两份
A的数据;构造与析构顺序混乱:由于有两份 A,构造和析构时会分别调用两次 A 的构造和析构函数,容易引发资源管理混乱;
4.2 解决办法
使用虚继承可以解决这个问题,修复后的代码示例如下:
class A { public: int a; };
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {};D 对象内存布局(简化示意):
+-------------------+
| vptr_B | // B 的虚基表指针
+-------------------+
| vptr_C | // C 的虚基表指针
+-------------------+
| B 的其他成员 |
+-------------------+
| C 的其他成员 |
+-------------------+
| A::a | // 只有一份A
+-------------------+
| D 的成员 |
+-------------------+- 只有一份 A 的成员,二义性消除。
- 通过虚基表指针(
vptr_B、vptr_C)定位到唯一的A。
注意
虚继承解决了基类成员重复的问题,但不会自动解决派生类同名方法的二义性, 在上面的代码示例中,如果B,C存在同名方法,且D没有重写这个方法,在D中的函数内调用这个方法时,仍然需要显示指定命名空间来表明当前使用的是哪个类中的函数(如何D没有声明这个方法,编译器会直接报错),比如需要使用如下手段:
class D : public B, public C {
public:
void foo() override {
B::foo(); // 或 C::foo(),或者自定义
}
};
// 现在可以直接 d.foo(),不会二义性4.3 虚表与虚继承的关系
- 虚继承时,每个派生类会有指向虚基类的指针(虚基表指针),以保证只有一份虚基类成员。
- 虚表中会包含虚基表指针的偏移信息,保证多态和唯一性。
5. 构造函数与析构函数
明确两点,第一,构造函数不能被指定为虚函数,其次,如果需要被继承的类,析构函数最好是虚函数,下面解释这两点
5.1 为什么构造不能是虚函数
实际上,最大的原因是这个是一个没有意义的行为,因为你初始化对象的时候你必须知道你在初始化的是谁,不然编译器怎么知道你想初始化谁,具体原因可以从这下面三点分析:
对象还没“完整”构造好
- 当基类构造函数运行时,派生类的成员还没初始化,虚表指针还指向基类的虚表。
- 如果构造函数是虚函数,调用时会试图“多态”地调用派生类的构造函数,但此时派生类部分还没初始化,会导致未定义行为。
语义不合理
- 构造函数的目的是创建对象,而虚函数的多态机制是在对象已经存在时才有意义。
- 你必须先有一个对象,才能通过虚表实现多态。而构造函数本身就是在“造”这个对象。
技术实现复杂且无意义
- 如果允许构造函数虚拟化,编译器需要在对象还没构造好时就决定调用哪个构造函数,这在技术上很难实现,也没有实际意义。
5.2 为什么析构函数建议是虚函数
如果基类析构函数不是虚函数,通过基类指针删除派生类对象时,只会调用基类的析构函数,不会调用派生类的析构函数。这样会导致派生类的资源没有被正确释放,比如内存泄漏、文件句柄未关闭等。
6. 总结
- 虚函数的动态分派机制让内联优化几乎无用。
- 每个含虚函数的类有一份虚表,子类重写会覆盖虚表项。
- 菱形继承会导致成员重复、二义性、虚表混乱等问题。
- 虚继承是 C++ 解决菱形继承问题的机制,内存中只保留一份虚基类成员。
- 实际开发中应尽量避免复杂多重继承,优先考虑组合或接口继承。