Go的秘密生活:错误处理
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.Is和errors.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.EOF、errors.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 的作者。