Follyrich_exception_ptr设计解析:64 位位打包联合体、RTTI-free 快速路径与取消传播
【免费下载链接】follyAn open-source C++ library developed and used at Facebook.项目地址: https://gitcode.com/GitHub_Trending/fol/folly
导读
folly::result是 Folly 中用于消除「灵活的异常」与「朴素的错误码」之间二选一困境的核心错误处理组件,而rich_exception_ptr正是支撑result的能力底座:它既要像std::exception_ptr/folly::exception_wrapper一样能承载任意动态异常,又要在常见 64 位 libcxx / libstdc++ 系统上获得数量级更好的性能,从而把那些「仅因便宜而使用错误码」的 99% 非热路径代码替换掉。本文以 folly/result/docs/rich_exception_ptr.md 设计文档为主线,结合 rich_exception_ptr_storage.h、rich_exception_ptr.h 等源码与测试,完整拆解它「用一个指针同时表达动态 eptr、rich_error、不可变错误、取消信号与小型值」的位打包方案、OC(OperationCancelled)对象身份取舍、四个候选设计以及最终落地实现。读完你将掌握:rich_exception_ptr能存什么、为什么底部 3 个对齐位可以安全复用、get_exception<T>()如何做到无 RTTI 快速判定,以及folly::coro取消传播如何被一劳永逸地保护起来。
为什么需要rich_exception_ptr
在folly::result的设计里(见 design_notes.md),目标不是「禁止异常」,而是与异常互操作的同时给出更便宜、更显式的替代方案。问题在于,经典的两条路各有硬伤:
- 纯异常方案:灵活、有结构化信息,但抛出一次异常往往超过 1 微秒,且
std::exception::what()返回const char*,动态消息难以避免分配。 - 纯错误码方案:便宜,但丢失类型信息、不可携带上下文,使用体验差。
rich_exception_ptr被定位为std::exception_ptr或folly::exception_wrapper的对等物(analog),但在常见 64 位 libcxx / libstdc++ 系统上拥有「质变级」更好的性能——便宜到足以在非热路径的 99% 代码中取代只给错误码的系统。它的公共形态在 rich_exception_ptr.h 中定义:
/// `rich_exception_ptr` is an analog of `exception_wrapper` or /// `std::exception_ptr`, with some extra efficiency optimizations, and /// integration with `rich_error` / `epitaph`. class [[nodiscard]] rich_exception_ptr final : public detail::rich_exception_ptr_base { // 唯一的类型查询途径:folly::get_exception<Ex>(rich_eptr) };与exception_wrapper相比,它的 API 刻意做小:唯一测试是否包含Ex类型异常的方式就是folly::get_exception<Ex>(rich_eptr)。它通常「拥有」一个std::exception_ptr,或存一个「廉价可复制」的不可变异常指针;内部私有 API 还可能在联合体里存其它状态(sigil、小型值),因此文档明确建议:终端用户代码不要直接使用rich_exception_ptr,而应使用result暴露的error_or_stopped。
rich_exception_ptr能存什么
文档开宗明义:除了必须能存动态std::exception_ptr(并保留folly::exception_wrapper在 make / move 上的优化)之外,它还在内部联合体中为下面几类特殊状态提供高效的联合存储。这些缩写会在后文频繁出现。
动态std::exception_ptr(eptr)
最基本的形态:持有类型擦除的动态异常。folly::exception_wrapper已有的「make 与 move 免分配」优化被完整继承。
Rich errors(RE)
rich_error是一族实现 rich_error_base 接口的错误类型,可视为「更好的异常基类」。文档列举了生产项目几乎必然想要的几个特性:
- 错误码:兼容
std::error_code的语义,但支持constexpr。 - 日志:内建
fmt与operator<<(ostream&)支持,日志免分配;对照之下std::exception的const char* what()API 在记录动态消息时很难避免分配。 - epitaph:异常在传播过程中可通过
epitaph()叠加 / 串联上下文信息(详见 epitaph.h 与 epitaphs.md)。该操作很便宜(约 60ns,可优化到 5ns 以下),且不改变底层异常的类型身份。
更进一步,rich_errorAPI 的典型用法可以完全避开 RTTI,因此比std::exception继承体系更快。
Immortalrich_error异常(immortal RE)
「不可变 rich_error」是指向不可变constexpr异常的廉价可复制指针,可以直接作为rich_exception_ptr传递,并暴露rich_errorAPI。之所以必须存在这个形态,是因为动态std::exception_ptr有四个痛点:
- 构造必然分配;
- 复制 / 析构要更新原子引用计数;
- 不可能是
constexpr; - 静态变量有 SIOF(静态初始化顺序失败)风险,或需要函数调用才能初始化。
一句话:异常指针比std::error_code贵得多。这也是为什么在设计相近的boost::outcome中,outcome与result是两个独立类型。Immortal RE 恰好弥合了这个差距——成本和错误码差不多,却能原生用于result的异常风格 API。构造方式见 immortal_rich_error.h:
static constexpr auto badFruit = immortal_rich_error<coded_rich_error<FruitCode>, FruitCode::BAD, "Rotten, moldy, or damaged"_litv>.ptr();为什么要特判OperationCancelled(OC)
folly::coro有一个约定:子任务可以向父任务传递「因取消而停止」的信号。folly/OperationCancelled.h(当前仓库实现于 folly/OperationCancelled.h)如今是以异常形式穿过folly::coro协程传播的。子任务有多种方式发出它,但传播语义一律默认抛出:
co_yield co_stopped_may_throw; throw OperationCancelled{}; // discouraged co_yield co_error{OperationCancelled{}}; // discouraged大部分用户代码本不该直接操作取消原语——它主要是实现高层算法的工具,99% 情况下用户代码的正确行为是「尽快展开栈并返回」。但今天基于异常的实现把「取消完成」的细节暴露得过于彻底:下面这些无辜的用户代码,在没有任何特判的情况下就会打断取消传播:
try { co_await task(); } catch (...) { handleError(); } try { co_await task(); } catch (const std::exception& ex) { handleError(ex); } auto res = co_await co_awaitTry(task());后果是用户代码里充斥着大量显式、易错的取消处理,以及大量 bug。文档作者计划提出co_yield co_stopped_nothrow及配套迁移策略:既让现有代码继续工作,又鼓励新代码 / 重构代码采用新原语。
对本设计文档而言,只需知道三点:
- 将来会有两种
...OperationCancelled类型; co_stopped_nothrow发出的类型在folly::coro代码中总是像被co_nothrow()包裹一样传播;一旦走出协程(如blocking_wait),因为没有更好的替代方案,它仍会被抛出;- 因此,两者都必须能被
rich_exception_ptr表示,并且彼此之间、以及与其他类型之间都要能廉价地区分。
小型值(small values)
联合存储的另一个用途是小值优化:类似result<int>或result<Foo*>的类型可以只占8 字节。这是文档列出的「将来态」(源码中kImplementsSmallValue = false,见下文),但位布局已为其预留编码SMALL_VALUE_eq = 6。
位打包联合体:把 8 种状态塞进一个指针
在受支持的 64 位平台上,rich_exception_ptr与std::exception_ptr尺寸相同,却塞进了上述全部额外功能。做法是:变体选择信息存在指针底部 3 个「对齐位」里(这 3 位本来是零),有时还会再检查其余 61 个「指针位」是否全零。要点是:
- 当低 3 位告诉我们存的是小型值时,不能把「高位全零」当作特殊状态(小型值的任意位都可能是 0);
- 但在其它所有情况下,「空指针」可以是一个被区分的状态。
设计文档特别解释了为什么用低 3 位而不是高字节:虽然 64 位指针的顶部字节(甚至更多)通常也用不到,但复用这些位会干扰内存标签(memory tagging)方案;相反,8 字节对齐保证低 3 位恒为零,复用它们既便宜又安全。
理论上用 4 位可表达2**4 - 2 = 14种 error-or-stopped 状态、存 7 种 error-or-stopped 指针。不过除了「小型值」外,当前设计只需要8 个状态 + 6 种指针,这给了位打包 / 解包更多的 CPU 优化空间。文档明确说:这一节是为了解释「为什么最终是现在的状态集和位表示」,如果只想看位表,直接跳到「Idea 1」的表格。
源码中的具体佐证见 rich_exception_ptr_storage.h:bits_t枚举定义了全部位值,且严禁使用大于 7 的值:
enum bits_t : uintptr_t { // 该位为 1 表示联合体是我们拥有、需要析构的 std::exception_ptr OWNS_EXCEPTION_PTR_and = 1, // immortal_storage_ 为空时表示空 exception_ptr IS_IMMORTAL_RICH_ERROR_OR_EMPTY_eq = 0, // 将来小值优化:把 <=61 位的平凡 T(如 int、void&)内联进来 SMALL_VALUE_eq = 6, // 存 private_rich_exception_ptr_sigil(值在 ...uintptr_ 中) SIGIL_eq = 2, // eptr_ref_guard_ 里是一份「非拥有」的泄漏单例 StoppedNoThrow 副本 NOTHROW_OPERATION_CANCELLED_eq = 4, // 用于快速类型查询的掩码(位 1-2) mask_FAST_PATH_TYPES = 6, // 快速路径判定值(masked_eq 语义): IS_RICH_ERROR_BASE_masked_eq = 0, // rich_error_base 位于偏移 0 IS_OPERATION_CANCELLED_masked_eq = 4, // OperationCancelled 快速路径 // 仅当 OWNS_EXCEPTION_PTR 置位时使用: owned_eptr_KNOWN_NON_FAST_PATH_TYPE_masked_eq = 6, // 已知类型但非快速路径 owned_eptr_UNKNOWN_TYPE_masked_eq = 2, // 慢路径,需要 RTTI };存储布局:packed 与 separate 两套实现
同一份bits_t背后有两套存储访问器:
rich_exception_ptr_packed_storage:data_t data_;单成员,位藏在data_.uintptr_低 3 位。static_assert(sizeof(...) == sizeof(std::exception_ptr))保证与std::exception_ptr同尺寸。rich_exception_ptr_separate_storage:data_t data_; bits_t bits_;分开存放,作为 packed 不受支持时的回退。static_assert(sizeof(...) == sizeof(std::exception_ptr) + sizeof(uintptr_t))。
rich_exception_ptr_base(rich_exception_ptr.h 末尾)按is_supported在两者间选择:
using rich_exception_ptr_base = rich_exception_ptr_impl< rich_exception_ptr, conditional_t< rich_exception_ptr_packed_storage::is_supported, rich_exception_ptr_packed_storage, rich_exception_ptr_separate_storage>>;packed 是否可用由编译期常量判定(is_supported),要求三者的对齐度都 ≥ 8(3 位):
static constexpr bool is_supported = alignof(detail::immortal_exception_storage) >= min_alignment_for_packed_rich_exception_ptr && // 8 alignof(std::max_align_t) >= min_alignment_for_packed_rich_exception_ptr && alignof(std::exception) >= min_alignment_for_packed_rich_exception_ptr;- 对
immortal_exception_storage*,直接检查其对齐度; - 对
std::exception_ptr,依赖平台实现细节:Itanium ABI 下__cxa_allocate_exception只接收thrown_size,必然以最大基础对齐存放异常对象;Windows 下对齐未公开文档化,但至少须满足std::exception的对齐要求(源码用alignof(std::exception)探测)。GNU 系统上exception_ptr含 1 个指针,MSVC 上含 2 个(detail_num_ptrs_in_exception_ptr有对应的static_assert)。
联合体成员本身也值得注意——rich_exception_ptr_base_storage::data_t是一个匿名联合:
union alignas(std::exception_ptr) data_t { uintptr_t uintptr_; // 位访问 / sigil / 小型值 const detail::immortal_exception_storage* immortal_storage_{}; // immortal RE mutable_eptr_ref_guard eptr_ref_guard_; // owned eptr / nothrow OC 单例 };其中eptr_ref_guard用uintptr_t数组 +std::launder/reinterpret_cast进行类型双关——注释说明这是刻意为之:如果直接用匿名联合放std::exception_ptr,类型就无法用于常量求值(constant-evaluated code),所以只能冒reinterpret_cast的风险。这也是整套「位打包」能同时服务constexpr与运行时性能的关键。
位打包设计的输入:状态、效率优先级与 OC 身份
需要表示的状态
设计文档完整列出如下状态集:
- 小型值(small value)——必须允许任意 61 位(全零或非零皆可)。
- 空
Try——为了让Try未来能用result重新实现。 - 空
exception_ptr——最好所有位全为零;可以共享 immortal RE 的 3 位编码(空的不存在,无歧义)。
以下状态要求高位存非空指针:
- Immortal
rich_error——底部 3 位必须是000,因为 C++20 不允许在constexpr代码里对指针做位操作(源码中 packed 存储的get_bits()在std::is_constant_evaluated()时直接返回 0,apply_bits...在 consteval 下要求 bits 为 0,正是这条约束的体现)。 - 未知类型的
exception_ptr。 - 指向
rich_error的exception_ptr。 - 指向两种
OperationCancelled变体的exception_ptr:- Legacy / 被抛出的异常:目前存动态
exception_ptr; - 新的
co_stopped_nothrow:目前存一个泄漏单例指针。
- Legacy / 被抛出的异常:目前存动态
- 已知类型但非快速路径的
exception_ptr。
为什么「已知类型非快速路径」要与「未知类型」区分?因为在「快乐路径」上,一个错误只由rich_exception_ptr处理,我们希望get_exception<Ex>()在Ex为rich_error_base与OperationCancelled时始终走无 RTTI 快速路径。仅仅回答「是的,它派生自Ex」不够,位还必须能回答「它肯定不是Ex」——这就是快速否定(fast negative)能力,用于把不匹配的查询廉价挡掉。
运行时效率优先级(从高到低)
实现(Idea 1)满足以下优先级,每条都对应源码中的一行位测试:
has_dynamic_exception_ptr()是极常见的检查,必须快——测试bits_ & 0x1(即OWNS_EXCEPTION_PTR_and,1 个周期);- 「是不是 rich error 指针」的廉价测试——
!(bits_ & 0x6)(即快速路径位为 0 =IS_RICH_ERROR_BASE_masked_eq); has_stopped()要胜过 RTTI——4 == bits_ & 0x6(IS_OPERATION_CANCELLED_masked_eq)。
源码get_exception_from_owned_eptr()正是这么做的:先取mask_FAST_PATH_TYPES & bits,命中IS_RICH_ERROR_BASE_masked_eq或IS_OPERATION_CANCELLED_masked_eq就走exception_ptr_get_object静态转换;命中「已知非快速路径」直接返回nullptr;只有UNKNOWN_TYPE才 fall through 到需要 RTTI 的慢路径。
必须保留OperationCancelled的对象身份吗?
理想世界中(参考 P1667 的讨论),OperationCancelled不应被视为异常,而只是一个「尽快拆栈」的简单 sigil——sigil不需要对象身份。可惜 legacyOperationCancelled经常以exception_wrapper形式存在,能暴露OperationCancelled*,且该指针在受支持平台上历史上是稳定的(除非被重新抛出)。设计文档给出的调查结论是:几乎不可能有现存用户代码依赖对象身份,那样的代码看起来就很奇怪:
OperationCancelled* innerPtr = nullptr; auto ew = co_await co_awaitTry([&innerPtr]() -> now_task<> { auto innerEw = make_exception_wrapper<OperationCancelled>(); innerPtr = get_exception<OperationCancelled>(innerEw); co_yield co_error(std::move(innerEw)); }()).exception(); auto* outerPtr = get_exception<OperationCancelled>(ew); // Outcome A: assert(innerPtr == outerPtr); // Outcome B: assert(innerPtr != outerPtr); // AND, `innerPtr` is no longer valid把OperationCancelled从「背后是exception_ptr」迁移成「简单 sigil」有不错的效率收益,问题是:从今天的「Outcome A」迁到更便宜的「Outcome B」是否存在不可接受的(现在或将来)风险。实际风险看似很低,但std::exception_ptr_cast提案(P2927)支持「在不抛异常的代码里保留异常对象身份」的想法(Windows 上被抛出的异常可能被复制)——接受该提案等于祝福「不 rethrow 也能处理 eptr」的泛型代码,这类代码会理所当然地假设指针稳定。
最终实现(Idea 1)以最保守的方式解决这个难题:
- legacy(被抛出的)
OperationCancelled:保留动态对象身份; - 新的
co_stopped_nothrow:使用泄漏单例,但仍保存其指针——万一两个 DSO 各自实例化了不同的单例,对象身份依然得以保留(Meyer 单例天然是「每个 DSO 一份」)。
源码中rich_exception_ptr_impl(StoppedNoThrow)构造函数正是这个单例模式:局部InitializedSingleton继承mutable_eptr_ref_guard,首次使用时构造exception_ptr,通过静态局部变量形成线程安全的 Meyer 单例并故意泄漏以避免 SDOF(静态析构顺序失败);复制时按位拷贝而不增加引用计数(NOTHROW_OPERATION_CANCELLED_eq状态由bits_store_eptr()识别为非拥有关系)。对应地,rich_exception_ptr.h 里的桩类型也写得很清楚:StoppedNoThrow不是std::exception,StoppedMayThrow才是。
被否决或无限期搁置的需求
设计文档明确列出了「占着额外位但最终不做」的几个想法及理由:
- RTTI-free 的
std::exception:对 immortal RE 已天然成立,对动态 eptr 也容易做到,但被否决——一是实现有取舍,无法与 Idea 1 结合而不伤害「is eptr?」的性能;二是它唯一能带来的是让 RTTI-free 代码去 logwhat(),而what()是糟糕的 API,记录带详细上下文与 epitaph 栈的rich_error才应被强烈推荐。 - 支持非
rich_error类型的不可变异常:技术上直截了当,但被阻塞——直到 C++(或所有相关工具链)允许改写 constexpr 指针的对齐位;且大多数有意义的使用场景是派生自std::exception的错误,而std::exception要到 C++26 才 constexpr。另外,让你的不可变错误会说rich_error协议通常只有好处没有坏处。 - 在 epitaph 栈中缓存「无底层错误」:正如
rich_error_base的 docblock 所述,如果rich_exception_ptr能再省出一个空闲位,就能缓存 epitaph 栈中不存在底层错误这一事实。但当前方案已相当不错,且再挤出一个跨平台位并不容易,故搁置。
设计空间:四个候选方案的取舍
Idea 0:为两种 OC 都存动态 eptr
给两种 OC 各分配一个 3 位槽位,都保留动态 eptr。
- 未采用——「has dynamic eptr?」将不再是 1 位测试;
- 而且正常
co_stopped_nothrow用法也不想分配动态 eptr。
Idea 1:只为 legacy 抛出的 OC 存动态 eptr(当前实现)
- 用一个 3 位槽位让 legacy 抛出 OC 的 eptr 可变,匹配现有行为、简化迁移;
- 新的 nothrow OC 也存一个 eptr,但其构造函数被限制为只能指向泄漏单例。
CAVEAT:如果你抛出其中一个,在
exception_ptr里接住,再把它 ingest 进rich_exception_ptr,绝不能把它放进 RTTI-free 状态,否则会丢失对象身份。
- 现阶段 OC 用户文档一律写成「OC 指针从不保证稳定」,以保留将来迁移到 Idea 2 的选项价值——即不承认 P2927 被接受可能带来更强的契约。
位表如下(文档原表):
Top 61 bits 1-bit 2-bit 4-bit (dynamic eptr?) (is RE?; is OC?) small value any 0 1 1 immortal RE not all zero 0 0 <-RE-> 0 empty eptr 0...0 0 0 <-RE-> 0 Constants -- empty Try sigil 0...0 0 1 0 (future sigils) not all zero 0 1 0 nothrow OC not all zero 0 0 <-OC-> 1 Dynamic eptrs -- unknown-type eptr not all zero 1 1 0 RE eptr not all zero 1 0 <-RE-> 0 thrown OC not all zero 1 0 <-OC-> 1 non-fast-path eptr not all zero 1 1 1把该表与源码bits_t枚举一一对照,可以得到每个状态的具体 3 位码(这是从代码可验证的事实):
| 状态 | 3 位码(二进制) | 对应的bits_t值 |
|---|---|---|
| immortal RE / 空 eptr | 000 | IS_IMMORTAL_RICH_ERROR_OR_EMPTY_eq = 0 |
| RE eptr(owned) | 001 | OWNS_EXCEPTION_PTR_and \| IS_RICH_ERROR_BASE_masked_eq = 1 |
空Trysigil / 将来 sigil | 010 | SIGIL_eq = 2 |
| unknown-type eptr(owned) | 011 | OWNS_EXCEPTION_PTR_and \| owned_eptr_UNKNOWN_TYPE_masked_eq = 3 |
| nothrow OC | 100 | NOTHROW_OPERATION_CANCELLED_eq = 4 |
| thrown OC(owned) | 101 | OWNS_EXCEPTION_PTR_and \| IS_OPERATION_CANCELLED_masked_eq = 5 |
| small value | 110 | SMALL_VALUE_eq = 6 |
| 已知非快速路径 eptr(owned) | 111 | OWNS_EXCEPTION_PTR_and \| owned_eptr_KNOWN_NON_FAST_PATH_TYPE_masked_eq = 7 |
对照表可见:位 0(1-bit列)即「dynamic eptr?」标志;位 1-2(mask_FAST_PATH_TYPES = 6)即「RE / OC」快速路径字段——00为 rich(IS_RICH_ERROR_BASE),10为 OC(IS_OPERATION_CANCELLED);空 eptr 与 immortal RE 共享编码000,靠「高位是否全零」区分,正如文档所述「空 eptr 可以共享 immortal RE 的 3 位编码,因为空的 immortal 不存在」。
Idea 2:两种 OC 都不存 eptr
两种 OC 都避免动态 eptr,但用一个既区别于「空Try」也区别于「immortal RE」的 3 位码。
- 暂不采用——主要原因是迁移所有现存构造 legacy OC 对象的代码到「泄漏单例 eptr」语义成本太高;而且 legacy OC 哪怕只抛一次,其代价也比分配动态 eptr 贵 50 倍。
Idea 3:两种 OC 与「空Try」共享 3 位码
把两种 OC 都当作无 eptr 常量,并且与Try共用同一个 3 位码。
- 未采用——会丢弃 OC 对象身份;位不紧缺;还会让「is OC?」测试变慢。
Idea 4:两种 OC 与「空Try」共享 immortal RE 的 3 位码
分配 constexpr 指针,把 OC 与空Try都放进「immortal RE」命名空间。
- 省下一个 3 位码,代价是每次从
Try里取 RE 都要测试「is emptyTry?」; - 必须让 OC 遵守 RE 接口(通过「is RE? && is OC?(经 vtable)」测试);
- 需要把 legacy OC 迁移到单例 eptr 模型;
- 未采用——没必要,复杂度与运行时成本更高。
源码中的落地实现细节
构造、复制、移动与析构的位状态机
rich_exception_ptr.h 的rich_exception_ptr_impl完整实现了按位的生命周期管理:
- 默认构造 = 空 eptr(
IS_IMMORTAL_RICH_ERROR_OR_EMPTY_eq+ 空指针); - 按值构造
Ex ex:按Ex的静态类型选择位——派生自rich_error_base走IS_RICH_ERROR_BASE快速路径(要求rich_error_base在偏移 0,has_offset0_base静态断言),派生自OperationCancelled走IS_OPERATION_CANCELLED,其余走owned_eptr_KNOWN_NON_FAST_PATH_TYPE。注释明确警告:不希望用户从类型擦除的std::exception_ptr/exception_wrapper构造,那会破坏result代码里的 RTTI 规避优化; from_exception_ptr_slow(std::exception_ptr&&):把类型擦除的 eptr 设为unknown-type(OWNS | UNKNOWN_TYPE),避免急切 RTTI;注释提到将来可让可变 getter 机会式地更新bits_;- 复制 / 移动:owned eptr 走真正的
exception_ptr复制 /extract_exception_ptr移动;nothrow OC 按位复制单例(不执行exception_ptr的复制构造);immortal / sigil / small value 走copy_unowned_pointer_sized_state; make_moved_out:带异常的状态(owned eptr、nothrow OC、immortal)移出后统一回到「空 eptr」状态;测试rich_exception_ptr_fundamentals_test.cpp断言移出后EXPECT_EQ(rich_exception_ptr{}, src)。
epitaph 透明性:with_underlying与get_exception
rich_exception_ptr的一个关键承诺是「对 epitaph 透明」:get_exception<Ex>()访问的是底层原始异常,而不是存上下文的包装层;但fmt/operator<<会展示完整 epitaph 栈。实现靠两件事:
with_underlying(fn):如果外层是rich_error_base且存在underlying_error(),就沿链走下去再调用fn——throw_exception()与to_exception_ptr_slow()都经由它,确保 rethrow 时丢弃 epitaph 包装、直接命中用户catch的目标类型;get_exception<Ex>()返回rich_ptr_to_underlying_error<Ex>(一个指针样对象,而非裸Ex*),内部保留top_rich_error_,这样LOG(INFO) << ex能同时给出「底层类型」与「完整 epitaph 栈」。把返回值转成裸Ex*就会丢失 epitaph,头文件 docblock 明确劝阻。
operator==:比较底层异常对象指针
相等比较在with_underlying之后进行,忽略 epitaph 包装:
- immortal 对 immortal:比较
get_immortal_storage()指针(假定每个 immortal 异常有唯一的 storage); - immortal 对 owned:检查 immortal 的可变单例是否已创建,若已创建再比较其中的 eptr;
- owned 对 owned / nothrow OC:比较底层
exception_ptr。
文档注释还记录了一个多 DSO 警告:同一immortal_rich_error<...>::ptr()在多个 DSO 实例化会产生多份底层错误,指针比较会不等;解决办法是指定单一链接单元实例化,通过头文件只暴露rich_exception_ptr。
格式化输出:format_to与operator<<
rich_exception_ptr.cpp 的format_to是位状态机的「渲染端」,逐状态处理:
SIGIL_eq:打印[result has value]/[empty Try]/[unknown sigil];NOTHROW_OPERATION_CANCELLED_eq:打印folly::detail::StoppedNoThrow;- immortal 且指针为空:
[empty]; - rich error:
rex->format_with_epitaphs(out)输出完整 epitaph 栈; - owned 非 rich 异常:
exception_ptr_get_type拿 typeid、folly::demangle(try-catch 包裹,避免格式化时因 OOM 抛异常)、再叠加what(),形如TypeName: message。
operator<<则通过detail::ostream_write_via_fmt复用format_to,因此fmt::format与流式输出行为完全一致(fmt::formatter<folly::rich_exception_ptr>也已特化)。
空 eptr 的 UB 防护
按标准 rethrow 空 eptr 是 UB,因此throw_exception_impl在非法位状态 / 空 eptr 时走terminate_on_empty_or_invalid_eptr,与exception_wrapper的行为对齐。bad_result_access_singleton()则用「空析构函数的 union + Meyer 单例 + 故意泄漏」的技巧构造bad_result_access_error的exception_ptr,供 sigil / small-value 状态的错误访问兜底。
测试与基准:设计如何被验证
folly/result/test/rich_exception_ptr_fundamentals_test.cpp 覆盖了基本操作语义:
checkEptrRoundtrip:对 owned rich error、owned 非 rich error(如std::runtime_error)、immortal、nothrow OC 做std::exception_ptr往返(from_exception_ptr_slow↔to_exception_ptr_slow),并验证「复制往返不改变源对象」「移动往返后源对象等于空 eptr」;- 相等语义:新建的 owned rich error 对象指针不同故不等;同一
immortal_rich_error的所有实例共享同一对象指针故相等;immortal 往返后变成「指向其可变单例的 owned eptr」,但聪明的比较逻辑依然判等; - nothrow OC 的往返同样保持逻辑相等。
同目录还有 rich_exception_ptr_constexpr.cpp(验证 constexpr 约束:immortal 位必须为 0)、rich_exception_ptr_immortal_test.cpp、rich_exception_ptr_misc_test.cpp、rich_exception_ptr_fmt_test.cpp 以及基准 rich_exception_ptr_bench.cpp。
性能预期可对照 rich_error.md 的 Performance 一节(同为仓库文档):immortal rich error<5ns完成「复制构造→比较→析构」,get_exception查询2-5ns;动态 rich error 构造 / 析构约60ns;无 RTTI 优化下部分get_exception查询3-10ns;对照基线是exception_wrapper的构造30ns、复制~7ns、移动~1ns。设计文档给出的 epitaph 成本60ns(可降至<5ns)也与之一致。
小结
rich_exception_ptr的设计本质上是一次指针位级的信息论练习:用 8 字节同时表达「动态异常 / rich_error / 不可变错误 / 取消信号 / 小型值 / 内部 sigil」6 大类、8 种具体状态,并为最常见的运行时查询(is dynamic eptr?、is rich error?、has stopped?、is rich_error_base?)各留一条单指令路径。整套设计由三条硬约束驱动:
constexpr友好:C++20 不允许 constexpr 指针位操作,因此 immortal 错误必须是000,常量求值时代码路径必须退化为「bits=0」;- 对象身份取舍:legacy OC 保留动态对象身份,nothrow OC 用泄漏单例且保留指针以应对多 DSO;
- RTTI 规避:快速路径既要能回答「是」,也要能回答「肯定不是」,把 RTTI 死死限制在 unknown-type 慢路径上。
最终落地为bits_t枚举 + packed / separate 双存储 +with_underlying/get_exception/format_to一套完整位状态机(见 rich_exception_ptr_storage.h、rich_exception_ptr.h、rich_exception_ptr.cpp)。对使用者而言,这条设计链路的收益是具体的:folly::result的error_or_stopped可以在不分配、不碰 RTTI的前提下承载富错误与取消信号,而folly::coro中「catch 一切」的用户代码再也无法无意中打断取消传播——这正是「灵活异常」与「廉价错误码」两种范式的合流点。
【免费下载链接】follyAn open-source C++ library developed and used at Facebook.项目地址: https://gitcode.com/GitHub_Trending/fol/folly
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考