Go的秘密生活:错误处理

发布: (2025年12月29日 GMT+8 10:11)
11 min read
原文: Dev.to

Source: Dev.to

星期一的早晨,浓重的灰雾笼罩着整座城市。档案室里寂静无声,唯一的声音是 Eleanor 用骨质文件夹抚平古老地图皱褶时的有节奏的 scrape‑scrape‑scrape

Ethan 走了进来,雨伞滴着水。他把一个小面包店的盒子放在桌子上。

“梨和杏仁塔。还有一杯加厚奶沫的 Flat White。”

Eleanor 停下手中的工作,检查起塔来。

“经典组合。梨的甜味平衡了杏仁的苦味。选得好。”

Ethan 坐下,叹了口气,声音有点太大。

“那声音,” Eleanor 抬头也不看地说,“是程序员与编译器搏斗时的叹息。”

“不是编译器,” Ethan 纠正道,“是模板代码。我在写这个文件解析器,代码里有一半都是在检查错误。感觉……太原始了。我想念异常。我想念可以把所有代码包在一个 try‑catch 块里,最后统一处理问题的方式。”

他把屏幕转向她。

func ParseFile(filename string) {
    f, err := os.Open(filename)
    if err != nil {
        log.Fatal(err)
    }
    // ... logic continues
}

“我在想,” Ethan 说,“也许我该在出错时直接 panic,然后在顶层 recover?就像全局异常处理器一样?”

Eleanor 放下骨质文件夹,表情变得严肃。

“Ethan,想象一下你在读一本书。你正翻到第 50 页。突然之间,没有任何预警,你被传送到第 200 页。你根本不知道是怎么到那儿的,也不知道中间发生了什么。这就是异常。”

她抿了一口 Flat White。

“异常是不可见的控制流。它们允许函数跳过栈,绕过 return 语句和清理逻辑。在 Go 中,我们更看重可见性而不是便利性。我们把错误视为 values。”

错误只是值

她打开了一个新文件。“Go 语言中的错误并不是一种神奇的事件。它是一个值,就像整数或字符串一样。它是一个简单的接口:”

type error interface {
    Error() string
}

“它只是任何能够把自己描述为字符串的东西。因为它是一个值,你可以传递它、存储它、包装它,或者忽略它——虽然你绝对不应该这么做。”

她敲入了一个新示例。“你说你想 panic。让我给你展示一下把错误当作值来处理为什么更好。”

package main

import (
    "errors"
    "fmt"
    "os"
)

// 定义一个“哨兵错误”——一个我们可以检查的常量值
var ErrEmptyConfig = errors.New("config file is empty")

func loadConfig(path string) (string, error) {
    if path == "" {
        // 我们就在这里创建错误值
        return "", errors.New("path cannot be empty")
    }

    file, err := os.Open(path)
    if err != nil {
        // 我们把错误值向上返回
        return "", err
    }
    defer file.Close()

    stat, err := file.Stat()
    if err != nil {
        return "", err
    }

    if stat.Size() == 0 {
        // 返回我们特定的哨兵错误
        return "", ErrEmptyConfig
    }

    return "config_data", nil
}

func main() {
    _, err := loadConfig("missing.txt")
    if err != nil {
        fmt.Println("Error occurred:", err)
    }
}

“看看这个流程,”Eleanor 指着说。“没有隐藏的跳转。错误通过返回值向上层传播。你可以准确看到它在哪里退出。这是 显式的控制流。”

“但是代码太冗长了,”Ethan 抱怨道。 “if err != nil 到处都是。”

“这确实重复,”Eleanor 承认,“但考虑另一种情况。如果 file.Stat() 抛出异常,你会记得关闭文件吗?在 Go 中,defer file.Close() 无论后面出现什么错误都会执行。显式的检查迫使你决定:‘如果现在失败,我该怎么做?’”

Decorating the Error

“不过,”Eleanor 补充道,“仅仅返回 err 往往太懒了。如果 loadConfig 返回‘文件未找到’,调用方根本不知道是 哪个 文件。你需要添加上下文信息。”

她修改了代码:

func loadConfig(path string) (string, error) {
    file, err := os.Open(path)
    if err != nil {
        // Wrap the error with context using %w
        // We only wrap when we are adding useful information.
        return "", fmt.Errorf("failed to open config at %s: %w", path, err)
    }
    // ...
}

%w 动词表示‘包装’。它把原始错误放入一个新的错误中,形成一个错误链。”

Ethan 皱眉道:“但是如果我把它包装了,怎么检查原始错误是什么?比如我想判断是否是 ErrEmptyConfig?”

“一个绝妙的问题,”Eleanor 回答道。“在 Go 1.13 之前这很困难。现在我们有 errors.Iserrors.As。”

