Featured image of post 常见编程语言的错误处理方式对比

常见编程语言的错误处理方式对比

从异常、错误返回值、Result、Sum Type 和 Error Union 出发,对比 Java、Go、Rust、Python、TypeScript/JavaScript 等语言的错误处理设计。

常见编程语言的错误处理方式对比

先说明,笔者主要以后端开发视角来讨论,比较熟悉 Java、Go 和 Rust。本文也会顺带提到 Python、TypeScript/JavaScript、Swift、Kotlin、Scala、Haskell、Zig 等语言,但重点不是做语言百科(因为我大部分也不会……),而是比较不同错误处理设计背后的取舍。

最近刷面经时,我看到过这样一道问题:

你简历上写了不止一种编程语言。那你能不能对比一下,这几门语言的错误处理方式有什么不同?

这个问题看起来很普通,好像只是在问类似 Java 的 try-catch-finally、Go 的 if err != nil、Rust 的 Result<T, E>。但如果继续追问下去,它其实会变成一个更底层的问题:

一门语言到底希望程序员如何处理失败?

是让正常路径保持干净,把失败作为异常抛给上层?还是把失败作为普通返回值,让调用者在每个调用点显式判断?或者把可恢复失败写进类型系统,让编译器逼你承认它?

所以这篇文章不只是罗列语法,而是想借几门常见语言,梳理主流错误处理模型,以及它们在 API 契约、控制流、类型系统和工程实践里的取舍。

1. 为什么错误处理值得单独讨论

错误处理不是一个“语法小节”。它会直接影响代码的形状。

比如同样是读取一个配置文件,失败时可能有几种写法:

Java 代码:

try {
    Config config = loadConfig(path);
} catch (IOException e) {
    throw new ConfigException("failed to load config", e);
}

Go 代码:

config, err := LoadConfig(path)
if err != nil {
    return fmt.Errorf("load config: %w", err)
}

Rust 代码:

let config = load_config(path)
    .context("failed to load config")?;

这几段代码背后的问题并不只是语法不同,而是语言在帮我们回答这些问题:

  • 调用者能不能从函数签名看出这里可能失败?
  • 失败会不会中断当前控制流,并跳到别的地方?
  • 调用者是否必须处理失败?
  • 错误是业务分支、外部环境失败,还是程序 bug?
  • 错误应该在当前层处理,还是传到 HTTP handler、CLI main、任务调度器这类边界统一处理?
  • 日志、重试、降级、资源清理应该放在哪里?

所以对比错误处理方式,本质上是在比较不同语言如何处理同一个矛盾:

正常路径越干净,错误路径往往越隐蔽;错误路径越显式,主路径往往越容易被打断。

2. 错误处理机制分类

先不急着按语言对比。更好的方式是先按机制分类,因为很多语言不是只有一种错误处理方式,而是在不同场景下组合使用多种机制。

大体上,常见错误处理机制可以分成三组:

类别机制核心想法典型语言或风格
Non-local control flowExceptions错误脱离普通返回路径,沿调用栈传播Java、C#、Python、JavaScript、C++
Value-based error handlingSentinel Values用约定好的特殊值表示失败C 风格 API、系统调用、部分标准库 API
Value-based error handlingError Tuples错误作为普通返回值返回Go、Odin
Type-modeled error handlingSum Types用类型表达 success OR errorRust、Haskell、Scala、TypeScript union、Kotlin sealed class
Type-modeled error handlingError Unions语言原生提供专用的 error OR value 类型Zig、C3

2.1 Exceptions:错误作为非局部控制流

Exceptions:错误作为非局部控制流

Exception 的核心设计是:失败不通过普通返回值返回,而是通过 throw 改变控制流,沿调用栈向上传播,直到遇到能处理它的 catch

典型语言包括 Java、C#、Python、JavaScript/TypeScript 和 C++。

比如下面这段 Java 代码:错误被 catch 并处理,而不是作为函数返回值被处理

try {
    User user = userService.findById(id);
    return Response.ok(user);
} catch (UserNotFoundException e) {
    return Response.status(404).body(e.getMessage());
}

异常的好处很直观:正常路径很干净。中间层如果没有处理能力,可以不在每一层手动判断错误,而是让异常继续向上传播,到真正有上下文的位置统一处理。

这也是为什么 Web 框架、RPC 框架、任务调度框架经常喜欢用全局异常处理器:service 层抛出异常,controller advice、middleware 或 handler 在边界把异常转换成 HTTP 响应、日志、告警或退出码。

