虚函数的实现:vtable与vptr

虚函数的实现:vtable与vptr

1. 要解决的问题

先看这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;

class Animal {
public:
virtual void speak() const {
cout << "Animal" << endl;
}
};

class Dog : public Animal {
public:
void speak() const override {
cout << "Dog" << endl;
}
};

int main() {
Animal* p = new Dog();
p->speak();
delete p;
return 0;
}

问题是:

1
2
Animal* p = new Dog();
p->speak();

p 的类型是 Animal*,但真实对象是 Dog

程序为什么会调用:

1
Dog::speak()

而不是:

1
Animal::speak()

答案通常是:

编译器用 vptr 和 vtable 实现虚函数的动态绑定。

2. 先记住三个词

1
2
3
virtual function:虚函数
vtable:虚函数表
vptr:虚函数表指针

它们的关系可以先看成:

1
2
3
对象内部有一个隐藏指针 vptr
vptr 指向这个对象所属类的 vtable
vtable 里保存虚函数的地址

图示:

1
2
3
4
5
6
7
8
9
10
11
12
对象 object
+----------------+
| vptr ----------|----+
| 其他成员变量 | |
+----------------+ |
v
vtable
+----------------+
| 虚函数1的地址 |
| 虚函数2的地址 |
| 虚函数3的地址 |
+----------------+

注意:这是常见实现方式,不是 C++ 标准强制规定的唯一实现,但主流编译器基本都这样做。

3. 没有虚函数时:普通函数调用看静态类型

如果没有 virtual

1
2
3
4
5
6
7
8
9
10
11
12
13
class Animal {
public:
void speak() const {
cout << "Animal" << endl;
}
};

class Dog : public Animal {
public:
void speak() const {
cout << "Dog" << endl;
}
};

调用:

1
2
Animal* p = new Dog();
p->speak();

因为 speak() 不是虚函数,所以编译器直接根据 p 的静态类型决定函数:

1
2
p 的静态类型是 Animal*
所以 p->speak() 绑定到 Animal::speak()

调用过程可以理解为:

1
2
3
4
p->speak()
|
v
Animal::speak()

输出:

1
Animal

这里不需要 vtable。

4. 有虚函数时:对象里会有 vptr

现在把 speak() 写成虚函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Animal {
public:
virtual void speak() const {
cout << "Animal" << endl;
}
};

class Dog : public Animal {
public:
void speak() const override {
cout << "Dog" << endl;
}
};

因为 Animal 里有虚函数,编译器通常会给含虚函数的对象加入一个隐藏成员:

1
vptr

它指向该对象真实类型对应的虚函数表。

如果真实对象是 Animal

1
2
3
4
Animal object
+----------------+
| vptr ----------|----> Animal vtable
+----------------+

如果真实对象是 Dog

1
2
3
4
5
6
7
Dog object
+----------------+
| Animal 部分 |
| vptr --------|----> Dog vtable
+----------------+
| Dog 自己的成员 |
+----------------+

重点:

即使用 Animal* 指向它,对象内部的 vptr 仍然指向真实类型 Dog 的 vtable。

5. vtable 里放什么

对这两个类:

1
2
3
4
5
6
7
8
9
class Animal {
public:
virtual void speak() const;
};

class Dog : public Animal {
public:
void speak() const override;
};

编译器大致会准备两张表:

1
2
3
4
Animal vtable
+-------------------+
| &Animal::speak |
+-------------------+
1
2
3
4
Dog vtable
+-------------------+
| &Dog::speak |
+-------------------+

因为 Dog 重写了 speak(),所以 Dog vtablespeak 对应的位置放的是:

1
&Dog::speak

不是:

1
&Animal::speak

6. Animal* p = new Dog(); 的内存图

代码:

1
Animal* p = new Dog();

可以想象成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
栈上:
+----------------+
| p |
| 指向 Dog 对象 |
+-------|--------+
|
v
堆上:Dog object
+----------------+
| vptr ----------|----+
+----------------+ |
v
Dog vtable
+----------------+
| &Dog::speak |
+----------------+

虽然 p 的类型是:

1
Animal*

但它指向的对象内部有一个 vptr,这个 vptr 指向:

1
Dog vtable

7. p->speak() 的执行流程

代码:

1
p->speak();

因为 speak() 是虚函数,所以不能只看 p 的静态类型。

运行时流程大致是:

1
2
3
4
5
1. 通过 p 找到对象
2. 从对象中取出 vptr
3. 通过 vptr 找到 Dog vtable
4. 在 vtable 中找到 speak 对应的函数地址
5. 调用 Dog::speak()

图示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
p
|
v
Dog object
+----------------+
| vptr ----------|----+
+----------------+ |
v
Dog vtable
+----------------+
| &Dog::speak |
+-------|--------+
|
v
Dog::speak()

所以输出:

1
Dog

这就是动态绑定。

8. 多个虚函数时的 vtable

如果基类有多个虚函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Animal {
public:
virtual void speak() const {
cout << "Animal" << endl;
}

virtual void move() const {
cout << "Animal move" << endl;
}
};

class Dog : public Animal {
public:
void speak() const override {
cout << "Dog" << endl;
}

void move() const override {
cout << "Dog run" << endl;
}
};

vtable 可以想象成:

1
2
3
4
5
Animal vtable
+-------------------+
| &Animal::speak |
| &Animal::move |
+-------------------+
1
2
3
4
5
Dog vtable
+-------------------+
| &Dog::speak |
| &Dog::move |
+-------------------+

调用:

1
2
3
Animal* p = new Dog();
p->speak();
p->move();

流程都是:

