虚函数和虚表

虚函数(Virtual Function)和虚表(Virtual Table,简称 vtable)是 C++ 等面向对象编程语言中实现运行时多态(Runtime Polymorphism)的核心机制。
简单来说,虚函数允许你在基类中定义一个接口,而在派生类中提供具体的实现;虚表则是编译器为了实现这种"动态绑定"而在幕后生成的一张查找表。

下面我将分层次详细讲解它们的原理、工作机制以及注意事项。

1.什么是虚函数?

在 C++ 中,如果在基类的成员函数声明前加上关键字 virtual,该函数就成为虚函数。
· 目的:实现动态绑定(Dynamic Binding)。即程序在运行时,根据对象的实际类型(而不是指针或引用的声明类型)来决定调用哪个版本的函数。
**·**语法示例:

cpp 复制代码
class Base {
public:
    virtual void speak() { 
        cout << "Base speaks" << endl; 
    }
};

class Derived : public Base {
public:
    void speak() override { // override 是可选的,但推荐用于检查
        cout << "Derived speaks" << endl; 
    }
};

行为对比:
· 非虚函数:编译时绑定。如果你用 Base* ptr = new Derived(); ptr->speak();,调用的是 Base::speak()。
**·**虚函数:运行时绑定。同样的代码,调用的是 Derived::speak()。

2.什么是虚表(vtable)?

虚表是编译器为每个包含虚函数的类生成的一张静态数组(通常在只读数据段)。
· 内容:表中存储了该类所有虚函数的地址指针。
· 结构:

· 如果一个类有 3 个虚函数,它的虚表里就有 3 个条目,分别指向这 3 个函数的具体实现地址。

· 如果派生类重写了某个虚函数,虚表中对应位置的指针就会指向派生类的函数地址。

**·**如果派生类没有重写,则继承基类虚表中该位置的指针(指向基类的函数)。

虚表的生成规则

1.每个类一张表:只要类中声明了至少一个虚函数,编译器就会为这个类生成一张虚表。

2.继承关系:
· 派生类会复制基类的虚表。
· 对于被重写(Override)的虚函数,派生类虚表中对应的条目会被更新为派生类函数的地址。
**·**对于新增的虚函数,会追加到虚表末尾。

3.核心机制:虚表指针(vptr)

既然每个类有一张虚表,那么对象如何找到自己的虚表呢?答案是虚表指针(vptr)。
· 隐藏成员:编译器会在含有虚函数的类的对象内存布局中,隐式地插入一个指针成员,这就是 vptr。
· 初始化:

当创建对象时(在构造函数执行期间),vptr 会被自动初始化,指向当前正在构造的那个类的虚表。

注意:如果在基类构造函数中调用虚函数,此时 vptr 指向基类的虚表,因此不会发生多态(调用的是基类版本)。

·调用过程:

当你执行 ptr->speak() 时,编译器生成的汇编代码大致如下:

1.通过对象指针找到对象内存中的 vptr。

2.通过 vptr 找到对应的虚表(vtable)。

3.在虚表中查找 speak 函数对应的索引位置(例如第 0 项)。

4.取出该位置的函数地址并跳转执行。

假设我们有以下类结构:

cpp 复制代码
class Animal {
public:
    virtual void sound() { cout << "Animal"; }
    virtual void move() { cout << "Move"; }
};

class Dog : public Animal {
public:
    void sound() override { cout << "Bark"; }
    // move 未重写
};

内存布局示意:

虚表区 (Static Data):

·Animal_vtable: [ &Animal::sound, &Animal::move ]

·Dog_vtable: [ &Dog::sound, &Animal::move ] (注意:sound 被替换,move 继承)
堆/栈上的对象 (Object Instance):

·Animal 对象: [ vptr -> Animal_vtable | ...其他成员... ]

·Dog 对象: [ vptr -> Dog_vtable | ...其他成员... ]

调用流程 (Animal* a = new Dog(); a->sound();):

1.访问 a 指向的对象。

2.读取对象头部的 vptr,它指向 Dog_vtable。

3.在 Dog_vtable 的第 0 个位置(对应 sound)找到地址 &Dog::sound。

4.执行 Dog::sound,输出 "Bark"。

4.关键特性与注意事项

A. 构造函数与析构函数
· 构造函数中调用虚函数:

在基类构造函数执行时,派生类部分尚未构造完成,对象的 vptr 指向基类的虚表。

因此,构造函数中调用虚函数不会发生多态,只会调用基类版本。

建议:避免在构造函数中调用虚函数。
· 析构函数必须是虚函数:

如果你打算通过基类指针删除派生类对象(delete basePtr),基类的析构函数必须是虚函数。

如果不是虚函数,delete 只会调用基类析构函数,导致派生类特有的资源(如动态分配的内存)无法释放,造成内存泄漏。

B. 性能开销
· 空间开销:

每个对象增加一个指针的大小(32位系统4字节,64位系统8字节)。

每个含虚函数的类增加一张虚表(存储函数指针数组)。
· 时间开销:

调用虚函数比调用普通函数多一次间接寻址(查表)。

由于目标地址在运行时才能确定,虚函数通常不能被内联(inline)优化(除非编译器能确定对象的具体类型,进行去虚拟化优化)。

在现代 CPU 上,这个开销非常小,通常可以忽略不计,但在极度追求性能的循环中需注意。

C. 纯虚函数与抽象类

如果虚函数声明为 = 0,则为纯虚函数:virtual void func() = 0;。

包含纯虚函数的类称为抽象类,不能实例化对象。

纯虚函数在虚表中通常占用一个特殊位置(有时指向一个报错函数,如 __cxa_pure_virtual),强制派生类必须实现该函数,否则派生类也是抽象类。

一句话总结:虚函数通过引入虚表指针和虚表,以微小的时空代价,换取了代码极大的灵活性和可扩展性,是面向对象设计中"开闭原则"(对扩展开放,对修改关闭)的技术基石。

相关推荐
王ASC2 小时前
Java不重启加载新的class文件
java·开发语言
靠沿2 小时前
【优选算法】专题十七——多源BFS(最短路径问题)
java·算法·宽度优先
王老师青少年编程2 小时前
信奥赛C++提高组csp-s之数论基础专题课:中国剩余定理2(编程案例实践1)
c++·数论·中国剩余定理·csp·信奥赛·csp-s·提高组
乐观勇敢坚强的老彭2 小时前
c++信奥for循环强化03
开发语言·c++
咚为2 小时前
告别 lazy_static:深度解析 Rust OnceCell 的前世今生与实战
开发语言·后端·rust
重生之我是Java开发战士2 小时前
【递归、搜索与回溯】优美的排列,N皇后,有效的数独,解数独,单词搜索,黄金矿工,不同路径III
算法·深度优先
ejjdhdjdjdjdjjsl2 小时前
halcon算子
人工智能·算法·计算机视觉
全栈开发圈2 小时前
干货分享|R语言聚类分析1
开发语言·r语言