不完全对。
通常可以理解为:基类和派生类各自有对应的虚函数表,而不是共用基类的一张表。
另外,vtable 是主流编译器实现虚函数的方式,
C++ 标准并没有规定必须使用它。
示例
以常见的单继承为例:
cpp
class Base {
public:
virtual void f() { /* 基类实现 */ }
virtual void g() { /* 基类实现 */ }
virtual ~Base() = default;
};
class Derived : public Base {
public:
void f() override { /* 派生类实现 */ }
};
可以把虚函数表简化理解为下面这样(暂时省略析构函数和其他信息):
| 虚函数槽位 | Base 的虚表 | Derived 的虚表 |
|---|---|---|
f |
指向 Base::f |
指向 Derived::f |
g |
指向 Base::g |
指向 Base::g |
三个关键点
- 虚表通常属于类。 同一个类的多个对象通常共享对应的虚表,不会每创建一个对象就复制一张表。
- 对象中通常有一个隐藏的虚表指针
vptr。 在这个简单例子里,构造完成的Base对象指向Base的虚表,Derived对象指向Derived的虚表。 - 派生类没重写的虚函数,表项仍指向基类实现。 这里共享的是函数实现,不意味着两个类共用同一张虚表。
虚调用如何选择实现
cpp
Derived d;
Base* p = &d;
p->f(); // 调用 Derived::f
p->g(); // 调用 Base::g
虽然 p 的类型是 Base*,但它指向的是 Derived 对象,虚调用会根据对象对应的虚表选择实现。
编译器如果能确定具体类型,也可能直接调用函数(去虚化),省去查表。
更准确的表述
原来的理解可以改成:
基类声明虚函数后,派生类会继承它的虚函数机制;各个类通常有对应的虚表,派生类的表项根据是否重写,指向派生类或基类的实现。
补充
多重继承时,一个对象可能包含多个 vptr,对象布局会更复杂。
-
几点说明:
-
"编译器直接调用"补充标注为"去虚化"(devirtualization),方便读者搜索相关资料,不需要可以删掉。
文章修改日志:
核心结论(各类有各自的虚表、vtable 不是标准要求、未重写的槽位指向基类实现)都是对的。有几处表述可以更严谨:
-
"各个类通常有对应的虚表":严格说只有多态类(有虚函数或虚基类的类)才有虚表,没有虚函数的普通类没有。这里的上下文是有虚函数的继承体系,所以问题不大,但可以加半句限定。
-
"构造完成的对象指向对应的虚表" :这个说法是对的,而且"构造完成"这个限定很关键。
构造
Derived时,先执行Base的构造函数,此时 vptr 指向Base的虚表,Base构造完后才改指向Derived的虚表。所以在基类构造函数里调用虚函数,不会调用到派生类的重写。这是一个常见的坑,很适合作为补充。
-
表格只是概念模型 :真实的虚表还包含 RTTI 信息(
type_info指针)、offset-to-top 等内容,析构函数在 Itanium ABI(GCC/Clang)下还有 complete 和 deleting 两个条目。文中已经写了"简化理解、省略析构函数和其他信息",所以不算错,但可以明确说一句"实际布局因 ABI 而异"。
-
"虚调用会根据对象对应的虚表选择实现" :更准确的说法是通过对象里的 vptr 找到虚表,再按槽位取函数地址,由对象的动态类型决定调用哪个实现。
可以在"补充"一节加一段:
补充
- 只有多态类才会有虚表和
vptr。 - 构造过程中
vptr会随构造阶段变化:构造Derived时,先执行Base的构造函数,此时vptr指向Base的虚表,因此在基类构造函数中调用虚函数不会分派到派生类的重写。 - 多重继承时,一个对象可能包含多个
vptr,对象布局会更复杂。 - 虚表的具体布局(RTTI 指针、析构函数条目等)由各平台 ABI 决定,上面的表格只是便于理解的简化模型。