跳到主要内容

错误处理

问题

Go 为什么用返回值而不是 try-catch 处理错误?errors.Iserrors.As 怎么用?

答案

Go 错误处理哲学

Go 选择用返回值而非异常来处理错误,核心理由是:

  1. 显式 > 隐式:错误是代码逻辑的一部分,不应被隐藏在异常机制中
  2. 可预测性:调用方必须处理错误,不会有意外的异常跳转
  3. 性能:异常处理(try-catch)有栈展开的开销,返回值检查几乎零成本
// Go 惯用的错误处理模式
result, err := doSomething()
if err != nil {
return fmt.Errorf("doSomething failed: %w", err)
}
// 使用 result

error 接口

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

type error interface {
Error() string
}

创建错误的方式:

// 1. errors.New(简单错误)
err := errors.New("something went wrong")

// 2. fmt.Errorf(格式化错误)
err := fmt.Errorf("user %d not found", userID)

// 3. 自定义错误类型
type NotFoundError struct {
Resource string
ID int
}

func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s with ID %d not found", e.Resource, e.ID)
}

err := &NotFoundError{Resource: "user", ID: 42}

哨兵错误(Sentinel Errors)

预定义的错误值,用于标识特定错误类型:

// 标准库中的哨兵错误
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrInternal = errors.New("internal error")
)

func FindUser(id int) (*User, error) {
if id <= 0 {
return nil, ErrNotFound
}
// ...
}

// 调用方通过 errors.Is 检查
_, err := FindUser(-1)
if errors.Is(err, ErrNotFound) {
// 处理未找到
}

错误包装(Go 1.13+)

fmt.Errorf + %w 包装错误,形成错误链:

func readConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
// %w 包装错误:保留原始错误,添加上下文
return fmt.Errorf("read config %s: %w", path, err)
}

if err := json.Unmarshal(data, &config); err != nil {
return fmt.Errorf("parse config %s: %w", path, err)
}
return nil
}

// 错误链:parse config /etc/app.yml: unexpected end of JSON input
// └── read config /etc/app.yml: open /etc/app.yml: no such file or directory
// └── open /etc/app.yml: no such file or directory
// └── no such file or directory

errors.Is 和 errors.As

Go 1.13 引入的错误链检查函数:

// errors.Is:检查错误链中是否包含特定错误值
if errors.Is(err, os.ErrNotExist) {
// err 或其包装链中包含 os.ErrNotExist
}

// errors.As:从错误链中提取特定类型的错误
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("Failed path:", pathErr.Path)
fmt.Println("Operation:", pathErr.Op)
}
%w vs %v
  • %w:包装错误,保留错误链,errors.Iserrors.As 可以穿透
  • %v:只格式化错误消息,不保留错误链
err := fmt.Errorf("wrapped: %w", os.ErrNotExist)
errors.Is(err, os.ErrNotExist) // true ✅

err2 := fmt.Errorf("not wrapped: %v", os.ErrNotExist)
errors.Is(err2, os.ErrNotExist) // false ❌

自定义错误类型

// 带额外信息的错误类型
type APIError struct {
Code int
Message string
Cause error
}

func (e *APIError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Cause)
}
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}

// 实现 Unwrap() 支持 errors.Is/As 穿透
func (e *APIError) Unwrap() error {
return e.Cause
}

// 使用
err := &APIError{
Code: 404,
Message: "user not found",
Cause: ErrNotFound,
}
errors.Is(err, ErrNotFound) // true(通过 Unwrap 链查找)

多错误处理(Go 1.20+)

Go 1.20 引入了 errors.Join 合并多个错误:

var errs []error
if err := validate1(); err != nil {
errs = append(errs, err)
}
if err := validate2(); err != nil {
errs = append(errs, err)
}

// 合并多个错误
if len(errs) > 0 {
return errors.Join(errs...)
}

// errors.Is 可以检查合并错误中的每一个
err := errors.Join(ErrNotFound, ErrUnauthorized)
errors.Is(err, ErrNotFound) // true
errors.Is(err, ErrUnauthorized) // true

错误处理最佳实践

