Web

Go 的错误处理与 defer

Go 通常不会通过异常传递可预期的失败,而是把错误作为函数的返回值交给调用方处理。这样一来,正常结果和失败原因都会明确出现在函数签名与调用位置。

本文从多返回值开始,依次介绍 error、错误包装、panicrecoverdefer,并说明如何使用 defer 安全地清理资源。

多返回值

Go 函数可以返回多个值。下面的函数同时返回除法结果和余数:

go
func divide(dividend, divisor int) (int, int) {
	quotient := dividend / divisor
	remainder := dividend % divisor

	return quotient, remainder
}

func main() {
	quotient, remainder := divide(10, 3)
	fmt.Println(quotient, remainder) // 3 1
}

如果不需要其中某个返回值,可以使用 _ 忽略它:

go
quotient, _ := divide(10, 3)

多个返回值也适合同时表达计算结果和失败原因。按照 Go 的惯例,error 通常放在最后一个返回位置:

go
func divide(dividend, divisor int) (int, error) {
	if divisor == 0 {
		return 0, errors.New("divisor cannot be zero")
	}

	return dividend / divisor, nil
}

成功时返回计算结果和 nil,失败时返回结果类型的零值与具体错误。调用方应先判断 err,再使用结果:

go
result, err := divide(10, 0)
if err != nil {
	fmt.Println("divide failed:", err)
	return
}

fmt.Println(result)

error 是一个值

error 是 Go 的内置接口,它只要求实现一个返回错误信息的方法:

go
type error interface {
	Error() string
}

因此,错误可以像其他值一样被创建、返回和判断。errors.New 适合创建内容固定的简单错误:

go
var ErrNonPositive = errors.New("number must be positive")

把需要识别的错误声明为包级变量后,产生错误的函数和处理错误的调用方可以共享同一个值。这类值通常称为哨兵错误:

go
func requirePositive(number int) error {
	if number <= 0 {
		return ErrNonPositive
	}

	return nil
}

错误信息应说明发生了什么,而错误处理则由调用方决定。函数不必在每一层都打印错误,否则同一个错误可能被重复记录多次。

包装错误

函数向上返回错误时,通常需要补充当前操作的上下文。fmt.Errorf 配合 %w 可以包装原始错误:

go
func parsePositive(input string) (int, error) {
	number, err := strconv.Atoi(input)
	if err != nil {
		return 0, fmt.Errorf("parse %q: %w", input, err)
	}

	if err := requirePositive(number); err != nil {
		return 0, fmt.Errorf("validate %q: %w", input, err)
	}

	return number, nil
}

这里的错误既保留了底层原因,也增加了“解析”或“验证”这层上下文。调用方可以直接展示完整错误信息:

text
validate "-2": number must be positive

使用 %v 只能把错误信息写进新字符串,使用 %w 才会保留可供检查的错误链。

使用 errors.Is 判断错误

错误经过包装后,最外层的错误值已经不再等于原始错误。需要判断错误链中是否包含某个目标错误时,应使用 errors.Is

go
number, err := parsePositive("-2")
if err != nil {
	if errors.Is(err, ErrNonPositive) {
		fmt.Println("please enter a positive number")
		return
	}

	fmt.Println("invalid number:", err)
	return
}

fmt.Println(number)

errors.Is 会沿着由 %w 建立的错误链逐层检查,因此调用方不需要解析错误文本。

TIP

如果调用方需要读取自定义错误中携带的字段,可以定义错误类型并使用 errors.As。只需识别某个固定错误时,包级哨兵错误与 errors.Is 通常已经足够。

panic 与 recover 的边界

普通的输入错误、文件不存在或网络请求失败都属于可以预期的情况,应通过 error 返回。panic 表示当前函数无法继续维持程序所依赖的约束:

go
func mustPositive(number int) int {
	if number <= 0 {
		panic("number must be positive")
	}

	return number
}

这类 must 函数只适合处理程序内部已经确保有效的数据。如果数字来自用户输入,函数应返回 error,而不是触发 panic