但异常的代价也来自同一个地方:错误路径是非局部的。调用点看起来只是普通函数调用,但执行过程中可能突然跳出当前流程。除了 Java checked exception 这类特殊设计,多数语言的异常集合不会进入函数签名。调用者想知道一个函数可能抛什么异常,往往只能依赖文档、源码、测试和经验。

所以异常很适合“跨层传播”和“边界统一处理”,但不适合所有失败。比如缓存 miss、查找不到、表单字段校验失败这些高频普通分支,如果全部用异常表达,代码语义会变重,性能和可读性也可能受影响。

2.2 Sentinel Values:错误作为特殊返回值

Sentinel Values:错误作为特殊返回值

Sentinel Value 的核心设计是:用约定好的特殊返回值表示失败。

比如 C 风格 API 中常见的 NULL-1EOF,或者某些 API 用空字符串、特殊 enum 表示失败。

int fd = open(path, O_RDONLY);
if (fd == -1) {
    // handle error
}

这种方式非常朴素:没有额外语言机制,也不需要异常系统或类型系统配合。函数就是返回一个值,调用者按约定检查这个值。

问题也很明显:它太依赖约定。调用者忘记检查,编译器通常不会提醒;特殊值还可能和合法值冲突。比如 -1 到底是错误,还是一个合法业务值?null 到底表示不存在,还是出错,还是没有加载?

因此 Sentinel Value 更适合非常局部、非常简单、约定足够稳定的场景。现代业务代码里,最好不要让这种特殊值穿过复杂业务边界,否则错误语义很容易丢失。

2.3 Errors as Values / Error Tuples:错误作为普通返回值

Errors as Values / Error Tuples:错误作为普通返回值

Go 是这一类设计最典型的代表。它的核心设计是:把错误作为普通返回结果,让调用者在每个调用点显式处理。

user, err := repo.FindUser(ctx, id)
if err != nil {
    return nil, fmt.Errorf("find user %s: %w", id, err)
}

return user, nil

Go 官方文章《Errors are values》里有一个很重要的视角:错误不是特殊语法,错误就是普通值。既然是普通值,它就可以被传递、包装、比较、转换,也可以进入普通控制流。

这让 Go 的失败路径非常可见。代码审查时,一眼就能看到调用方有没有检查 err,有没有补充上下文,有没有把错误吞掉。

代价是样板明显。大量 if err != nil 会把主路径切碎。如果只是机械地 return err,虽然形式上显式了,但错误里缺少上下文,排查问题时仍然很痛苦,这也是很多人写 Go 时抱怨的一个点。

另外,Go 的 error 本质上是接口。函数签名能告诉你“这里可能失败”,但不能精确告诉你“会失败成哪些类型”。如果调用方需要识别错误类别,就要靠 sentinel error、自定义 error type、errors.Iserrors.As 和团队约定来维持一致性。

所以 Go 的错误处理不是“更啰嗦的异常”,而是一种设计选择:把失败放回普通控制流,让调用者在当前上下文里决定是重试、降级、包装后返回,还是立即处理。

2.4 Sum Types:错误作为互斥分支

Sum Types:错误作为互斥分支

Sum Type 的核心设计是:用类型表达 success OR error 两种互斥状态,让调用者不能把失败当作成功使用。

Rust 的 Result<T, E> 是最容易理解的例子,下面是 Result 的源码:表示结果只能有两种状态,成功或失败。

enum Result<T, E> {
    Ok(T),
    Err(E),
}

一个可能失败的函数可以返回 Result

fn load_config(path: &str) -> Result<Config, ConfigError> {
    let content = std::fs::read_to_string(path)?;
    parse_config(&content)
}

这里的错误也是看作返回值,但它和 Go 的 (T, error) 有一个关键差别:成功值和失败值是互斥分支。你拿到的是一个 Result,必须处理、转换、匹配,或者向上传播。

Rust 还会明确区分两类失败:

类型含义典型表达
recoverable error预期内的失败,调用方有机会恢复、重试、降级,或选择替代路径Result<T, E>
unrecoverable error无法安全继续的错误,程序进入不应继续的状态,通常代表 bug 或不变量被破坏panic!
absence没有错误细节的缺值,只关心“有没有”而不是“为什么失败”Option<T>