1
p -> 对象 -> vptr -> Dog vtable -> 找对应函数地址

因此输出:

1
2
Dog
Dog run

9. 派生类只重写一部分虚函数

如果 Dog 只重写 speak(),没有重写 move()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Animal {
public:
virtual void speak() const {
cout << "Animal" << endl;
}

virtual void move() const {
cout << "Animal move" << endl;
}
};

class Dog : public Animal {
public:
void speak() const override {
cout << "Dog" << endl;
}
};

那么可以想象:

1
2
3
4
5
Dog vtable
+-------------------+
| &Dog::speak |
| &Animal::move |
+-------------------+

因为:

  • speak() 被重写,所以表里放 Dog::speak
  • move() 没被重写,所以沿用 Animal::move

调用:

1
2
3
Animal* p = new Dog();
p->speak();
p->move();

输出:

1
2
Dog
Animal move

10. override 和 vtable 的关系

override 不负责生成 vtable。

真正和 vtable 有关的是:

1
virtual

override 的作用是让编译器检查:

派生类这个函数是否真的重写了基类虚函数。

例如:

1
2
3
4
5
6
7
8
9
class Animal {
public:
virtual void speak() const;
};

class Dog : public Animal {
public:
void speak() override; // 错误:少了 const
};

这里少了 const,所以没有重写成功。

如果写了 override,编译器会报错。

如果没写 override,编译器可能把它当成一个新的普通成员函数,导致你以为重写了,其实没有。

11. 虚析构函数和 vtable

析构函数也可以是虚函数:

1
2
3
4
5
6
7
8
9
class Animal {
public:
virtual ~Animal() {}
};

class Dog : public Animal {
public:
~Dog() override {}
};

如果写:

1
2
Animal* p = new Dog();
delete p;

它和普通虚函数调用类似:

1
2
3
4
5
6
7
8
9
10
11
delete p
|
v
通过对象的 vptr 找到 Dog vtable
|
v
从 Dog 的析构入口开始
|
v
~Dog()
~Animal()

注意:

vtable 负责找到正确的析构入口,逐级析构是析构函数自己的语言规则。

所以虚析构函数的本质也是动态绑定。

12. 成本:为什么虚函数不是完全免费的

虚函数通常带来两个成本:

  1. 对象里多一个隐藏指针 vptr
  2. 调用虚函数时需要通过 vtable 间接查找函数地址。

所以普通函数调用大致是:

1
直接调用固定函数地址

虚函数调用大致是:

1
对象 -> vptr -> vtable -> 函数地址 -> 调用

但这个成本通常很小。只有在性能极敏感场景才需要特别考虑。

13. 速通总结

核心图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Animal* p = new Dog();
p->speak();

p
|
v
Dog object
+----------------+
| vptr ----------|----+
+----------------+ |
v
Dog vtable
+----------------+
| &Dog::speak |
+----------------+

记住五句话:

  1. 有虚函数的类,对象里通常会有隐藏的 vptr
  2. vptr 指向该对象真实类型对应的 vtable
  3. vtable 保存虚函数的地址。
  4. 通过基类指针或引用调用虚函数时,会通过 vtable 找到真实类型的函数版本。
  5. virtual 提供动态绑定能力,override 只负责检查重写是否成功。

14. 课堂练习

问题 1

vptrvtable 分别是什么?

你的回答:每个对象指向其所属的类的vtable的一个指针;每个类中存储其所有虚函数地址的表。

问题 2

下面代码中,p->speak() 为什么会调用 Dog::speak()

1
2
Animal* p = new Dog();
p->speak();

前提:Animal::speak() 是虚函数,Dog::speak() 正确重写了它。

你的回答:p->Dog.vptr->Dog.Vtable->Dog::speak()

问题 3

如果 Dog 只重写了 speak(),没有重写 move(),那么 Dog vtablemove 对应的位置会是什么?

你的回答: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
2
Animal* p = new Dog();
p->speak();

虽然 p 的静态类型是 Animal*,但它指向的真实对象是 Dog。因为 speak() 是虚函数,调用时会通过对象内部的 vptr 找到 Dog vtable,再从表中找到 Dog::speak() 的地址并调用。

问题 4

你的回答:不会,用于向编译器声明这是个重写。

基本正确,但建议把“声明”改成“检查”。

override 不负责创建 vtable,也不负责让函数变成虚函数。

它的作用是:

告诉编译器:我认为这个函数正在重写基类虚函数,请检查它是否真的重写成功。

真正让函数进入虚函数机制的是基类中的 virtual

问题 5

你的回答:存储虚函数位置;virtual

这里需要修正。

在虚析构函数中,vtable 的关键作用不是泛泛地“存储虚函数位置”,而是:

通过对象的真实类型,找到正确的析构入口。

例如:

1
2
Animal* p = new Dog();
delete p;

如果 Animal 的析构函数是虚函数,那么 delete p 会通过 Dog 对象中的 vptr 找到 Dog vtable,从而从 Dog 的析构函数开始:

1
2
~Dog()
~Animal()

但是逐级析构不是 virtual 直接负责的。

更准确地说:

  • virtual / vtable:负责让 delete p 找到真实对象 Dog析构入口。
  • C++ 析构规则:负责在进入 ~Dog() 后,自动继续调用 ~Animal()

标准记忆:

1
2
vtable 负责入口正确。
析构函数自身规则负责从派生类到基类逐级析构。

本次结论

这份 VTABLE 笔记的前 4 题可以通过。第 5 题要重点记住:

1
2
3
虚析构函数也是动态绑定。
动态绑定只负责找到真实对象的析构入口。
逐级析构是 C++ 语言规则自动完成的。