第19.7章:用构建选项禁止遗留异常
1. 选项清单
在文档里写"我们不使用遗留异常"什么也不会改变:半年后有人会引入一个会抛出的头文件,unwinder 又回到了链接里,而在某个无法展开栈的目标上构建失败之前,没人会注意到。让它变成机械性的。你编译的每一个翻译单元,始终加上:
-fno-exceptions -fno-rtti -fno-unwind-tables -fno-asynchronous-unwind-tables -fno-ident
每一个各删掉了什么:
| 选项 | 它让什么变得不可能 |
|---|---|
-fno-exceptions |
throw、try、catch 在构建里任何位置都无法使用,随之消失的还有整套 __cxa_* 运行时和 personality routine。赫布异常照常工作:throws 和 catch throws 是另一套机制,不受影响
|
-fno-rtti |
typeid、dynamic_cast,以及遗留异常处理块匹配所依赖的那些类型信息对象。赫布异常不使用 RTTI,所以 throws 通道上没有任何东西受影响
|
-fno-unwind-tables |
栈遍历器所需的 .eh_frame 帧表 |
-fno-asynchronous-unwind-tables |
同样这些表的异步形式,调试器和性能分析器也会用到它们 |
-fno-ident |
编译器写在 .comment 里的标识字符串,它们泄露构建机器的细节,在二进制里纯粹是噪音
|
这些不是风格问题。每一条都删掉一个机制,而一旦有人试图使用它,构建就会大声失败。
2. 它究竟删掉了什么
一个小程序,一个赫布异常,编译两次:一次用默认选项,一次用上面那份清单。
#include <fast_io.h>
int divide(int a, int b) throws
{
if (b == 0)
{
throw throws ::std::errc::invalid_argument;
}
return a / b;
}
int main()
{
int result{};
try
{
result = divide(84, 2);
}
catch throws (::std::error e)
{
::fast_io::io::print("error ", e, "\n");
return 1;
}
::fast_io::io::print("84/2 = ", result, "\n");
}
84/2 = 42
输出相同,二进制更小,而差值恰好就是程序从未使用的那套机制的机器码:
| 默认选项 | 禁令清单 | 它是什么 |
|---|---|---|
5,869 bytes of .text |
4,859 bytes | 代码,包括 personality routine 能触达的部分 |
.eh_frame 644 bytes |
128 bytes | unwinder 遍历的那些帧表 |
.eh_frame_hdr 116 bytes |
44 bytes | 对那些帧表做二分查找的索引 |
.gcc_except_table 16 bytes |
消失 | 语言异常表:区间、landing pad、处理块类型 |
| 13 FDEs | 4 FDEs | 到底有多少函数带有展开信息 |
.comment 111 bytes |
69 bytes | 编译器版本字符串 |
剩下的四个 FDE 来自 C 运行时的启动对象,而不是你的代码,所以这已经是最低值了。在没有加载器的目标上,没有任何东西可供注册它们,而这正是重点:这些选项让你的程序所提出的要求,与它要运行的那些平台实际具备的东西对齐。
3. 你想要的那条诊断
有了禁令清单,遗留异常会在引入它的那一行变成编译错误,而不是在某个无法展开栈的目标上变成一次莫名其妙的运行时失败:
legacy_demo.cc: In function 'int main()':
legacy_demo.cc:13:78: error: exception handling disabled, use '-fexceptions' to enable
13 | throw ::std::runtime_error("division by zero");
| ^
4. 放进构建,而不是放进维基
用普通的 makefile:
CXXFLAGS += -std=c++26 -fherbceptions \
-fno-exceptions -fno-rtti -fno-unwind-tables \
-fno-asynchronous-unwind-tables -fno-ident
LDFLAGS += -lherbceptions
用 CMake,并且应用到项目里的每一个目标,而不只是可执行文件:
add_compile_options(
-fherbceptions
-fno-exceptions -fno-rtti
-fno-unwind-tables -fno-asynchronous-unwind-tables
-fno-ident)
add_compile_options 故意是全局的:一个没有加这些选项编译出来的静态库,能把那套行为重新带进用这些选项编译出来的可执行文件,而失败随后会在一个看起来毫不相关的目标上、在链接时冒出来。一份清单,所有目标,包括测试和基准。
5. 你放弃了什么,以及为什么那不是损失
- 会抛出的第三方库。你根本无法用这些选项编译它们,而这正是诚实的结果:它们不能运行在你需要运行的地方。如果你的某个依赖发布了使用遗留异常的二进制块,它会把 unwinder 拖进你的链接 —— 那是一个应当移除、而不是应当包装的依赖。
-
typeid和dynamic_cast。两者在 第18.5章 就已经被排除:用函数指针表做类型擦除能完成同样的工作,不产生任何类型信息对象,也没有运行时搜索。 -
关于错误,什么都没放弃。本书里的每一次失败都走
throws通道(第3.16章),它不需要上面任何一个机制。这正是整份禁令清单得以存在的原因。
如果你只从本章拿走一行,就拿这份选项清单。用它的项目无法再漂回遗留异常,而每一个没有 unwinder、没有加载器、没有 C++ 运行时的目标,都仍然是你能够构建的目标。