解开谜团

她键入了一个健壮的错误处理示例:

package main

import (
    "errors"
    "fmt"
    "io/fs"
)

func main() {
    _, err := loadConfig("config.json")
    if err != nil {
        // 1. 检查它是否匹配特定的值(哨兵)
        if errors.Is(err, ErrEmptyConfig) {
            fmt.Println("请提供一个非空的配置文件。")
            return
        }

        // 2. 检查它是否匹配特定的类型(如 PathError)
        var pathErr *fs.PathError
        if errors.As(err, &pathErr) {
            fmt.Println("文件系统路径错误:", pathErr.Path)
            return
        }

        // 3. 回退
        fmt.Println("未知错误:", err)
    }
}

“把 errors.Is 想象成相等性检查。它会遍历包装层,看看 ErrEmptyConfig 是否埋在某处。”
“把 errors.As 想象成类型断言。它会检查链中是否有 *fs.PathError 类型的错误。”

“所以我可以把错误包装十次,在每一层添加上下文,而 errors.Is 仍然能够找到原始原因?”
“没错。你在保留根本原因的同时,添加了它是如何失败的叙述。”

Source:

不要惊慌

我什么时候可以使用 panic?” 伊桑问。
“几乎从不,” 埃莉诺严肃地回答。 “panic 只用于程序根本崩溃、无法继续的情况——比如内存耗尽,或是开发者的错误导致内部状态无效。它 不是 用来处理‘文件未找到’或‘网络超时’。”

她拿起塔塔。“如果你在我导入的库里 panic,我的整个应用就会崩溃。那很不礼貌。返回错误,让我决定是否要崩溃。”

伊桑看着雨水在窗户上划过的痕迹。“它迫使你先处理不愉快的路径。”

“是的。它让你的代码左对齐,” 埃莉诺说,手在空中描绘代码的形状。 “快乐路径——成功的逻辑——保持最小的左侧缩进。错误处理向右嵌套并提前返回。这样逻辑更易于扫描。”

埃莉诺咬了一口塔塔。“错误 不是 异常,伊桑。异常表示‘出现了意外情况’,而错误表示‘这是该函数的可能结果’。在软件中,失败始终是可能的结果。”

她擦去嘴边的面包屑。“尊重你的错误。给它们提供上下文。专门检查它们。永远,永远不要假设它们不会发生。”

伊桑合上笔记本电脑的盖子。“不再使用 try‑catch。”

“不再使用 try‑catch,” 埃莉诺赞同道。 “只用值。从手到手传递,直到它们被解决。”

第12章关键概念

错误是值

在 Go 中,错误不是特殊的控制流机制。它们是实现了 error 接口的值。

error 接口

type error interface {
    Error() string
}

哨兵错误

为特定错误预先定义的全局变量(例如 io.EOFerrors.New("some error"))。用于简单的相等性检查。

包装错误

使用 fmt.Errorf("... %w ...", err) 来包装错误,在添加上下文的同时保留底层的原始错误。

errors.Is

检查包装链中是否存在特定的错误值。处理包装错误时请 使用它而不是 ==

if errors.Is(err, io.EOF) {
    // handle EOF
}

errors.As

检查链中是否存在特定 类型 的错误,并将其赋给变量。

var pathErr *fs.PathError // must be a pointer to the error type
if errors.As(err, &pathErr) {
    fmt.Println(pathErr.Path)
}

Panic 与 Error 对比

错误Panic
何时预期的失败情况(I/O、验证、网络)意外的、不可恢复的状态(空指针、索引越界)
处理方式作为值返回,由调用者检查程序崩溃(除非被恢复,但这很少见)
指导原则从库返回错误不要 在库中 panic;让调用者自行决定

对齐正常路径

将代码结构化为尽早处理错误并返回。保持成功逻辑的缩进最少。


下一章:Testing——在本章中,Eleanor 向 Ethan 展示编写测试不是负担,而是证明系统真正可行的唯一方法。


Aaron Rose 是一名软件工程师兼技术作家,供职于 tech-reader.blog ,也是 Think Like a Genius 的作者。

Back to Blog

相关文章

阅读更多 »

C++ 说 “我们在家尝试”。

许多具有异常机制的语言¹也都有 finally 子句,所以你可以这样写: cpp try { // ⟦ stuff ⟧ } finally { always; } 快速检查表明,这种 co…

C++ says “我们在家尝试”

在其他语言中的 finally 许多拥有异常机制的语言¹也都有 finally 子句,因此你可以这样写: cpp try { // stuff } finally { always; } 一个快速的 c…

Go的秘密生活:包和结构

第十一章:建筑师的蓝图 星期五下午的阳光斜斜地透过档案室的窗户,照亮了空气中舞动的尘埃。伊桑发现…