20、多态(下):纯虚函数与抽象类、接口设计、运行时类型识别(RTTI)、dynamic_cast与typeid
好,咱们接着聊多态的下半场。上一章我们把虚函数和虚表讲透了,这一章要聊的东西,在实际工程里更常用,也更考验设计功底。
纯虚函数、抽象类、接口设计,还有RTTI这套运行时识别机制。说实话,这些概念在教科书里往往被讲得很枯燥,但在我十几年的C++生涯里,几乎每个大型项目都离不开它们。
一、纯虚函数与抽象类
先说说纯虚函数。这东西说白了就是:我定义一个接口,但我不给你实现,你自己去填坑。
语法很简单,就是在虚函数后面加个 = 0:
class Shape {
public:
virtual double area() const = 0; // 纯虚函数
virtual void draw() const = 0;
virtual ~Shape() = default; // 基类析构函数记得虚
};
一旦类里有了纯虚函数,这个类就成了抽象类。抽象类不能实例化——你写 Shape s; 编译器直接报错。为什么?因为有个纯虚函数没实现,编译器不知道该怎么调用它。
核心要点:抽象类就是用来当基类的,它定义了一个契约。所有派生类必须实现这些纯虚函数,否则派生类自己也会变成抽象类。
我在项目中遇到过这么个事:有个同事设计了一个数据处理器基类,里面十几个纯虚函数。结果每个派生类都要实现一大堆用不到的函数,代码冗余得厉害。后来我建议他把大接口拆成几个小接口,每个派生类只继承自己需要的。嗯,这就是接口隔离原则,后面会细说。
二、接口设计——C++里的“协议”
其他语言比如Java有专门的 interface 关键字,C++没有。但我们可以用纯虚函数来模拟接口。说白了,就是一个只包含纯虚函数和虚析构函数的抽象类。
class IPrintable {
public:
virtual void print() const = 0;
virtual ~IPrintable() = default;
};
class ISerializable {
public:
virtual std::string serialize() const = 0;
virtual ~ISerializable() = default;
};
然后一个类可以同时继承多个这样的“接口”:
class Document : public IPrintable, public ISerializable {
public:
void print() const override {
// 实现打印逻辑
}
std::string serialize() const override {
// 实现序列化逻辑
}
};
你想想看,这种设计有什么好处?解耦。调用方只依赖接口,不依赖具体实现。我可以换掉整个底层实现,只要接口不变,上层代码一行都不用改。
个人经验:我习惯在接口类名前加个 I 前缀,比如 IRenderer、ILogger。这样一眼就能看出这是个接口,不是普通类。团队里统一命名规范,能省很多沟通成本。
三、运行时类型识别(RTTI)
RTTI,全称 Runtime Type Information。说白了就是程序在运行的时候,能知道自己正在操作的对象到底是什么类型。
C++里RTTI主要通过两个操作符实现:dynamic_cast 和 typeid。
先说说 typeid。它返回一个 std::type_info 对象,里面包含了类型信息:
#include <typeinfo>
Base* ptr = new Derived();
if (typeid(*ptr) == typeid(Derived)) {
std::cout << "ptr 指向的是 Derived 对象" << std::endl;
}
注意我写的是 typeid(*ptr) 而不是 typeid(ptr)。前者解引用,得到的是实际对象的类型;后者得到的是指针本身的类型——Base*。这个坑我踩过,调试了半天才发现。
警告:typeid 作用于指针时,如果指针是空指针,会抛出 std::bad_typeid 异常。所以用之前最好判空。
四、dynamic_cast——安全的向下转型
这是RTTI里最常用的工具。它的作用:把基类指针或引用安全地转换成派生类指针或引用。
class Base {
public:
virtual ~Base() = default;
};
class Derived : public Base {
public:
void specificMethod() { /* ... */ }
};
Base* basePtr = new Derived();
// 安全向下转型
Derived* derivedPtr = dynamic_cast<Derived*>(basePtr);
if (derivedPtr) {
derivedPtr->specificMethod(); // 转型成功,安全调用
} else {
// 转型失败,basePtr 实际不是 Derived 类型
}
为什么说它“安全”?因为如果转型失败,dynamic_cast 返回空指针(对指针)或抛出 std::bad_cast 异常(对引用)。不会像 static_cast 那样直接产生未定义行为。
我曾经在一个插件系统里大量使用 dynamic_cast。每个插件都实现了一个公共接口,但有些插件有额外的能力。主程序通过 dynamic_cast 来检测插件是否支持某个特定接口:
IPlugin* plugin = loadPlugin("some_plugin.so");
auto* renderer = dynamic_cast<IRenderer*>(plugin);
if (renderer) {
renderer->render(); // 这个插件支持渲染
}
auto* audioPlayer = dynamic_cast<IAudioPlayer*>(plugin);
if (audioPlayer) {
audioPlayer->play(); // 这个插件还支持音频播放
}
这种用法在大型框架里非常常见。但要注意,dynamic_cast 是有性能开销的,因为它需要遍历继承体系。在性能敏感的循环里,尽量避免频繁使用。
五、RTTI的代价与取舍
RTTI不是免费的。它需要额外的内存来存储类型信息,运行时也有开销。有些项目为了性能,会禁用RTTI(编译选项 -fno-rtti)。
什么时候该用RTTI?我个人的经验是:
- 插件系统、框架扩展点:这些地方类型不确定,RTTI很合适
- 调试和日志:用
typeid打印类型名,方便排查问题 - 序列化/反序列化:需要根据实际类型做不同处理
什么时候不该用?
- 高频调用的热路径:比如每帧都要执行的渲染循环
- 可以用虚函数替代的场景:能用多态解决的问题,别用RTTI
- 嵌入式或实时系统:对内存和性能要求苛刻的地方
避坑指南:我曾经在一个网络库里用 dynamic_cast 来判断每个数据包的类型。结果压测时发现CPU占用率飙升。后来改成在基类里加一个枚举类型字段,用 switch 来分发,性能提升了将近10倍。RTTI虽好,但别滥用。
六、知识体系总览
下面这张图把本章的核心知识点串起来了,你可以对照着梳理一下思路:
七、总结
这一章的内容,说白了就是三件事:
- 用纯虚函数定义接口,让派生类去实现具体行为
- 用抽象类做设计,实现代码的解耦和扩展
- 用RTTI做运行时类型判断,但要有节制
我个人觉得,接口设计能力是区分初级和高级C++程序员的一个重要标志。初学者往往喜欢把所有东西塞到一个类里,而有经验的开发者会花更多时间在设计接口上——接口定好了,实现只是时间问题。
至于RTTI,记住一句话:它是工具,不是拐杖。能用多态解决的问题,优先用多态。只有在确实需要运行时类型信息的场景下,才动用RTTI这把刀。
最后一个小建议:写代码的时候,多想想“如果明天要加一个新功能,我的代码需要改多少地方?”如果答案是需要改很多,那说明接口设计可能有问题。好的接口设计,应该让你只需要加新类,而不需要改旧代码。