这类设计的优点是错误进入了函数类型。尤其当错误集合用 enum 表达时,模式匹配可以配合穷尽性检查:如果未来新增一种错误分支,编译器更容易提醒调用方还有地方没处理。

代价是 API 设计压力更前置。你需要决定错误类型长什么样、底层错误要不要暴露、跨模块错误如何转换、库层和应用层是否使用不同错误抽象。错误建模太粗,类型约束价值有限;建模太细,调用链又会变得笨重。

2.5 Error Unions:语言原生的 error-or-value

Error Unions:语言原生的 error-or-value

Error Union 和 Sum Type 思路接近,但它不是用通用 ADT 自己建模,而是由语言直接提供面向错误处理的专用机制。

以 Zig 为例,函数返回类型可以写成 Error!T,表示结果要么是错误集合里的某个错误,要么是成功值 T

fn loadConfig(path: []const u8) !Config {
    const file = try std.fs.cwd().openFile(path, .{});
    defer file.close();

    return parseConfig(file);
}

这种方式的核心设计是:语言原生支持 error OR value,让错误进入返回类型,同时保留轻量的传播语法。

它比手写 Result<T, E> 更贴近错误处理场景,也比隐式异常更容易看见失败路径。不过这类机制目前主要存在于 Zig、C3 等语言中,生态范围和主流语言相比还比较小。

3. 错误处理范式与取舍

上一章讨论的是机制,这一章讨论背后的设计哲学。因为真正写代码时,问题通常不是“该用哪种语法”,而是“这个失败应该被建模成什么”。

3.1 异常范式:失败是非局部控制流

代表语言或风格:JavaC#PythonJavaScriptC++

异常范式的核心取舍是:让正常路径保持连续,把失败交给更有上下文的上层处理。

这套思想非常适合分层应用。底层函数发现文件不存在、数据库连接失败、权限不足时,不一定知道该如何恢复;真正能决策的位置可能是 HTTP handler、CLI 入口、消息消费入口或事务边界。

异常让中间层不必机械传递错误,主流程看起来更接近业务叙事,比如下面这段 python 代码:

def handle_request(user_id: str):
    user = user_service.get_user(user_id)
    orders = order_service.list_orders(user)
    return render_profile(user, orders)

但问题是,失败路径隐藏起来了。get_user 可能抛认证异常、数据库异常、用户不存在异常,也可能抛一个意料之外的运行时异常。如果函数签名和文档不说,调用者很难只靠调用点判断。

所以异常范式的关键不是“抛得越多越好”,而是分层:中间层可以传播,边界层必须负责转换;真正可恢复、需要调用方分支决策的失败,不应该只藏在一个宽泛异常里。

3.2 显式返回范式:错误是普通返回值

代表语言或风格:GoOdin

显式返回范式的核心取舍是:把错误作为普通返回结果,让调用者在每个调用点显式处理。

它拒绝“失败突然跳走”的控制流。函数返回什么,调用者就检查什么;调用者有当前上下文,也最适合决定下一步。

这在基础设施代码里尤其有价值。比如一个 RPC 调用失败,当前层可能要决定是否重试;一个缓存读取失败,当前层可能要决定是否降级到数据库;一个文件解析失败,当前层可能要补充文件路径、行号、任务 ID 等上下文。

显式返回的优势是清楚,代价是对于主路径来说“噪声多”。它把错误处理放在主路径旁边,读者不会错过,但也容易被大量错误分支打断。

因此 Go 代码质量的关键不只是写 if err != nil,而是每次传播时都想清楚:这里要不要恢复?要不要包装上下文?调用方未来是否需要用 errors.Iserrors.As 识别它?

3.3 类型驱动范式:可恢复失败进入类型

代表语言或风格:RustHaskellOCaml、typed Result 风格

类型驱动范式的核心取舍是:把可恢复失败纳入函数返回类型,让调用者必须面对 success OR error 两种可能。

它和普通错误返回有相似之处:失败仍然是返回值。但它更进一步,把失败变成函数类型的一部分。

这带来两个好处。

第一,调用者不能假装没有失败。一个 Result<User, FindUserError> 不是 User,你必须处理它。

第二,错误集合如果是封闭的,就可以借助模式匹配对错误做穷尽性处理:

match repo.find_user(id) {
    Ok(user) => render(user),
    Err(FindUserError::NotFound) => render_404(),
    Err(FindUserError::PermissionDenied) => render_403(),
    Err(FindUserError::Storage(err)) => render_500(err),
}

