错误处理
问题
Go 为什么用返回值而不是 try-catch 处理错误?errors.Is 和 errors.As 怎么用?
答案
Go 错误处理哲学
Go 选择用返回值而非异常来处理错误,核心理由是:
- 显式 > 隐式:错误是代码逻辑的一部分,不应被隐藏在异常机制中
- 可预测性:调用方必须处理错误,不会有意外的异常跳转
- 性能:异常处理(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.Is和errors.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)存在问题:
- 异常隐藏了控制流:代码中任何一行都可能抛异常,难以追踪
- 程序员倾向于忽略异常:很多语言中 catch 空处理很常见
- 性能开销:异常的栈展开(stack unwinding)有运行时成本
- 误用:异常经常被用于正常的流程控制(如 Java 的
NumberFormatException)
Go 通过返回值强制调用者显式处理每个错误,确保错误不会被意外忽略(虽然可以用 _ 跳过)。
Q2: errors.Is 和 errors.As 有什么区别?什么时候用哪个?
答案:
| 维度 | errors.Is(err, target) | errors.As(err, &target) |
|---|---|---|
| 作用 | 检查错误链中是否有特定值 | 从错误链中提取特定类型 |
| 匹配方式 | == 值比较 | 类型匹配 |
| 适用场景 | 哨兵错误(ErrNotFound) | 自定义错误类型(*PathError) |
| 返回值 | bool | bool(并填充 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 应该极少使用,仅在以下场景:
- 程序初始化失败(无法继续运行):如配置文件不存在、必需的环境变量缺失
- 不可能发生的编程错误(Bug):如 switch 的 default 分支"不可能到达"
- 标准库约定:如
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
}