mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3673 字
10 分钟
虚函数表(Vtable)
2026-06-13

一、为什么需要虚函数表#

你正在写一个绘图程序,需要支持圆形、矩形、三角形。每种形状都要计算面积和周长。最直觉的做法是用 switchif-else 根据类型字段分发到对应的计算函数。

// 用类型标签 + switch 实现多态
typedef enum { SHAPE_CIRCLE, SHAPE_RECT, SHAPE_TRIANGLE } ShapeType;
typedef struct {
ShapeType type;
double radius; // 圆形用
double width, height; // 矩形用
double a, b, c; // 三角形用
} Shape;
double shape_area(Shape* s) {
switch (s->type) {
case SHAPE_CIRCLE: return 3.14159 * s->radius * s->radius;
case SHAPE_RECT: return s->width * s->height;
case SHAPE_TRIANGLE: /* 海伦公式 */ break;
default: return 0.0;
}
return 0.0;
}
double shape_perimeter(Shape* s) {
switch (s->type) {
case SHAPE_CIRCLE: return 2 * 3.14159 * s->radius;
case SHAPE_RECT: return 2 * (s->width + s->height);
case SHAPE_TRIANGLE: return s->a + s->b + s->c;
default: return 0.0;
}
}

三个形状还好,加到二十种呢?每新增一个形状,所有 switch 都要加一个 case。漏改一处就是 bug。更要命的是,Shape 结构体也得跟着改——加入五边形、六边形的字段,一个联合体越撑越大。

更根本的问题是:调用方不应该关心具体的类型。绘图程序只想说「计算这个形状的面积」,至于形状是圆还是方,那是形状自己的事。这就是多态——同一接口,不同实现。编译器怎么在运行时知道该调用哪个函数?答案就是虚函数表(vtable)。

这里涉及一个经典的架构决策问题——表达式问题(Expression Problem)。它描述的是:在一个类型-操作的二维矩阵中,你是更容易添加新类型,还是更容易添加新操作?switch 方案容易加新操作——加一个函数就行,但加新类型要改所有函数。vtable 方案恰好反过来——加新类型只需新增一个 vtable 实例,但加新操作要改所有 vtable 定义。理解这个 trade-off,是选择分发策略的关键。

虚函数表不是什么神秘的东西。它就是一个函数指针结构体,每个类型有一个实例,所有该类型的对象共享同一个 vtable。对象里存一个指向 vtable 的指针。调用方法时,通过 vtable 指针间接调用——一次指针寻址,O(1) 完成分发。C++ 的 virtual 方法、Go 的 interface、Rust 的 dyn Trait,底层都是这个机制。

二、现实类比#

餐厅菜单上每道菜都链接到厨房的一张菜谱卡。服务员不会做菜——他只是查找点单对应的菜谱卡,交给对应的厨师。不同餐厅可以为同一道菜名使用不同的菜谱卡。服务员不需要知道菜是怎么做的,他只需要查表找到对应的厨师。

菜谱卡就是 vtable,厨师就是具体的类型实现,服务员就是调用方。新开一家分店(新类型),只需要给服务员一套新的菜谱卡——不需要重新培训服务员。

三、核心思想#

vtable 是一个函数指针结构体,定义了类型上可用的操作。每个对象将其 vtable 指针与数据一起存储。调用方法时,通过 vtable 指针间接调用。

flowchart TD subgraph Circle C_Data["data: r=5"] C_VPtr["vtable ──→"] end subgraph Rectangle R_Data["data: w=4, h=6"] R_VPtr["vtable ──→"] end C_VPtr --> CVT["circle_vtable\narea: π·r·r\nperim: 2·π·r"] R_VPtr --> RVT["rect_vtable\narea: w·h\nperim: 2·(w+h)"]

分发过程:shape.vtable.area(shape.data)。一次指针间接寻址,找到具体函数,传入数据,完成。

3.1 内存布局#

把上面的 mermaid 图翻译成内存视角,每个对象在内存中的布局如下:

// vtable:函数指针结构体,每个类型一份
typedef struct {
double (*area)(void* self);
double (*perimeter)(void* self);
} ShapeVtable;
// 圆形对象:vptr + 数据
typedef struct {
ShapeVtable* vptr; // 8 字节,指向 circle_vtable
double radius;
} Circle;
// 矩形对象:vptr + 数据
typedef struct {
ShapeVtable* vptr; // 8 字节,指向 rect_vtable
double width;
double height;
} Rect;

