Menu

Golang 错误处理:if err != nil、包装与检查

Go 把错误当作函数返回的普通值来处理。学习 error 接口、if err != nil、errors.New 和 fmt.Errorf、带上下文返回错误、用 errors.Is 和 errors.As 检查错误,以及每个错误只处理一次。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

错误就是值

可能失败的 Go 函数会把 error 作为最后一个返回值。调用方立即检查它。

输出:

parsed: 42
could not parse: strconv.Atoi: parsing "forty-two": invalid syntax

这就是整个机制。没有异常,没有 try 或 catch,也没有隐藏的控制流:错误只会沿着你的代码传递它的路径走。代价是 if err != nil 要一遍遍地写。好处是每个可能失败的地方都清清楚楚摆在代码里,由你在每一处决定接下来怎么办。

error 类型

error 是一个内置接口,只有一个方法:

type error interface {
	Error() string
}

任何带有 Error() string 方法的类型都是错误。nil 错误表示成功。用 fmt.Println(err) 或 %v 打印错误时会调用 Error()。

创建错误

两个函数就能应付大多数情况。

errors.New 创建一个固定文本的错误。fmt.Errorf 用和 Printf 相同的格式化动词生成错误。按照惯例,错误字符串以小写字母开头,结尾不加标点,因为它们通常会被嵌进更长的消息里:load config: open app.yaml: no such file or directory。

if err != nil 模式

地道的写法是:调用、检查、提前返回。成功路径始终贴着左边距,每个失败一发生就立即退出。

func loadUser(id int) (*User, error) {
	row, err := db.Query(id)
	if err != nil {
		return nil, err
	}
	u, err := parseUser(row)
	if err != nil {
		return nil, err
	}
	if err := u.Validate(); err != nil {
		return nil, err
	}
	return u, nil
}

有两个惯例值得注意:

  • 出错时,其他返回值返回零值(nil、0、"")。当 err != nil 时,调用方不能使用它们。
  • 当函数只返回一个错误时,if err := f(); err != nil 会把 err 的作用域限制在 if 内,让外层作用域保持干净。

错误返回之后不要再写 else。if err != nil { return err } else { ... } 只会让正常路径平白多缩进一层。

返回错误时添加上下文

原封不动往上传的错误,会丢掉它来自哪里的来龙去脉。open config.yaml: no such file or directory 并不能告诉你启动的哪一步失败了。用 fmt.Errorf 和 %w 动词加上上下文:

输出:

start server: read config: open /etc/myapp/config.yaml: no such file or directory
true

每一层都补上自己当时在做什么,最终的消息读起来就像一条从调用顶层一路通到原因的线索。好的上下文会写明操作和输入:parse line 12、fetch user 42。不要在每一层都加上“error”或“failed”,这条消息本身就已经是错误了。

%w 会包装:它把原错误保存在新错误内部,所以 errors.Is 和 errors.As 仍然能找到它。%v 只复制文本。如果你有意向调用方隐藏实现细节,比如不想让他们依赖某个数据库驱动的错误类型,就用 %v。

检查特定错误:errors.Is 和 errors.As

有时调用方需要针对某一种失败做出反应:文件不存在就“使用默认值”,超时就“重试”。有两个函数可以解决这个问题,而且它们都会穿过每一层包装去查找。

经验法则:

  • 和预定义的错误值(io.EOF、os.ErrNotExist、sql.ErrNoRows 这类哨兵错误)比较时用 errors.Is,不要用 ==。错误一旦被包装,== 就失效了。
  • 出于同样的原因,提取类型化错误时用 errors.As,不要用类型断言。errors.As 接收一个指向目标类型变量的指针。
  • 永远不要匹配 err.Error() 的文本。消息会随版本变化,一旦变了,文本匹配就会悄无声息地失效。

如何定义自己的哨兵错误和错误类型,以及如何用 errors.Join 合并多个错误,在自定义错误一节中介绍。

一个错误只处理一次

一个错误应该恰好被处理一次。处理指的是以下其中一种:返回它(通常要包装)、记录日志后继续、重试,或者把它转换成给用户的响应。同时做两件事,是 Go 代码里最常见的错误处理 bug。

// Wrong: logged here, and returned, so it is logged again by every caller.
if err != nil {
	log.Printf("could not fetch user: %v", err)
	return err
}

// Right: add context and return. The top of the program logs once.
if err != nil {
	return fmt.Errorf("fetch user %d: %w", id, err)
}

