跳到主要内容
版本:2.9.x(Latest)

panic与返回错误的对比

我们先来看一个简单的例子。假设我们要创建一个timeIn函数,这个函数接收一个时区名称,然后返回该时区的当前时间。

Go语言中,当timeIn函数遇到错误时,标准的做法是将错误返回给调用者,让调用者自己决定如何处理。代码如下:

package main

import (
"fmt"
"os"
"time"
)

func timeIn(zone string) (time.Time, error) {
loc, err := time.LoadLocation(zone)
if err != nil {
return time.Time{}, err // 返回time.LoadLocation()产生的任何错误
}

return time.Now().In(loc), nil
}

func main() {
tz := "Asia/Shang"
t, err := timeIn(tz)
if err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}

fmt.Println("Current time in", tz, "is", t)
}
$ go run main.go
Error: unknown time zone Asia/Shang
exit status 1

当然,你也可以选择另一种方式:在timeIn函数内部使用panic来处理错误,而不是将错误返回给调用者。代码可以这样写:

package main

import (
"fmt"
"time"
)

func timeIn(zone string) time.Time {
loc, err := time.LoadLocation(zone)
if err != nil {
panic(err) // 以错误为参数调用panic()
}

return time.Now().In(loc)
}

func main() {
tz := "Asia/Shang"
t := timeIn(tz)
fmt.Println("Current time in", tz, "is", t)
}
$ go run main.go
panic: unknown time zone Asia/Shang

goroutine 1 [running]:
main.timeIn({0x4c2c7e?, 0x7d40fe626108?})
/tmp/main.go:11 +0xc5
main.main()
/tmp/main.go:20 +0x2b
exit status 2

当在Go代码中使用panic时,会发生以下四个步骤:

  1. 立即停止当前函数:函数中panic语句后面的代码将不会被执行。
  2. 执行所有的defer函数:按照“后进先出”的顺序,执行当前协程中所有的defer语句。
  3. 打印错误信息:在控制台输出“panic:”字样,后面跟着传给panic的错误信息,并显示当前协程的调用堆栈。
  4. 终止程序:使用退出码2结束程序的运行。

不过,有一种方法可以阻止程序终止:在defer函数中使用recover函数来捕获和处理panic。这样只会执行到第2步,第3和第4步不会发生。

为什么panic被认为是不好的?

panic本身并不是一个坏东西。实际上,它提供了一些有用的功能,比如执行所有的defer函数、打印详细的堆栈信息等。

但在大多数情况下,返回错误是一种更好的选择。原因在于:

当使用panic时,程序会按照固定的流程执行(停止函数、执行defer、打印错误、终止程序),但你无法控制这个过程。

而当函数返回错误时,调用者有完全的自由决定如何处理这个错误。例如,调用者可以:

  • 将错误记录到日志
  • 将错误信息显示给用户
  • 重新尝试调用该函数
  • 忽略这个错误
  • 将错误传递给更上层的调用者

这种灵活性让程序能够更优雅地处理各种错误情况。

返回错误还有其他好处:

  • 更丰富的上下文信息:在将错误向上传递到调用栈时,你可以选择"包装"它们,以在每一步提供额外的上下文信息。这些额外的上下文可以使错误更具信息性和实用性,并可能比仅依赖于panic的堆栈跟踪更容易调试。
  • 更易于测试:对于返回错误的函数,编写单元测试更容易。虽然在测试期间验证函数是否按预期触发panic并非不可能,但这比仅检查错误返回值更尴尬且不那么清晰。
  • 更好的库设计:如果你要创建供他人导入和使用的包,返回错误而不是触发panic是更优雅的做法。记住:panic会终止正在运行的应用程序(当前goroutine),这可能不是使用你的包的人所期望的。最好返回一个错误,并让调用者决定下一步做什么。如果他们愿意,他们可以根据返回的错误主动调用panic
  • 符合Go语言惯例:最后,这就是Go的方式。错误通常是被返回的 - Go标准库是这么做的,也是其他Go开发者期望的标准做法。通过坚持这一惯例,你的代码更加可预测,也更容易被其他人理解。

什么时候panic是合适的?

要回答这个问题,我们需要区分两种不同类型的错误:“预期错误”和“代码缺陷”。

预期错误

