虚函数的实现:vtable与vptr
虚函数的实现:vtable与vptr
Disboundary虚函数的实现:vtable与vptr
1. 要解决的问题
先看这段代码:
1 |
|
问题是:
1 | Animal* p = new Dog(); |
p 的类型是 Animal*,但真实对象是 Dog。
程序为什么会调用:
1 | Dog::speak() |
而不是:
1 | Animal::speak() |
答案通常是:
编译器用 vptr 和 vtable 实现虚函数的动态绑定。
2. 先记住三个词
1 | virtual function:虚函数 |
它们的关系可以先看成:
1 | 对象内部有一个隐藏指针 vptr |
图示:
1 | 对象 object |
注意:这是常见实现方式,不是 C++ 标准强制规定的唯一实现,但主流编译器基本都这样做。
3. 没有虚函数时:普通函数调用看静态类型
如果没有 virtual:
1 | class Animal { |
调用:
1 | Animal* p = new Dog(); |
因为 speak() 不是虚函数,所以编译器直接根据 p 的静态类型决定函数:
1 | p 的静态类型是 Animal* |
调用过程可以理解为:
1 | p->speak() |
输出:
1 | Animal |
这里不需要 vtable。
4. 有虚函数时:对象里会有 vptr
现在把 speak() 写成虚函数:
1 | class Animal { |
因为 Animal 里有虚函数,编译器通常会给含虚函数的对象加入一个隐藏成员:
1 | vptr |
它指向该对象真实类型对应的虚函数表。
如果真实对象是 Animal:
1 | Animal object |
如果真实对象是 Dog:
1 | Dog object |
重点:
即使用
Animal*指向它,对象内部的 vptr 仍然指向真实类型Dog的 vtable。
5. vtable 里放什么
对这两个类:
1 | class Animal { |
编译器大致会准备两张表:
1 | Animal vtable |
1 | Dog vtable |
因为 Dog 重写了 speak(),所以 Dog vtable 中 speak 对应的位置放的是:
1 | &Dog::speak |
不是:
1 | &Animal::speak |
6. Animal* p = new Dog(); 的内存图
代码:
1 | Animal* p = new Dog(); |
可以想象成:
1 | 栈上: |
虽然 p 的类型是:
1 | Animal* |
但它指向的对象内部有一个 vptr,这个 vptr 指向:
1 | Dog vtable |
7. p->speak() 的执行流程
代码:
1 | p->speak(); |
因为 speak() 是虚函数,所以不能只看 p 的静态类型。
运行时流程大致是:
1 | 1. 通过 p 找到对象 |
图示:
1 | p |
所以输出:
1 | Dog |
这就是动态绑定。
8. 多个虚函数时的 vtable
如果基类有多个虚函数:
1 | class Animal { |
vtable 可以想象成:
1 | Animal vtable |
1 | Dog vtable |
调用:
1 | Animal* p = new Dog(); |
流程都是:
1 | p -> 对象 -> vptr -> Dog vtable -> 找对应函数地址 |
因此输出:
1 | Dog |
9. 派生类只重写一部分虚函数
如果 Dog 只重写 speak(),没有重写 move():
1 | class Animal { |
那么可以想象:
1 | Dog vtable |
因为:
speak()被重写,所以表里放Dog::speakmove()没被重写,所以沿用Animal::move
调用:
1 | Animal* p = new Dog(); |
输出:
1 | Dog |
10. override 和 vtable 的关系
override 不负责生成 vtable。
真正和 vtable 有关的是:
1 | virtual |
override 的作用是让编译器检查:
派生类这个函数是否真的重写了基类虚函数。
例如:
1 | class Animal { |
这里少了 const,所以没有重写成功。
如果写了 override,编译器会报错。
如果没写 override,编译器可能把它当成一个新的普通成员函数,导致你以为重写了,其实没有。
11. 虚析构函数和 vtable
析构函数也可以是虚函数:
1 | class Animal { |
如果写:
1 | Animal* p = new Dog(); |
它和普通虚函数调用类似:
1 | delete p |
注意:
vtable 负责找到正确的析构入口,逐级析构是析构函数自己的语言规则。
所以虚析构函数的本质也是动态绑定。
12. 成本:为什么虚函数不是完全免费的
虚函数通常带来两个成本:
- 对象里多一个隐藏指针
vptr。 - 调用虚函数时需要通过 vtable 间接查找函数地址。
所以普通函数调用大致是:
1 | 直接调用固定函数地址 |
虚函数调用大致是:
1 | 对象 -> vptr -> vtable -> 函数地址 -> 调用 |
但这个成本通常很小。只有在性能极敏感场景才需要特别考虑。
13. 速通总结
核心图:
1 | Animal* p = new Dog(); |
记住五句话:
- 有虚函数的类,对象里通常会有隐藏的
vptr。 vptr指向该对象真实类型对应的vtable。vtable保存虚函数的地址。- 通过基类指针或引用调用虚函数时,会通过
vtable找到真实类型的函数版本。 virtual提供动态绑定能力,override只负责检查重写是否成功。
14. 课堂练习
问题 1
vptr 和 vtable 分别是什么?
你的回答:每个对象指向其所属的类的vtable的一个指针;每个类中存储其所有虚函数地址的表。
问题 2
下面代码中,p->speak() 为什么会调用 Dog::speak()?
1 | Animal* p = new Dog(); |
前提:Animal::speak() 是虚函数,Dog::speak() 正确重写了它。
你的回答:p->Dog.vptr->Dog.Vtable->Dog::speak()
问题 3
如果 Dog 只重写了 speak(),没有重写 move(),那么 Dog vtable 里 move 对应的位置会是什么?
你的回答:Animal::move()
问题 4
override 会不会创建 vtable?它真正的作用是什么?
你的回答:不会,用于向编译器声明这是个重写
问题 5
虚析构函数中,vtable 负责什么?逐级析构又是谁负责的?
你的回答:存储虚函数位置;virtual
本次批改
问题 1
你的回答:每个对象指向其所属的类的 vtable 的一个指针;每个类中存储其所有虚函数地址的表。
基本正确。
更标准地说:
vptr:对象内部隐藏的虚函数表指针,通常指向该对象真实类型对应的 vtable。vtable:类对应的虚函数表,保存该类各个虚函数最终应该调用的函数地址。
补充:严格说不是“每个对象一定都有 vptr”,而是“含虚函数的类的对象通常有 vptr”。这是主流编译器实现方式,不是 C++ 标准明文规定的唯一实现。
问题 2
你的回答:p -> Dog.vptr -> Dog.vtable -> Dog::speak()。
正确。
更完整地说:
1 | Animal* p = new Dog(); |
虽然 p 的静态类型是 Animal*,但它指向的真实对象是 Dog。因为 speak() 是虚函数,调用时会通过对象内部的 vptr 找到 Dog vtable,再从表中找到 Dog::speak() 的地址并调用。
问题 4
你的回答:不会,用于向编译器声明这是个重写。
基本正确,但建议把“声明”改成“检查”。
override 不负责创建 vtable,也不负责让函数变成虚函数。
它的作用是:
告诉编译器:我认为这个函数正在重写基类虚函数,请检查它是否真的重写成功。
真正让函数进入虚函数机制的是基类中的 virtual。
问题 5
你的回答:存储虚函数位置;virtual。
这里需要修正。
在虚析构函数中,vtable 的关键作用不是泛泛地“存储虚函数位置”,而是:
通过对象的真实类型,找到正确的析构入口。
例如:
1 | Animal* p = new Dog(); |
如果 Animal 的析构函数是虚函数,那么 delete p 会通过 Dog 对象中的 vptr 找到 Dog vtable,从而从 Dog 的析构函数开始:
1 | ~Dog() |
但是逐级析构不是 virtual 直接负责的。
更准确地说:
virtual/ vtable:负责让delete p找到真实对象Dog的析构入口。- C++ 析构规则:负责在进入
~Dog()后,自动继续调用~Animal()。
标准记忆:
1 | vtable 负责入口正确。 |
本次结论
这份 VTABLE 笔记的前 4 题可以通过。第 5 题要重点记住:
1 | 虚析构函数也是动态绑定。 |