关键点:vptr 永远在对象的第一个字段。这样不管对象后面跟什么数据,调用方只需要知道 vptr 的偏移量(永远是 0),就能找到正确的函数。这也是 C++ 编译器在单继承下不需要调整 this 指针的原因。

属性
调用开销一次指针间接寻址(vtable 查找)
添加新类型添加新 vtable——无需修改现有代码
添加新操作必须更新所有 vtable(表达式问题)
内存每个类型一个 vtable(所有实例共享),每个实例一个 vptr

关键洞察:vtable 是按类型的,vptr 是按实例的。100 万个圆形对象共享同一个 circle_vtable,但每个对象各自存储一个 8 字节的 vptr。总开销是 8MB(vptr)加上几百字节(vtable)。

3.2 继承与 vtable 扩展#

当类型之间存在继承关系时,vtable 是如何组织的?C++ 的做法是扩展父类的 vtable——子类的 vtable 以父类的函数指针为前缀,后面追加自己新增的虚函数。

// 基类 Shape 的 vtable
typedef struct {
void (*draw)(void* self);
double (*area)(void* self);
} ShapeVtable;
// 子类 ColoredShape 的 vtable = Shape 的 + 自己的
typedef struct {
// 继承自 Shape(前缀部分,偏移完全一致)
void (*draw)(void* self);
double (*area)(void* self);
// ColoredShape 新增的虚函数
void (*set_color)(void* self, int color);
int (*get_color)(void* self);
} ColoredShapeVtable;

指向 ColoredShapeVtable 的指针可以直接当作 ShapeVtable* 使用,因为前缀部分的内存布局完全相同。这也是 C++ 中子类指针能隐式转换为父类指针的底层原因——不需要偏移调整,vptr 指向的内存天然兼容。多重继承更复杂一些,每个基类各有一个 vptr,但核心思想不变。

3.3 部分 vtable 模式#

不是所有类型都实现了 vtable 中的每个操作。Linux 内核的 file_operations 就是一个典型——不是每个文件系统都支持 mmap,不是每个设备都支持 ioctl。解决办法是在 vtable 中放 NULL,调用方在间接调用前先检查。

// 调用方检查 NULL,而不是假设每个函数指针都有效
if (file->f_op->mmap) {
return file->f_op->mmap(file, vma);
}
return -ENODEV; // 该操作不支持

这种「部分 vtable」模式让类型可以按需实现操作子集,而不需要像 Java 那样用 UnsupportedOperationException 运行时异常来兜底。NULL 函数指针是显式的「不支持」声明,编译器甚至可以帮你检查是否遗漏了 NULL 判断。

四、变体与对比#

模式与 vtable 的关系添加新类型添加新操作
标签联合体两者都实现多态——vtable 通过间接调用,标签联合通过 switch需改所有 switch加一个函数即可
访问者访问者的分发表在概念上是按节点类型索引的 vtable加新节点类型要改所有 Visitor加新 Visitor 即可
中间件/管道链每个中间件处理器是一个函数指针,形成动态 vtable动态插入链本身是固定的操作集
vtable 本身函数指针间接调用加新 vtable 即可改所有 vtable 定义

vtable vs 标签联合体的核心差异在于表达式问题的方向。vtable 容易添加新类型(加一个 vtable 就行),但难添加新操作(要改所有 vtable)。标签联合体相反:容易添加新操作(加一个 switch case),但难添加新类型(要改所有 union 定义)。

4.1 静态分发 vs 动态分发#

vtable 是动态分发——每次调用都通过指针间接跳转。静态分发(单态化,monomorphization)是另一种选择:编译器为每个具体类型生成一份函数的专用版本,调用时直接跳转,没有间接寻址。

