2.3 多态与虚表透视


2.3 多态与虚表透视

本节摘要:多态的机器实现是每个多态对象头部的一个虚表指针,指向所属类的虚函数表;虚调用 = 取指针、查表、跳转三步。本节把虚表结构画到条目级,量化虚调用的真实成本,复盘 override 与纯虚函数的用法,并解剖构造期虚调用、切片丢多态、非虚析构三大会计事故。

你将学会

阅读完本节,你应当能够:

  1. 画出含两个虚函数的继承体系里,基类与派生类各自的虚表内容;
  2. 说出一次虚调用在机器层面的三步与大致开销;
  3. 解释多态基类为何必须虚析构,缺了会发生什么;
  4. 用 override 与纯虚函数写出安全的接口类。

一、一个指针如何撑起多态

#include <iostream> #include <memory> #include <vector> class Shape { public: virtual ~Shape() = default; // 多态基类第一戒律 virtual double area() const { return 0; } virtual void draw() const { std::cout << "画图形\n"; } }; class Circle : public Shape { public: Circle(double r) : r_(r) {} double area() const override { return 3.14159 * r_ * r_; } void draw() const override { std::cout << "画圆\n"; } private: double r_; }; int main() { std::vector<std::unique_ptr<Shape>> shapes; shapes.push_back(std::make_unique<Circle>(2)); shapes.push_back(std::make_unique<Shape>()); for (auto& s : shapes) s->draw(); // 同一条语句 不同行为 }

s->draw() 怎么知道该调谁的 draw?秘密在对象头部的虚表指针(vptr)。每个含虚函数的类,编译器为它生成一张虚函数表(vtable)——一个函数指针数组,按虚函数声明顺序登记各自的实现;每个该类的对象头部都嵌一个 vptr 指向本类的表。Circle 对象的 vptr 指 Circle 的表,表里 draw 那格放的是 Circle::draw 的地址。

图:两个对象、两张虚表、一次虚调用

图:两个对象、两张虚表、一次虚调用

理解这张图,三个高频问题迎刃而解:

虚函数多大代价?对象多 8 字节(vptr);每次调用多两次内存读取加一次间接跳转。单次开销个位数纳秒,绝大多数业务代码不必挂怀;真正的代价在阻止内联——普通调用编译器能内联展开,虚调用要等运行时查表,无法内联。热点路径里连续虚调用叠加缓存不友好才需要优化,手段是"先查一次类型再批量处理"或编译期多态(模板,第 3.5 节)。

为什么构造函数不能是虚的?虚调用靠对象里的 vptr,而 vptr 要在构造过程中才被设为本类的表——鸡生蛋问题。构造顺序(基类先构造)决定了构造时 vptr 逐步从基类表切换到派生类表,所以构造函数里调用虚函数只会命中"当前层"的版本,不是最终派生类的版本。析构同理反序进行。

纯虚函数是什么?virtual double area() const = 0; 把表里那一格标记为"无实现",含纯虚函数的类是抽象类,不能实例化——它定义接口契约,强制派生类填表。全部函数纯虚、无数据成员的类就是接口类,Java 的 interface 在 C++ 里的对应物:

class Printable { public: virtual ~Printable() = default; virtual void print(std::ostream&) const = 0; };

二、override:让编译器替你查表

struct Base { virtual void run(int x); }; struct Derived : Base { void run(long x) override; // 编译错误:签名不一致 不是重写 void run(int x) const override; // 编译错误:const 不一致 void run(int x) override; // 正确 };

不带 override 时,前两行会被当成新函数静默通过——基类接口没被重写,多态悄然失效,这类 bug 在深层继承体系里极难发现。C++11 的 override 显式声明"我要重写",签名对不上直接编译报错。所有重写一律写 override,零成本买一份保险。同理 final 禁止进一步重写或继承,给编译器放开内联的可能。

三、非虚析构:金额最大的经典事故

Shape* s = new Circle(2); delete s; // 若 ~Shape 不是 virtual:只调用基类析构

没有虚析构时,delete s 走的是静态类型 Shape 的析构——Circle 自有成员的析构被跳过。若 Circle 拥有堆资源(一个 std::string、一个文件句柄),资源就此泄漏;即便没有,行为也未定义。规矩一句话:只要类有虚函数,析构就必须是虚的virtual ~Shape() = default; 一行解决。反过来,不做基类的类别加 virtual(白白多 8 字节与间接成本)。这条与 2.2 的切片、构造期虚调用并称"多态三大事故",全都源自布局与生命周期没对齐。

虚析构的近亲dynamic_casttypeid 依赖虚表里的类型信息做运行时类型识别,dynamic_cast<Derived*>(base) 失败返回空指针(引用版本抛 bad_cast),用于"不确定向下转型"的场景。能不用尽量不用——需要频繁向下转型通常说明接口设计欠了一层抽象。

💡 关键直觉:把虚表想成"对象自带的说明书目录"。出厂时(构造)目录页码逐步换成本类版本,翻页(虚调用)按目录找到真正的章节。凡是"目录还没换好"(构造析构中间)或"目录被切掉"(对象切片)的时刻,多态都会缺席——这不是语言缺陷,是同一套布局规则的必然推论。

本节要点回顾

  • 一指针一表:vptr 在对象头部,vtable 每类一张,虚调用三步走:取指针、查槽位、跳转。
  • 成本真相:单次纳秒级,真正代价是无法内联;热点路径的批量虚调用才值得优化。
  • 构造期无多态:vptr 逐层切换,构造/析构中调用虚函数只命中当前层。
  • override 必写:签名不一致立即报错,防"静默新函数";接口类用纯虚函数搭骨架。
  • 虚函数必配虚析构:经由基类指针 delete 派生对象,非虚析构直接漏资源。

第二章收官。对象已能诞生、继承、多态地活着;下一章给它们配上真正的资源——堆内存,然后把"谁负责释放"这个 C++ 的世纪难题正面解决掉:RAII、五法则、智能指针、移动语义。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U