// ✅ 添加上下文信息
return fmt.Errorf("create user %s: %w", name, err)

// ✅ 只处理一次错误(不要 log 了又 return)
if err != nil {
return fmt.Errorf("xxx: %w", err) // 只 return
}
// ❌ 不要这样
if err != nil {
log.Error(err) // log 了
return err // 又 return,上层可能再 log 一次
}

// ✅ 在调用链最外层记录日志
func handleRequest(w http.ResponseWriter, r *http.Request) {
if err := processOrder(r); err != nil {
log.Error("handle request failed", "err", err) // 只在最外层 log
http.Error(w, "internal error", 500)
}
}

// ✅ 使用哨兵错误或自定义类型,不要匹配错误字符串
if errors.Is(err, ErrNotFound) { } // ✅
if err.Error() == "not found" { } // ❌ 脆弱

常见面试问题

Q1: Go 为什么没有 try-catch?

答案

Go 的设计者认为异常机制(try-catch)存在问题:

  1. 异常隐藏了控制流:代码中任何一行都可能抛异常,难以追踪
  2. 程序员倾向于忽略异常:很多语言中 catch 空处理很常见
  3. 性能开销:异常的栈展开(stack unwinding)有运行时成本
  4. 误用:异常经常被用于正常的流程控制(如 Java 的 NumberFormatException

Go 通过返回值强制调用者显式处理每个错误,确保错误不会被意外忽略(虽然可以用 _ 跳过)。

Q2: errors.Iserrors.As 有什么区别?什么时候用哪个?

答案

维度errors.Is(err, target)errors.As(err, &target)
作用检查错误链中是否有特定值从错误链中提取特定类型
匹配方式== 值比较类型匹配
适用场景哨兵错误(ErrNotFound自定义错误类型(*PathError
返回值boolbool(并填充 target)
// 用 errors.Is:检查特定错误值
if errors.Is(err, sql.ErrNoRows) { }

// 用 errors.As:需要从错误中提取数据
var apiErr *APIError
if errors.As(err, &apiErr) {
fmt.Println(apiErr.Code) // 获取错误码
}

Q3: 什么时候应该 panic?

答案

Go 中 panic 应该极少使用,仅在以下场景:

  1. 程序初始化失败(无法继续运行):如配置文件不存在、必需的环境变量缺失
  2. 不可能发生的编程错误(Bug):如 switch 的 default 分支"不可能到达"
  3. 标准库约定:如 regexp.MustCompile(编译期确定的正则)
// ✅ 合理的 panic
func MustCompileRegex(pattern string) *regexp.Regexp {
r, err := regexp.Compile(pattern)
if err != nil {
panic("invalid regex: " + pattern) // 编程错误,不是运行时错误
}
return r
}

// ❌ 不应该 panic
func FindUser(id int) *User {
user, err := db.Query(id)
if err != nil {
panic(err) // 网络/数据库错误应该用 error 返回
}
return user
}

Q4: 如何优雅地处理重复的 if err != nil

答案

这是 Go 社区的经典话题。几种常见模式:

// 1. 提前返回(最推荐)
func process() error {
a, err := step1()
if err != nil {
return fmt.Errorf("step1: %w", err)
}
b, err := step2(a)
if err != nil {
return fmt.Errorf("step2: %w", err)
}
return step3(b)
}

// 2. 错误变量累积(适合同质操作)
func writeAll(w io.Writer) error {
var err error
write := func(data []byte) {
if err != nil {
return
}
_, err = w.Write(data)
}
write(header)
write(body)
write(footer)
return err
}

// 3. Must 模式(仅用于初始化)
var tmpl = template.Must(template.ParseFiles("index.html"))

Q5: 怎么判断 error 是不是 nil 的接口陷阱?

答案

参见接口的 nil 接口陷阱。关键规则:在返回 error 时,如果没有错误,直接 return nil,不要返回具体类型的 nil 指针

// ❌ 陷阱
func check() error {
var err *MyError = nil
return err // 接口 {type: *MyError, value: nil} ≠ nil
}

// ✅ 正确
func check() error {
var err *MyError = nil
if err != nil {
return err
}
return nil // 显式返回 nil
}

相关链接