写过一段时间 C++ 的人大概都有这么个阶段:语法书翻到“多态”那一章,看完虚函数、纯虚函数、虚析构,觉得自己懂了,合上书也能背出“同一接口,不同实现”。可真到了项目里,一个基类指针数组跑起来结果不对,或者 delete 完之后内存曲线不降反升,才发现当初那点理解只够应付选择题。这篇笔记不打算复述教材,我想把多态这件事从“怎么用”往下再挖一层:编译器到底替你做了什么、虚函数表长什么样、哪几个地方几乎人人都会踩、以及什么时候你根本不该用它。不管你是刚学完继承、准备上手写第一个接口类,还是已经写了几年代码但一直没细究过底层,下面这些内容应该都能对上你遇到过的某些场景。
1. 从一坨 if-else 说起:多态要解决的到底是什么问题
1.1 一个典型的“类型分支”场景
先看一段几乎所有人都写过的代码。做一个图形计算的小模块,要算各种形状的面积,最直觉的写法是搞一个枚举加一个结构体,然后在函数里 switch:
enum class ShapeType { Circle, Rect, Triangle }; struct Shape { ShapeType type; double a, b; // 不同形状复用字段,含义各不相同 }; double area(const Shape& s) { switch (s.type) { case ShapeType::Circle: return 3.14159265 * s.a * s.a; case ShapeType::Rect: return s.a * s.b; case ShapeType::Triangle: return 0.5 * s.a * s.b; } return 0.0; }刚写出来的时候这段代码没有任何问题,甚至比多态版本更短、更快、更好读。真正的问题出在第三次需求变更之后:产品要加圆环、要加多边形、要加带圆角的矩形。于是你要动枚举,要往Shape里塞更多用不上的字段,要去全局搜索所有对type做 switch 的地方挨个补分支,还得祈祷没有哪个分支忘了写。这种写法的扩展点集中在函数内部,每加一种类型都要回到已有代码里改,改一次就有一次引入回归的风险。
多态版本长这样:
class Shape { public: virtual ~Shape() = default; virtual double area() const = 0; }; class Circle : public Shape { public: explicit Circle(double r) : r_(r) {} double area() const override { return 3.14159265 * r_ * r_; } private: double r_; }; class Rect : public Shape { public: Rect(double w, double h) : w_(w), h_(h) {} double area() const override { return w_ * h_; } private: double w_, h_; };同样的功能,代码行数翻了一倍。所以这不是“更优雅”的问题,而是扩展点的位置换了:switch 方案的扩展点在函数内部,多态方案的扩展点在一个新的类文件里。加一种形状,你只需要新增一个文件,area()的调用方一行都不用改,甚至不用重新编译。
1.2 多态的本质:把“选哪个函数”从运行期代码搬到类型系统里
很多人把多态理解成“一个指针能指向不同对象”,这个描述只说对了一半。更准确的说法是:多态把“调用哪个具体实现”这个决策,从运行期的条件判断,转移到了编译期的类型信息里,运行期只做一次查表跳转。
对比一下两种方案的执行路径。switch 方案是:读枚举值 → 比较 → 跳转 → 执行。这个“比较”是实实在在的指令,而且类型越多比较越多,虽然编译器可能优化成跳转表,但那份表是代码的一部分,改类型就得改表。多态方案是:读对象头部的虚表指针 → 按固定偏移取函数地址 → 间接调用。整个过程没有任何“判断”,因为对象自己就记住了自己是谁。
这个转变带来的最大好处不是性能,而是调用方不需要知道有哪些类型。写totalArea(const std::vector<std::unique_ptr<Shape>>& shapes)这个函数的时候,你完全不需要知道未来会有什么形状,也不需要为每一种新形状改一行代码。在大型项目里,这条性质的价值远远超过那点虚调用开销——它把“修改”这件事局部化了。
1.3 接口与实现分离带来的编译期收益
还有一个容易被忽略的收益:编译依赖。用 switch 方案时,Shape结构体和枚举定义在一个头文件里,所有用到它的 .cpp 都 include 这个头文件。加一个枚举值,理论上所有包含它的翻译单元都要重新编译。在一个几百万行的工程里,这种“改一行等十分钟”的体验是非常具体的痛。
多态方案里,调用方只 include 抽象基类的头文件,那个头文件里只有虚函数声明和虚析构,没有任何具体实现的信息。新增Polygon类时,调用方的 .cpp 根本不知道它存在,也不需要重新编译,链接的时候把新的目标文件接上就行。这就是所谓的“面向接口编程”在工程层面的真实收益,不是什么空泛的设计口号。
注意:这个收益有前提——调用方不能为了
make_unique<Circle>()而 includeCircle的头文件。如果创建对象的地方和调用逻辑混在一起,编译依赖还是会传染。常见的做法是把对象的创建收敛到一个工厂函数里,调用方只拿到unique_ptr<Shape>。
2. 虚函数表的真实布局:多态在内存里长什么样
2.1 对象里的那个隐藏指针
标准里从头到尾没提过“虚函数表”这个词,它属于实现细节。但主流编译器(GCC、Clang、MSVC)在这件事上的做法高度一致,理解它对于排查问题是必要的。
规则很简单:一个类只要含有虚函数,或者从含有虚函数的基类派生,它的每个对象里就会多一个隐藏的指针(通常叫 vptr),指向该类共享的一张函数指针数组(vtable)。vtable 是类级别的,全局只有一份,放在只读数据段;vptr 是对象级别的,每个对象都有。
这就解释了一个常被问到的现象:一个空类sizeof是 1,但一个只含虚函数的空类sizeof在 64 位平台上是 8。多出来的这 8 个字节就是 vptr。写代码的时候算内存占用,这一条千万别忘:
struct Empty { }; // sizeof == 1 struct WithVirtual { virtual void f(); }; // sizeof == 8(64 位)在 GCC/Clang 的实现里,vptr 通常位于对象的起始位置(偏移 0),MSVC 在单继承情况下也是如此。这个位置选择不是随意的——把 vptr 放在偏移 0,意味着“从对象地址取一个指针大小”就能拿到虚表,代价最低。多继承的时候情况会复杂,后面再说。
2.2 一次虚函数调用的完整路径
写下p->area()这一行,编译器生成的大致是这么三步:
- 从
p指向的内存起始处取出 vptr; - 用 vptr 加上
area在虚表中的槽位偏移取出函数地址; - 通过这个地址做间接调用,并把
p作为this传进去。
关键在于第二步的“槽位偏移”是编译期就确定的常量。编译器知道area是Shape虚表里的第几个槽,所以生成的代码里没有循环、没有字符串比较、没有任何查找逻辑,就是一次“基址加固定偏移”的内存读取。复杂度是 O(1),而且是真正的 O(1)。
这也解释了为什么虚函数不能是模板成员函数:模板要到实例化时才知道具体形态,而虚表的槽位布局必须在看到类定义时就定死。同理,虚函数也不能是 static 的,因为静态成员函数没有this,拿不到 vptr。
2.3 单继承与多继承的差异
单继承的情况最干净:派生类的虚拟表在基类表的基础上扩展,重写了的函数把对应槽位的指针替换掉,新增的虚函数追加在后面。布局是兼容的,所以基类指针指向派生类对象时,指针值不需要调整。
多继承就麻烦了。假设Derived同时继承Base1和Base2,两个基类都有虚函数,那么Derived对象里会有两个 vptr,分别对应两个基类子对象。做指针转换的时候,编译器要负责加上偏移量:
class Base1 { public: virtual void f(); int a; }; class Base2 { public: virtual void g(); int b; }; class Derived : public Base1, public Base2 { public: void f() override; void g() override; }; Derived d; Base1* p1 = &d; Base2* p2 = &d; // p1 和 p2 的地址值通常不相等,p2 指向对象内部的第二个子对象这件事的实际影响是:不要用void*去中转多态对象指针。static_cast<void*>(p2)之后再转回来,如果转成了Derived*,编译器没法帮你做偏移修正,结果是错的。同理,跨模块传递对象指针时如果在中间层做了类型擦除,一定要保证擦除和恢复用的是同一类型。
2.4 手动把虚表打出来看看
理论说再多不如看一眼。下面这段代码依赖具体 ABI,不保证可移植,纯粹是为了观察,别写进生产代码:
#include <cstdio> class Demo { public: virtual void first() { std::puts("Demo::first"); } virtual void second() { std::puts("Demo::second"); } virtual ~Demo() {} int data = 0x1234; }; int main() { Demo d; void** vtbl = *reinterpret_cast<void***>(&d); using Fn = void (*)(Demo*); reinterpret_cast<Fn>(vtbl[0])(&d); // Demo::first reinterpret_cast<Fn>(vtbl[1])(&d); // Demo::second std::printf("sizeof(Demo) = %zu\n", sizeof(Demo)); }跑一下你会看到两行输出和sizeof(Demo) = 16(64 位下 vptr 8 字节加一个 int 加 4 字节填充)。把~Demo()改成非虚的,sizeof可能不变,但虚表会少一个槽,vtbl[2]就是别的函数了。这个实验能让你非常直观地记住:虚析构函数也是要占槽位的,一个类只要加了虚析构,它的虚表布局就和没加时不一样了。
提示:GDB 里有
info vtbl <对象表达式>命令,能直接列出某个对象的虚表内容,排查“为什么调到了错的函数”这类问题时特别省事。Clang 还有-fdump-vtable-layouts可以输出虚表布局。
3. 重载、重写、隐藏:三个词背后是三套完全不同的规则
3.1 重载:同一个作用域里的名字复用
重载(overload)说的是在同一个作用域内,多个函数名字相同但参数列表不同。编译器根据调用的实参类型挑选最匹配的那个,这个选择完全发生在编译期。
有个经典问题:为什么不能只靠返回值类型区分重载?因为函数调用表达式里,返回值类型通常不参与决策。f(1)这一句,编译器看到的是“调用名字叫 f 的函数,参数是一个 int”,它得在候选集里挑一个;如果两个候选只差返回值,那挑哪个都说得通,编译器没法选。
还有一点容易被忽略:重载决议看的是静态类型。const对象调用const成员函数,非const对象优先匹配非const版本,这套规则跟虚函数机制完全无关,是两码事。
3.2 重写:替换虚表里的槽位
重写(override)的条件比很多人想象的严格,四条全部满足才算:
- 基类那个函数必须是
virtual的(或者是通过继承链传递下来的虚函数); - 函数名相同;
- 参数列表完全相同(包括
const修饰、引用限定符、异常规格); - 返回类型相同,或者满足协变规则(返回基类指针/引用时可以改成派生类指针/引用)。
只要有一条不满足,你以为的“重写”就变成了“新增一个虚函数”,虚表里多一个槽,基类指针调用时走的还是原来的实现。这种 bug 极其难查,因为代码看起来完全正常。
C++11 引入的override关键字就是为这件事准备的:把它写在函数声明后面,编译器会强制检查“这个函数是否真的重写了基类的某个虚函数”,不满足就报错。这个关键字没有任何运行期开销,纯编译期检查,没有理由不写。
class Base { public: virtual void run(int x) const; }; class Derived : public Base { public: void run(int x); // 少了 const,这是新增而不是重写 void run(int x) const override; // 这个才是重写 };3.3 隐藏:派生类把基类的整个名字遮住了
隐藏(hide)是三者里最容易误伤的。规则是:派生类作用域里出现了某个名字,就会把基类作用域里所有同名的东西全部遮住,不管参数列表是否相同。
class Base { public: virtual void process(int v) { /* ... */ } void process(double v) { /* ... */ } }; class Derived : public Base { public: void process(int v) override { /* ... */ } }; Derived d; d.process(3.14); // 编译错误:基类的 process(double) 被隐藏了解决办法是using Base::process;把基类的名字引入派生类作用域:
class Derived : public Base { public: using Base::process; void process(int v) override { /* ... */ } };这个坑在实际项目里出现频率很高,特别是在给已有类加新重载的时候——原本能编译的调用,加了个重写函数之后就报错了,很多人第一反应是“我什么都没改啊”。
3.4 三者的对照
| 维度 | 重载 | 重写 | 隐藏 |
|---|---|---|---|
| 发生位置 | 同一作用域 | 基类与派生类之间 | 基类与派生类之间 |
| 是否要求虚函数 | 不要求 | 要求 | 不要求 |
| 函数签名 | 必须不同 | 必须相同 | 名字相同即可 |
| 决议时机 | 编译期 | 运行期 | 编译期 |
| 典型症状 | 挑错了重组 | 实际没重写成功 | 基类版本调不到 |
还有一个隐藏得极深的坑:默认参数是静态绑定的。看这段:
class Base { public: virtual void draw(int quality = 1) { /* 高精度 */ } }; class Derived : public Base { public: void draw(int quality = 0) override { /* 低精度 */ } }; Base* p = new Derived(); p->draw(); // 调用 Derived::draw,但 quality 用的是 Base 里的 1函数的实现按动态类型走,默认参数却按静态类型取。这种“一半动态一半静态”的行为是设计上的历史包袱,能不用默认参数就别在虚函数上用。
4. 虚析构函数:多态体系里翻车最集中的地方
4.1 基类指针 delete 派生类对象到底发生了什么
看这段代码,它几乎出现在每一本 C++ 教材里,但还是有人栽:
class Base { public: ~Base() { std::puts("~Base"); } // 注意:不是虚函数 }; class Derived : public Base { public: ~Derived() { delete[] buf; std::puts("~Derived"); } private: char* buf = new char[4096]; }; Base* p = new Derived(); delete p; // 未定义行为在绝大多数实现上,这段代码只打印~Base,~Derived压根没执行,那块 4096 字节的堆内存直接泄漏。原因很直接:delete p的时候,编译器只知道p的静态类型是Base,它能看到的析构函数是Base::~Base。因为析构函数不是虚的,它不会去查虚表,直接静态绑定就调了基类版本。
标准把这种情况定为未定义行为,所以理论上编译器可以干出任何事,实际表现通常就是上面这种“静默泄漏”——不崩溃、不报错、只是内存慢慢涨,这种问题在生产环境里排查起来非常磨人。
修法只有一个字:
class Base { public: virtual ~Base() = default; };加了virtual之后,delete p会走虚表找到Derived::~Derived,先执行派生类析构函数体,然后自动逐层向上调用基类析构函数。整条链完整跑完,堆内存正确释放。
4.2 一个实用的判断口诀
我自己总结的判断标准是:只要这个类有可能被基类指针 delete,析构函数就必须是虚的。展开说就是:
| 场景 | 析构函数要不要虚 |
|---|---|
| 类里至少有一个虚函数 | 必须虚 |
| 类要作为多态基类被使用 | 必须虚 |
| 类只作为值类型用,不通过指针删除 | 不需要虚 |
类被final修饰,不会有人继承 | 不需要虚 |
反过来,一个类如果没有虚函数、也不打算被继承,加虚析构反而是负担:多一个 vptr 让对象变大,破坏标准布局(standard-layout),影响它和 C 结构体的互操作性。所以“析构函数一律加 virtual”这个习惯也不好,要分情况。
还有一种特殊情况:纯虚析构函数。听起来矛盾,但它是合法的,而且有用——它让类变成抽象类(不能直接实例化),同时仍然是虚函数能正确分派。
class Interface { public: virtual void run() = 0; virtual ~Interface() = 0; // 纯虚析构 }; Interface::~Interface() = default; // 必须提供定义,否则链接错误为什么纯虚函数还要给定义?因为派生类析构的时候,最后一步一定要调用基类析构函数,哪怕它是纯虚的。很多人在头文件里写完virtual ~Interface() = 0;就跑了,然后在一个完全不相干的 cpp 里看到链接错误,对着错误信息的函数名找半天。
4.3 构造和析构函数里调用虚函数:一个反直觉的结果
这是面试常客,也是实际代码里容易出错的地方:
class Base { public: Base() { init(); } virtual void init() { std::puts("Base::init"); } }; class Derived : public Base { public: void init() override { std::puts("Derived::init"); } }; Derived d; // 输出 Base::init,不是 Derived::init直觉上Derived对象的init应该是多态的,应该输出Derived::init。实际不会。
原因藏在对象构造的顺序里。Derived对象的构造过程分几步:先构造基类子对象,这时候派生类部分还不存在,vptr 被设置为Base的虚表;基类构造完成后,vptr 被改成Derived的虚表;然后才进入派生类构造函数体。所以在Base::Base()里调用init(),查表查到的是Base::init。
如果init是纯虚的,那更糟:这时候虚表里对应的槽是空的或者是个占位函数,运行期直接崩,或者编译器给你一个“pure virtual method called”的报错。
析构阶段是镜像的:~Derived执行完之后,vptr 会被改回Base的虚表,然后再执行~Base。所以基类析构函数里调用虚函数,调用的是基类版本。
实践上有个更隐蔽的变体:不要在构造/析构函数里调用任何会间接触发虚函数的函数,比如启动一个线程,线程里调用了this->init()。这类问题在代码审查时几乎看不出来,只能靠规范约束。
5. 纯虚函数与接口设计:几个不写在教材里的取舍
5.1 纯虚函数可以有实现
先说一个很多人不知道的事实:= 0并不意味着“这个函数没有函数体”。纯虚函数完全可以有定义,只是必须在类外写:
class Shape { public: virtual double area() const = 0; virtual void describe() const = 0; }; // 合法的类外定义 void Shape::describe() const { std::printf("面积 = %.2f\n", area()); }派生类仍然必须重写describe(否则它自己也是抽象类),但重写版本里可以显式调用Shape::describe()复用那段通用逻辑。这是一个很实用的技巧:接口类里放一份“通用骨架”,派生类只需要实现真正差异化的部分。
class Circle : public Shape { public: double area() const override { return 3.14159265 * r_ * r_; } void describe() const override { std::puts("[圆形]"); Shape::describe(); // 复用基类逻辑 } private: double r_ = 1.0; };5.2 接口类里该放什么、不该放什么
我在好几个项目里见过这样的写法:一个“接口”基类里塞满了数据成员、构造函数、一堆非虚的辅助函数。这种类称不上接口,它就是个普通基类,只是恰好有几个虚函数。
接口类我个人的写法是:只放纯虚函数 + 虚析构,不放数据成员,不放构造函数。理由是接口的意义在于“调用方只需要知道有哪些操作”,一旦带上数据成员,实现类就被绑死了内部布局,而且调用方为了构造对象还得关心这些成员,抽象的边界就模糊了。
class ILogger { public: virtual ~ILogger() = default; virtual void write(const std::string& msg) = 0; virtual void flush() = 0; };接口宽度也要克制。一个只有两个函数的接口,实现起来轻松,Mock 起来也快;一个二十个函数的接口,每个实现类都得写一遍,测试的时候还得把二十个都桩掉。宁可多定义几个窄接口再组合,也别造一个大而全的类。
5.3 对象切片:多态对象千万别按值传
这个坑非常经典:
void process(Shape s) { // 按值传参 s.area(); } Circle c(2.0); process(c); // 编译可能通过,但 Shape 部分被切掉,area 是纯虚,行为出错按值传递基类类型时,拷贝构造只拷贝基类那部分,派生类的成员和 vptr 全部丢失,这叫对象切片(object slicing)。修复方式就是永远按引用或指针传多态对象。
配套的还有容器:std::vector<Shape>存不了多态对象,因为 vector 要求元素类型是完整可拷贝的定长类型。要存多态对象,只能用std::vector<std::unique_ptr<Shape>>或者std::vector<std::shared_ptr<Shape>>。
注意:用
shared_ptr<Base>管理new Derived时,正确写法是std::make_shared<Derived>(...)或者std::shared_ptr<Base>(new Derived(...))。如果你把一个unique_ptr<Derived>转成shared_ptr<Base>,得保证转换发生在上游,别让指针在中途以Base*的形式裸奔过——那会导致 deleter 类型错误,析构时不走派生类版本。
6. 多态不是免费的:什么时候该换别的方案
6.1 虚调用的真实开销在哪
先把结论摆出来:间接跳转本身在现代 CPU 上通常不贵,贵的是它阻止了内联。
一次虚调用的机器码大概是“三步”:读 vptr、读表项、call寄存器。间接分支预测器对同一调用点反复调用同一类型的情况命中率很高,所以单纯比较“一次虚调用比一次直接调用慢多少”意义不大,差别经常落在几个时钟周期内。
但一旦涉及内联,性质就变了。编译器看不到被调函数的实体,就没法做内联展开、常量传播、循环不变量外提、甚至没法判断这个函数有没有副作用。在一个热点循环里,虚调用可能让整个循环体的优化都失效,最后差距可能是几倍而不是几个周期。
所以判断标准应该是:这个调用点是不是在热点路径上,以及它能不能被优化掉。一个每秒调用一万次的接口,虚函数完全没问题;一个在内层循环里跑了十亿次的简单计算,就值得考虑换方案。
6.2 CRTP:编译期就定死实现
CRTP(奇异递归模板模式)的思路是把派生类自己作为模板参数传给基类,这样基类在编译期就知道真正的类型是谁:
template <typename Derived> class ShapeBase { public: double area() const { return static_cast<const Derived*>(this)->areaImpl(); } }; class Circle : public ShapeBase<Circle> { public: double areaImpl() const { return 3.14159265 * r_ * r_; } private: double r_ = 1.0; };static_cast是编译期完成的,没有任何运行期查表。ShapeBase<Circle>::area()可以被完全内联进调用点,r_的值在编译期甚至可能被常量折叠。代价是ShapeBase和Circle在类型系统里是绑死的,你没法用一个ShapeBase*去装不同派生类——它没有统一的基类类型。
这套方案在数学库、序列化框架、线性代数库这类“性能敏感 + 类型在编译期已知”的场景里用得非常多。你得接受两个缺点:一是不能运行时混装;二是每个实例化都会生成一份代码,类型多了二进制体积会膨胀。
6.3 std::variant + std::visit:封闭集合的值语义方案
C++17 之后多了一个选择:把可能的类型枚举出来,用std::variant装:
#include <variant> #include <vector> #include <type_traits> struct Circle { double r; }; struct Rect { double w, h; }; using Shape = std::variant<Circle, Rect>; double area(const Shape& s) { return std::visit([](const auto& v) -> double { using T = std::decay_t<decltype(v)>; if constexpr (std::is_same_v<T, Circle>) { return 3.14159265 * v.r * v.r; } else { return v.w * v.h; } }, s); }这个方案的好处很实在:对象存在栈上或连续数组里,没有堆分配,没有 vptr,缓存局部性好得多;值语义天然支持拷贝;类型集合是封闭的,改类型时编译器会逼你处理所有分支。
坏处也是“封闭”:你没法在另一个模块里悄悄加一种新形状,必须回到定义variant的地方改。这跟多态正好相反——多态是“加类型容易、加操作难”,variant 是“加操作容易、加类型难”。选哪个取决于你的需求变更模式:如果类型会不停增加而操作相对固定,用多态;如果类型固定而操作经常增加,用 variant。
6.4 选型对照
| 方案 | 分派时机 | 运行时扩展类型 | 主要开销 | 适合的场景 |
|---|---|---|---|---|
| 虚函数 | 运行期查表 | 支持 | vptr + 阻止内联 | 插件、跨模块接口、类型会增长 |
| CRTP | 编译期 | 不支持 | 无运行期开销 | 热点路径、库内部实现 |
| std::variant | 运行期按索引 | 不支持 | 取最大类型尺寸 | 类型封闭、值语义、性能敏感 |
| switch/if | 运行期分支 | 要改源码 | 分支预测 | 类型少且长期稳定 |
这张表不是让你二选一。实际项目里混用非常常见:对外暴露的接口用虚函数保证扩展性,内部热点路径用 CRTP 或模板消除开销,小规模的封闭类型集用variant减少堆分配。
7. 踩过几次之后的几条实际操作建议
7.1 override 和 final 一定要写
override前面说过,纯编译期检查,零开销,没有理由不写。final的使用频率低一些,但它在两个场景很有价值:一是标记“这个类不打算被继承”,防止别人误继承带来的析构问题;二是标记“这个虚函数到此为止”,让编译器有机会把虚调用去虚化(devirtualization)。
class Circle final : public Shape { // 不会被继承 public: double area() const final; // 不会被进一步重写 };配合-Wsuggest-override(GCC)或者-Winconsistent-missing-override(Clang)这类警告,可以把历史代码里所有“以为重写了其实没重写”的地方扫出来。我一般在新项目里直接把override缺失当成编译错误处理。
7.2 值语义和多态语义不要混着用
这条我吃过亏。一个类如果设计成多态基类,那它的对象就应该通过unique_ptr/shared_ptr或引用来传递,不要提供拷贝构造、不要放进值容器、不要按值返回。一旦混用,切片问题会以各种意想不到的形式冒出来,而且往往在运行时才暴露。
一个具体的信号:如果你的基类同时有虚函数和拷贝构造函数被实际调用,就要警觉了。可以给基类加= delete的拷贝构造,强制调用方用指针:
class Shape { public: virtual ~Shape() = default; virtual double area() const = 0; Shape() = default; Shape(const Shape&) = delete; Shape& operator=(const Shape&) = delete; };这个改动会在编译期把所有按值传递的地方全部打出来,一次性改干净。
7.3 排查多态问题的几个手段
遇到“明明重写了却没生效”这类问题,我一般按这个顺序查:
先看typeid。对多态类型(含至少一个虚函数)解引用指针,typeid(*p).name()拿到的是动态类型;对非多态类型,拿到的永远是静态类型。这个区别本身就能帮你判断“这个类到底有没有虚函数”。
Shape* p = new Circle(2.0); std::printf("%s\n", typeid(*p).name()); // 打印出 Circle 的修饰名再看虚表。GDB 的info vtbl p能直接列出表里每一项的地址和对应符号,如果发现槽位指向的是Base::area而不是Derived::area,那问题就在重写条件上——八成是漏了const或者参数类型差了一点点。
最后看sizeof。如果sizeof(Base)和sizeof(Derived)差得离谱,说明布局有问题;如果两个类大小一样但派生类明明加了成员,那可能是成员被优化掉了或者根本没加进去。
提示:用
nm -C或者objdump -d看编译产物里有没有Derived::area这个符号,能快速确认“这个函数到底有没有被生成”。如果是内联函数,可能看不到,那就得看汇编里的虚表初始化代码。
我在实际项目里还有个习惯:任何一个多态基类,我都会在它旁边写一行注释,说明这个类要不要虚析构、允许不允许值拷贝。看起来是小事,但半年后回来看代码的时候,这几行注释能省掉不少翻来覆去的思考。多态这套机制本身其实不复杂,真正花时间的是那些边界情况——构造析构里的分派、默认参数的静态绑定、多继承的指针偏移,每一个都是“看起来没问题但实际有问题”的类型。理解它们最好的方式不是背结论,而是自己写几段代码跑一遍,把虚表打印出来看一眼,那些模糊的地方会一下子清楚。