资讯动态

C++虚函数表的原理与使用解析

发布时间:2026/10/21 15:32:34 来源:尧图企业网站定制
前言虚函数表是 C 面试和底层学习中绕不开的话题但关于它的说法里混着大量把实现细节当成语言规则的内容。必须先说清楚C 标准只规定虚函数调用的语义——通过基类指针或引用调用时实际执行哪个函数由对象的动态类型决定标准没有规定虚函数表virtual table常简称 vtable、虚表指针vptr的存在形式、位置和布局。我们平时讲的对象头部有一个 vptr、vptr 指向一张函数指针数组是 Itanium C ABIGCC、Clang 等使用和 MSVC ABI 的常见实现方式不是标准条文。理解这套实现仍然有价值它能解释对象大小为什么多出一个指针、为什么多重继承下指针转换会调整地址、为什么虚调用通常无法内联、为什么按值传递会发生对象切片。但价值仅限于解释现象不能拿来写依赖布局的代码。把对象memcpy到另一块内存、按固定偏移读 vptr、把 vtable 地址当句柄序列化这些做法都依赖具体实现换编译器或换版本就可能失效。本文先讲单继承下的典型布局与调用过程再讲多重继承的指针调整、构造与析构期间的虚调用规则、虚析构的必要性最后给出一个可读的示例和一组常见误区。文中所有布局描述都冠以常见实现涉及标准语义的地方会明确区分。一、单继承下的典型布局在一个常见的实现里含虚函数的类会生成一张虚函数表表里按固定顺序存放该类各个虚函数的地址每个该类的对象里则插入一个隐藏的指针指向这张表。可以把它想成这样对象 Circle vtable for Circle---------------- ----------------------| vptr ---------------| Circle::~Circle || r_ (double) | | Circle::area |---------------- | Circle::name |----------------------这个模型解释了三件事。第一含虚函数的对象通常比同样数据成员的普通对象大一个指针因为要存 vptr。第二同一类的所有对象共享同一张表表本身不占每个对象的内存。第三调用p-area()时编译器生成取出 vptr、按固定槽位取函数地址、间接调用的序列槽位在编译期由声明顺序确定。标准保证的是行为如果p的动态类型是Circlep-area()一定调用Circle::area即使p的静态类型是Shape*。至于这个分派是通过查表、通过缓存、还是被编译器直接解析成直接调用标准不关心。这就是as-if 规则只要可观察行为一致实现可以任意优化。也正因为如此当编译器能证明对象的动态类型时虚调用可以被去虚化devirtualization成普通调用甚至内联。例如对一个局部对象直接调用obj.virtual_func()或者对final类、final虚函数调用编译器有充分信息做这种优化。但这不是保证取决于优化级别和编译器能力。二、调用过程与对象切片// 适用于 C17#include iostream#include memoryclass 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一旦被调用就终止程序。它不是空指针也不意味着调用会返回默认值。三、多重继承与指针调整单继承时基类子对象通常位于派生对象起始处基类指针和派生类指针的数值相同。多重继承就不同了// 适用于 C17struct 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的重写版本。// 适用于 C17struct 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往往是更轻的选择。C20 的 concepts 让编译期多态更容易表达但它不能替代需要运行时分派的场景。常见坑点❌ 多态基类的析构函数不写virtual却通过基类指针delete派生对象。✅ 写virtual ~Base() default;否则行为未定义。❌ 认为标准规定了 vptr 一定在对象开头、vtable 槽位顺序固定。✅ 这些都是 ABI 实现细节不要memcpy多态对象、不要序列化原始字节。❌ 在基类构造函数里调用虚函数期待执行派生类版本。✅ 构造期间动态类型是当前类只会调用基类版本需要派生类参与就用两阶段初始化。❌ 重写函数时漏写const或参数类型略有差异以为在重写。✅ 加上override签名不匹配时编译器直接报错而不是悄悄变成新函数。❌ 按值传递多态对象派生部分被切片。✅ 用const Base或Base*容器里存指针或std::unique_ptrBase。❌ 对非多态类型使用dynamic_cast。✅ 源类型必须是多态类型至少有一个虚函数否则编译失败。❌ 认为虚函数调用一定无法内联、一定比普通函数慢数倍。✅ 编译器在能确定动态类型时可以去虚化开销要用实际测量判断。❌ 在析构函数里调用纯虚函数或让对象在基类析构阶段发生虚调用。✅ 纯虚调用是未定义行为析构阶段只做基类自己的清理。总结主题标准规定常见实现动态分派由动态类型决定调用哪个重写vptr vtable 查表对象大小未规定多一个 vptr多重继承转换必须指向正确子对象指针加偏移、thunk 调整构造/析构期虚调用只到当前构造/析构的类构造过程中更新 vptr虚析构多态基类必须可虚析构表中占一个槽位去虚化允许只要行为一致已知动态类型时直接调用虚函数表是理解 C 对象模型的一把钥匙但这把钥匙只能用来看懂不能用来依赖。写代码时坚持标准语义多态基类加虚析构、重写加override、传递用引用或指针、不碰对象布局。这样无论编译器内部怎么实现程序都是正确的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