面向对象(三):多态与虚函数

原理

虚函数表(vtable)的结构

每个包含虚函数的类(或从虚基类派生的类)有一张虚函数表。vtable 是一个函数指针数组,存储在只读数据段(.rodata)。该类的所有对象共享同一张 vtable。

Base 对象                Base vtable
┌──────────┐            ┌──────────────────┐
│ vptr ────┼───────────→│ &Base::func1      │ ← slot 0
├──────────┤            │ &Base::func2      │ ← slot 1
│ baseData │            │ &type_info(Base)  │ ← RTTI
└──────────┘            └──────────────────┘

Derived 对象             Derived vtable
┌──────────┐            ┌──────────────────┐
│ vptr ────┼───────────→│ &Derived::func1   │ ← slot 0 (重写)
├──────────┤            │ &Base::func2      │ ← slot 1 (未重写)
│ baseData │            │ &Derived::func3   │ ← slot 2 (新增)
├──────────┤            │ &type_info(Derived)│ ← RTTI
│derivedData│           └──────────────────┘
└──────────┘

关键事实:

  • vptr 位于对象内存的起始位置(偏移 0)
  • 每个类只有一张 vtable,所有对象共享
  • 非虚函数不在 vtable 中,调用时静态绑定
  • vptr 在构造函数执行时初始化——先初始化为基类 vtable,再切换为派生类 vtable

虚函数调用的汇编路径

; ptr->func1()  — 虚函数调用需要两次内存读
mov    rax, [rdi]        ; rax = vptr(读 this 前 8 字节)
mov    rax, [rax]        ; rax = vtable[0](读 vtable 第一项)
call   rax                ; 间接跳转到实际函数
 
; ptr->nonVirtual()  — 非虚函数只需一次直接调用
call   Base::nonVirtual

虚函数调用的开销:额外一次指针解引用 + 无法内联优化。对于热路径中的紧密循环,这个开销可能显著;对于大多数场景可忽略不计。

构造/析构期间的 vptr 行为

构造函数中调用的虚函数使用的是当前类的版本,不是最终派生类的版本:

class Base {
    Base() { virtualFunc(); } // 调用的是 Base::virtualFunc
    virtual void virtualFunc() {}
};
class Derived : public Base {
    Derived() { virtualFunc(); } // 调用的是 Derived::virtualFunc
    void virtualFunc() override {}
};

原因:执行 Base 构造函数时,vptr 指向 Base 的 vtable。Base 构造完成后进入 Derived 构造时,vptr 才更新为 Derived 的 vtable。析构过程相反——先切换到 Derived 的 vtable,执行完派生类析构后再切回 Base 的 vtable。

RTTI 与 type_info

vtable 中还存储 type_info 对象,为 dynamic_casttypeid 提供支撑。dynamic_cast 通过查询 vtable 中的 RTTI 信息确定运行时类型,实现安全的向下转型。

; dynamic_cast<Derived*>(basePtr) 的简化流程
; 1. 读取 vptr → 获取 vtable 地址
; 2. 从 vtable 中读取 type_info
; 3. 遍历继承层级比较类型信息
; 4. 返回调整后的指针或 nullptr

禁用 RTTI(-fno-rtti)后 dynamic_casttypeid 不可用。

虚函数默认参数的陷阱

默认参数是静态绑定的——在编译期根据指针/引用的静态类型决定。虚函数本身是动态绑定的:

Base* ptr = new Derived();
ptr->print(10);  // 使用 Base::print 的默认参数(10),但调用 Derived::print
// 结论:永远不要在重写的虚函数中改变默认参数值

语法

virtual 与 override

class Base {
public:
    virtual void draw() const { /* ... */ }    // 声明虚函数
    virtual std::string name() const = 0;      // 纯虚函数
    virtual ~Base() = default;                  // 虚析构(必须)
};
 
class Derived : public Base {
public:
    void draw() const override { /* ... */ }   // 重写并使用 override 检查
    std::string name() const override { return "Derived"; }
};

纯虚函数与抽象类

class Shape {                    // 抽象类——不能实例化
public:
    virtual double area() const = 0;   // 纯虚函数
    virtual ~Shape() = default;
};

纯虚函数可以有实现(virtual void f() = 0 {}),派生类可选择调用默认实现。

final 阻止重写(C++11)

class Base {
    virtual void critical() final { /* 不允许被重写 */ }
};

协变返回类型

class Base {
    virtual Base* clone() const { return new Base(*this); }
};
class Derived : public Base {
    Derived* clone() const override { return new Derived(*this); }
    // 返回类型从 Base* 协变为 Derived*
};

dynamic_cast 与 typeid

// 对指针:失败返回 nullptr
if (auto* d = dynamic_cast<Derived*>(basePtr)) { d->derivedOnly(); }
 
// 对引用:失败抛 std::bad_cast
Derived& d = dynamic_cast<Derived&>(*basePtr);
 
// typeid
if (typeid(*basePtr) == typeid(Derived)) { /* 精确类型判断 */ }

实践

力扣题目:力扣数组题(可设计 Student 基类 + 不同评价标准的派生类),力扣排序(抽象比较逻辑)。

AI 自检:1) 给 AI 一个包含虚函数的类层次,要求 AI 画出完整的 vtable 布局;2) 问”为什么构造/析构函数中不应该调用虚函数”——要求 AI 从 vptr 切换时间线来说明;3) 给虚函数添加默认参数的危险代码,问 AI 为什么输出不符合预期。

class A {
public:
    virtual void f(int x = 10) { cout << "A: " << x << endl; }
};
class B : public A {
public:
    void f(int x = 20) override { cout << "B: " << x << endl; }
};
A* p = new B(); p->f(); // 输出?为什么?

建议先阅读05_面向对象(一)类与对象基础 — vptr 在对象布局中的位置;06_面向对象(二)继承 — 派生类对象的完整内存结构;ASM_03_C++在汇编层的体现 — 虚调用在汇编层的完整路径。