Th3.8 这个编号,一看就是某个面向对象课程或讲义系列里的固定节次。标题把三个高频考点放在一起——基类和子类指针、虚函数与纯虚函数、多态性,其实这三者讲的是同一件事:继承体系下,编译器如何决定一次调用最终调用到谁的函数。我见过太多人背概念时头头是道,一写代码就露馅:最常见的是把子类对象交给基类指针后,调出来的函数完全不是预期那个,或者delete一个"猫"对象却只执行了"动物"的析构函数,内存泄漏了还一脸茫然。
这篇文章我按自己学习和带人时的思路来写,不按教材目录平铺。先把指针的类型兼容问题讲透,再进虚函数的底层机制,然后是纯虚函数与抽象类,最后用一段完整示例收拢多态的真实用法。每个部分我都会放能直接跑的代码、容易踩的坑,以及"为什么会这样"的底层原因。适合正在啃 C++ 面向对象、被各种概念绕晕的初学者,也适合想把这几个知识点串成体系的人。
1. 基类指针与子类指针:先搞懂"类型兼容"这条铁律
1.1 向上转型为什么总是安全的
先看一段最经典的代码:
class Animal { public: void eat() { cout << "Animal::eat" << endl; } }; class Cat : public Animal { public: void catchMouse() { cout << "Cat::catchMouse" << endl; } }; int main() { Cat cat; Animal* a = &cat; // 子类指针/对象 赋给 基类指针 a->eat(); // 编译通过,调用 Animal::eat return 0; }Animal* a = &cat;这就是向上转型(upcasting),也叫向上造型。很多初学者会疑惑:"我明明创建了一只猫,为什么用一个动物指针指过去?这会不会丢信息?"
答案是:不会丢,而且编译器百分百允许。原因在内存布局。一个Cat对象在内存里并不是"猫"这种独立结构,而是由两块拼起来的:基类子对象(Animal 的部分)加上子类自己的成员(Cat 的部分)。基类子对象通常排在对象内存布局的前面部分,所以你的Animal*指向Cat对象的起始地址时,编译器只需要做一次隐式偏移,把这个指针解释成"只访问对象前部的 Animal 部分"。
换句话说,Animal* a = &cat;是你主动告诉编译器:"我现在只按动物来看待这只猫。" 从类型的角度,猫是一种动物,所以这个转换成立;从内存的角度,子类对象里面必定包含完整的基类部分,所以也不会越界访问。这就是它安全的根本原因。
这里还要纠正一个常见误解:向上转型不是"把猫变成动物并丢掉猫的特征"。对象本身还是Cat,catchMouse方法还在,只是当前这个Animal*看不到它。想看到?转回来就行,但转回来就不是白送的了,见下一节。
1.2 向下转型:能编译不代表能运行
方向反过来就麻烦得多:
Animal* a = new Cat(); Cat* c = static_cast<Cat*>(a); // 编译能通过问题来了:如果a实际指向的并不是Cat,而是一只Dog呢?
class Dog : public Animal {}; Animal* a = new Dog(); Cat* c = static_cast<Cat*>(a); // 编译通过,但运行期访问 c->catchMouse() 就是未定义行为这里static_cast是编译期的"我信任你"式转换,编译器不做任何运行时检查。你告诉它"这个动物指针其实指向猫",编译器就信了,直接把地址交给你。万一你判断错了,指针指向的内存区域根本不是猫的布局,后面调用catchMouse就是在错误的内存上干活,轻则得到垃圾数据,重则直接程序崩溃。这就是**向下转型(downcasting)**的风险本质:子类不一定有,基类指针指向的对象可能压根不含子类特有的那部分。
教材里常说一句"向下转型不安全",但不说全,导致很多人以为只要编译过了就安全。真实情况是:static_cast的向下转型必须配合程序员自己的判断力,你要百分之百确定这个对象确实是目标类型,否则就是自己给自己埋雷。
那有没有安全的方式?有,dynamic_cast,后面第 4 章专门讲。
1.3 切片:使用基类对象接收子类对象的隐藏坑
指针讲完了,再补一个几乎所有初学者都会踩的坑:用基类"对象"来接子类对象。
Animal a = Cat(); // 注意,不是指针,是对象赋值这一行能编译,但和"指向"完全是两码事。Cat对象里除了 Animal 部分,还有自己特有的成员;而a这个变量的内存空间从一开始就是按Animal的大小分配的,放不下整只猫。所以编译器会做一个操作:只把 Animal 部分拷贝过去,Cat 特有的成员全部丢弃。这就是著名的对象切片(slicing)。
直观感受一下:
class Cat : public Animal { public: int lives = 9; // 猫特有的:九条命 }; Cat c; Animal a = c; // lives 没了,a.lives 根本不存在你可以把切片理解为"拿一个小盒子去装一个大物件,装不下的部分被强制留在外面"。这带来两个后果:第一,子类数据丢失;第二,如果后面再想从a还原出Cat,数据已经不完整了。所以涉及继承体系的传参、赋值、容器存储,几乎一律要使用指针或引用,而不是基类对象。vector<Animal>装猫装狗,装进去全是切片后的动物,这个错误在项目里非常常见。
2. 虚函数真正的底层机制:编译期看不到的那张表
2.1 静态绑定和动态绑定的分界线
现在做个实验,给之前的Animal和Cat都加一个同名方法speak(),看通过Animal*调用到底执行哪个:
class Animal { public: void speak() { cout << "Animal::speak" << endl; } }; class Cat : public Animal { public: void speak() { cout << "Cat::speak" << endl; } }; int main() { Animal* a = new Cat(); a->speak(); // 输出 Animal::speak,不是 Cat 的 return 0; }反直觉的输出出现了。明明指向猫,调speak却执行动物的版本。原因在于:a的静态类型是Animal*,编译器在编译期间看到a->speak()时,只按"Animal 里有没有 speak"来解析,找到了就直接绑定到Animal::speak。这叫静态绑定,也叫早绑定,在编译阶段就把调用地址确定死了。
这就是虚函数出现之前 C++ 处理继承关系的方式:同一个名字,在子类里重新定义,各自调用各自版本。通过基类指针,只能调到基类的那个版本。
要让"同一行代码,执行不同函数",必须引入动态绑定,也就是虚函数。把speak前面加上virtual:
class Animal { public: virtual void speak() { cout << "Animal::speak" << endl; } }; class Cat : public Animal { public: void speak() { cout << "Cat::speak" << endl; } }; int main() { Animal* a = new Cat(); a->speak(); // 输出 Cat::speak return 0; }为什么加了virtual就有截然不同的表现?因为编译器不再敢在编译期拍板"就是这个函数地址"了。它改成:运行时去对象里查一张表,表里记录着"这个对象真正的 speak 函数应该在哪里"。这位主角就是虚函数表。
2.2 虚函数表与对象头部的隐藏指针
主流 C++ 编译器实现虚函数的方式高度一致:每个含有虚函数的类会生成一张虚函数表(vtable),本质是一个函数指针数组;每个对象实例会在内存开头多出一个隐藏指针(vptr),这个指针指向它所属类的那张虚函数表。
流程是这样的:
- 定义
Cat cat;时,cat的内存布局变成:[vptr][Animal 成员][Cat 成员],vptr 指向Cat类的 vtable。 Cat类的 vtable 里记录:speak函数指针 →Cat::speak的真实地址。- 调用
a->speak()时,编译器生成的代码先取对象头部的 vptr,再通过 vptr 找到 vtable,再取出speak条目的函数指针,最后间接调用。
这个"查表再跳转"的过程,就是动态绑定。动态的含义在于:决定权推迟到运行期,由对象头部的 vptr 来决定最终去哪。
有一个很容易被忽视的细节:vptr 在对象构造阶段就会被初始化。对象的构造函数在进入函数体之前,编译器会插入一段代码,把 vptr 设置成当前类对应的 vtable 地址。这引出一个极其经典的坑——构造函数里调用虚函数不会产生多态:
class Base { public: Base() { print(); } virtual void print() { cout << "Base::print" << endl; } }; class Derived : public Base { public: Derived() { print(); } void print() override { cout << "Derived::print" << endl; } }; int main() { Derived d; return 0; }输出是什么?Base::print然后Derived::print,而不是想象中两次Derived::print。原因就在 vptr 的初始化顺序:先构造基类子对象,此时 vptr 指向 Base 的 vtable,Base()里的print()动态绑定到 Base 版本;基类构造完毕,编译器才把 vptr 改成 Derived 的 vtable,接着执行Derived(),这时再调print()才是 Derived 版本。
记住这条经验:构造函数和析构函数内部,虚函数规则自动退化成普通函数调用。这是语言设计者刻意为之,因为在基类构造期间,子类部分还没有初始化,如果允许调到子类版本,子类成员全是未初始化的垃圾数据,后果不堪设想。
2.3 override、final、默认参数:容易被名字掩盖的暗坑
C++11 之后,重写虚函数建议带上override关键字。它不是必须的,但作用极大:让编译器帮你检查"这个函数到底有没有真的重写基类的虚函数"。最容易翻车的情况是签名不匹配,比如基类virtual void speak() const;,子类写void speak();,漏了const,编译器不报错,因为这是两个完全不同的函数,但你的意图是重写,实际上变成了隐藏。加了override,编译器会直接报错,逼你正视签名。
final则用来终止重写:
class Animal { public: virtual void speak() {} }; class Cat : public Animal { public: void speak() override final {} }; class PersianCat : public Cat { public: void speak() override {} // 编译错误:speak 是 final };还有一个几乎没人注意的坑——虚函数带默认参数:
class Animal { public: virtual void speak(string prefix = "animal: ") { cout << prefix << "Animal::speak" << endl; } }; class Cat : public Animal { public: void speak(string prefix = "cat: ") { cout << prefix << "Cat::speak" << endl; } }; int main() { Animal* a = new Cat(); a->speak(); // 输出 cat: Cat::speak 吗?不,是 animal: Cat::speak return 0; }默认参数是编译期静态绑定的,它绑的是指针的静态类型(Animal)提供的默认值,而函数体是动态绑定的(Cat)。结果就是参数与函数体"拼装"在一起,输出animal: Cat::speak。任何一本教材都会告诉你"不要给虚函数设置默认参数",但只有踩过坑的人才知道为什么。
3. 纯虚函数与抽象类:把"约定"写进类型系统
3.1 从"不知道具体怎么画"说起:抽象类的真实用途
虚函数解决的是"同名函数在不同子类里有不同实现",但有时你连一个合理的基础实现都写不出来。比如设计一个图形类:
class Shape { public: virtual void draw() { /* 画什么?图形太抽象了,画不了 */ } virtual double area() { return 0.0; } };Shape::draw到底该怎么画?圆形、矩形、三角形各有各的画法,"形状"这个概念本身没有可执行的绘制逻辑。如果硬给它一个空实现或返回 0 的实现,反而有副作用:万一子类忘记重写area(),程序会静默使用那个 0,造成逻辑错误。
更好的做法是让这个函数"没有实现",强制子类必须提供:
class Shape { public: virtual void draw() = 0; // 纯虚函数 virtual double area() const = 0; }; class Circle : public Shape { public: void draw() override { cout << "draw circle" << endl; } double area() const override { return 3.14159 * r * r; } private: double r = 1.0; };= 0后面的含义是:这个类不提供该函数的实现,且该类成为抽象类。抽象类有三个特点:
- 不能实例化。
Shape s;编译直接报错。 - 子类必须实现所有纯虚函数,才能成为具体类并实例化。
- 纯虚函数是"接口契约"的载体:父类规定"你必须能画、有面积",至于怎么画,你自己决定。
这在设计层面意义重大。它把"抽象"从设计文档里的形容词,变成了语言强制检查的规则。只要编译器能通过,就说明所有子类都兑现了"能画、有面积"的承诺。接口类(只含纯虚函数,没有数据成员)更是把这种思想推到极致,它定义的就是一个纯粹的规范。
3.2 析构函数为什么必须虚:底层因果链
析构函数和虚函数的关系,是 C++ 里最容易出内存问题的组合之一。还是用 Animal 的例子:
class Animal { public: Animal() {} ~Animal() { cout << "~Animal" << endl; } }; class Cat : public Animal { public: int* buf = new int[1024]; ~Cat() { delete[] buf; cout << "~Cat" << endl; } }; int main() { Animal* a = new Cat(); delete a; // 只输出 ~Animal,Cat 的析构没执行,buf 内存泄漏 return 0; }问题链条非常清晰:a的静态类型是Animal*,如果~Animal不是虚函数,delete a会走静态绑定,只调用Animal::~Animal。而Cat对象里分配的那块buf永远不会被释放,内存泄漏就这么悄无声息地发生了。
把析构函数改成虚函数:
class Animal { public: virtual ~Animal() { cout << "~Animal" << endl; } };delete a时,动态绑定生效,先调用Cat::~Cat,释放buf,再自动调用基类析构Animal::~Animal。这里的关键是:派生类的析构函数天然会调用基类析构函数,你只需要保证"先调子类析构、再调基类析构"的顺序,而虚析构正是启动这一顺序的开关。
所以一条普遍经验是:只要一个类设计为基类,析构函数就要写成虚函数。如果一个类既不用继承,也没有虚函数,那就别加virtual,避免无谓的 vptr 开销。
有一种更极端的情况:析构函数本身是纯虚函数。它听起来矛盾——纯虚函数没有实现,但析构函数必须有实现,因为在子类析构的末尾,编译器仍会调用基类析构。解决办法是类内声明、类外实现:
class AbstractBase { public: virtual ~AbstractBase() = 0; }; AbstractBase::~AbstractBase() { // 必须提供空实现,否则链接报错 }AbstractBase成为抽象类,禁止实例化,同时子类析构时还能正常调用基类析构完成清理。这种写法适合"我需要一个抽象基类,且它自身需要清理某些资源"的场景。
3.3 纯虚函数带函数体:一个冷门但关键的细节
与析构函数同理,其他纯虚函数也可以在声明= 0的同时给出函数体,语法是类外定义:
class Shape { public: virtual void draw() = 0; }; void Shape::draw() { cout << "common preparation" << endl; } class Circle : public Shape { public: void draw() override { Shape::draw(); // 显式调用基类的公共逻辑 cout << "draw circle" << endl; } };这个写法的价值在于:纯虚函数保证了"子类必须自己实现",但基类还能提供一个默认的公共骨架,子类通过ClassName::function()的显式限定来复用。这在模板方法模式里很常见:基类定义步骤框架,部分步骤做成纯虚函数,由子类填充具体逻辑。
不过我需要提醒一句:这种写法在面试选择题里经常出现,很多人以为带= 0的函数不能有函数体,一看到"AbstractBase::foo 可以定义函数体吗"就答错。记住:= 0规定的是"子类必须重写",不剥夺基类定义实现的权利。二者并不冲突。
4. 多态的真实战场:同一段代码服务不同对象
4.1 一个可以直接运行的完整示例
把前面所有概念拼在一起,写一个能体现多态价值的完整程序:
#include <iostream> #include <vector> #include <memory> using namespace std; class Shape { public: virtual double area() const = 0; virtual void describe() const = 0; virtual ~Shape() {} }; class Circle : public Shape { public: explicit Circle(double r) : r_(r) {} double area() const override { return 3.14159 * r_ * r_; } void describe() const override { cout << "Circle, r=" << r_ << endl; } private: double r_; }; class Rectangle : public Shape { public: Rectangle(double w, double h) : w_(w), h_(h) {} double area() const override { return w_ * h_; } void describe() const override { cout << "Rectangle, " << w_ << "x" << h_ << endl; } private: double w_; double h_; }; void printArea(const Shape& s) { s.describe(); cout << "area=" << s.area() << endl; } vector<unique_ptr<Shape>> buildShapes() { vector<unique_ptr<Shape>> shapes; shapes.push_back(make_unique<Circle>(2.0)); shapes.push_back(make_unique<Rectangle>(3.0, 4.0)); return shapes; } int main() { for (const auto& s : buildShapes()) { printArea(*s); } return 0; }printArea接收一个const Shape&,它完全不知道传进来的是圆还是矩形,但调用s.area()和s.describe()时,动态绑定自动派发到正确的子类实现。将来再加一个Triangle类,只要继承Shape并实现两个纯虚函数,printArea一行不用改,新类型即可无缝加入。这就是开闭原则的朴素版本:对扩展开放,对修改关闭。
这里有个使用细节值得强调:如果按值传参会触发切片,那printArea(const Shape& s)用引用就必须是常态。引用和指针是 C++ 中维持多态性的两条通道,引用语法更干净,还天然规避了空指针问题。容器里存unique_ptr<Shape>,则避免了裸指针手动delete的繁琐和泄漏风险。
4.2 多态不是免费的:性能与设计上的账要算清
多态这么好,为什么实际项目里不会到处都用?因为动态绑定是有代价的。
一是调用开销。虚函数调用比普通函数多一次间接跳转,现代 CPU 对间接跳转的预测通常不如直接调用,在高频循环里差距会被放大。早期 C++ 项目里,某些实时系统会刻意避免虚函数,改用模板和 CRTP 把多态解析推到编译期。
二是对象体积膨胀。每个有虚函数的对象都会多一个 vptr。假设某个类有大量短生命周期对象,比如游戏引擎里的粒子系统,每个粒子多 8 字节(64 位平台),百万级粒子的额外内存就很可观。
三是内联失效。虚函数的调用通过函数指针完成,编译器在编译期不知道最终调用哪个实现,通常无法内联。而普通函数或模板可以在编译期展开,做大量优化。
我的个人建议是:先按设计正确性选择,再按性能热点优化。不要为了让一个函数"快一点"而放弃多态,那是过早优化;但也不要在一个每秒调用百万次的内部循环里用虚函数而不做任何性能验证。先用工具测出热点,再用模板、分支或改写方案处理局部性能瓶颈,这是一个成熟工程的选择顺序。
4.3 dynamic_cast 和 typeid:运行时类型识别的正确用法
多态体系里,有时候你需要知道"这个基类引用/指针到底指向什么具体类型"。C++ 提供了两个运行时类型识别(RTTI)工具:dynamic_cast和typeid。
dynamic_cast的安全向下转型用法:
Animal* a = new Cat(); if (Cat* c = dynamic_cast<Cat*>(a)) { c->catchMouse(); } else { cout << "not a cat" << endl; }dynamic_cast会在运行时检查a指向的对象类型,如果确实是Cat,返回对应指针;否则返回空指针(对引用转型则抛出bad_cast异常)。注意前提:基类必须有虚函数。因为dynamic_cast需要借助 vptr 中的信息来确认对象真实类型。一个没有虚函数的类层次,RTTI 无法工作。
typeid则用来直接查询类型信息:
cout << typeid(*a).name() << endl; // 输出实现相关,通常含类名typeid返回一个type_info对象,typeid(*a) == typeid(Cat)可以用于判断。
这两种工具在正确场景下很有用,比如调试时确认对象类型、处理跨模块的接口调用。但我要泼一盆冷水:项目里大量出现dynamic_cast往往不是好兆头。它通常意味着你对类型的判断没有利用好多态,本该由虚函数完成的差异化行为,被写成了"先判断类型、再手动分支"的代码。每出现一次dynamic_cast,都应该停下来问一句:"能不能把这个判断改成新增一个虚函数?" 真正健康的继承设计,调用方是不需要关心具体类型的。
最后说一点我在实际项目里反复体会到的经验:学这几个概念,不要满足于"知道每种写法的结果",一定要亲手把类和指针组合起来跑一遍。尤其是析构函数的链式调用、构造函数里虚函数失效、默认参数静态绑定这三个坑,它们是面试高频题,更是生产环境里真实发生过多次的故障点。把这几个例子的输出自己推一遍,C++ 面向对象的底子才算真的立住了。