news 2026/9/29 3:30:41

异常与 RTTI 的代价

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
异常与 RTTI 的代价

① 钩子:try/catch 不经过 if,为什么总被说"贵"?

try/catch不经过if,为什么总被说"贵"?

真相是反直觉的:不抛异常时几乎零成本(这就是"零成本异常模型");代价全藏在"会抛"的那条路径 + 一张编译期就生成好的隐藏异常表里。

这一集我们回答三个问题:

  1. 为什么try块本身"免费"?
  2. 抛一次异常,机器到底干了什么?
  3. typeid和dynamic_cast谁贵、贵在哪?

② 源码 vs 汇编对照

intmay_throw(intx){if(x<0)throw-1;returnx*2;}intuses_try(intx){try{returnmay_throw(x);}catch(inte){returne;}}structBase{virtual~Base(){}};structDer:Base{};booluse_rtti(Base&b){returntypeid(b)==typeid(Der);}booluse_dyn(Base&b){returndynamic_cast<Der*>(&b)!=nullptr;}

正常路径(不抛)—— try 块零开销:

uses_try(int): testl %ecx, %ecx js .L12 ; 只有 x < 0 才进入异常路径 leal (%rcx,%rcx), %eax ; 正常路径:就是 return x*2 ret

抛异常路径:

.L12: call may_throw.part.0 call __cxa_begin_catch ; 进入 catch(int) movl (%rax), %eax call __cxa_end_catch ... may_throw.part.0: ; 真正"抛"的代码 movl $4, %ecx call __cxa_allocate_exception ; 分配异常对象(一个 int) movl $-1, (%rax) call __cxa_throw ; 抛出

隐藏的异常表(LSDA)—— 编译期静态生成,映射"try 区域 → 处理代码":

.LLSDA19: ... .uleb128 .LEHB0-.LFB19 ; try 区域起点 .uleb128 .LEHE0-.LEHB0 ; try 区域长度 .uleb128 .L11-.LFB19 ; landing pad(处理代码)地址 ...

RTTI:typeid vs dynamic_cast:

use_rtti(Base&): ; typeid 比较 leaq _ZTI3Der(%rip), %rdx ; typeid(Der) 的 type_info movq (%rcx), %rax ; 取对象 vptr movq -8(%rax), %rcx ; 真实 type_info 在 vptr-8 处 jmp type_info::operator== use_dyn(Base&): ; dynamic_cast call __dynamic_cast ; 整个交给运行时库

RTTI 表(.rdata,每个多态类型一段只读数据):

_ZTS4Base: .ascii "4Base\0" ; 类型名字符串 _ZTI4Base: .quad __class_type_info+16 ; type_info 对象 .quad _ZTS4Base _ZTI3Der: .quad __si_class_type_info+16 ; 单继承 type_info .quad _ZTS3Der .quad _ZTI4Base ; 指向基类的 type_info

③ 为什么这么设计

  • 零成本异常模型(Itanium/SEH 阵营的主流设计):编译期不往正常代码路径里插任何检查指令,而是把"try 区域 → 处理代码"的映射表(LSDA)静态存进数据区。抛出时,运行时库(__cxa_throw)沿调用栈查表找 handler。
  • 所以"异常贵"的正确理解是:"可以用 try"不贵(正常路径零开销),"抛异常"很贵(分配对象、展开栈、沿途析构局部对象、查表匹配),量级常在毫秒级。
  • RTTI:每个多态类型在.rdata里有一份type_info(类型名字符串 + 继承关系)。typeid通过 vptr 前面的指针拿到type_info比较;dynamic_cast要沿继承链运行时查证,是纯运行时库调用,比typeid贵。
  • 为什么 type_info 在 vptr-8:vptr 指向虚表"内部",而虚表的前 8 字节正好放 typeinfo 指针——一个对象头指针同时能查函数和查类型。

④ 深入一:异常展开(stack unwinding)到底走了多远

抛异常的成本大头在栈展开。throw之后,运行时库(Itanium 下的__cxa_throw→_Unwind_RaiseException)做的事:

  1. 分配异常对象:__cxa_allocate_exception(本集movl $4, %ecx就是给 int 分配 4 字节);
  2. 沿调用栈逐帧向上:对每一帧查 LSDA,看这个帧有没有try区域能捕获当前异常类型;
  3. 找到能捕获的帧:开始"展开",执行该帧内从 try 起点到抛点之间所有存活局部对象的析构(这就是 RAII 保证"异常时资源也被释放"的机制,E07 讲过);
  4. 进入 landing pad:调用__cxa_begin_catch让 handler 能访问异常对象,执行 catch 块;
  5. 清理:__cxa_end_catch,必要时__cxa_free_exception释放异常对象。

