1964年,IBM在设计PL/I语言时引入了一个被称为"ON语句"的构造。这个看似简单的语法元素,开创了编程语言中结构化错误处理的先河。六十年后,当Rust的?操作符和Go的显式错误检查成为现代语言的主流选择时,我们不禁要问:为什么错误处理这个问题困扰了语言设计者如此之久?
2003年8月,Anders Hejlsberg在一场著名的访谈中被问及为什么C#没有采用Java的受检异常(Checked Exceptions)。这位曾创造Turbo Pascal、Delphi和C#的语言设计大师直言不讳:“在小型程序中,受检异常看起来很诱人。但当你构建大型系统时,它会变成一种恼人的负担,以至于人们完全绕过了这个特性。“这番话揭示了一个深层的矛盾:错误处理机制的设计,本质上是在编译时保证与运行时灵活、显式控制流与隐式传播、安全性与便利性之间寻找平衡。六十年过去了,这个平衡点至今仍在移动。
mindmap
root((错误处理<br/>设计权衡))
控制流
显式传播
Go显式检查
Rust Result
隐式传播
C++异常
Java异常
编译时保证
受检异常
Java
Swift typed throws
无保证
C++
Python
性能模型
零成本正常路径
C++异常
一致开销
Go错误检查
Rust Result
ON语句:一个被遗忘的开端
PL/I的ON语句是编程语言中第一个结构化的异常处理机制。它的设计理念相当超前:程序可以声明当特定条件发生时应该执行的代码块。例如:
ON ZERODIVIDE BEGIN;
/* 处理除零错误的代码 */
END;
这个设计在1960年代是革命性的。在此之前,程序员只能通过检查每个函数的返回值来处理错误——这是一种繁琐且容易出错的方式。ON语句的引入,使得错误处理代码可以与正常业务逻辑分离,这是软件工程领域的一个重要进步。
然而,PL/I的ON语句存在一个根本性的缺陷:当异常发生时,处理程序在ON语句执行时的状态中运行,而不是在异常发生点的状态中运行。这意味着处理程序无法访问异常发生时的上下文信息。这个设计决策的影响是深远的——它让后来的语言设计者意识到,错误处理不仅仅是"跳转到某个处理代码”,还需要考虑状态的保存和恢复。
graph LR
subgraph PL/I ON语句问题
A[ON语句声明] --> B[程序执行]
B --> C[异常发生]
C --> D[跳转到ON处理程序]
D --> E[在声明点状态执行<br/>丢失上下文信息]
end
style C fill:#f66
style E fill:#f66
1975年,John B. Goodenough在《Communications of the ACM》上发表了一篇开创性的论文《Exception Handling: Issues and a Proposed Notation》。这篇论文首次系统地定义了异常条件(Exception Conditions),讨论了异常处理语言特性必须满足的需求,并提出了一个符号表示法。Goodenough指出,一个优秀的异常处理机制需要解决三个核心问题:如何识别可能发生异常的代码?异常发生时如何传递信息?如何确保资源被正确清理?
这三个问题,至今仍是错误处理设计的核心考量。
CLU与Barbara Liskov的贡献
1974年,Barbara Liskov在MIT开始设计CLU语言。CLU引入了signal和handler机制,这是对PL/I ON语句的重要改进。在CLU中,函数可以声明它可能signal的异常,调用者可以使用handler来捕获这些异常:
open_file = proc (name: string) returns (stream) signals (not_found, no_permission)
% 函数实现
end
% 调用者
begin
s: stream := open_file("data.txt")
except when not_found: ...
when no_permission: ...
end
CLU的设计有几个关键创新。首先,异常成为了函数签名的一部分——这是Java受检异常的先驱。其次,CLU区分了"正常返回"和"异常返回”,这为后来的类型系统设计提供了灵感。最重要的是,CLU的异常处理程序在异常发生的上下文中执行,而不是在声明处理程序的地方执行,这解决了PL/I ON语句的核心缺陷。
graph TD
subgraph CLU异常处理改进
A[函数调用] --> B{执行成功?}
B -->|是| C[正常返回]
B -->|否| D[signal异常]
D --> E[在调用点上下文<br/>执行handler]
E --> F[处理程序可访问<br/>调用时的变量]
end
style C fill:#6c6
style F fill:#6c6
然而,CLU的设计也有其局限性。异常处理代码必须与调用代码在同一个词法作用域中,这限制了灵活性。此外,CLU没有解决"谁负责清理资源"这个关键问题——这是C++后来通过RAII(Resource Acquisition Is Initialization)才真正解决的问题。
C++异常:从setjmp/longjmp到零成本异常
1983年,Bjarne Stroustrup在设计C++时面临一个艰难的选择:C语言的传统是使用返回值表示错误,但这种方式在面向对象编程中显得笨拙。Stroustrup最终选择了引入异常机制,但C++的实现方式与CLU和后来的Java都有本质区别。
早期C++异常的实现基于setjmp和longjmp——这两个C语言函数可以保存和恢复程序执行状态。当异常被抛出时,程序会使用longjmp跳转到最近的try块,然后执行相应的catch处理程序。这种实现方式简单直接,但代价昂贵:每个try块都需要保存当前的执行状态,即使异常从未发生。
1990年代后期,基于DWARF调试信息的表驱动异常处理(Table-based Exception Handling)成为主流。这种技术被称为"零成本异常"(Zero-cost Exceptions),因为它在正常执行路径上几乎没有开销——编译器会生成静态的异常处理表,记录每个指令范围内可能抛出什么异常以及如何处理。只有当异常真正发生时,运行时系统才会查询这些表来决定如何展开栈。
graph TD
A[try块开始] --> B[执行代码]
B --> C{异常发生?}
C -->|否| B
C -->|是| D[查询EH表]
D --> E[栈展开]
E --> F[执行catch块]
style D fill:#f9f,stroke:#333
style E fill:#ff9,stroke:#333
零成本异常的性能模型非常有趣:正常路径几乎免费,但异常路径极其昂贵。根据Microsoft的测量数据,抛出一个异常的开销大约是正常函数返回的1000倍。这意味着异常应该真正用于"异常"情况,而不是正常的控制流。
xychart-beta
title "C++异常 vs 返回错误码 性能对比(相对值)"
x-axis ["正常路径", "错误路径"]
y-axis "相对开销" 0 --> 1200
bar [1, 1000]
bar [3, 5]
Herb Sutter在《Exceptional C++》中定义了异常安全的四个等级:
- 无保证:异常发生时对象可能处于不一致状态
- 基本保证:对象保持有效状态,资源不泄漏
- 强保证:操作要么成功,要么回滚到调用前状态
- 不抛出保证:操作保证不会抛出异常
这些概念深刻影响了后来的语言设计。Rust的所有权系统某种程度上可以看作是对异常安全问题的系统性解决方案——通过编译时保证资源的正确释放,Rust消除了异常安全的大部分顾虑。
Java受检异常:一场失败的实验?
1995年,Java引入了受检异常(Checked Exceptions),这是对CLU异常声明的继承和发展。在Java中,方法必须声明它可能抛出的受检异常,调用者必须处理或继续声明这些异常。设计者的初衷是好的:通过编译器强制程序员处理可能的错误,避免异常被静默忽略。
然而,二十年后的今天,受检异常被广泛认为是Java设计中最具争议的特性之一。问题出在哪里?
版本控制的噩梦。Hejlsberg在访谈中举了一个例子:假设你发布了一个方法foo(),它声明抛出异常A、B、C。在第二版中,你想添加新功能,现在foo()可能抛出异常D。在Java的受检异常模型下,这是一个破坏性的变更——所有调用foo()的代码都必须修改以处理新的异常。这不是理论上的担忧,而是实际开发中的日常痛点。
graph LR
subgraph Java受检异常版本问题
A["v1.0: foo() throws A, B, C"] --> B[发布]
B --> C["v2.0: foo() throws A, B, C, D"]
C --> D[破坏性变更!]
D --> E[所有调用方<br/>必须修改代码]
end
style D fill:#f66
style E fill:#f66
扩展性问题。当你的代码调用多个子系统,每个子系统可能抛出多种异常,你的方法签名会迅速膨胀。Hejlsberg观察到,在大型系统中,开发者往往被迫写出throws Exception这样的声明,完全绕过了受检异常的初衷。或者更糟:写出空的catch块,假装异常不存在。
// 糟糕但常见的模式
try {
doSomething();
} catch (Exception e) {
// TODO: 稍后处理这个异常
// 当然,"稍后"永远不会到来
}
学术研究也支持这些观察。2015年发表在《Journal of Systems and Software》上的一项研究发现,异常处理代码通常质量较低,经常被开发者忽视。研究分析了多个开源项目,发现空catch块、过度宽泛的异常捕获等反模式普遍存在。
C#的设计团队观察到了这些问题,做出了一个关键决策:不采用受检异常。Hejlsberg解释道:“异常处理的重要之处不在于处理异常,而在于保护自己不受异常影响。在事件驱动的应用程序中,你通常在主消息循环周围放置一个异常处理程序,异常在传播过程中被集中处理。你应该确保沿途释放资源,保持一致状态,而不是在100个不同的地方处理异常并弹出错误对话框。”
Go的"errors are values"哲学
2007年,Robert Griesemer、Rob Pike和Ken Thompson开始设计Go语言。这三个人曾在C语言、Unix和Plan 9的设计中积累了丰富的经验,他们对错误处理有自己的见解。
Go的设计哲学可以概括为一句话:“Errors are values”(错误是值)。在Go中,错误不是特殊的控制流机制,而是普通的返回值:
file, err := os.Open("data.txt")
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
// 继续处理
这种设计有几个直接的后果。首先,错误处理代码与正常代码交织在一起,这是批评者最常指出的缺点——代码变得冗长,难以阅读。其次,编译器不强制你检查错误,你可以像忽略任何返回值一样忽略它。第三,错误处理是显式的,你可以在任何地方对错误进行转换、包装或聚合。
flowchart TD
subgraph Go错误处理模式
A[调用函数] --> B[返回 result, err]
B --> C{err != nil?}
C -->|是| D[处理错误]
C -->|否| E[使用result]
D --> F[返回/包装错误]
end
style C fill:#ff9
style D fill:#f9f
2019年,Go团队提出了一个内置函数try()的提案,旨在减少错误处理的样板代码。这个提案引发了激烈的辩论,最终被拒绝。Russ Cox在总结中说:“Go团队将停止追求错误处理的语法级改进。我们意识到,好的错误处理关乎更好的库支持和文化习惯,而不是新的语法。”
这个决定背后的理念值得深思。Go的设计者认为,显式的错误检查虽然有代价,但它迫使开发者思考每个可能失败的操作。当错误处理变得机械化时,人们倾向于忽略错误而不是真正处理它们——这是受检异常失败的教训。
Go的错误处理模型还有一个重要特点:错误可以携带任意信息。通过实现error接口,任何类型都可以成为错误。这使得Go的错误可以包含丰富的上下文信息——堆栈跟踪、错误代码、建议的修复方案等。这与Java的异常形成对比:Java异常必须是Throwable的子类,这在某些情况下限制了灵活性。
Rust的Result:类型系统的力量
Rust的设计者选择了完全不同的路径:用类型系统来编码错误的可能性。在Rust中,可能失败的操作返回Result<T, E>类型:
pub enum Result<T, E> {
Ok(T),
Err(E),
}
这个设计的精妙之处在于,错误成为了类型的一部分。编译器知道Result可能是Ok或Err,你必须显式处理这两种情况,否则代码无法编译。这不是受检异常的简单复制——没有运行时的异常抛出和捕获,错误只是普通的值。
graph TD
subgraph Rust Result类型系统
A["Result<T, E>"] --> B["Ok(T)"]
A --> C["Err(E)"]
B --> D[编译器强制<br/>处理两种情况]
C --> D
D --> E[类型安全的<br/>错误传播]
end
style D fill:#6c6
style E fill:#6c6
Rust的?操作符是另一个关键创新。它自动传播错误,同时保持类型安全:
fn read_config() -> Result<Config, Error> {
let content = fs::read_to_string("config.toml")?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}
当?操作符应用于Err(e)时,函数立即返回Err(e.into()),错误被自动转换为目标错误类型。这比Java的受检异常更优雅:错误传播是显式的(每个可能失败的操作都有?),但又不像Go那样冗长。
?操作符的演进本身就是一个有趣的故事。它最初是try!宏,需要显式调用。2016年,Rust RFC 243引入了?操作符,将这个模式语法化。2020年,RFC 3058引入了Trytrait,使得?操作符可以用于任何实现了该trait的类型——不仅是Result,还包括Option和用户自定义类型。
Rust的错误处理还有一个重要特点:Result是一个普通的枚举类型,可以像任何其他值一样被存储、传递和操作。你可以编写返回Result的函数,该函数本身不执行任何可能失败的操作——它只是组合其他可能失败的操作。这种组合性是函数式编程的核心优势,也是Rust错误处理模型如此强大的原因。
Zig的错误联合:简化到极致
Zig语言的设计者Andrew Kelley采取了更加激进的方法:错误联合类型(Error Union Types)。在Zig中,可能失败的操作返回一个联合类型:
fn openFile(path: []const u8) !File {
// 返回File或错误
}
!File表示"File或某种错误"。Zig不需要泛型Result类型——错误联合是语言的内置特性。这使得错误处理更加轻量:没有额外的类型参数,没有运行时开销。
Zig的错误处理有一个独特的设计:错误集(Error Sets)。你可以定义一组可能的错误:
const FileError = error {
NotFound,
PermissionDenied,
TooBig,
};
编译器会跟踪每个函数可能返回的具体错误,在开发模式下提供精确的错误信息。这与Rust的Box<dyn Error>形成对比——后者在编译时丢失了具体的错误类型信息。
Zig的设计哲学是"显式优于隐式"。错误联合类型强制开发者处理错误,但又不像Java受检异常那样侵入函数签名。错误只是返回类型的一部分,可以被显式处理或传播。
性能的真相:零成本真的零成本吗?
错误处理机制的性能特征是语言设计中的一个关键考量。让我们看看不同方法的实际开销。
异常 vs 返回值。零成本异常模型的"零成本"指的是正常执行路径的开销。在没有异常发生时,异常处理代码不增加任何运行时开销——没有额外的分支判断,没有状态保存。但当异常发生时,代价是巨大的:栈展开需要查询异常处理表,执行析构函数,这些都是昂贵的操作。
根据Google发布的基准测试数据,在异常发生的路径上,C++异常比返回错误码慢10-100倍。这意味着异常不适合用于"预期的"错误情况——比如用户输入验证失败。异常应该真正用于"异常"情况:内存耗尽、网络中断、文件损坏。
Result类型 vs 异常。Rust的Result类型在两条路径上都是可预测的。返回Result涉及一次枚举判别,这是廉价的操作。传播错误(通过?)同样廉价。缺点是:即使正常路径也需要额外的分支判断——每个?操作符都会生成一个条件跳转。
一篇2020年发表在Reddit上的讨论分析了这个问题:Rust的错误处理确实在正常路径上有微小开销——它污染了指令缓存。但这个开销是可预测的、一致的,不像异常那样在错误路径上有巨大的峰值。
Go的错误检查。Go的显式错误检查是最直接的方法:每个可能失败的操作后都跟随一个条件判断。这在正常路径上有固定的开销,但代价是一致且可预测的。更重要的是,Go的编译器可以对这种模式进行优化——比如寄存器分配器知道错误值通常只被检查一次然后丢弃。
xychart-beta
title "不同错误处理机制性能对比(错误率对性能影响)"
x-axis ["0.1%错误率", "1%错误率", "5%错误率", "10%错误率"]
y-axis "相对执行时间" 0 --> 100
line [95, 96, 97, 98]
line [100, 100, 100, 100]
line [98, 100, 105, 112]
TypeScript的基准测试提供了有趣的数据:当错误发生概率低于1%时,异常的性能优于Result模式;当错误概率高于5%时,Result模式明显更快。这验证了一个设计原则:错误处理机制的选择应该考虑错误发生的频率。
异常安全:超越语法的深层问题
错误处理不仅仅是语法问题,更关乎程序的语义正确性。Herb Sutter定义的异常安全等级揭示了这个问题的深度。
基本保证是最重要的:当异常发生时,程序应该保持有效状态,不泄漏资源。C++的RAII(资源获取即初始化)是确保基本保证的关键技术:通过将资源绑定到对象的生命周期,析构函数自动释放资源。
{
std::unique_ptr<Widget> ptr(new Widget);
// 即使这里抛出异常
// ptr的析构函数也会被调用,内存被释放
}
Rust的所有权系统可以看作是RAII的系统化实现。编译器保证每个值都有且只有一个所有者,当所有者离开作用域时,值被自动释放。这使得基本异常安全成为Rust的默认保证——你不需要显式地写出try-finally。
graph LR
subgraph 异常安全等级
A[无保证] --> B[基本保证<br/>资源不泄漏<br/>对象有效]
B --> C[强保证<br/>事务性<br/>可回滚]
C --> D[不抛出保证<br/>永不失败]
end
style A fill:#f66
style B fill:#ff9
style C fill:#9f9
style D fill:#6c6
强保证更难实现:操作应该具有事务性,要么完全成功,要么完全回滚。这通常需要先执行所有可能失败的操作,然后再执行不可逆的操作。在Rust中,这可以通过谨慎的状态管理实现;在C++中,这通常需要复制或使用pimpl模式。
Andrei Alexandrescu在《Modern C++ Design》中提出了一种优雅的技术:ScopeGuard。这是一个在作用域结束时自动执行指定操作的对象,可以用于实现各种清理逻辑。Rust社区后来发明了类似的defer宏,Go语言甚至将defer作为语言特性。
现代语言的设计共识?
回顾六十年的演进,我们可以观察到一个有趣的趋势:现代语言正在向"显式错误处理 + 类型系统支持"的方向收敛。
Swift 6引入了typed throws,允许函数声明它们抛出的具体错误类型。这借鉴了Java受检异常的理念,但避免了其最大的缺陷:Swift的错误类型是真正的类型,可以被泛型参数化,可以在协议中使用。
Kotlin通过sealed class提供了类似的模式:
sealed class Result<out T> {
data class Success<T>(val value: T) : Result<T>()
data class Failure(val error: Error) : Result<Nothing>()
}
sealed class确保所有子类在编译时已知,编译器可以检查exhaustiveness——所有可能的错误情况都必须被处理。
C++23引入了std::expected<T, E>,这是一个与Rust Result非常相似的类型。C++社区花了三十年,最终承认了Rust的设计选择。std::expected支持monadic操作,允许链式错误处理:
auto result = open_file("data.txt")
.and_then(read_content)
.and_then(parse_config);
这标志着C++从"异常优先"到"返回值优先"的微妙转变。异常仍然存在,但不再被视为唯一的错误处理机制。
timeline
title 错误处理机制六十年演进
section 早期探索
1964 : PL/I ON语句<br/>首个异常处理机制
1974 : CLU signal/handler<br/>异常成为签名
1975 : Goodenough论文<br/>理论奠基
section 面向对象时代
1983 : C++异常<br/>零成本实现
1995 : Java受检异常<br/>编译时强制
section 现代转向
2007 : Go errors are values<br/>显式处理
2010 : Rust Result类型<br/>类型系统编码
2015 : Zig错误联合<br/>简化极致
2023 : C++ std::expected<br/>承认Result模式
没有银弹:权衡的艺术
Fred Brooks在《No Silver Bullet》中说软件工程的核心困难是本质复杂性(Essential Complexity)。错误处理正是这种本质复杂性的体现——程序必须与现实世界交互,而现实世界充满了失败的可能性。
六十年来,语言设计者尝试了各种方法来驯服这种复杂性:
- PL/I的ON语句试图通过动态绑定将错误处理与正常逻辑分离,但牺牲了上下文信息。
- CLU的异常声明试图通过类型系统保证错误的可见性,但局限于词法作用域。
- Java的受检异常试图通过编译器强制错误处理,但引入了版本控制和扩展性问题。
- C++的异常试图实现零成本抽象,但在错误路径上代价巨大。
- Go的显式错误检查试图简化一切,但牺牲了代码简洁性。
- Rust的Result类型试图用类型系统编码错误,但增加了类型复杂性。
- Zig的错误联合试图消除泛型开销,但增加了语言复杂性。
quadrantChart
title 错误处理机制设计空间
x-axis 低显式性 --> 高显式性
y-axis 低类型安全 --> 高类型安全
quadrant-1 显式且安全
quadrant-2 隐式但安全
quadrant-3 隐式且不安全
quadrant-4 显式但不安全
Java: [0.3, 0.7]
C++: [0.2, 0.3]
Go: [0.9, 0.4]
Rust: [0.8, 0.9]
Zig: [0.85, 0.85]
Python: [0.1, 0.2]
每个选择都有代价。没有完美的错误处理机制,只有特定场景下的最佳权衡。
当你设计一个新系统时,考虑这些问题:
- 错误发生的频率如何?频繁的错误应该用返回值,罕见的错误可以用异常。
- 错误的可恢复性如何?不可恢复的错误可以直接终止;可恢复的错误需要提供足够的上下文信息。
- 性能要求如何?实时系统可能无法接受异常的不确定延迟。
- 团队文化如何?显式的错误检查需要团队有处理错误的习惯。
Anders Hejlsberg在访谈结束时说了一段发人深省的话:“我坚信,如果你没有正确的话要说,或者没有推动艺术进步的东西,你最好保持沉默和中立,而不是试图制定框架。“这句话不仅适用于语言设计,也适用于错误处理本身:不是每个错误都需要被"处理”——有时候,让它传播到可以真正处理它的地方,才是最好的选择。
六十年过去了,错误处理的设计空间仍然没有被完全探索。2024年,OCaml正在讨论引入algebraic effects;Rust正在考虑async fn的错误处理改进;Kotlin正在探索更简洁的错误传播语法。这场始于PL/I ON语句的旅程,还在继续。
参考文献
-
Goodenough, J. B. (1975). Exception handling: Issues and a proposed notation. Communications of the ACM, 18(12), 683-696.
-
Liskov, B. H., & Snyder, A. (1979). Exception handling in CLU. IEEE Transactions on Software Engineering, SE-5(6), 546-558.
-
Hejlsberg, A. (2003). The Trouble with Checked Exceptions. Artima Developer.
-
Sutter, H. (2000). Exceptional C++: 47 Engineering Puzzles, Programming Problems, and Solutions. Addison-Wesley.
-
Pike, R. (2015). Errors are values. The Go Blog.
-
Rust RFC 243: Trait-based exception handling. (2016).
-
Rust RFC 3058: Try trait v2. (2020).
-
Ebert, F., et al. (2013). A study on developers’ perceptions about exception handling bugs. IEEE International Conference on Software Maintenance and Evolution.
-
Alexandrescu, A. (2000). Modern C++ Design: Generic Programming and Design Patterns Applied. Addison-Wesley.
-
Go Team. (2019). Experiment, Simplify, Ship: The Go Programming Language Blog.