既记录日志又返回,会让同一个失败在日志里出现好几次,而且每次的上下文都比最终消息少。让错误一路向上传到能决定怎么处理它的地方(HTTP 处理函数、main、worker 循环),在那里记录日志。

错误的最终归宿

在程序的最顶层,总得有代码对错误采取行动。在 main 里,这通常意味着打印错误并以非零状态码退出:

不带参数运行它,会向 stderr 打印 error: usage: app <name>,并以状态码 1 退出(在“参数”面板里输入一个名字,就能看到另一条路径)。让 main 保持这种结构、把真正的工作放进 run,程序就便于测试,而且 run 里的 defer 语句仍会执行,因为 os.Exit 会跳过延迟调用。

在 HTTP 服务器中,最顶层就是处理函数:它把错误映射成状态码和一条可以安全展示给客户端的消息,并为你记录详细的消息。

可以忽略的错误,以及绝不能忽略的错误

忽略错误有时是对的,但要用 _ 明确写出来,让读代码的人知道这是有意为之:

_ = conn.SetDeadline(t) // best effort

有些调用在实践中不可能失败(strings.Builder.WriteString、bytes.Buffer.Write)。另一些看起来无害,其实不然:对写入过的文件调用 Close,可能会报告数据根本没写到磁盘上,而 json.Marshal 遇到 channel 和函数会失败。拿不准时,就检查。

errcheck linter(已包含在 golangci-lint 中)会报告未检查的错误。go vet 自身不会标记它们。

错误与 panic

Go 也有 panic,但它不是异常机制。正常运行中可能出问题的一切都用错误处理:错误的输入、缺失的文件、网络故障。panic 留给 bug(不可能出现的状态、被破坏的不变量),以及继续运行毫无意义的启动失败。一个库几乎永远不应该让 panic 穿过它的 API。参见 panic 与 recover。

减少重复

if err != nil 写起来很啰嗦,为它增加新语法的提案也屡次被否决;Go 团队在 2025 年宣布,不再推进错误处理方面的语法改动。在语言现有范围内,有一些模式可以减少噪音:

  • 提前返回,保持函数短小。 大多数重复来自一个函数里做了太多步骤。
  • 粘滞错误。 对一连串写操作,把第一个错误保存在结构体字段中,一旦设置了它,后续调用就什么都不做。bufio.Writer 就是这样工作的:你只需在 Flush 之后检查一次错误。
  • 每个函数只包装一次。 一个捕获了命名返回值的延迟闭包,可以给函数返回的每个错误加上相同的上下文(参见 defer)。

常见错误

  • err 不为 nil 时仍然使用返回值。 先检查,再使用。
  • 既记录日志又返回。 二选一。
  • 包装后还用 == 比较。 用 errors.Is。
  • 用 %v 丢掉了原因。 除非你就是要隐藏它,否则用 %w。
  • 把类型化的 nil 指针当作 error 返回。 var e *MyErr; return e 在调用方看来不是 nil。直接返回字面量 nil。
  • 消息首字母大写或带标点。 errors.New("Failed to connect.") 一旦被包装,读起来就很别扭。写成 connect to db: ...。

常见问题

Go 的错误处理是怎么工作的?

可能失败的函数会把 error 作为最后一个返回值。调用方立即检查它:v, err := f(); if err != nil { return err }。error 是一个普通的接口值,只有一个方法 Error() string,nil 表示成功。Go 没有异常。

Go 有 try/catch 吗?

没有。Go 没有异常,也没有 try/catch。可预见的失败以 error 值的形式返回,并用 if err != nil 检查。panic 和 recover 确实存在,但它们用于程序 bug 和无法恢复的状态,而不是正常的错误流程。

在 Go 中怎样返回错误?

把 error 声明为最后一个返回值,成功时返回 nil。固定文本的错误用 errors.New("message") 创建,要给收到的错误加上下文则用 fmt.Errorf("reading %s: %w", name, err)。失败时,其他返回值返回零值。

fmt.Errorf 中的 %w 和 %v 有什么区别?

两者都会把原错误的消息放进新错误里。%w 还会包装原错误,所以 errors.Is 和 errors.As 仍然能找到它。%v 生成的新错误只包含文本。调用方可能需要检查原因时用 %w,想隐藏原因时用 %v。

在 Go 中怎样判断返回的是哪种错误?

用 errors.Is(err, target) 和 io.EOF、os.ErrNotExist 这类哨兵错误比较,用 errors.As(err, &target) 提取 *fs.PathError 这样的特定错误类型。两者都会逐层查看被包装的错误。避免比较 err.Error() 字符串。

Coddy 编程语言插图

和 Coddy 一起学编程

开始