☰
C++虚函数表的原理与使用解析
2026/10/21 15:32:29 网站建设 项目流程

前言


"虚函数表"是 C++ 面试和底层学习中绕不开的话题,但关于它的说法里混着大量把实现细节当成语言规则的内容。必须先说清楚:C++ 标准只规定虚函数调用的语义——通过基类指针或引用调用时,实际执行哪个函数由对象的动态类型决定;标准没有规定虚函数表(virtual table,常简称 vtable)、虚表指针(vptr)的存在形式、位置和布局。我们平时讲的"对象头部有一个 vptr、vptr 指向一张函数指针数组",是 Itanium C++ ABI(GCC、Clang 等使用)和 MSVC ABI 的常见实现方式,不是标准条文。


理解这套实现仍然有价值:它能解释对象大小为什么多出一个指针、为什么多重继承下指针转换会调整地址、为什么虚调用通常无法内联、为什么按值传递会发生对象切片。但价值仅限于"解释现象",不能拿来写依赖布局的代码。把对象memcpy到另一块内存、按固定偏移读 vptr、把 vtable 地址当句柄序列化,这些做法都依赖具体实现,换编译器或换版本就可能失效。


本文先讲单继承下的典型布局与调用过程,再讲多重继承的指针调整、构造与析构期间的虚调用规则、虚析构的必要性,最后给出一个可读的示例和一组常见误区。文中所有布局描述都冠以"常见实现",涉及标准语义的地方会明确区分。


一、单继承下的典型布局


在一个常见的实现里,含虚函数的类会生成一张虚函数表,表里按固定顺序存放该类各个虚函数的地址;每个该类的对象里则插入一个隐藏的指针,指向这张表。可以把它想成这样:


对象 Circle vtable for Circle
+----------------+ +----------------------+
| vptr --------------->| &Circle::~Circle |
| r_ (double) | | &Circle::area |
+----------------+ | &Circle::name |
+----------------------+

这个模型解释了三件事。第一,含虚函数的对象通常比同样数据成员的普通对象大一个指针,因为要存 vptr。第二,同一类的所有对象共享同一张表,表本身不占每个对象的内存。第三,调用p->area()时,编译器生成"取出 vptr、按固定槽位取函数地址、间接调用"的序列,槽位在编译期由声明顺序确定。


标准保证的是行为:如果p的动态类型是Circle,p->area()一定调用Circle::area,即使p的静态类型是Shape*。至于这个分派是通过查表、通过缓存、还是被编译器直接解析成直接调用,标准不关心。这就是"as-if 规则":只要可观察行为一致,实现可以任意优化。


也正因为如此,当编译器能证明对象的动态类型时,虚调用可以被去虚化(devirtualization)成普通调用,甚至内联。例如对一个局部对象直接调用obj.virtual_func(),或者对final类、final虚函数调用,编译器有充分信息做这种优化。但这不是保证,取决于优化级别和编译器能力。


二、调用过程与对象切片


// 适用于 C++17
#include <iostream>
#include <memory>

class Shape {
public:
virtual ~Shape() = default;
virtual double area() const = 0;
virtual const char* name() const { return "Shape"; }
};

class Circle : public Shape {
public:
explicit Circle(double r) : r_(r) {}
double area() const override {
return 3.141592653589793 * r_ * r_;
}
const char* name() const override { return "Circle"; }
private:
double r_;
};

class Square : public Shape {
public:
explicit Square(double side) : side_(side) {}
double area() const override { return side_ * side_; }
const char* name() const override { return "Square"; }
private:
double side_;
};

void print_area(const Shape& s) {
std::cout << s.name() << ": " << s.area() << '\n';
}

print_area接收引用,引用在底层通常用地址实现,所以调用时传进来的是Circle对象的地址;函数内部通过这个地址找到 vptr,再找到Circle::area。如果写成按值参数void print_area(Shape s),实参会被切片(object slicing):只拷贝Shape子对象,r_丢失,vptr 也变成Shape的表,调用时要么命中纯虚函数,要么执行基类版本。这是多态必须用指针或引用的根本原因。


纯虚函数在表里的槽位通常被填成一个由运行时库提供的占位函数(例如 Itanium ABI 下的__cxa_pure_virtual),一旦被调用就终止程序。它不是"空指针",也不意味着"调用会返回默认值"。


三、多重继承与指针调整


单继承时,基类子对象通常位于派生对象起始处,基类指针和派生类指针的数值相同。多重继承就不同了:


// 适用于 C++17
struct A {
virtual void fa() { }
int a = 1;
};

struct B {
virtual void fb() { }
int b = 2;
};

struct C : A, B {
void fa() override { }
void fb() override { }
int c = 3;
};

在常见实现中,C对象里有两个 vptr:一个属于A子对象,一个属于B子对象。B子对象不在对象起始位置,所以把一个C*转换成B*时,编译器会调整指针数值,让它指向B子对象。这个调整是标准语义的一部分(转换后必须指向正确的子对象),但具体偏移量是实现细节。