预期错误是指那些在正常运行中可能会发生的错误。比如:

  • 数据库连接失败
  • 网络资源不可用
  • 文件权限不足
  • 操作超时
  • 用户输入不符合要求

这些错误并不意味着你的程序有问题,而是由外部因素引起的。因为这些错误是可以预见的,所以应该通过返回错误的方式来处理它们,而不是使用panic

代码缺陷

代码缺陷是指那些“本不应该发生”的错误。这些错误通常由以下原因导致:

  • 开发者编写的代码有错误
  • 程序逻辑存在缺陷
  • 以不正确的方式使用某个函数或特性

这类错误应该在开发或测试阶段就被发现和解决,而不应该出现在生产环境中。

当遇到代码缺陷时,意味着程序已经处于一种意外的、不可预测的状态。在这种情况下,使用panic可能是一种合适的选择。

尽管我们前面提到了返回错误的各种好处,但在某些特定情况下,使用panic可能是更好的选择。

使用panic可能合适的两种主要情况:

  1. 错误无法恢复时:当遇到的错误使程序无法安全地继续运行,且没有合理的方法来处理这种情况。

  2. 错误处理会导致代码过于复杂时:如果为了处理某些很少发生的错误情况,需要在整个代码库中添加大量的错误处理代码,这可能会使代码变得非常复杂且难以维护。尤其是当这些错误在正常情况下不应该发生时。

Go标准库中有许多使用panic的例子,这些例子很好地展示了何时使用panic是合适的:

Go标准库中的panic例子

  • 当数字除以0
  • 当访问数组或切片的越界元素时
  • 当尝试使用nil指针时
  • 当尝试向nil的map中写入数据时
  • 当解锁一个未锁定的互斥锁时
  • 当向已关闭的通道发送数据时
  • 当在同一个flag.FlagSet中定义两个相同名称的标志时
  • 当给http.ResponseWriter.WriteHeader()传递一个无效的HTTP状态码(小于100或大于999)时
  • sync.WaitGroup的计数器变为负数时

这些例子的共同点

仔细观察这些例子,我们可以发现它们有两个重要的共同点:

  1. 它们都是代码缺陷:这些情况都表明代码中存在逻辑错误或使用不当。在正常的程序中,这些情况应该在开发或测试阶段就被发现和解决。

  2. 如果使用错误返回会导致代码过于复杂:想象一下,如果每次除法运算、每次访问数组元素、每次使用map时都要检查错误,代码将变得多么复杂和臃肿。

所以,在这些情况下使用panic是合理的选择,因为它们要么是无法恢复的错误,要么是使用错误返回会导致代码过于复杂的情况。

当然,判断什么程度的错误处理复杂性是“难以接受的”,这个标准因人而异,也依赖于具体项目的特点。这并没有一个绝对的标准答案。

除了上面提到的情况外,还有两种常见的场景也适合使用panic

  1. 作为最后的安全防线:有时候我们会在代码中添加一些安全检查,来防止那些“绝对不应该发生”的情况。如果这些检查失败并触发panic,这通常意味着程序中存在严重的bug或逻辑错误。

  2. 当程序无法继续时:在某些情况下,错误可能会使程序处于一种无法继续运行的状态,而且没有其他合适的错误处理方式。在这种情况下,使用panic可能是唯一的选择。

实际案例分析

到目前为止,我们已经讨论了使用panic的理论原则。现在,让我们通过一些实际的代码案例来看看这些原则如何应用。

需要强调的是,panic应该被谨慎使用,只有在真正适合的情况下才使用它。在我的实际工作经验中,大约有一半的项目完全不使用panic,即使使用,也只在少数几个关键位置。

下面是我在实际项目中遇到的几个使用panic的案例,这些案例可以帮助我们更好地理解何时使用panic是合适的。

例子一:Web应用中的上下文检查

这个例子来自一个Web应用,其中我们需要从请求的上下文中获取用户信息:

type contextKey string

const userContextKey = contextKey("user")

func contextGetUser(r *http.Request) user.User {
user, ok := r.Context().Value(userContextKey).(user.User)
if !ok {
panic("missing user value in request context")
}

return user
}

这个函数的设计基于一个重要的前提:只有当我们确定上下文中存在用户信息时,才会调用这个函数。所以,如果用户信息不存在,这意味着我们的代码中存在严重的逻辑错误。

