第二十四回:Go 错误处理

含笑半步癫的解药(Error Handling)
唐伯虎
唐伯虎: 哎呀!刚刚在配制“含笑半步癫”的解药,结果手一抖,蜂蜜加成了砒霜!程序直接崩了,就像吃了毒药一样,两腿一蹬就挂了!秋香姐,救命啊!
秋香
秋香: 公子莫慌!行走江湖(写代码),难免会遇到意外。关键是要学会错误处理(Error Handling)。Go 语言把错误当成一种普通的“值”来处理,而不是随时会爆炸的“异常”。我们要把小病治好,别让它变成绝症(Panic)。

💡 核心概念: Go 语言中,错误(Error)是一个内置的接口类型。通常函数会返回最后一个值作为 error。我们通过显式检查 if err != nil 来处理错误。Go 1.13 引入了强大的错误包装和检查机制(Is/As)。

💊 第一招:把脉问诊 (基本错误返回)

Go 的函数通常会返回两个值:一个是结果,一个是错误。如果一切正常,错误就是 nil(无)。

package main
import (
    "errors"
    "fmt"
)

// 试图打开药柜
// 返回值:(结果 string, 错误 error)
func OpenCabinet(key string) (string, error) {
    if key != "9527" {
        // 创建一个新错误
        // 就像医生写诊断书一样
        return "", errors.New("钥匙不对,打不开药柜!")
    }
    return "药柜开了,取出解药原料", nil
}

func main() {
    result, err := OpenCabinet("1234")
    
    // 必须检查错误!这是 Go 程序员的基本素养
    if err != nil {
        fmt.Println("出事了:", err) // 处理错误
        return
    }
    
    fmt.Println(result) // 只有没错误时才使用结果
}
华夫人
华夫人: 哼!很多新手只顾着写功能,完全忽略 err。记住,Never ignore errors! 除非你确信那个错误无关紧要(比如关闭文件时的错误有时候可以忽略,但最好还是记录一下)。

🎁 第二招:层层包装 (Error Wrapping & %w)

有时候,我们收到错误后,想加点料再往上报。比如“配药失败”,是因为“柜子打不开”,而“柜子打不开”是因为“钥匙断了”。我们需要保留原始错误的信息。

Go 1.13 引入了 %w 动词来包装错误,就像俄罗斯套娃。

func MakeAntidote() error {
    _, err := OpenCabinet("1234")
    if err != nil {
        // %w = Wrap (包装)
        // 就像把原始错误包在一个新盒子里,并贴上新的标签
        return fmt.Errorf("配药失败: %w", err)
    }
    return nil
}

func main() {
    err := MakeAntidote()
    if err != nil {
        fmt.Println(err) 
        // 输出:配药失败: 钥匙不对,打不开药柜!
        
        // 🕵️‍♂️ 透视眼 (errors.Unwrap)
        // 看看里面到底包着什么原始错误
        originErr := errors.Unwrap(err)
        fmt.Println("根源是:", originErr)
    }
}

🔍 第三招:火眼金睛 (errors.Is & errors.As)

当错误被层层包装后,直接用 == 比较可能就不行了。这时候我们需要用 errors.Iserrors.As

// 定义一个“哨兵错误” (Sentinel Error)
var ErrKeyBroken = errors.New("钥匙断了")

func OpenDoor() error {
    // 模拟发生错误,并包装它
    return fmt.Errorf("门打不开: %w", ErrKeyBroken)
}

func main() {
    err := OpenDoor()

    // ❌ 错误做法:直接比较
    // if err == ErrKeyBroken { ... } // 这会返回 false,因为 err 被包装过了

    // ✅ 正确做法:使用 Is
    if errors.Is(err, ErrKeyBroken) {
        fmt.Println("确实是钥匙断了!快找锁匠!")
    }
}

🧪 第四招:炼制毒药 (自定义 Error 结构体)

普通的 error 只是个字符串。如果我们想携带更多信息(比如毒性等级、错误代码),可以自定义结构体来实现 error 接口。

// 定义一种特殊的毒药错误
type PoisonError struct {
    Name  string // 毒药名字
    Level int    // 毒性等级
}

// 实现 error 接口的 Error() 方法
func (e *PoisonError) Error() string {
    return fmt.Sprintf("中毒了!毒药:%s (等级 %d)", e.Name, e.Level)
}

func CheckPulse() error {
    return &PoisonError{Name: "含笑半步癫", Level: 99}
}

func main() {
    err := CheckPulse()
    
    // 🕵️‍♂️ 验尸 (errors.As)
    // 尝试把 err 转成 PoisonError 类型,如果成功,poison 变量会被赋值
    var poison *PoisonError
    if errors.As(err, &poison) {
        fmt.Printf("居然是 %s!等级高达 %d!快找解药!\n", poison.Name, poison.Level)
    }
}
秋香
秋香: 这里的 errors.As 就像是“照妖镜”,不管错误伪装成什么样,只要它本质是那个类型,就能把它现出原形。

💥 第五招:走火入魔 (Panic)

有些错误是致命的,比如数组越界、空指针引用,或者你自己手动调用 panic。一旦 Panic 发生,程序就像疯了一样,停止执行当前函数,并逐层向上崩溃,直到整个程序退出。

func main() {
    fmt.Println("开始练功...")
    
    // 手动触发崩溃
    // 注意:除非无法挽回,否则不要轻易使用 panic
    panic("不好!走火入魔了!")
    
    fmt.Println("这就永远不会执行了...")
}

🛡️ 第六招:护体神功 (Defer & Recover)

怎么在程序崩溃前把它救回来?我们需要配合使用 defer(遗言/延迟执行)和 recover(复活/恢复)。这就像是给自己加了一个“复活甲”。

func ProtectRun() {
    // 1. 提前定义好护体神功 (defer)
    // 必须在 panic 发生之前定义
    defer func() {
        // 2. 尝试恢复 (recover)
        if r := recover(); r != nil {
            fmt.Println("🛑 捕获到崩溃,正在运功疗伤...")
            fmt.Println("错误原因:", r)
        }
    }()
    
    fmt.Println("正在正常运行...")
    panic("啊!含笑半步癫发作了!") // 3. 发生崩溃
    fmt.Println("这句依然不会执行")
}

func main() {
    ProtectRun()
    fmt.Println("呼~ 虽然受了内伤,但程序没有挂,还能继续运行!")
}

⚠️ 注意事项:
1. recover 只有在 defer 函数中调用才有效。
2. 只有在当前的 goroutine 中才能捕获 panic。
3. 不要滥用 panic/recover 来做普通的控制流(比如不要用它来当 try-catch 用),它只应该用于真正的异常情况。

🎯 练功房(解毒)

下面的代码会因为除以零而崩溃。请使用 deferrecover 来捕获这个错误,防止程序退出。

package main
import "fmt"

func SafeDivide(a, b int) {
    // 任务:在这里写 defer func() { ... }
    defer func() {
        if r := ______(); r != nil {
            fmt.Println("捕获错误:", r)
        }
    }()

    fmt.Println("结果是:", a / b)
}

func main() {
    SafeDivide(10, 0)
    fmt.Println("程序安全结束")
}

任务: 填空处的函数名是什么?(用于从恐慌中恢复)

答案: recover

解析: recover() 是一个内置函数,用于重新获得 panic 协程的控制权。它必须在 defer 函数中直接调用。