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 前缀,比如 IRendererILogger。这样一眼就能看出这是个接口,不是普通类。团队里统一命名规范,能省很多沟通成本。

三、运行时类型识别(RTTI)

RTTI,全称 Runtime Type Information。说白了就是程序在运行的时候,能知道自己正在操作的对象到底是什么类型

C++里RTTI主要通过两个操作符实现:dynamic_casttypeid

先说说 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虽好,但别滥用。

六、知识体系总览

下面这张图把本章的核心知识点串起来了,你可以对照着梳理一下思路:

多态(下)知识体系 多态(下) 纯虚函数与抽象类 定义契约 禁止实例化 接口设计 多重继承 解耦与扩展 运行时类型识别 typeid 操作符 dynamic_cast 核心原则 能用虚函数解决的问题,别用RTTI;接口要小,职责要单一

七、总结

这一章的内容,说白了就是三件事:

  1. 用纯虚函数定义接口,让派生类去实现具体行为
  2. 用抽象类做设计,实现代码的解耦和扩展
  3. 用RTTI做运行时类型判断,但要有节制

我个人觉得,接口设计能力是区分初级和高级C++程序员的一个重要标志。初学者往往喜欢把所有东西塞到一个类里,而有经验的开发者会花更多时间在设计接口上——接口定好了,实现只是时间问题。

至于RTTI,记住一句话:它是工具,不是拐杖。能用多态解决的问题,优先用多态。只有在确实需要运行时类型信息的场景下,才动用RTTI这把刀。

最后一个小建议:写代码的时候,多想想“如果明天要加一个新功能,我的代码需要改多少地方?”如果答案是需要改很多,那说明接口设计可能有问题。好的接口设计,应该让你只需要加新类,而不需要改旧代码。


专注资料整理