所以"抛一次异常"不是"跳个 goto",而是一场带对象析构的运行时库协作。栈越深、沿途局部对象越多,展开越贵。这就是"异常只用于异常路径"的机器理由。

⑤ 深入二:LSDA 的"表驱动"为什么是工程胜利

比较两种异常实现:

  • 检查式(老式/部分嵌入式):每进入一个 try 块都插入"是否异常"检查指令——正常路径也要付钱;
  • 表驱动/零成本(主流):正常路径零指令,表只在"异常真的发生"时被查。

表驱动胜出,靠的正是"把成本从正常路径挪到异常路径"。这和"冷热分离"(E04)、“优化手术”(E19)是同一个思想:别让高频路径为低频路径买单。这也是为什么现代 C++ 编译器默认开异常(-fexceptions)却几乎不影响正常代码性能。

⑥ 常见误区

  • 误区 1:“try 块本身很贵”:错。正常路径零开销(LSDA 表是数据,不进执行路径)。
  • 误区 2:“异常和 return code 差不多快”:错。throw是分配 + 栈展开 + 析构 + 查表,比return贵几个数量级。
  • 误区 3:“typeid和dynamic_cast一样贵”:typeid比较 type_info 指针(快);dynamic_cast是运行时库沿继承链查证(慢)。
  • 误区 4:“-fno-exceptions只是关语法”:它还去掉异常表/运行时开销,代码更小更确定;但std::vector越界等"靠异常报告"的路径会变成abort或 UB。
  • 误区 5:“异常展开会自动析构所有局部对象”:只析构"已构造完成"的局部对象(编译器在展开表里记录每个对象的构造完成点)。手动管理资源(裸 new)不在其列——用 RAII 让析构自动发生。
  • 误区 6:“typeid只能用于多态类型”:typeid对任意类型都可用,只是非多态类型的typeid是编译期常量、不查 vptr;对多态对象才走"vptr → vptr-8 → type_info"的运行时路径。同理,dynamic_cast也要求多态类型(有虚函数),否则编译报错。
  • 误区 7:“-fno-rtti只影响调试信息”:它还让typeid/dynamic_cast无法编译、去掉.rdata里的_ZTI...数据。体积更小,但"基于 RTTI 的序列化/断言"也要一并改造。

⑦ 实战启示

  1. 异常只用于异常路径,别当控制流:每抛一次都是"分配 + 栈展开 + 一路析构"。解析文本、预期频繁失败的场景,用返回值 /std::optional。
  2. 嵌入式/实时场景可-fno-exceptions(以及-fno-rtti):换来更小、更确定的代码,代价是异常语法不可用。
  3. dynamic_cast有真实成本,热点别用:需要多态类型判断时优先虚函数或typeid(一般只比较指针,便宜)。
  4. RTTI 让每个多态类型多一段只读数据:类型名等字符串常驻可执行文件;开-fno-rtti可省(但dynamic_cast/typeid也失效)。
  5. 异常规格noexcept是关键优化信息:标记noexcept后编译器能跳过异常处理路径,代码更小更快(E19 会看到)。

⑧ 扩展专题一:noexcept 与异常的开销联动

noexcept不只是"声明不抛",它直接改变生成的代码:

  • noexcept函数:编译器不生成/不预留异常展开表,调用它可以走"无异常"快速路径;
  • 析构函数默认 noexcept:所以析构里别让可能 throw 的代码逃逸,否则 terminate;
  • std::vector的移动/拷贝策略(E14 会展开):noexcept移动构造让 vector 扩容时敢用移动而非拷贝——这是性能分水岭。

反汇编里,带noexcept和不带的函数,异常表(LSDA)的大小和 handler 数量会明显不同。给"不该抛"的函数加noexcept,既是契约也是优化。

⑨ 扩展专题二:SEH vs Itanium——两套异常模型

本系列用的是 Itanium C++ ABI(g++/clang),但 Windows/MSVC 用SEH(Structured Exception Handling)。差异有:

  • Itanium:表驱动 + 运行时_Unwind库,异常对象经__cxa_allocate/throw;
  • SEH:基于"帧内异常记录"(_except_handler3等),每帧在栈上存异常处理信息,函数入口出口有__try相关记录;
  • 结论:无论哪套,主流实现都走"正常路径低开销"路线;跨平台写 C++ 时不用纠结实现细节,但要知道"异常不是零成本的"。

