Golang 错误处理
9.1 错误处理基础
如“5.1.3 多返回值<86页>”所述,Go 语言通过将 error 类型的值作为函数最后一个返回值来处理错误。这完全是一种约定俗成的做法,但该约定极其严格,永远不应被打破。当函数按预期执行时,error 参数返回 nil。若出现异常,则返回错误值。调用函数随后通过将 error 返回值与 nil 进行比较来检查错误,处理错误或返回错误自身。具有错误处理的简单函数,示例如下:
func calcRemainderAndMod(numerator, denominator int) (int, int, error) {
if denominator == 0 {
return 0, 0, errors.New("denominator is 0")
}
return numerator / denominator, numerator % denominator, nil
}
通过调用 errors 包中的 New 函数可以从字符串创建新错误。错误信息不应使用大写字母开头,也不应以标点符号或换行符结尾。多数情况下,当返回非 nil 错误时,应将其他返回值设为相应类型的零值(Zero Value)。在介绍哨兵(Sentinel)错误时(“9.3 哨兵(Sentinel)错误<183页>”),会看到此规则的一个例外。
与支持异常机制的语言不同,Go 没有专门检测错误返回的特殊语法结构。当函数返回错误时,使用 if 语句检查错误变量是否为非 nil 值:
func main() {
numerator := 20
denominator := 3
remainder, mod, err := calcRemainderAndMod(numerator, denominator)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println(remainder, mod)
}
error 是一个定义了单个方法的内置接口:
type error interface {
Error() string
}
实现了 error 接口的类型即被视为错误类型。函数返回 nil 表示未发生错误,其原理在于 nil 是所有接口类型的零值(Zero Value)。
Go 语言采用返回错误而非抛出异常的设计有两个重要原因。
- 首先,异常会为代码引入至少一条新的执行路径。这些路径有时并不明确,尤其是在函数声明中未包含可能抛出异常提示的语言中。这会导致代码在异常未被正确处理时以意外方式崩溃,或者更糟糕的是——代码虽未崩溃但数据未被正确初始化、修改或存储。
- 其次,Go 编译器要求所有变量必须被读取。将错误作为返回值强制开发者要么检查并处理错误条件,要么通过使用下划线(
_)显式表明忽略错误返回值。
异常处理或许能减少代码行数,但更少的代码行数未必使程序更易理解或维护。正如所见,惯用的 Go 代码更推崇清晰性,即使需要更多代码行数。
另需注意的是 Go 代码的执行流特征。错误处理代码缩进在 if 语句内部,而业务逻辑则保持平级。这为快速区分“黄金路径”代码与异常条件代码提供了直观的视觉线索。
第二种情况是重复使用的
err变量。Go 编译器要求每个变量至少被读取一次,但不会强制要求每次对变量的写入都被读取。如果多次使用err变量,只需读取一次即可满足编译器要求。在staticcheck工具中,可以找到检测这种情况的方法。
9.2 用字符串处理简单错误
Go 标准库提供了两种从字符串创建错误的方式:
- 第一种是
errors.New函数,它接收一个字符串并返回一个错误。当对返回的错误实例调用Error方法时,该字符串会被返回。如果将错误传递给fmt.Println,它会自动调用Error方法:
func doubleEven(i int) (int, error) {
if i%2 != 0 {
return 0, errors.New("only even numbers are processed")
}
return i * 2, nil
}
func main() {
result, err := doubleEven(1)
if err != nil {
fmt.Println(err) // prints "only even numbers are processed"
}
fmt.Println(result)
}
- 第二种方式是使用
fmt.Errorf函数。该函数允许通过fmt.Printf格式化谓词来格式化错误字符串,从而在错误信息中包含运行时信息。与errors.New类似,当对返回的错误实例调用Error方法时,该字符串会被返回:
func doubleEven(i int) (int, error) {
if i%2 != 0 {
return 0, fmt.Errorf("%d isn't an even number", i)
}
return i * 2, nil
}
9.3 哨兵(Sentinel)错误
某些错误用于表示由于当前状态问题而无法继续处理。Go 社区活跃多年的开发者 Dave Cheney 在其博客文章 Don’t just check errors, handle them gracefully | Dave Cheney 中创造了哨兵(Sentinel)错误这一术语来描述这类情况:
哨兵(Sentinel)一词源自计算机编程中的实践,即使用特定值来表示无法进行进一步处理。在 Go 中,同样使用特定值来表示错误。
哨兵(Sentinel)错误是少数在包级别声明的变量之一。按照惯例,它们的名称以 Err 开头(io.EOF 是一个显著例外)。这些错误应被视为只读变量——虽然 Go 编译器无法强制实施这一点,但修改它们的值属于编程错误。
在一个 Go 程序实例中,一个特定包的一个特定包级变量通常只有一个实例,所有导入该包的代码共享它。
哨兵(Sentinel)错误通常用于表示无法开始或继续处理。例如,标准库中的 ZIP 文件处理包 archive/zip 就定义了几个哨兵错误,包括当传入非 ZIP 文件数据时返回的 ErrFormat。:
func main() {
data := []byte("This is not a zip file")
notAZipFile := bytes.NewReader(data)
_, err := zip.NewReader(notAZipFile, int64(len(data)))
if err == zip.ErrFormat {
fmt.Println("Told you so")
}
}
标准库中另一个哨兵(Sentinel)错误的例子是 crypto/rsa 包中的 rsa.ErrMessageTooLong。它表示消息因超过所提供公钥的长度限制而无法加密。“十四 上下文(Context)<305页>”一章将介绍哨兵(Sentinel)错误 context.Canceled。
在定义哨兵(Sentinel)错误前,请务必确认其必要性。一旦定义,它就成为了公共 API 的一部分,必须承诺在所有向后兼容的版本中保持其可用性。更好的做法是复用标准库中现有的哨兵错误,或者定义一个包含错误成因信息的错误类型(下一节将展示具体方法)。但如果遇到表示应用程序已达到特定状态、无法继续处理且无需额外信息解释错误的情况,哨兵(Sentinel)错误就是正确选择。
如何检测哨兵错误?如前文代码示例所示,当调用明确声明返回哨兵(Sentinel)错误的函数时,使用 == 运算符进行判断。在“9.7 Is 与 As 方法<190页>”一节中,将介绍其他情况下检测哨兵错误的方法。
对哨兵(Sentinel)错误使用常量
在 Constant errors | Dave Cheney 一文中,Dave Cheney 提出常量可以作为有效的哨兵(Sentinel)错误。可以在包(将在“十 模块、包与导入<197页>”介绍如何创建包)中定义这样的类型:
package consterr type Sentinel string func (s Sentinel) Error() string { return string(s) }然后,可以像这样使用该哨兵错误:
package mypkg const ( ErrFoo = consterr.Sentinel("foo error") ErrBar = consterr.Sentinel("bar error") )这看起来像函数调用,但实际上是将字符串字面量(Literal)转换为实现了
error接口的类型。修改ErrFoo和ErrBar的值是不可能的。乍看之下,这似乎是个不错的解决方案。然而,这种做法并不符合 Go 语言的惯用风格。如果在不同包中使用相同类型创建常量错误,当错误字符串相同时,两个错误就会被判定为相等。它们还会与相同值的字符串字面量(Literal)相等。而使用
errors.New创建的错误仅与自身或显式赋值的变量相等。你肯定不希望不同包中的错误被判定为相等——否则为什么要声明两个不同的错误?(可以通过在每个包中创建非公开的错误类型来避免这个问题,但这会产生大量样板代码(Boilerplate Code)。)哨兵(Sentinel)错误模式体现了 Go 语言的设计哲学。哨兵(Sentinel)错误应该很少见,因此可以通过约定而非语言规则来处理。是的,它们是公开的包级变量,这意味着它们可以被修改,但开发者意外重赋值包中公共变量的可能性极低。简而言之,这是一个通过其他特性和模式处理的极端情况。Go 语言的哲学是:与其增加语言特性,不如保持语言简洁,信任开发者和工具链。
到目前为止,所介绍的所有错误都是字符串。但是 Go 中的错误对象可以包含更多信息。让我们看看如何操作。
9.4 错误是值类型²
² 译者注:“Errors Are Values”是 Go 语言中一种重要的错误处理哲学,强调错误(error)应被视为普通的数值(value),而不仅仅是需要处理的异常或字符串。
错误是一个接口,因此可以定义自己的错误类型,可以包含额外的信息用于日志记录或错误处理。例如,可能希望将状态码作为错误的一部分,以指示应向用户报告的错误类型。这样可以避免通过字符串比较(其错误文本可能会变化)来确定错误原因。让我们看看具体如何实现。首先,定义枚举类型来表示状态码:
type Status int
const (
InvalidLogin Status = iota + 1
NotFound
)
接下来,定义一个 StatusErr 结构体来保存错误状态码与错误文本:
type StatusErr struct {
Status Status
Message string
}
func (se StatusErr) Error() string {
return se.Message
}
现在,即可使用 StatusErr 提供有关出错原因的更多详细信息:
func LoginAndGetData(uid, pwd, file string) ([]byte, error) {
token, err := login(uid, pwd)
if err != nil {
return nil, StatusErr{
Status: InvalidLogin,
Message: fmt.Sprintf("invalid credentials for user %s", uid),
}
}
data, err := getData(token, file)
if err != nil {
return nil, StatusErr{
Status: NotFound,
Message: fmt.Sprintf("file %s not found", file),
}
}
return data, nil
}
即使定义了自己的自定义错误类型,也应该始终使用 error 作为错误结果的返回类型。这将允许从函数中返回不同类型的错误,并让函数的调用者可以选择不依赖特定的错误类型。
如果使用自己的错误类型,请确保不要返回该类型的未初始化的实例。如果返回未初始化的实例,会发生什么?
func GenerateErrorBroken(flag bool) error {
var genErr StatusErr
if flag {
genErr = StatusErr{
Status: NotFound,
}
}
return genErr
}
func main() {
err := GenerateErrorBroken(true)
fmt.Println("GenerateErrorBroken(true) returns non-nil error:", err != nil)
err = GenerateErrorBroken(false)
fmt.Println("GenerateErrorBroken(false) returns non-nil error:", err != nil)
}
运行上述代码,将得到如下输出:
true
true
这并不是指针类型与值类型的问题;如果将 genErr 声明为 *StatusErr 类型,输出结果仍会相同。err 之所以非 nil,是因为 error 是一个接口。如“7.10 接口与 nil<147页>”一节所述,只有当接口的底层类型和底层值均为 nil 时,接口才会被视为 nil。无论 genErr 是否为指针,将 genErr 赋值给 error 接口后,error 接口的底层类型部分都不为 nil。
可以通过两种方式修复此问题。最常见的方法是在函数成功完成时,显式返回 nil 作为错误值:
func GenerateErrorOKReturnNil(flag bool) error {
if flag {
return StatusErr{
Status: NotFound,
}
}
return nil
}
这种方式的优势在于无需逐行检查代码以确保返回语句中的错误变量正确定义。
另一种方法是确保存储错误的局部变量类型为 error:
func GenerateErrorUseErrorVar(flag bool) error {
var genErr error
if flag {
genErr = StatusErr{
Status: NotFound,
}
}
return genErr
}
使用自定义错误时,切勿将变量声明为自定义错误类型。若未发生错误,应显式返回
nil,或将变量定义为error类型。
如“7.13 类型断言(Type Assertion)与类型判断(Type Switch)<150页>”一节所述,不要通过类型断言(Type Assertion)或类型判断(Type Switch)来访问自定义错误的字段和方法。应改用 errors.As(“9.7 Is 与 As 方法<190页>”)。
9.5 错误封装
当错误在代码中传递时,通常需要为其添加额外信息。此信息可能是接收到错误的函数名称,或是它试图执行的操作。在保留原始错误的同时添加信息的行为称为“错误封装(wrapping)”。当存在一系列被封装的错误时,就形成了“错误树(error tree)”。
Go 标准库提供了错误封装函数,如 fmt.Errorf 函数有一个特殊谓词 %w。使用它创建的格式化错误字符串会包含另一个错误的格式化字符串,同时保留原始错误。惯例做法是在错误格式字符串末尾添加:%w,并将需要封装的错误作为 fmt.Errorf 函数的最后一个参数。
标准库还提供了错误解包函数——errors.Unwrap。为其传入一个错误后,如果存在被封装的错误则返回该错误,否则返回 nil。以下是一个快速示例,展示如何使用 fmt.Errorf 封装错误以及用 errors.Unwrap 解包。
func fileChecker(name string) error {
f, err := os.Open(name)
if err != nil {
return fmt.Errorf("in fileChecker: %w", err)
}
f.Close()
return nil
}
func main() {
err := fileChecker("not_here.txt")
if err != nil {
fmt.Println(err)
if wrappedErr := errors.Unwrap(err); wrappedErr != nil {
fmt.Println(wrappedErr)
}
}
}
运行此程序,将得到以下输出:
in fileChecker: open not_here.txt: no such file or directory
open not_here.txt: no such file or directory
通常不会直接调用
errors.Unwrap。而是,应使用errors.Is和errors.As来查找特定的被封装错误。将在下一节(“9.6 封装多个错误<189页>”)介绍这两个函数。
若需用自定义错误类型封装错误,该类型需实现 Unwrap 方法,此方法无参数且返回一个 error。以下是先前定义错误的改进版本,演示其实现方式:
type StatusErr struct {
Status Status
Message string
Err error
}
func (se StatusErr) Error() string {
return se.Message
}
func (se StatusErr) Unwrap() error {
return se.Err
}
现在,可通过 StatusErr 封装底层错误:
func LoginAndGetData(uid, pwd, file string) ([]byte, error) {
token, err := login(uid, pwd)
if err != nil {
return nil, StatusErr{
Status: InvalidLogin,
Message: fmt.Sprintf("invalid credentials for user %s", uid),
Err: err,
}
}
data, err := getData(token, file)
if err != nil {
return nil, StatusErr{
Status: NotFound,
Message: fmt.Sprintf("file %s not found", file),
Err: err,
}
}
return data, nil
}
并非所有错误都需要封装。当库返回的错误表明处理无法继续,且错误信息包含程序其他部分无需了解的实现细节时,直接创建全新错误并返回也是合理的。需根据实际情况判断应返回的内容。
若需基于另一个错误的消息创建新错误,但不想封装该错误,可使用
fmt.Errorf函数并采用%v谓词(而非%w):err := internalFunction() if err != nil { return fmt.Errorf("internal failure: %v", err) }
9.6 封装多个错误
当函数需要返回多个错误时(例如,验证结构体字段时,最好为每个无效字段返回独立错误),由于标准函数签名返回的是单个 error 而非 []error,此时需使用 errors.Join 合并多个错误:
type Person struct {
FirstName string
LastName string
Age int
}
func ValidatePerson(p Person) error {
var errs []error
if len(p.FirstName) == 0 {
errs = append(errs, errors.New("字段 FirstName 不能为空"))
}
if len(p.LastName) == 0 {
errs = append(errs, errors.New("字段 LastName 不能为空"))
}
if p.Age < 0 {
errs = append(errs, errors.New("字段 Age 不能为负数"))
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
另一种合并方式是通过 fmt.Errorf 使用多个 %w 谓词:
err1 := errors.New("错误1")
err2 := errors.New("错误2")
err3 := errors.New("错误3")
err := fmt.Errorf("错误1: %w, 错误2: %w, 错误3: %w", err1, err2, err3)
若要实现支持多错误封装的自定义类型,需让 Unwrap 方法返回 []error:
type MyError struct {
Code int
Errors []error
}
func (m MyError) Error() string {
return errors.Join(m.Errors...).Error()
}
func (m MyError) Unwrap() []error {
return m.Errors
}
处理可能封装零个、单个或多个错误的场景时,可采用以下模式:
var err error
err = funcThatReturnsAnError()
switch err := err.(type) {
case interface{ Unwrap() error }:
// 处理单个包装错误
innerErr := err.Unwrap()
case interface{ Unwrap() []error }:
// 处理多个包装错误
innerErrs := err.Unwrap()
for _, innerErr := range innerErrs {
// 处理每个错误
}
default:
// 处理无包装错误
}
注意:标准库未预定义两种 Unwrap 变体的接口,此处通过类型判断(Type Switch)中的匿名接口实现方法匹配。建议优先使用 errors.Is 和 errors.As 检查错误链。
9.7 Is 与 As 方法
错误封装虽能提供额外错误信息,但会引发新问题:当哨兵(Sentinel)错误被封装后,既无法用 == 直接比较,也无法通过类型断言(Type Assertion)或类型判断(Type Switch)匹配。Go 通过 errors 包的 Is 和 As 函数解决这些问题。
错误一旦被封装,变量
err里直接保存的就不再是原来的那个错误,而是“外层错误对象”。err == ErrNotFound // false接口比较时,err 为
(*fmt.wrapError, 外层对象),而ErrNotFound是:(*errors.errorString, 原始对象)此外,普通类型断言/类型判断都只检查“最外层”的动态类型,不会自动向内层查找。
要检查返回的错误或其封装的任何错误是否匹配特定的哨兵(Sentinel)错误实例,请使用 errors.Is。该函数接收两个参数:待检查的错误和要对比的目标错误实例。如果在错误树中存在与目标错误匹配的错误,errors.Is 将返回 true。可以通过以下简短程序观察其实际行为(代码可在 The Go Playground 或本章资源库仓库 sample_code/is_error 目录中运行):
func fileChecker(name string) error {
f, err := os.Open(name)
if err != nil {
return fmt.Errorf("in fileChecker: %w", err)
}
f.Close()
return nil
}
func main() {
err := fileChecker("not_here.txt")
if err != nil {
if errors.Is(err, os.ErrNotExist) {
fmt.Println("That file doesn't exist")
}
}
}
运行此程序,将得到如下输出:
That file doesn't exist
默认情况下,errors.Is 使用 == 运算符比较每个被封装错误与目标错误。若自定义的错误类型不适用此比较方式(例如,不可比较类型),需在该错误类型上实现 Is 方法:
type MyErr struct {
Codes []int
}
func (me MyErr) Error() string {
return fmt.Sprintf("codes: %v", me.Codes)
}
func (me MyErr) Is(target error) bool {
if me2, ok := target.(MyErr); ok {
return slices.Equal(me.Codes, me2.Codes)
}
return false
}
slices.Equal 函数用法,如“3.2 切片(slice)<33页>”一节所述。
自定义 Is 方法的另一用途是支持与非相同实例的错误进行比较。有时可能需要对错误进行模式匹配,通过指定包含部分相同字段的过滤实例来实现。首先,定义新的错误类型 ResourceErr:
type ResourceErr struct {
Resource string
Code int
}
func (re ResourceErr) Error() string {
return fmt.Sprintf("%s: %d", re.Resource, re.Code)
}
若要使两个 ResourceErr 实例在任一字段匹配时即视为相等,可通过自定义 Is 方法实现:
func (re ResourceErr) Is(target error) bool {
if other, ok := target.(ResourceErr); ok {
ignoreResource := other.Resource == ""
ignoreCode := other.Code == 0
matchResource := other.Resource == re.Resource
matchCode := other.Code == re.Code
return matchResource && matchCode ||
matchResource && ignoreCode ||
ignoreResource && matchCode
}
return false
}
现在即可查找所有涉及数据库的错误(无论错误码是何值):
if errors.Is(err, ResourceErr{Resource: "Database"}) {
fmt.Println("The database is broken:", err)
// process the codes
}
As 函数用于检查返回的错误(或其封装的任何错误)是否匹配特定类型。该函数接收两个参数:第一个参数为待检查的错误,第二个参数为指向目标类型变量的指针。若函数返回 true,则表明错误树中存在匹配项,且匹配的错误会被赋值给第二个参数;若返回 false,则表示未找到匹配项。以下为 MyErr 的用法示例:
err := AFunctionThatReturnsAnError()
var myErr MyErr
if errors.As(err, &myErr) {
fmt.Println(myErr.Codes)
}
注意:需用 var 声明目标类型的零值变量,并将该变量的指针传入 errors.As。
errors.As 的第二个参数不仅限于错误类型变量指针,也可传递接口指针以匹配满足该接口的错误:
err := AFunctionThatReturnsAnError()
var coder interface {
CodeVals() []int
}
if errors.As(err, &coder) {
fmt.Println(coder.CodeVals())
}
此示例使用匿名接口,但任何接口类型均可接受。完整示例代码位于本章资源库 sample_code/custom_as_error 目录。
若
errors.As的第二个参数不是指向错误类型或接口类型的指针,该方法将触发panic。
正如可通过实现 Is 方法覆盖 errors.Is 的默认比较行为,也可通过实现 As 方法覆盖 errors.As 的默认类型匹配。实现 As 方法需使用反射(详见“十六 神龙驾到:reflect、unsafe 与 cgo 包<363页>”),仅建议在特殊场景下使用,例如,需要将一种错误类型匹配并转换为另一种类型时。
当需要匹配特定错误实例或特定值时使用
errors.Is,当需要匹配特定错误类型时使用errors.As。
9.8 用 defer 封装错误
有时,会发现用同一条消息封装了多个错误:
func DoSomeThings(val1 int, val2 string) (string, error) {
val3, err := doThing1(val1)
if err != nil {
return "", fmt.Errorf("in DoSomeThings: %w", err)
}
val4, err := doThing2(val2)
if err != nil {
return "", fmt.Errorf("in DoSomeThings: %w", err)
}
result, err := doThing3(val3, val4)
if err != nil {
return "", fmt.Errorf("in DoSomeThings: %w", err)
}
return result, nil
}
可以使用 defer 关键字简化上述代码:
func DoSomeThings(val1 int, val2 string) (_ string, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("in DoSomeThings: %w", err)
}
}()
val3, err := doThing1(val1)
if err != nil {
return "", err
}
val4, err := doThing2(val2)
if err != nil {
return "", err
}
return doThing3(val3, val4)
}
必须为返回值命名,才能在 defer 函数中引用 err。若命名单个返回值,则必须命名所有返回值,因此此处对未显式赋值的字符串返回值使用下划线(_)。
在 defer 闭包中,代码检查是否返回了错误。若是,则将错误重新赋值为封装原始错误的新错误实例,并附加检测到错误的函数信息,以指示哪个函数检测到了错误。
此模式适用于需用相同信息封装所有错误的场景。若需添加更详细的封装信息,应在每个 fmt.Errorf 中同时包含具体和通用信息。
9.9 panic 与 recover
前文曾简略提及 panic,但未详述其本质。panic 类似于 Java 或 Python 中的 Error,当 Go 运行时无法确定后续执行逻辑时,会触发该状态。通常由编程错误导致,例如:尝试读取超出切片范围的元素,或向 make 传递负值参数。若运行时检测到自身缺陷(如垃圾回收器异常)也会触发 panic,但此类情况极为罕见。若出现 panic,应优先排查代码问题而非运行时环境。
panic 触发时,当前函数立即终止执行,随后运行该函数关联的 defer 语句。待 defer 执行完毕后,调用方的 defer 会接续执行,此过程逐级向上回溯直至 main 函数。最终程序将输出错误信息及堆栈跟踪后退出。⁴
任何 goroutine 发生
panic且未被recover时,都会导致整个进程退出。若
panic发生在非 main goroutine 中,栈展开只发生在该 goroutine 自己的调用栈中,并依次执行该调用栈上的defer;展开到该 goroutine 的入口函数后结束,不会继续传播到创建或启动该 goroutine 的函数调用栈中。若最终未被recover,运行时终止整个进程。package main import ( "fmt" "time" ) func main() { defer fmt.Println("main defer") go worker() time.Sleep(time.Second) fmt.Println("main end") } func worker() { defer fmt.Println("worker defer") f() } func f() { defer fmt.Println("f defer") panic("boom") }输出结果:
f defer worker defer panic: boom goroutine 19 [running]: main.f() main.go:26 +0x59 main.worker() main.go:20 +0x52 created by main.main in goroutine 1 main.go:11 +0x5d exit status 2没有输出
main defer,原因是 panic 发生在workergoroutine 中,调用woker()后仍然没有recover,整个进程中止。
⁴ 译者注:即按代码的正常执行流程,后定义的
defer函数会先执行,而先定义的defer函数会后执行。
若程序中存在不可恢复的异常场景,可主动触发 panic。内置函数 panic 接收单个任意类型的参数(通常为字符串)。以下为触发 panic 的简单示例(代码可在 The Go Playground 或本章资源库 sample_code/panic 目录运行):
func doPanic(msg string) {
panic(msg)
}
func main() {
doPanic(os.Args[0])
}
运行此代码,将生成以下输出:
panic: /tmpfs/play
goroutine 1 [running]:
main.doPanic(...)
/tmp/sandbox567884271/prog.go:6
main.main()
/tmp/sandbox567884271/prog.go:10 +0x5f
如您所见,panic 会打印出其消息,后跟堆栈跟踪。
Go 提供了捕获 panic 的机制,可通过内置的 recover 函数实现优雅关闭或阻止程序终止。在 defer 中调用 recover 可检测是否发生 panic:若发生 panic,则返回 panic 的参数值;当 recover 成功捕获 panic 后,当前 goroutine 的 panic 栈展开过程会停止,程序不会因为这次 panic 而继续向上展开调用栈并最终崩溃。但是,程序不会回到 panic 发生的位置继续执行;包含该 defer 的函数会结束执行,并返回到它的调用者,之后程序从调用者的后续代码继续运行。
如果调用 recover 时当前 goroutine 并没有正在处理的 panic,则 recover() 返回 nil,不会产生其他效果,程序按照原来的控制流程继续执行。
func div60(i int) {
defer func() {
if v := recover(); v != nil {
fmt.Println(v)
}
}()
fmt.Println(60 / i)
}
func main() {
for _, val := range []int{1, 2, 0, 6} {
div60(val)
}
}
使用 recover 存在特定模式:通过 defer 注册处理函数捕获潜在 panic,在 if 语句中调用 recover 并检查其返回值是否为非 nil。必须通过 defer 调用 recover,因 panic 触发后仅会执行已注册的延迟函数。
运行此代码将生成以下输出:
60
30
runtime error: integer divide by zero
10
由于 recover 依赖非 nil 值检测 panic 是否发生,可能引发疑问:若调用 panic(nil) 后执行 recover 会如何?在 Go 1.21 之前的版本中,recover 会阻止 panic 传播,但不会保留任何错误信息。自 Go 1.21 起,panic(nil) 等效于 panic(new(runtime.PanicNilError))。
尽管 panic 和 recover 与其他语言的异常处理相似,但其设计初衷并非如此。panic 应仅用于致命场景,recover 则用于优雅处理这些场景。程序触发 panic 后,继续执行需格外谨慎——内存耗尽等资源问题引发的 panic 应通过 recover 记录日志后调用 os.Exit(1) 终止;编程错误导致的 panic 虽可尝试恢复,但很可能再次触发相同问题。前文示例中的除零检查,更符合 Go 惯例的做法是预先验证参数并返回 error。
不推荐依赖 panic/recover 的原因在于,recover 无法明确捕获的故障类型。其仅保证在故障发生时能打印信息并继续执行。Go 更推崇显式处理已知错误的代码风格,而非笼统捕获所有异常的简短写法。
唯一推荐使用 recover 的场景是:开发第三方库时,不应让 panic 跨越公共 API 边界。公共函数应通过 recover 将 panic 转换为 error 返回,由调用方决定后续处理逻辑。
尽管 Go 内置的 HTTP 服务器会从处理函数的
panic中恢复,但 David Symonds 在 GitHub 评论中指出,截至 2015 年,Go 团队已将此视为设计失误。
9.10 从错误中获取堆栈跟踪(Stack Trace)
部分 Go 初学者倾向于使用 panic 和 recover 的原因在于希望获取错误发生时的堆栈跟踪信息:
panic: /tmpfs/play
goroutine 1 [running]:
main.doPanic(...)
/tmp/sandbox567884271/prog.go:6
main.main()
/tmp/sandbox567884271/prog.go:10 +0x5f
默认情况下 Go 不提供此功能。虽然可通过手动封装错误构建调用链,但部分第三方库的错误类型能自动生成堆栈信息(详见“十 模块、包与导入<197页>”,了解如何集成第三方代码)。例如,Cockroachdb 提供了一个第三方库 cockroachdb/errors: Go error library with error portability over the network,其中包含用于使用堆栈跟踪封装错误的函数。
默认情况下,不打印堆栈跟踪信息。如需查看堆栈跟踪,需使用 fmt.Printf 配合详细输出格式符(%+v)。具体用法请参阅相关文档。
当错误包含堆栈跟踪时,输出信息会显示编译时文件的完整路径。若需隐藏路径,构建代码时使用
-trimpath标志,该标志会将完整路径替换为包名。
评论