当然,我们也可以让contextGetUser函数返回一个错误,而不是使用panic。这样调用者可以处理这个错误,比如记录日志并返回一个500错误给用户。

但是,考虑到这个函数在程序中被频繁调用,如果每次调用都要处理这个在正常情况下不应该发生的错误,会导致代码中充斥大量的错误处理逻辑。因此,在这种情况下使用panic是合适的选择。

例子二:环境变量配置加载

这个例子展示了在程序启动时处理配置的情况:

func getEnvInt(key string, defaultValue int) int {
value, exists := os.LookupEnv(key)
if !exists {
return defaultValue
}

intValue, err := strconv.Atoi(value)
if err != nil {
panic(err)
}

return intValue
}

这个getEnvInt函数的作用是从环境变量中读取值并将其转换为整数。如果环境变量不存在,它会返回默认值;但如果环境变量存在但不能转换为整数,它会触发panic

一开始看,这似乎不符合使用panic的原则。毕竟,环境变量的值无法转换为整数是一种完全可能发生的情况,应该属于“预期错误”。

但这个函数的使用场景很特殊:它只在程序启动时用来加载基本配置,如:

httpPort := getEnvInt("HTTP_PORT", 3939)

在这个阶段,程序的其他部分(包括日志系统)还没有初始化完成。如果配置加载失败,程序就无法正常运行,而且没有合适的方式来处理这个错误(比如记录日志)。

在这种情况下,使用panic是合理的,因为:

  1. 程序无法在没有正确配置的情况下继续运行
  2. 在这个早期阶段,没有其他更好的错误处理机制

当然,我们也可以让getEnvInt函数返回错误,然后由调用者决定是否触发panic。但这会增加额外的错误处理代码,而最终结果可能还是一样的。因此,直接在getEnvInt函数内触发panic是一种更简洁的方式。

例子三:SQL注入防护

这个例子展示了如何使用panic作为安全防护机制:

var safeChars = regexp.MustCompile("^[a-z0-9_]+$")

type SortValues struct {
Column string
Ascending bool
}

func (sv *SortValues) OrderBySQL() string {
if !safeChars.MatchString(sv.Column) {
panic("unsafe sort column: " + sv.Column)
}

if sv.Ascending {
return fmt.Sprintf("ORDER BY %s ASC", sv.Column)
}

return fmt.Sprintf("ORDER BY %s DESC", sv.Column)
}

这段代码的背景是:我们需要根据用户的输入来生成SQL查询语句,其中包含动态的ORDER BY部分。

这里有一个安全问题:SQL不支持在ORDER BY子句中使用参数占位符(如?:param),所以我们必须直接将列名插入到SQL字符串中。这就带来了SQL注入的风险。

正常情况下,在调用OrderBySQL方法之前,应该已经有其他代码验证了Column字段的值是否在允许的列名白名单中。但如果由于程序中的bug或开发者的疑忌,这个验证步骤被遗漏了,就可能导致SQL注入攻击。

因此,我们在OrderBySQL方法中添加了一个额外的安全检查,确保Column字段只包含安全的字符(小写字母、数字和下划线)。如果检测到不安全的字符,就触发panic

这种情况下使用panic而不是返回错误的原因是:

  1. 这个检查是一个“最后的防线”,在正常情况下不应该失败
  2. 如果这个检查失败,意味着程序中存在严重的安全漏洞
  3. 在这种情况下,立即停止程序比继续运行并可能导致数据库被破坏要安全得多

总结

通过以上的讨论和实例,我们可以得出以下结论:

Go语言编程中,大多数情况下应该选择返回错误,而不是使用panic。这符合Go的设计哲学和最佳实践。

但在以下几种特定情况下,使用panic可能是合适的选择:

  1. 错误无法恢复时:当程序遇到的错误使其无法安全地继续运行时。

  2. 错误处理会导致代码过于复杂时:当为了处理非常罕见的错误情况而需要添加大量额外的错误处理代码时。

  3. 作为安全防护机制:在代码中添加安全检查,防止那些绝对不应该发生的情况。

  4. 当没有更好的错误处理方式时:如程序启动时的配置加载错误。

最重要的是,要记住panic会导致程序终止运行(除非被recover捕获),因此应该谨慎使用,只在真正适合的情况下才使用它。

Ask me