⑩ 扩展 FAQ

  • Q:throw的对象怎么销毁?
    A:catch结束后由运行时释放(__cxa_end_catch→ 必要时__cxa_free_exception)。异常对象是独立分配的,不是栈对象,所以能跨越函数边界存活到 handler。
  • Q:catch(...)能抓住什么?
    A:所有异常(包括非 C++ 抛出的)。但它是"不知道类型"的最后防线,通常只用于"收尾后重新抛出"(catch(...) { cleanup(); throw; })。
  • Q:typeid对非多态类型能用吗?
    A:能,但typeid(T)(类型参数)是编译期常量;typeid(*obj)(表达式参数)只有当对象是多态时才走 vptr 查表,否则也是编译期已知。
  • Q:为什么type_info是"每类型一段只读数据"?
    A:因为比较需要稳定的身份标识。每个多态类型在.rdata有一段_ZTI...数据(含类型名 + 继承关系),typeid比较的就是这些数据的地址/内容(E23 会讲 mangled 名称)。
  • Q:异常对象分配可以避免吗?
    A:throw通常要分配;但"只抛不接"或某些实现会优化。工程上别依赖"异常不分配"——那是实现细节。

⑪ 扩展实验

  1. 看 LSDA:g++ -O2 -S E10_except.cpp,找.LLSDA段,确认"try 区域 → landing pad"的映射表。
  2. 正常路径零指令:对照uses_try与may_throw的正常路径汇编,确认没有额外的"异常检查"指令。
  3. typeidvsdynamic_cast对比:反汇编use_rtti(比较 type_info)和use_dyn(调__dynamic_cast),数指令条数。
  4. -fno-exceptions对比:g++ -O2 -fno-exceptions -S,看异常相关调用是否消失、LSDA 是否为空。
  5. 析构次数实验:在深层函数里 throw,沿途对象的析构函数打印日志——看展开时析构调用顺序(先构造的后析构)。

⑬ 扩展专题三:异常安全三级别,从汇编看为什么

异常安全级别是 C++ 面试常客,但它的机器根源就在本集:

  • 基本保证:抛异常后对象保持有效(可能不是原值)。本质是"要么完成,要么在展开路径上正确析构+回滚"。
  • 强保证:要么成功,要么状态完全不变(回滚)。实现上常靠"先做副本,成功才替换"(copy-and-swap)。
  • 不抛保证:绝不抛。用noexcept声明 + 内部只做不抛操作(如整型运算、交换指针)。

从汇编看:强保证的"副本 + 替换"意味着多一次构造/拷贝/析构(E07 的隐藏代码 + E05 的拷贝成本);不抛保证则让编译器敢省掉异常展开路径。异常安全等级越高,正常路径可能越贵——所以"强保证"只用在必要处。

⑭ 扩展专题四:RTTI 的"成本账"怎么算

RTTI(typeid/dynamic_cast)的真实成本分三块:

  1. 空间:每个多态类型在.rdata多一段_ZTI...(类型名 + 继承表)。类型越多,可执行文件越大。
  2. 时间(typeid):一次 vptr 取 + 一次 vptr-8 取 + 一次比较,通常几纳秒内。
  3. 时间(dynamic_cast):运行时沿继承链查证(可能回溯多个基类),比 typeid 贵一个量级;跨多重继承的dynamic_cast更贵(要查多个子对象路径)。

取舍:能用虚函数表达"行为差异"就别用 RTTI 判型;必须判型用typeid比较;只有"不确定能否转、要安全降级"才用dynamic_cast(还要承受它可能返回空/抛std::bad_cast)。