发生 panic 后,当前函数会停止正常执行,并逐层退出调用栈。退出期间,已经登记的 defer 仍会执行。recover 只能在延迟调用的函数中捕获当前 Goroutine 正在传播的 panic:

go
func runSafely(task func()) {
	defer func() {
		if value := recover(); value != nil {
			fmt.Println("task panicked:", value)
		}
	}()

	task()
}

recover 适合放在程序入口、任务执行器等明确的边界,避免一个任务的 panic 直接终止整个进程。捕获后仍应记录必要信息,并决定当前任务能否安全结束。

WARNING

recover 不能捕获另一个 Goroutine 中的 panic,也不应被当作普通错误处理方式。过早恢复可能让程序在状态不完整的情况下继续运行。

defer 的执行时机

defer 用于登记一个稍后执行的函数调用。包含它的外层函数即将返回时,延迟调用才会执行:

go
func example() {
	defer fmt.Println("cleanup")

	fmt.Println("working")
}

输出顺序如下:

text
working
cleanup

无论函数执行到末尾,还是提前遇到 return,已经登记的延迟调用都会在函数返回前执行。发生 panic 时,它们也会在调用栈退出过程中执行。

参数会立即求值

执行到 defer 语句时,延迟函数的参数会立刻求值:

go
func example() {
	message := "before"
	defer fmt.Println(message)

	message = "after"
	fmt.Println(message)
}

输出结果是:

text
after
before

如果需要在函数返回时读取变量的最新值,可以延迟执行一个匿名函数:

go
func example() {
	message := "before"

	defer func() {
		fmt.Println(message)
	}()

	message = "after"
}

匿名函数的函数体在最后才执行,因此它会读取到 "after"

多个 defer 后进先出

同一个函数中的多个 defer 按照登记顺序的相反方向执行:

go
func example() {
	defer fmt.Println("first")
	defer fmt.Println("second")

	fmt.Println("body")
}

输出结果是:

text
body
second
first

后获得的资源通常要先释放,这种后进先出的顺序正好符合逐层清理资源的需要。

使用 defer 清理资源

成功获得资源后,可以立即使用 defer 登记清理操作。下面的函数打开文件并读取内容:

go
func readFile(path string) ([]byte, error) {
	file, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("open file: %w", err)
	}
	defer file.Close()

	content, err := io.ReadAll(file)
	if err != nil {
		return nil, fmt.Errorf("read file: %w", err)
	}

	return content, nil
}

defer file.Close() 必须放在错误检查之后。只有 os.Open 成功时,file 才是可以关闭的有效资源。完成登记后,即使读取失败并提前返回,文件也会被关闭。

只读文件通常可以采用上面的写法。如果写入文件时 FlushClose 的失败会影响数据完整性,就应显式检查清理操作返回的错误。

defer 的作用域是整个函数,而不是当前代码块。在一个长循环中反复登记资源清理,资源会一直保留到外层函数返回。可以把单轮工作提取成独立函数,让每轮结束时及时执行清理:

go
func printFile(path string) error {
	file, err := os.Open(path)
	if err != nil {
		return err
	}
	defer file.Close()

	content, err := io.ReadAll(file)
	if err != nil {
		return err
	}

	fmt.Println(string(content))
	return nil
}

func printFiles(paths []string) error {
	for _, path := range paths {
		if err := printFile(path); err != nil {
			return fmt.Errorf("print %q: %w", path, err)
		}
	}

	return nil
}

总结

Go 使用多返回值把正常结果和失败原因一起交给调用方。简单错误可以通过 errors.New 创建,向上返回时使用 %w 补充上下文,再通过 errors.Is 检查错误链。

panicrecover 只用于无法继续维持程序约束的情况及明确的恢复边界。对于普通失败,返回 error 仍然是首选。defer 则适合把清理操作放在资源获取位置附近,但要注意参数立即求值、多个调用后进先出,以及它绑定整个函数的执行范围。