更微妙的是虚调用。通过B*调用fb()时,this指针指向的是B子对象,而C::fb期望的this指向完整的C对象。常见实现会在虚表槽位里放一小段"跳板"(thunk),先把this减去偏移量,再跳到真正的函数。这一切对源代码透明,但解释了为什么多重继承下的虚调用可能比单继承稍复杂,以及为什么不能假设"所有基类指针数值相等"。


虚继承(virtual inheritance)更复杂,常见实现使用虚基类偏移表来定位共享的虚基类子对象。入门阶段只需知道:虚继承的对象布局高度依赖实现,不要做任何基于偏移的假设。


四、构造、析构与虚析构函数


在构造和析构期间,虚调用不会"穿越"到尚未构造或已经销毁的派生部分。标准规定:在基类子对象的构造阶段,对象的动态类型被视为当前正在构造的类;析构阶段同理。因此在Base的构造函数里调用虚函数,命中的是Base的版本,而不是Derived的重写版本。


// 适用于 C++17
struct Base {
Base() { /* 此处调用虚函数只会到 Base */ }
virtual void f() { }
virtual ~Base() = default;
};

struct Derived : Base {
Derived() {
f(); // 此时 Derived 子对象已构造,动态类型是 Derived
}
void f() override { }
};

这条规则不是"编译器偷懒",而是安全要求:基类构造时派生类的成员还没初始化,若允许调用派生类重写版本,函数会读到未初始化的数据。


析构函数的情况更危险。若基类的析构函数不是虚函数,通过基类指针delete一个派生类对象是未定义行为——派生类的析构函数可能根本不会被调用,派生类持有的资源泄漏。标准明确要求:多态基类的析构函数必须是虚函数(或受保护的)。写成virtual ~Base() = default;即可。


反过来,在析构函数里调用纯虚函数是未定义行为,因为对象已经进入基类析构阶段,纯虚槽位可能指向终止函数。


虚函数的代价与取舍


虚函数不是"免费"的,但也不像某些说法那样昂贵到不可用。它的代价主要有三处:每个多态对象多一个指针的存储;每个多态类一张表(只读数据,通常放在共享库的只读段);以及一次间接调用,编译器在无法确定动态类型时不能内联。


间接调用还会影响指令缓存和分支预测:目标地址不固定时,处理器难以提前取指。但具体影响取决于调用点是否"单态"(总是同一个目标)、是否被去虚化,不能一概而论。要评估真实开销,应当用实际负载配合perf stat或 Google Benchmark 测量,并注意测量环境、优化级别和 CPU 频率的干扰。本文不给出任何未经测量的百分比。


设计上,虚函数适合"运行时才知道具体类型"的场景,例如插件接口、图形对象、状态机。若类型在编译期就能确定,模板和std::variant往往是更轻的选择。C++20 的 concepts 让编译期多态更容易表达,但它不能替代需要运行时分派的场景。


常见坑点



  1. ❌ 多态基类的析构函数不写virtual,却通过基类指针delete派生对象。✅ 写virtual ~Base() = default;,否则行为未定义。



  1. ❌ 认为标准规定了 vptr 一定在对象开头、vtable 槽位顺序固定。✅ 这些都是 ABI 实现细节;不要memcpy多态对象、不要序列化原始字节。



  1. ❌ 在基类构造函数里调用虚函数,期待执行派生类版本。✅ 构造期间动态类型是当前类,只会调用基类版本;需要派生类参与就用两阶段初始化。



  1. ❌ 重写函数时漏写const或参数类型略有差异,以为在重写。✅ 加上override,签名不匹配时编译器直接报错,而不是悄悄变成新函数。



  1. ❌ 按值传递多态对象,派生部分被切片。✅ 用const Base&或Base*;容器里存指针或std::unique_ptr<Base>。



  1. ❌ 对非多态类型使用dynamic_cast。✅ 源类型必须是多态类型(至少有一个虚函数),否则编译失败。



  1. ❌ 认为虚函数调用一定无法内联、一定比普通函数慢数倍。✅ 编译器在能确定动态类型时可以去虚化;开销要用实际测量判断。



  1. ❌ 在析构函数里调用纯虚函数,或让对象在基类析构阶段发生虚调用。✅ 纯虚调用是未定义行为;析构阶段只做基类自己的清理。


总结




主题标准规定常见实现



动态分派由动态类型决定调用哪个重写vptr + vtable 查表

对象大小未规定多一个 vptr

多重继承转换必须指向正确子对象指针加偏移、thunk 调整

构造/析构期虚调用只到当前构造/析构的类构造过程中更新 vptr

虚析构多态基类必须可虚析构表中占一个槽位

去虚化允许,只要行为一致已知动态类型时直接调用



虚函数表是理解 C++ 对象模型的一把钥匙,但这把钥匙只能用来"看懂",不能用来"依赖"。写代码时坚持标准语义:多态基类加虚析构、重写加override、传递用引用或指针、不碰对象布局。这样无论编译器内部怎么实现,程序都是正确的。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询