⑮ 扩展 FAQ(第二轮)

  • Q:异常和assert有什么区别?
    A:assert是调试期检查(release 可能关掉),失败直接终止;异常是可恢复的错误传播机制。用途不同:前者防程序 bug,后者处理运行时错误。
  • Q:为什么main里不 catch 会 terminate?
    A:未捕获异常到达main之外,运行时调用std::terminate。所以"顶层 catch(…)“常用于"记录后安全退出”。
  • Q:noexcept函数真的不会抛吗?
    A:声明noexcept后若还抛,会直接std::terminate(比"没声明"更危险)。所以noexcept是承诺,不是摆设。
  • Q:std::bad_alloc怎么捕获?
    A:它是std::exception的派生类,catch (const std::exception&)能捕获;专门处理可用catch (const std::bad_alloc&)。
  • Q:为什么说"不要用异常做正常控制流"?
    A:因为每次throw是分配 + 栈展开 + 析构 + 查表的重活(毫秒级),而if/return是纳秒级。高频路径用异常 = 性能灾难(本集汇编就是证据)。
  • Q:catch 顺序影响性能吗?
    A:异常类型匹配是运行时逐 handler 试的。catch (const Base&)在前会先匹配派生异常(引用保多态),但"更具体的在前"仍是最佳实践——既减少误捕,也让"最常见类型"最先命中。
  • Q:noexcept与throw()有区别吗?
    A:noexcept(C++11 起)是动态异常说明的替代;旧的throw()等价于noexcept(C++17 移除)。noexcept若抛会terminate,语义更硬、优化信息更多。

⑯ 扩展实验(第二轮)

  1. 析构顺序观察:struct A{~A(){puts("~A");}}等,函数里按序声明几个,中间throw,看展开时析构顺序(逆序)。
  2. noexcept汇编对比:同一函数带/不带noexcept编译,对比 LSDA 和调用约定(是否省略异常处理)。
  3. -fno-rtti对比:编译带typeid的代码,看编译错误和.rdata里 RTTI 数据是否消失。
  4. dynamic_cast沿链成本:深继承链(5 层)+ 多重继承下的dynamic_cast,反汇编__dynamic_cast调用,对比浅继承的差异。
  5. 异常吞吐实测:循环里throw/catch1 万次 vs 循环里 if/return 1 万次,计时对比——把"数量级差距"变成数字。

⑱ 扩展专题五:异常对象与"复制"的隐藏成本

catch (int e)会拷贝异常对象(int 便宜)。catch (const std::exception& e)则引用不拷贝——这是为什么"按引用捕获"是推荐写法:

  • 按值捕获派生异常:切片 + 拷贝(异常对象大时昂贵,E07 的"隐藏拷贝"又出现);
  • 按引用捕获:零拷贝,且保有多态(能访问派生类字段,E08 的知识)。

从汇编看:catch (int e)对应__cxa_begin_catch后一次movl (%rax), %eax(取异常对象值);catch (const std::exception&)则直接拿(%rax)当引用。捕获方式不同,拷贝成本不同——这是异常代码里常见的隐藏性能点。

⑲ 扩展专题六:什么时候异常是"正确的选择"

讲了这么多异常成本,别误以为"异常是坏的"。它是对的工具,适用场景:

  • 构造函数失败:构造函数没有返回值,throw是唯一可靠的失败报告途径(返回std::optional也行,但要处理"半构造");
  • 库的契约层:接口约定"此函数失败抛std::runtime_error",调用方用 RAII 守护资源,代码比层层if (err)更清晰;
  • 操作符重载(operator[]越界、operator new失败)没有返回值可用来报错,异常是天然通道。

真正要避免的是:用异常表达"预期中的普通流程"(如解析文本时每个 token 都 try)。这类场景用std::optional/返回值,成本低一个数量级且意图更清楚。

⑳ 扩展 FAQ(第三轮)

  • Q:throw一个指针/基本类型合法吗?
    A:合法但少见。约定是抛对象(最好派生自std::exception),因为 catch 靠类型匹配、按引用捕获才高效安全。
  • Q:异常会破坏栈上的局部对象吗?
    A:不会"破坏",但会"析构"(展开时对已构造局部对象逐一调析构)。这既是 RAII 的保障,也是"展开很贵"的原因之一。
  • Q:-fno-exceptions后std::vector::at还安全吗?
    A:at用异常报越界,关异常后可能直接abort或未定义。这也是为什么容器越界检查默认走operator[](不检查)+at(检查)。
  • Q:为什么异常展开要"按帧查表",不能直接跳?
    A:因为要正确析构每一帧的局部对象,还得匹配 catch 类型。这些信息都在 LSDA 里,只能逐帧查。直接跳会跳过析构,破坏 RAII。
  • Q:性能敏感项目完全不用 RTTI/异常,是极端吗?
    A:游戏引擎、嵌入式常这么干(-fno-rtti -fno-exceptions),用typeid/dynamic_cast的替代方案(手工类型标签、虚函数、std::variant)。代价是"手写"这些机制,收益是更小更确定的二进制。