这对业务规则、协议解析、编译器、状态机和工作流非常有价值,因为这些场景下“失败有哪几种”本身就是领域模型的一部分。

代价也很明确:错误类型设计会提前变成 API 设计问题。库作者需要决定哪些错误是稳定契约,哪些只是内部实现细节。错误类型越精确,调用者拿到的信息越多,但 API 演化成本也越高。

3.4 显式传播范式:调用点必须标记失败

代表语言或风格:Swift、带显式 try 标记的错误传播模型

显式传播范式的核心取舍是:让调用者看见失败传播,但不一定把完整错误集合冻结进函数类型。

Swift 的 throws 是很典型的例子。调用一个可能失败的函数时,调用点必须写 try

let config = try loadConfig(path)

这比 unchecked exception 更显式,因为读代码的人至少能看到“这里可能失败”。但它又不像 Rust 的 Result<T, E> 那样,默认把具体错误集合写进返回类型。

这种设计站在异常和类型化错误之间:传播是显式的,错误集合保持开放。好处是库作者更容易演化 API;代价是调用者知道这里可能失败,却未必知道具体会失败成哪些类型。

3.5 函数式范式:失败是可组合的数据

代表语言或风格:HaskellScala、TypeScript 的 Result union、Kotlin sealed class

函数式范式的核心取舍是:把错误从控制流变成数据流,让失败路径可以被组合、转换和测试。

在这种视角下,错误不是一次“跳转事件”,而是一个普通数据结构。它可以被 mapflatMap、组合、聚合,也可以作为领域模型的一部分传递。

TypeScript 里常见的 discriminated union 就很适合表达业务失败:

type RegisterResult =
    | { ok: true; userId: string }
    | { ok: false; reason: "email_taken" | "weak_password" | "invalid_invite" };

function render(result: RegisterResult) {
    if (result.ok) {
        return showSuccess(result.userId);
    }

    return showError(result.reason);
}

这类写法特别适合表单校验、协议解析、领域规则、状态机和工作流。因为调用方真正关心的不是“有没有异常”,而是“失败是哪一种,下一步应该怎么走”。

代价是抽象重量更高。简单逻辑如果过度套上 EitherResult、effect system 或复杂 helper,反而会让代码变难读。函数式错误处理最好用在失败路径本身需要组合和建模的地方。

3.6 现代错误处理的分层趋势

“传统”和“现代”不能简单按语言年龄划分。C++ 很老,但 RAII、std::expected 体现了很现代的错误处理思想;TypeScript 很新,但运行时仍然继承 JavaScript 的动态异常模型。

更准确的分水岭,是看失败是否显式进入 API 契约:

问题传统异常倾向现代类型化倾向
失败是否进入函数类型通常不进入,Java checked exception 是例外ResultEither、sealed class、union 会进入
调用点是否显式unchecked exception 通常不显式?tryif err != nil、pattern match 显式
错误集合是否可穷尽catch 少数 case,其余泛化处理enum、ADT、union 鼓励穷尽
API 演化成本错误集合较隐式,演化轻一些错误类型越精确,演化成本越高
bug 和可恢复失败是否分开依赖实践和约定Rust 最明确,Go 也区分 errorpanic

现代错误处理不是简单消灭异常,而是更强调分层:

  • 业务失败:尽量显式、机器可读,适合错误码、错误枚举、Result、union、sealed class。
  • 外部环境失败:保留 cause 和上下文,逐层传播,在边界转换成日志、响应、重试或告警。
  • 程序 bug:尽早暴露,使用 panic、assert、unchecked exception 或 fail fast。

这也是我理解的核心趋势:异常没有消失,只是从“所有失败的默认答案”,逐渐回到更合适的位置。

4. 为什么现代错误处理越来越强调显式失败

讨论到这里,容易得出一个过度简化的结论:现代语言是不是都在反对异常?

我觉得不是。更准确的说法是:现代错误处理越来越强调把调用者需要决策的失败显式暴露出来

原因主要有三个。

第一,API 边界越来越重要。服务化、SDK、开放 API、微服务让错误经常跨过进程、网络、语言和团队边界。这个时候,错误如果只是一段 message 或隐式异常,调用者很难稳定分支。

第二,并发和异步让隐式失败更危险。同步调用里,异常还有一条相对直观的调用栈;但进入 Promise、goroutine、线程池、异步任务之后,失败可能发生在另一个时间点或另一个执行单元里。显式错误结果、结构化并发和 typed result 的价值,在于让失败可以被等待、组合、聚合,并和取消、超时一起处理。

