19. 异常与性能:异常处理的开销、RTTI 的开销、如何禁用异常

异常处理这个话题,在 C++ 社区里争议一直很大。有人觉得它是现代 C++ 的必备特性,有人则避之不及。我个人属于中间派——异常很好用,但你要清楚它的代价。

今天我们就来聊聊异常到底慢在哪,RTTI 又是什么鬼,以及怎么在项目中禁用它们。

异常处理的开销

很多人以为异常就是「抛出去,接住」这么简单。其实不然。异常的开销分两块:正常路径异常路径

正常路径的开销

如果代码里没有抛出异常,是不是就没开销?不是

编译器为了支持异常,会在每个可能抛出异常的函数入口和出口插入额外的代码。这些代码负责维护一个「异常处理栈」——说白了就是记录当前函数调用到了哪一层,如果抛异常了该往哪跳。

我曾在项目中做过一个测试:一个纯数值计算的循环,开启异常支持后,性能下降了 5%~8%。你想想看,循环里根本没抛异常,但编译器就是得给你留后路。

关键点:异常的正常路径开销,主要来自栈展开信息的维护。这部分开销通常不大(5%~15%),但在热循环中会被放大。

异常路径的开销

真正慢的是抛出异常的那一刻。我给大家拆解一下:

  1. 构造异常对象——需要动态内存分配(new 一个 exception 对象)
  2. 栈展开——遍历调用栈,找到匹配的 catch 块
  3. 析构局部对象——沿途调用所有局部对象的析构函数
  4. 异常对象清理——最后释放异常对象的内存

这一套下来,少说几百个 CPU 周期。如果异常发生在性能敏感路径上,那基本就是灾难。

警告:千万不要在热循环里用异常做控制流。比如用异常来跳出循环、或者用异常表示「没找到数据」——这种用法我见过太多次了,每次都是性能杀手。

RTTI 的开销

RTTI(运行时类型识别)是另一个容易被忽视的性能点。它主要靠 typeiddynamic_cast 两个操作。

typeid 的开销

typeid 本身开销不大。它只是从虚函数表里取一个指针,指向一个 std::type_info 对象。这个操作基本是 O(1) 的。

但要注意:typeid 返回的字符串比较(比如 typeid(*ptr).name())是字符串比较,不是指针比较。如果你在循环里频繁做这个操作,那就有问题了。

dynamic_cast 的开销

这才是大头。dynamic_cast 需要遍历继承链,检查类型是否匹配。对于单继承,开销还好;对于多继承或菱形继承,那可就麻烦了。

我记得有一次,一个同事在消息分发系统里大量使用 dynamic_cast 来做类型转换。每个消息处理都要 cast 好几次。后来我换成 static_cast + 手动类型枚举,性能直接翻了一倍。

操作 相对开销 说明
typeid 基本是虚表指针访问
dynamic_cast(单继承) 遍历继承链,但链不长
dynamic_cast(多继承) 需要多次偏移计算和类型检查
dynamic_cast(失败时) 更高 需要遍历所有可能路径才能确认失败

小技巧:如果你必须用 RTTI,尽量用 typeid 做一次判断,然后用 static_cast 转换。别反复 dynamic_cast

如何禁用异常

很多高性能项目(比如游戏引擎、嵌入式系统)会直接禁用异常。做法很简单:

编译器层面

在编译选项中加上:

// GCC/Clang
-fno-exceptions

// MSVC
/EHsc-

这样编译器就不会生成异常处理相关的代码了。正常路径的开销直接归零。

标准库层面

禁用异常后,标准库的某些功能会受影响。比如 std::vector::at() 会抛出 std::out_of_range,现在它不能抛了——行为变成未定义。

所以你需要:

  • operator[] 代替 at()
  • std::optional 或错误码代替异常
  • 自己实现简单的断言机制

注意:禁用异常后,new 操作失败也不会抛 std::bad_alloc,而是返回 nullptr。如果你用了 nothrow 版本,记得检查返回值。

代码层面的替代方案

我习惯用这种模式来替代异常:

// 错误码方式
enum class ErrorCode {
    Success,
    NotFound,
    InvalidArg,
    OutOfMemory
};

// 或者用 optional
std::optional<Result> doSomething() {
    if (/* 失败 */) {
        return std::nullopt;
    }
    return Result{...};
}

说实话,这种模式在性能上比异常好得多。没有栈展开,没有动态内存分配,就是简单的值返回和判断。

知识体系总览

下面这张图总结了异常和 RTTI 的性能影响,以及禁用异常的路径:

异常与 RTTI 性能影响总览 异常处理 正常路径开销 异常路径开销 栈展开信息维护 对象构造+栈展开 额外代码插入 动态内存分配 RTTI typeid dynamic_cast 虚表指针访问 继承链遍历 O(1) 操作 多继承开销大 禁用异常:-fno-exceptions / /EHsc- 替代方案:错误码 / std::optional / 手动断言

总结一下:异常和 RTTI 不是不能用,但你要知道它们的代价。在性能敏感的场景下,我建议禁用异常,用错误码或 optional 替代。RTTI 尽量少用,尤其是 dynamic_cast——能用虚函数解决的就别用 cast。

嗯,今天就聊到这。记住一句话:没有银弹。每个特性都有它的适用场景,关键是你要清楚它背后的成本。


公众号:蓝海资料掘金营,微信deep3321