// 静态分发:编译期为每个具体类型生成独立代码
fn total_area<T: Shape>(shapes: &[T]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
// 编译器生成 total_area::<Circle> 和 total_area::<Rect> 两个版本
// 动态分发:运行时通过 vtable 间接调用
fn total_area_dyn(shapes: &[&dyn Shape]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
// 只有一个版本,通过 vtable 分发
维度静态分发(impl Trait动态分发(dyn Trait
调用开销零——直接调用一次间接寻址(约 2-5ns)
内联优化编译器可以内联无法内联
代码体积每个类型生成一份,可能膨胀只有一份代码
异构集合不支持支持——Vec<&dyn Trait> 可存不同类型
编译速度类型越多编译越慢不受类型数量影响

4.2 Rust 如何选择#

Rust 同时支持 impl Trait(静态分发)和 dyn Trait(动态分发),选择原则清晰:

  • 默认用 impl Trait——零开销抽象是 Rust 的哲学,只要不需要异构集合就用静态分发
  • 在 API 边界用 dyn Trait——需要把不同类型放进同一个容器(比如 Vec<Box<dyn Handler>>),或者要控制代码体积时,用动态分发
  • dyn 只能通过引用或指针使用——dyn Trait 的大小编译期未知,必须用 &dynBox<dyn>Rc<dyn>

实际例子:Web 框架的路由表通常用 Vec<Box<dyn Handler>> 存储不同路由的处理函数。每个处理函数是不同类型,但都实现了 Handler trait,这里必须用动态分发。

五、多语言实现#

5.1 Go 实现#

import "math"
// ShapeOps 是形状的虚函数表
type ShapeOps struct {
Area func(data []float64) float64
Perimeter func(data []float64) float64
}
// Shape 把 vtable 指针和数据打包在一起
type Shape struct {
Ops *ShapeOps
Data []float64
}
// 各类型提供自己的 vtable 实例
var CircleOps = &ShapeOps{
Area: func(d []float64) float64 { return math.Pi * d[0] * d[0] },
Perimeter: func(d []float64) float64 { return 2 * math.Pi * d[0] },
}
var RectOps = &ShapeOps{
Area: func(d []float64) float64 { return d[0] * d[1] },
Perimeter: func(d []float64) float64 { return 2 * (d[0] + d[1]) },
}
func NewCircle(r float64) Shape {
return Shape{Ops: CircleOps, Data: []float64{r}}
}
func NewRect(w, h float64) Shape {
return Shape{Ops: RectOps, Data: []float64{w, h}}
}
// 多态分发——不关心具体类型,查表调用
func totalArea(shapes []Shape) float64 {
sum := 0.0
for _, s := range shapes {
sum += s.Ops.Area(s.Data) // 通过 vtable 间接调用
}
return sum
}

Go 的 interface 底层就是这个模式。根据 Russ Cox 的分析,一个 interface 值在内存中是一个双字结构:第一个字指向 itable(接口函数表),第二个字指向实际数据。itable 和我们手写的 ShapeOps 本质相同,但它由 Go 运行时在首次使用时动态生成,缓存在全局的 itable 表中。

这个设计有一个精妙之处:itable 不包含数据指针,只包含函数指针。数据指针存在 interface 值的第二个字中。这样一来,同一个 itable 可以被所有 (Circle, Shaper) 的 interface 值共享——和 C++ 中同一类的所有对象共享 vtable 一样的思路。区别在于 Go 的 itable 是运行时按需生成的,而 C++ 的 vtable 是编译期静态构造的。

另一个重要区别是 Go interface 的隐式满足。C++ 和 Rust 要求子类显式声明继承或实现,Go 只要求方法签名匹配。只要 CircleArea()Perimeter() 方法,运行时就能自动构建 itable,编译器不需要知道 Circle 实现了哪个接口。这种灵活性正是建立在 itable 动态生成机制之上的。

5.2 TypeScript 实现#

// 虚函数表定义接口
interface ShapeVtable {
area: (data: number[]) => number;
perimeter: (data: number[]) => number;
}
// 对象 = vtable 指针 + 数据
interface Shape {
vtable: ShapeVtable;
data: number[];
}
// 各类型的 vtable 实例
const circleVtable: ShapeVtable = {
area: (d) => Math.PI * d[0] * d[0],
perimeter: (d) => 2 * Math.PI * d[0],
};
const rectVtable: ShapeVtable = {
area: (d) => d[0] * d[1],
perimeter: (d) => 2 * (d[0] + d[1]),
};
// 多态分发
function totalArea(shapes: Shape[]): number {
return shapes.reduce((sum, s) => sum + s.vtable.area(s.data), 0);
}

TypeScript 的实现直接展示了 vtable 的本质:一个包含函数指针的对象。每个 Shape 实例持有 vtable 引用和数据数组,调用时通过 vtable 间接分发。

5.3 CPython 的 vtable#

CPython 中每个 Python 对象都指向一个 PyTypeObject,它就是 Python 类型的 vtable。与 C++ vtable 的关键区别在于:PyTypeObject 在运行时是可变的

// CPython 中类型对象的简化结构
typedef struct _typeobject {
PyObject_VAR_HEAD
const char *tp_name; // 类型名
Py_ssize_t tp_basicsize; // 实例大小
destructor tp_dealloc; // 析构函数
reprfunc tp_repr; // repr() 实现
hashfunc tp_hash; // hash() 实现
ternaryfunc tp_call; // 可调用对象 __call__
// ... 几十个 tp_* 函数指针
} PyTypeObject;

这个可变性是 Python 动态特性的基石。当你在运行时给一个类添加方法(MyClass.new_method = ...),CPython 直接修改 PyTypeObject 中的函数指针。C++ 做不到这一点——它的 vtable 在编译期就固定了,存储在只读段中。代价是每次属性查找都要走 MRO(方法解析顺序)链,比 C++ 的一次 vtable 间接寻址慢得多。Python 用灵活性换性能,C++ 用编译期确定性换运行时速度。

六、生产验证#

6.1 Linux 内核的 file_operations#

fs.h#L2093-L2163 中的 file_operations 结构体是函数指针 vtable 的教科书级应用:.read.write.open.release.mmap 等。每个文件系统(ext4、btrfs、tmpfs)提供自己的实例,VFS 层通过这个 vtable 分发调用。

file_operations 是「部分 vtable 模式」的典型案例。只读文件系统不需要实现 .write,内存文件系统不需要实现 .fsync。内核把这些不支持的函数指针设为 NULL,VFS 层在调用前先做检查:

// VFS 层的调用逻辑(简化)
ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos) {
if (!file->f_op->read && !file->f_op->read_iter)
return -EINVAL; // 该文件系统不支持读操作
// ... 调用具体的 read 实现
}

这种 NULL 检查模式贯穿整个 Linux 内核——address_space_operationsblock_device_operationsnet_device_ops 等几十种操作结构体,全部采用同样的设计。文件系统开发者只需实现自己关心的操作,其余留 NULL 即可,内核保证合理的默认行为。

6.2 CPython 的 tp_* 插槽#

object.h#L250-L340 中的 PyTypeObject 包含 tp_reprtp_hashtp_call 等几十个函数指针。CPython 的 tp_* 插槽有一个特殊设计:插槽之间有继承和覆盖关系。如果一个类型没有设置 tp_hash,CPython 会回退到 tp_richcompare 的逻辑,或使用默认的基于 id() 的哈希。这种回退链比 Linux 内核的简单 NULL 检查更复杂——更像一个多级分发机制。

另一个值得注意的细节:CPython 3.6 之后引入了 tp_vectorcall,它是一个向量调用协议,允许直接传参数数组而不需要构建元组。你可以把它理解为 vtable 中新增了一个「快速路径」函数指针,调用方优先走快速路径,走不通再回退到 tp_call

6.3 SQLite VFS#

SQLite 的虚拟文件系统层使用函数指针结构体实现操作系统抽象,允许自定义存储后端。SQLite 的独特之处在于:多个 VFS 可以同时注册,应用可以在打开数据库时选择使用哪个 VFS。这比 Linux 内核的静态 vtable 赋值更灵活——它更像是一个运行时的 vtable 注册表。

七、小结#

何时使用

  • 插件架构——插件提供一组回调的 vtable 供宿主调用,实现热插拔。Linux 内核的驱动模型就是如此:驱动注册时提供 file_operations,卸载时注销,内核不需要重新编译
  • 操作系统内核抽象——文件系统、设备驱动、网络协议都使用操作结构体。这是表达式问题中「类型多、操作固定」方向的典型场景
  • 语言运行时——Python 类型、Ruby 类、Lua 元表都是 vtable 的变体。动态语言选择可变 vtable 是因为需要在运行时修改类型行为
  • 数据库存储引擎——每个引擎(InnoDB、RocksDB)提供读/写/扫描操作集。接口相对稳定但引擎种类不断增加,vtable 是自然的选择

何时不用

  • 单一实现——如果只有一个实现,直接函数调用更简单更快
  • 热点内层循环——vtable 间接调用阻碍内联和分支预测,考虑单态化。游戏引擎的粒子系统通常为每种粒子类型生成专用代码
  • 操作多变类型少——频繁添加新操作时,表达式问题让 vtable 维护成本很高。编译器的 AST 节点就是这种场景——节点类型相对稳定,但经常需要新增遍历操作,访问者模式比 vtable 更合适

表达式问题的核心启示是:没有万能的分发策略。vtable 擅长「类型扩展」,访问者擅长「操作扩展」,标签联合体在两者之间提供简单但脆弱的平衡。做架构决策时,先问自己:我的系统未来更可能加新类型还是新操作?答案决定了你应该选哪种分发机制。

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

虚函数表(Vtable)
https://blog.souloss.com/posts/programming/behavioral/behavioral-vtable/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时