第三,业务失败需要机器可读,而不是人类可读 message。用户名重复、余额不足、权限不足、库存不足这类失败通常不是程序事故,而是业务分支。它们需要被前端展示、国际化、统计、重试或引导用户操作,所以更适合被建模成错误码、错误枚举或领域错误类型。

所以问题不是“异常 vs 返回值”二选一,而是:这个失败到底应该由谁看见、谁处理、谁负责转换成用户或系统能理解的结果。

5. 如果面试中被问到:如何对比几门语言的错误处理?

那么回到文章开头提到的面试题:“你简历上写了几门语言,那对比一下它们的错误处理方式”,不要急着背语法。更好的回答方式,是先给出比较维度,再把语言放进去。

可以这样回答:

我会从“错误是否显式”“调用方是否被强制处理”“错误是否进入 API 契约”这几个角度对比。

Java 主要依赖异常。checked exception 会进入方法签名,能表达一部分可恢复失败契约;unchecked exception 则更像隐式的非局部控制流,适合跨层传播并在边界统一处理。Python 和 JavaScript 也主要使用异常,不过异常集合通常不进入函数签名,更多依赖文档、测试和约定。

Go 把错误作为普通返回值,调用者通常在每个调用点显式判断。它的控制流很清楚,代码审查也容易发现错误有没有被处理,但会带来重复的 if err != nil,而且错误分类依赖约定。

Rust 更进一步,把可恢复错误放进 Result<T, E>,借助类型系统和 ? 传播,让失败路径既显式又能被编译器检查。TypeScript、Kotlin、Scala 也可以用 union、sealed class、Either/Result 这类方式,把业务失败建模成数据。

所以我理解错误处理的重点不是哪种语法更高级,而是先判断失败的性质:调用者能恢复的失败应该显式建模,跨层的系统失败可以在边界统一处理,程序 bug 则应该尽早暴露。

如果面试官继续追问,可以按下面四个维度展开:

维度可以怎么说
错误如何表示Java、Python、JavaScript 常用异常对象;Go 用 error 返回值;Rust 用 Result<T, E>;TypeScript 可以用 union type 表达业务失败。
错误如何传播异常会沿调用栈自动传播;Go 需要手动返回;Rust 可以用 ? 显式传播;JavaScript 异步场景里还要处理 Promise rejection。
调用方是否被强制处理Java checked exception、Rust Result、Swift try 更强调调用点可见;unchecked exception、Python、JavaScript 更依赖约定。
适合什么场景异常适合跨层传播和边界统一处理;显式返回适合调用者要立即决策的失败;类型化错误适合业务规则、协议解析、编译器、工作流等需要穷尽处理的场景。

最后给出自己的判断,会比单纯罗列语言更好:

我不认为存在一种绝对最好的错误处理机制。关键是先区分错误性质:业务失败、外部环境失败、程序 bug。业务失败应该尽量显式、机器可读;外部环境失败要保留上下文,并在合适的边界转换成日志、响应或重试策略;程序 bug 则应该 fail fast。不同语言的差异,本质上是在“主流程简洁性、错误路径显式性、API 契约稳定性、类型系统约束”之间做取舍。

6. 总结

错误处理方式的差异,表面上是 try-catchif err != nilResultthrow 的差异,本质上是语言设计哲学的差异。

  • 异常范式追求主路径简洁,适合跨层传播和边界统一处理,但错误路径容易隐藏。
  • 显式返回范式把失败放回普通控制流,调用点清楚,但会带来样板和噪音。
  • 类型驱动范式把可恢复失败纳入函数类型,让编译器参与错误处理,但也要求更认真地设计错误模型。
  • 函数式范式则进一步把失败当作可组合的数据,适合复杂领域建模和工作流。

我现在更倾向于用一个分层原则来选择错误处理方式:

  • 调用方能恢复、需要分支决策的失败,应该显式建模。
  • 外部环境失败,应该保留上下文,并在系统边界统一转换。
  • 违反程序不变量、无法安全继续的状态,应该尽早暴露。
  • 不要解析错误 message 做业务判断,也不要每一层都重复 log 同一个错误。

这样看,错误处理就不再是“哪门语言更优雅”的争论,而是一个 API 设计问题:你希望失败在哪里被看见,被谁处理,以及以什么形式进入系统契约。

References