在C++的技术版图中,异常处理机制一直是最具争议性的特性之一。与Java等语言将异常作为不可或缺的核心不同,C++社区内部对异常的态度呈现出明显的两极分化。以Google、LLVM、Qt为代表的一大批顶尖技术团队,在其代码规范中明确禁用了异常;而以Boost、Bloomberg为代表的一方则坚定不移地使用异常。这种割裂局面本身就值得我们深思:为什么一个被纳入语言标准二十余年的特性,至今未能获得普遍接受?
参考:https://bgnno.cn/category/guide.html
问题的核心在于,C++的异常处理机制与C++的零开销哲学存在内在张力。C++的设计理念是“你只用为你实际使用的特性付费”,但异常处理却打破了这一规则。即使你从未编写过一个throw语句,编译器也必须为每个局部对象生成用于栈展开的元数据,这些数据会占用二进制文件的空间,在某些嵌入式环境下可能达到5%到15%的体积膨胀。更关键的是,异常处理会抑制编译器的某些优化——特别是对于那些可能抛出异常的函数,编译器必须维持一个“可展开”的状态,这限制了寄存器分配和指令重排的自由度。
更深层的问题在于正确性。异常安全的代码需要遵循三个基本保证:基本保证(不泄露资源且对象保持有效状态)、强保证(操作要么成功要么回滚)和不抛出保证(绝不抛出异常)。然而在实践中,写出真正异常安全的代码极其困难。以经典的容器类为例,当向vector插入元素时如果发生异常,如何确保原vector保持不变?这需要精心设计的拷贝或移动策略,而移动操作本身又可能抛出异常——这就形成了一个死循环。C++11标准库为了解决这个问题,不得不为移动操作引入了noexcept标记,如果一个类型的移动构造函数没有标记为noexcept,vector在重新分配内存时会选择拷贝而非移动,这会带来严重的性能损失。这种隐晦的行为细节,即使是经验丰富的开发者也很容易踩坑。
参考:https://bgnno.cn/category/maintenance.html
从工程组织角度看,异常处理还带来了控制流的隐性化。常规函数调用通过返回值或输出参数传递错误信息,控制流是显式的;而异常则像一条隐形的通道,可以从深层的调用栈底部直接跳转到顶部的catch块。这种“非局部跳转”虽然在某些场景下方便了错误处理,但也使得代码的理解和调试变得困难。当你在阅读一段代码时,任何一行普通语句都可能触发异常、跳过后续所有逻辑——这意味着要理解这段代码的真正行为,你必须记住整个调用链上所有可能抛出的异常类型。
面对这些困境,许多团队选择了“编译时禁用异常”的策略。这并不意味着他们不处理错误,而是采用了一套完全不同的范式:通过返回错误码、optional、expected等类型,将错误变成普通值来处理。这种做法强制调用者显式检查错误,虽然代码会变得更冗长,但控制流是完全可预测的。Rust语言中的Result类型实质上正是这种思想的现代化体现——只不过Rust将错误传播的语法糖(问号运算符)内置到了语言中,既保持了显式性又减少了样板代码。
参考:https://bgnno.cn/category/limited.html
然而我们也要看到,禁用异常并非没有代价。构造函数失败是无法用返回值表达的错误场景(因为构造函数没有返回值),禁用异常后,常用的手法是将构造函数设为私有,转而使用工厂函数或二阶段初始化。后者需要对象引入“有效状态”标志,每一个公共方法都需要检查这个标志,这本身就容易出错。此外,标准库中的许多组件——从iostream到vector的at方法——都依赖异常来报告错误,禁用异常意味着你要么避免使用这些组件,要么接受未定义行为。
从更宏观的视角看,C++在异常处理上的挣扎反映了一个更深层的现实:C++试图同时服务于系统编程和高层应用编程,而这两种范式对错误处理的需求本质上是不同的。系统软件追求确定性、可预测性和极致的性能,倾向于将错误视为需要立即处理的本地事件;而应用软件追求健壮性,希望将错误处理与正常逻辑分离。没有一种统一的错误处理机制能完美满足这两种需求,于是C++选择将选择权交给开发者——这种选择权本身就是C++哲学的核心。
参考:https://bgnno.cn