你调用 open("file.txt"),期望文件存在,现实是文件不在。这个落差就是错误。
错误处理的本质不是”如何写 try-catch”,而是回答三个递进的问题:这个落差有多严重?谁来负责弥合它?它如何传达给负责人?不同语言对这三个问题的回答,造就了截然不同的设计哲学。
一、分类:落差有多严重
理解错误的第一步是承认:不是所有的”不对”都一样。根据落差的性质,错误分为三类——这不是语言特性,而是客观现实的映射。
可恢复错误(Recoverable):预期内的意外。文件不存在时创建默认配置,网络超时时重试,用户输入非法时提示修正。这类错误是程序正常运行的一部分,应该被预期并处理,程序可以继续执行。
不可恢复错误(Unrecoverable):程序的自我矛盾。数组越界、空指针解引用、断言失败——这类错误表示程序存在 bug,继续执行只会让状态更加混乱。正确做法是立刻死掉,暴露问题。Rust 的 panic! 和 Python 的 AssertionError 都属于此类。
领域错误(Domain):业务规则被违反。银行转账金额为负数、订单数量超过库存——是可恢复还是终止,取决于业务语义而非技术判断。这类错误的处理方式需要领域知识,不能仅凭技术规则决定。
三类错误的根本区别在于可逆性:可恢复错误可以优雅地绕过,不可恢复错误必须终止,领域错误需要业务判断。这个分类在三种语言中都成立——差异在于如何表达这种分类,以及谁来处理它。
二、责任归属:谁来弥合落差
这是三种范式真正分叉的地方。返回码、异常、Result 类型——它们不是解决同一问题的不同方式,而是对”谁应该为错误负责”这个问题的三种不同回答。
graph LR A["返回码<br/>C / Go"] -->|"显式检查"| B["调用者全权负责"] C["异常<br/>Python / Java / TS"] -->|"自动传播"| D["责任沿调用栈漂移"] E["Result 类型<br/>Rust / Haskell"] -->|"类型系统强制"| F["编译器要求声明责任方"] B -->|"缺点:责任可被无视"| G["运行时遗漏"] D -->|"缺点:责任位置不可知"| H["异常安全问题"] F -->|"优点:责任静态可见"| I["编译时保证"] style A fill:#ff6b6b,color:#fff style C fill:#ffa94d,color:#fff style E fill:#69db7c,color:#fff style I fill:#4dabf7,color:#fff
返回码:调用者全权负责
C/Go 风格。函数返回特殊值表示错误,调用者拿到结果后自行判断。
FILE* f = fopen("file.txt", "r");
if (f == NULL) {
// 你调用了这个函数,出了事你处理
perror("open failed");
return -1;
}哲学立场:个体主义——你呼叫了这个函数,责任从不离开调用现场。优点是控制流完全显式、无隐藏行为。致命弱点是责任可以被无视——if (f == NULL) 这行代码可以直接不写,编译器不会阻止你。
Go 通过多返回值 (result, err) 改进了这一模式,社区规范也要求检查 err,但仍然依赖开发者自律。
异常:责任沿调用栈漂移
Python/Java/TypeScript 风格。错误自动沿调用栈向上传播,第一个愿意处理的地方接住它。
def read_config():
return open("config.txt").read() # 不捕获,责任往上飘
def start():
try:
config = read_config() # 也不捕获,继续往上飘
except FileNotFoundError:
config = default_config() # 最终在这里接住哲学立场:责任推卸(中性意义)——我把问题抛出去,总有人接。优点是正常路径不被错误处理代码打断,可以在最合适的地方统一处理。
核心问题是责任位置在静态代码中不可见:你看着 read_config() 的调用,无法从代码本身判断 FileNotFoundError 最终在哪里被处理。这造成了异常安全问题——当异常抛出时,中间层已经申请的资源是否被正确释放?
Python 的 EAFP 哲学(Easier to Ask Forgiveness than Permission)正是建立在这个立场上:先尝试,失败了再道歉(处理异常)。这与 LBYL(Look Before You Leap,先检查再操作)相对——Python 社区认为 EAFP 更简洁,假设正常情况是常态。
# LBYL 风格(防御性)
if os.path.exists("file.txt"):
with open("file.txt") as f:
data = f.read()
# EAFP 风格(Python 惯用)
try:
with open("file.txt") as f:
data = f.read()
except FileNotFoundError:
data = default_data()Result 类型:编译器强制声明责任
Rust/Haskell 风格。错误是返回值的一部分,类型系统强制调用方表明态度。
// 函数签名本身就是合同:我可能成功,也可能失败
fn read_config(path: &str) -> Result<String, IoError>
// 调用方必须处理,否则编译失败
let config = match read_config("config.txt") {
Ok(data) => data,
Err(_) => default_config(),
};哲学立场:合同主义——责任写在函数签名里。你调用我,签名就是合同,编译器确保你表明了如何处理失败。不能无视,不能忘记。
Result<T, E> 在数学上是一个代数数据类型(ADT):Ok(T) 表示成功,Err(E) 表示失败。这也是一个 monad——支持 and_then(链式操作)和 map(转换成功值),使得多个可能失败的操作可以像管道一样串联:
fn process() -> Result<Data, Error> {
let file = open_file("x.txt")?; // ? = 失败则立即返回 Err
let raw = read_bytes(file)?;
let data = parse(raw)?;
Ok(data)
}? 运算符是 and_then 的语法糖——它让”显式传递”和”自动传播”的体验一样简洁,但每一层的责任都是静态可知、编译期确认的。
Checked Exceptions 的失败教训
Java 曾经试图用 checked exceptions 强制声明责任——函数必须在签名中声明可能抛出的异常。理论上与 Result 的合同主义相同,实践中却失败了,原因有三:
- 新增异常类型破坏所有调用方:API 演进成本极高
- 开发者为逃避声明,把 checked exceptions 包装成 unchecked exceptions:违背设计初衷
- 大量 try-catch 只是为了满足编译器:真正重要的错误处理被淹没在噪音里
Result 类型解决了这个问题:错误类型是返回值的一部分,而非附加在函数签名旁边的声明。演进时只需更改返回类型,调用方在编译时自然会感知到变化。TypeScript 明确选择不引入 checked exceptions,正是吸取了这个教训。
三、传播:落差如何传达给负责人
责任归属确定后,还需要一套机制把错误从发生地传送到处理地。
同步传播
| 传播方式 | 机制 | 优缺点 |
|---|---|---|
| 显式逐层传递 | 返回码,每层手动转发 | 控制流清晰,代码冗长 |
| 自动栈展开 | 异常,运行时自动处理 | 简洁,但中间层资源释放不可预测 |
| 显式 + 语法糖 | Result + ?,编译期展开 | 清晰且简洁,无运行时开销 |
异常安全保证是同步传播中的核心工程问题——当异常抛出时,中间层的资源能否正确释放?C++ 社区定义了三级保证,对所有使用异常的语言都有借鉴意义:
- 基本保证:异常发生时程序处于有效状态,资源不泄漏(最低要求)
- 强保证:操作要么完全成功,要么状态完全回滚(事务性语义)
- 不抛保证:操作绝不抛出异常(如 swap、析构函数)
Python 用 with 语句确保资源释放;Rust 用 Drop trait + 所有权系统,编译时证明资源不泄漏,根本不存在异常安全问题(panic 也会触发 Drop)。
异步传播
异步环境中,错误可能在多个并发任务中同时发生,传播路径更复杂。
Python 的 ExceptionGroup(Python 3.11+)允许将多个并发任务的异常打包:
async def main():
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch("url1"))
tg.create_task(fetch("url2"))
# 任意任务失败 → ExceptionGroup 收集所有异常
try:
await main()
except* NetworkError as eg: # except* 按类型分别处理
for err in eg.exceptions:
log(err)TypeScript 的 Promise 链通过 .catch() 和 async/await 处理异步错误:
async function process(): Promise<Data> {
const file = await readFile("x.txt"); // 失败 → rejected Promise 自动传播
return parse(file);
}Rust 的异步 Result 组合:async fn 返回 impl Future<Output = Result<T, E>>,? 在 async 上下文中同样有效,传播机制与同步代码一致。
四、恢复:弥合落差的方式
错误到达处理地后,有五种基本恢复策略:
忽略:不推荐,除非明确知道这个错误无关紧要。Python 的 except: pass 和 Rust 的 let _ = result 都是忽略,但后者至少是显式的。
重试:适用于瞬态错误(网络波动、资源暂时不可用)。应有退避策略(指数退避)和最大次数限制,避免无限重试。
降级/Fallback:提供替代值或简化功能。open("config.txt") 失败时使用默认配置——程序继续运行,只是功能降级。
快速失败(Fail Fast):不可恢复错误的正确处理。Rust 的 panic!、Python 的 assert——立刻暴露问题,避免错误状态蔓延。
包装后向上传播:把底层错误包装成更高层的语义,再向上传递。这是库开发的标准实践——调用方看到的应该是业务语义的错误,而非底层实现细节:
// thiserror:库层精确错误
#[derive(thiserror::Error, Debug)]
enum ConfigError {
#[error("config file not found: {path}")]
NotFound { path: String },
#[error("invalid config format")]
ParseError(#[from] serde_json::Error),
}
// anyhow:应用层快速传播
fn load() -> anyhow::Result<Config> {
let text = std::fs::read_to_string("config.json")
.context("failed to read config")?;
Ok(serde_json::from_str(&text)?)
}错误信息的三要素:好的错误信息是一份微型文档——What(什么错了)、Why(为什么错)、How(怎么修)。用户可见错误应简洁友好、隐藏技术细节;开发者可见错误应详细技术性、包含完整上下文链。
五、三语对照:不同哲学的工程落地
| 维度 | Python | TypeScript | Rust |
|---|---|---|---|
| 核心机制 | try/except 异常 | try/catch + union types | Result<T, E> + ? |
| 责任哲学 | 责任漂移(EAFP) | 灵活性优先 | 编译期强制合同 |
| 编译时检查 | 无 | 可选(unknown + 收窄) | 强制 |
| 性能开销 | 中等(栈展开) | 低(V8 优化) | 零(编译期展开) |
| 错误传播 | 自动沿调用栈传播 | 手动或 Promise 链 | ? 运算符 |
| 类型安全 | 低(动态类型异常) | 中(unknown 收窄) | 高(泛型 Result) |
| 不可恢复处理 | raise / assert | throw Error | panic! |
| 资源管理 | with + contextlib | try/finally + using | Drop trait + RAII |
| 异步错误处理 | ExceptionGroup + except* | Promise.catch + async/await | async Result + ? |
Python:信任开发者,责任动态漂移。EAFP 哲学使正常路径代码简洁,代价是错误处理位置不可静态确定。try/except/else/finally 四件套和 with 语句提供了细粒度控制,raise ... from ... 保留异常链便于调试。
TypeScript:灵活性优先,混合策略。继承 JavaScript 的 try/catch 模式,TS 4.4+ 将 catch 的 error 类型改为 unknown(强制类型收窄)。社区通过 neverthrow、fp-ts 等库弥补语言层面对 Result 模式的缺失——这是社区驱动的工程补丁,而非语言内置。
Rust:合同主义,编译期强制。二分策略清晰:可恢复错误用 Result,不可恢复用 panic,编译器强制区分,不能混用。? 运算符让强制处理的代价降到最低——安全性和易用性不是矛盾的。库用 thiserror 定义精确错误类型,应用用 anyhow 快速传播,分工明确。
这三种选择的深层逻辑与类型系统中的归属问题一脉相承:Python 把责任交给开发者(自律),Java/TS 把责任推给运行时(漂移),Rust 把责任编进类型(强制)。错误处理哲学是类型系统哲学在”异常情况”上的延伸。
延伸阅读
理论:
- Sutter, H. (2004). The Pragmatic Programmer’s Guide to Exception Handling
- Miller, D., et al. (1998). Exception Safety in Generic Components
实践:
工具:
- thiserror — Rust 自定义错误类型派生宏
- anyhow — Rust 应用层错误处理
- neverthrow — TypeScript Result 类型
- fp-ts — TypeScript 函数式编程工具链
关联 meta 维度
- 01 类型系统 — 类型系统和错误处理是语言安全性的双支柱;Result 类型是类型系统能力的直接应用
- 05 并发模型 — 并发场景下的错误传播比同步场景更复杂(ExceptionGroup、TaskGroup)
- 02 模块与可见性 — 错误边界往往也是模块边界;库 vs 应用的错误策略分工
- 范式: 函数式编程 — FP 中的 Result/Either monad 模式
- Python: 异常处理与资源 — Python 的 EAFP 哲学与上下文管理器
- TypeScript: 语言全景 — TS 的错误处理模型