第20.10章:赫布异常:这里唯一存在的错误处理

English

1. 同一个特性,换一种实现方式

一个赫布异常就是返回路径上的两个字加一个失败位:错误值是 {domain 指针, 错误码},而调用约定说明它是否存在(3.16)。没有对象要分配、没有栈要遍历、没有表要注册、没有 personality routine,因为根本没有东西在展开 —— 函数就是返回了,和一个返回错误码的函数完全一样。

所有让遗留异常在独立环境里不可能的东西,按构造在这个机制里都不存在。剩下的只有一个问题:错误域必须知道它的错误码是什么意思,而这份知识住在一个库里。那么那个库怎么保持独立呢?

2. 实测:一个符号而不是十一个

和 20.9 同一个 throw 和 catch 的程序,用本书使用的机制写:

#include <herbceptions/error>

void fail(int x) throws { if (x < 0) { throw throws ::std::errc::invalid_argument; } }

int main() { try { fail(-1); } catch throws (::std::error) { return 0; } return 1; }
$ g++ -std=c++26 -fherbceptions -O2 -c herb.cc && nm -u herb.o
                 U __cxa_error_domain_posix
  [1 undefined symbol]
$ size legacy.o herb.o
   text	   data	    bss	    dec	    hex	filename
    452	      0	      0	    452	    1c4	legacy.o
    230	      0	      0	    230	     e6	herb.o

一个符号,而且它是一个指向错误域单例的数据指针 —— 不是函数、不是运行时、不是需要加载器去找的表。这个目标文件体积也只有一半,而且它没有那些无法展开的帧所对应的 .eh_frame 条目。

还要注意这个目标文件不需要什么:malloc、__cxa_allocate_exception、_Unwind_Resume、__gxx_personality_v0,以及任何 RTTI。20.9 那十一行的依赖是真的消失了,不是被藏起来了。

3. 禁用选项碰不到它们

人们问的第一件事是:19.7 里那些构建选项会不会破坏赫布异常。不会,因为赫布异常不是异常:

$ g++ -std=c++26 -fherbceptions -fno-exceptions -O2 hb.cc -o hb -lherbceptions
  [exit 0]

-fno-exceptions 去掉的是遗留机制,而赫布异常机制继续工作,因为它从来没有用过它。一个独立构建可以、也应该同时含有 -fno-exceptions -fno-rtti 并在所有地方使用 throws —— 这正是本书其余部分使用的组合。

4. 唯一不同的一点:没有消息表

剩下的依赖是错误域,而它的工作是把错误码变成文本。文本是唯一必须用 flash 支付的东西:每个域一张错误字符串表,加上把字符串拷出来的格式化代码。在一块整个二进制只有 64 KiB 的板子上,为了一句没人会读的字符串,这是真实的代价。

所以运行时有一个构建开关。LIBHERBCEPTIONS_NO_MESSAGES(作为 __HERBCEPTIONS_NO_MESSAGES 传给翻译单元;独立构建选项 LIBHERBCEPTIONS_FREESTANDING 会默认打开它)让每个域只报告 [domain](code)。下面是同一个程序,一次用宿主运行时,一次用打开了这个宏构建的运行时,分别打印 name、message、name+message 和错误本身:

--- hosted runtime ---
name         posix
message      (12)Not enough space
name_message [posix](12)Not enough space
e            [posix](12)Not enough space

--- runtime built with __HERBCEPTIONS_NO_MESSAGES ---
name         posix
message      (12)
name_message [posix](12)
e            [posix](12)

同一份代码、同一个错误、同一个域。消失的是文本。而且它不是那种"格式化一个空字符串"的桩:消息表根本不会被触碰,省下来的字节就是从这里来的。从同一份源码构建,两个运行时:

$ size -A libhb_msg.so | grep -E '^\.(text|rodata)|^Total'
.text                 8771    4352
.rodata               3427   16384
Total                17964
$ size -A libhb_nomsg.so | grep -E '^\.(text|rodata)|^Total'
.text                 5587    4288
.rodata                291   12288
Total                11164

一个 17,964 字节运行时里的 6,800 字节,以及它只读数据的 91%,只为单个域 —— POSIX 那一个。再加上 parse、HTTP、cmath 和 Win32 域,合成的表就是整个库里最大的一块。在 x86-64 上这些数字是噪音;在本章关心的目标上,它们决定这个特性能不能塞得下。

5. 你也可以把属于自己的文本放回去

"没有消息"不是一个你必须永远忍受的降级,因为错误域是可覆盖的(19.1)。一个希望为它真正会上报的那二十个错误码提供描述性文本的内核,可以替换 POSIX 域,用自己的实现、自己的字符串、自己的 do_query_information 编译进自己的二进制。库给你的默认值是零成本的;是否要为文字付费由你决定。

遗留异常(20.9)赫布异常
需要的链接符号11一个,数据指针
失败时的堆要不要
栈遍历要不要
帧表、personality routine要不要
RTTI要不要
能否在板子、内核、GPU 上用不能能
消息文本抛出时分配一个构建选项;可按域覆盖

这张表就是本书教 throws 而不是 throw 的全部理由。错误处理在两行上是同一个特性;变的只是实现,而实现本来就是问题所在。