你调用 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 的合同主义相同,实践中却失败了,原因有三:

  1. 新增异常类型破坏所有调用方:API 演进成本极高
  2. 开发者为逃避声明,把 checked exceptions 包装成 unchecked exceptions:违背设计初衷
  3. 大量 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(怎么修)。用户可见错误应简洁友好、隐藏技术细节;开发者可见错误应详细技术性、包含完整上下文链。

五、三语对照:不同哲学的工程落地

维度PythonTypeScriptRust
核心机制try/except 异常try/catch + union typesResult<T, E> + ?
责任哲学责任漂移(EAFP)灵活性优先编译期强制合同
编译时检查可选(unknown + 收窄)强制
性能开销中等(栈展开)低(V8 优化)零(编译期展开)
错误传播自动沿调用栈传播手动或 Promise 链? 运算符
类型安全低(动态类型异常)中(unknown 收窄)高(泛型 Result)
不可恢复处理raise / assertthrow Errorpanic!
资源管理with + contextlibtry/finally + usingDrop trait + RAII
异步错误处理ExceptionGroup + except*Promise.catch + async/awaitasync Result + ?

Python:信任开发者,责任动态漂移。EAFP 哲学使正常路径代码简洁,代价是错误处理位置不可静态确定。try/except/else/finally 四件套和 with 语句提供了细粒度控制,raise ... from ... 保留异常链便于调试。

TypeScript:灵活性优先,混合策略。继承 JavaScript 的 try/catch 模式,TS 4.4+ 将 catch 的 error 类型改为 unknown(强制类型收窄)。社区通过 neverthrowfp-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 维度