㉑ 扩展实验(第三轮)

  1. 按值 vs 按引用捕获对比:catch (std::exception e)与catch (const std::exception&)反汇编,看拷贝指令差异。
  2. 构造函数 throw:在构造函数里throw,观察"半构造"的成员是否被正确析构(E07 的"已构造部分才析构")。
  3. noexcept容器扩容联动:std::vector存不可拷贝但可移动的类型,比较移动构造是否noexcept时扩容行为差异(E14 预告)。
  4. 计时:异常 vs optional:同样"可能失败"的函数,分别用 throw/catch 和std::optional实现,1 万次循环计时对比数量级。
  5. 跨平台异常:若方便,在 MSVC 下编译同一段异常代码,对比 SEH 与 Itanium 的汇编形态差异。

㉓ 扩展专题七:从"零成本异常"到"返回码"——完整选型谱

面对"可能失败"的操作,C++ 的选型谱大致是(成本从低到高):

方案形态成本适用
返回值/标志位bool ok()纳秒级,零隐藏成本预期频繁失败
std::optional值或空纳秒级,无堆分配可能无结果
std::expected(C++23)值或错误纳秒级,无堆分配值或错误信息
错误码 + 引用输出int err, out纳秒级传统 C 风格
异常throw/catch正常路径零,抛时毫秒级真正异常路径

核心心法:用最低成本满足需求的方案。预期失败的解析 → optional/expected;"本不该发生"的错误 → 异常(或 assert)。这样代码既清晰,性能也落在谱系的低端。

㉔ 扩展实验(第四轮)

  1. 同一逻辑三实现:解析一个"可能失败"的输入,分别用异常、optional、返回值实现,1 万次循环计时——把本集的成本谱变成你自己的数字。
  2. 检查 LSDA 大小:一个含 3 个 try 块的函数 vs 无 try 的函数,-O2 -S对比.LLSDA段大小,直观感受"表驱动"的空间成本。
  3. noexcept全链路:给调用链每个函数加noexcept,对比优化后汇编的差异(调用是否少了异常处理路径)。
  4. 析构 vs 异常展开:构造一棵含 10 个局部 RAII 对象的调用栈,throw 看展开析构调用次数与顺序。

㉖ 扩展专题八:为什么游戏引擎常常"关掉异常"

游戏引擎、实时渲染、嵌入式控制器是"关异常"的重灾区(-fno-exceptions -fno-rtti)。理由正是本集的核心:

  1. 确定性与体积:异常表(LSDA)和 RTTI 数据占可执行文件空间;关掉后二进制更小、加载更快。
  2. 帧预算:游戏每帧有固定时间预算,一次throw的"分配 + 栈展开 + 析构"可能瞬间吃掉整帧预算,造成卡顿——这是"异常不好预测"的典型。
  3. 替代方案成熟:用错误码、std::optional、自定义错误类型 + 断言,配合"状态机+日志",足够覆盖游戏内错误处理。
  4. 编译器优化面:没有异常时,函数不需要为"可能被展开"预留栈帧信息,某些优化更激进。

但要诚实:关异常不是"性能魔法",因为正常路径本来就零开销;真正的收益是确定性 + 体积 + 更简单的优化分析。对绝大多数业务/服务端代码,保留异常是合理选择——它在"真正出错"时给出更安全的行为(RAII 保证清理)。这条权衡,就是本集"零成本异常 + 抛时昂贵"结论的直接推论。

㉗ 悬念

异常和 RTTI 把"运行时"的复杂性带进来了。下一章我们回到现代 C++:你天天写的 lambda,"语法糖"编译出来其实是一个隐藏的 struct——闭包对象。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 3:30:39

PWM调节DCDC参数计算原理:从占空比到环路补偿的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 3:30:25

YOLOv11改进版精密零件缺陷检测实战指南

简介&#xff1a;本资源是一份面向工业视觉算法工程师与质检系统开发者的深度技术文档&#xff0c;聚焦YOLOv11改进版在精密零件缺陷检测场景中的落地实践&#xff0c;着力解决传统方法精度不足、小目标漏检及产线部署效率低等核心痛点。文档共30页PDF&#xff0c;结构完整、支…

作者头像 李华
网站建设 2026/9/29 3:28:49

unslop-review - SKILL

name: unslop-review description: ‘Rewrites code review comments so they read like a human teammate wrote them. Cuts corporate-AI throat-clearing (“I noticed…”, “I was wondering if perhaps…”, “It might be worth considering…”). Each comment is dire…

作者头像 李华
网站建设 2026/9/29 3:27:33

高可靠芯片烧录零缺陷:六大失效路径与全流程管控方法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华