Day 21 _ error 是个普通值_errors.New 与 fmt.Errorf 造出来,沿调用栈抛到 main 接住

Day 21 | error 是个普通值:errors.New 与 fmt.Errorf 造出来,沿调用栈抛到 main 接住

Day 21 | error 是个普通值:errors.New 与 fmt.Errorf 造出来,沿调用栈抛到 main 接住

本文是《100天精通Go基础入门》专栏第 21 篇,每天 20:00 更新,订阅读到这篇就算入伙了。
摘要 :本文基于 Go 1.27,终端与报错截图全部由本机 go run 真实抓取。把错误处理的主调立起来:error 只是一个只有一个方法的接口,errors.Newfmt.Errorf 造出来的都是普通值,能赋值、能传递、能沿调用栈一层层往上抛,最后由 main 这样"有信息做决定"的一层统一处理。今天先用一段真 panic 栈演示新手最狠的坑------拿 panicreturn,再把同一需求改成错误即值;顺带把哨兵错误(ErrNotFound 这种包级变量)和 == 比较的适用边界讲清。读完你手上会有一条完整上抛的三层调用链,和一个"出错不陪葬"的写法习惯。

本文目录

  • 先立规矩:error 只是"能把自己打印出来"的普通值

  • 翻车现场:拿 panic 当 return,啪,整个程序陪着死

  • 错误即值:沿调用栈一层层往上抛,main 负责收尾

  • 哨兵错误:给错误起个名字,让调用方用 == 认出来

  • 为什么 Go 选了"错误即值",没选异常那套

  • 回头看,三句话定调"错误处理"这件事

  • 动手改一改:三处 panic 换成 error 返回

  • 明天见:明天用 %w 把今天断掉的链接上

昨天 Day 20 我们把"非 nil 的 nil"那颗雷排了:checkOrder 返回具体指针 *MyErr、成功时把它的 nil 装箱进 error,类型格有货所以 err != nil。那天结尾说过,排雷是为了给今天的正主腾地方------error 本身。还有个旧账:Day 19 那个裸断言 panic,我从下午三点追到晚上九点,最后症状就是"出错就 panic,程序死在离现场很远的地方"。这篇解决的就是它:错误怎么当普通值造出来、怎么往上抛、抛给谁。你就得到一条不陪葬的错误链嘛。

前置条件 :Go 1.13 以上(errors.Is/As 明天用,今天先埋个伏笔),示例目录 D:\go\day21,下面每张终端图都是本机 go1.27.0 go run 真跑抓的。

先立规矩:error 只是"能把自己打印出来"的普通值

error 的全部家当,就一个方法:

Go 复制代码
type error interface {
        Error() string
}

对,就这么点要求,比 Day 18 里那个 WeatherSource 还单薄。因为是接口,谁实现了 Error() string 谁就是 error------隐式实现,老规矩了。errors.New 造出来的,是个装着一条消息的私有结构体;fmt.Errorf 只是带格式化的构造器。它们都是普通值:能赋值、能传参、能存进 map。跑一遍看现象:

Plaintext 复制代码
// 《100天精通Go基础入门》Day 21 · 平头哥 · 技术改变世界,勤奋改变人生
package main

import (
        "errors"
        "fmt"
)

func main() {
        // errors.New 造出来的就是一个实现了 Error() string 的普通值
        err1 := errors.New("配置文件不存在")
        fmt.Printf("err1 的类型: %T\n", err1)
        fmt.Println("调方法 err1.Error():", err1.Error())

        // fmt.Errorf 也只是"带格式化"的构造函数,同样返回 error 值
        err2 := fmt.Errorf("打开 app.conf 失败: %s", err1)
        fmt.Println("err2 打印出来:", err2)

        // 它能赋值、能当参数传、能存进结构体 ------ 就是普通值
        e := err2
        fmt.Println("赋给变量再打印,值没变:", e)
}

预期输出四行,实际运行结果一字不差:err1 的类型: *errors.errorString调方法 err1.Error(): 配置文件不存在err2 打印出来: 打开 app.conf 失败: 配置文件不存在赋给变量再打印,值没变: 打开 app.conf 失败: 配置文件不存在。退出码 0。第一行的 %T 最有意思:它打出 *errors.errorString------正是 Day 20 那两格的"类型格"内容;值格里装着你的消息字符串。Println 能直接打 error,是因为 fmt 见到实现了 Error() 的值就替你调用它。所以啊,error 没有任何魔法,它就是你会用了一百天的那个接口,换了个岗位上班而已。

翻车现场:拿 panic 当 return,啪,整个程序陪着死

知道 error 是值,只是半篇;另外半篇是把一个坏习惯戒掉。新手(甚至写了两年别家语言的)第一反应:出错了?panic 一下不就完了?来,看真实现场:

Plaintext 复制代码
// 《100天精通Go基础入门》Day 21 · 平头哥 · 技术改变世界,勤奋改变人生
package main

import "fmt"

// 新手第一反应:出错就 panic,把 panic 当 return 用
func loadUser(id int) string {
        if id <= 0 {
                panic("loadUser: id 必须大于 0") // 错:用 panic 当错误返回
        }
        return fmt.Sprintf("user-%d", id)
}

func main() {
        name := loadUser(0) // 传进去一个 0
        fmt.Println("拿到用户:", name)
        fmt.Println("后面的收尾、日志、关连接,全都执行不到了")
}

这就是真翻车:终端先甩出 panic: loadUser: id 必须大于 0,再挂上两帧栈,main.loadUsermain.go:9main.mainmain.go:15,exit status 2 是子进程自己的码,截图右下角那个退出码 1(非 0 即失败)是 go run 替你带回来的。现象呢:一个业务上完全可预期的输入错误(id=0),"咣"一下把进程干没了------后面两行打印根本没执行到,程序原地停了,连个招呼都没打。注意适用边界:panic 的前置条件是"程序自己的 bug 犯了",不是"业务这条路走不通";风险在于它不给你留任何收尾机会,日志没落盘、连接没关,监控里只有一坨栈。用户传个非法参数你就让服务陪葬,这买卖怎么算都亏呀。

正确的姿势回到"值"的轨道:错误作为第二个返回值往上走。改法见下一节。

错误即值:沿调用栈一层层往上抛,main 负责收尾

下面这条三层链,是最小可用的"错误即值":每层返回 (结果, error),不是 nil 就加一句"我这边是什么场景"再往上抛,只有 main 处理:

Plaintext 复制代码
// 《100天精通Go基础入门》Day 21 · 平头哥 · 技术改变世界,勤奋改变人生
package main

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

// 最底层:发现问题,造一个 error 值返回,绝不动 panic
func readLine(n int) (string, error) {
        if n == 3 {
                return "", errors.New("第 3 行不是数字")
        }
        return "42", nil
}

// 中间层:接住下层的错误,加上自己的上下文再往上抛
func parseConfig() (int, error) {
        line, err := readLine(3)
        if err != nil {
                return 0, errors.New("解析配置失败: " + err.Error())
        }
        return len(line), nil
}

// 上层:同样只抛不处理,错误即值一路向 main 走
func loadApp() error {
        _, err := parseConfig()
        if err != nil {
                return errors.New("启动失败: " + err.Error())
        }
        return nil
}

func main() {
        wd, _ := os.Getwd()
        fmt.Println("工作目录:", wd)
        if err := loadApp(); err != nil {
                // 只有这里真正"处理"错误:打印、决定怎么办
                fmt.Println("[main] 逐层上抛收到的错误:", err)
        }
        fmt.Println("[main] 收尾第 1 步: 关日志")
        fmt.Println("[main] 收尾第 2 步: 正常退出")
}

运行结果四行(完整输出见终端图):第一行 工作目录: D:\go\day21,和正文教你的目录一字不差------这张图顺带能当"示例目录"的验证信号;第二行就是那条三层上下文串起来的错误 启动失败: 解析配置失败: 第 3 行不是数字;后两行收尾照常执行,退出码 0。对比上一节的翻车图:同样的错误事实,那边进程暴毙,这边程序体面地走完全程。为什么处理要放在最顶层?机制在于信息量:readLine 只知道"第 3 行坏",main 才知道"这是用户导入配置文件,坏了该提示用户重传,而不是重启服务"。谁有信息做决定,谁负责收尾------这条原则长期适用,不随 Go 版本变。今天这三段我自己在 D:\go\day21 挨个跑了一遍,退出码全是 0,终端图就是凭据。

对了,你注意到中间层是 errors.New("解析配置失败: " + err.Error()) 手工拼的字符串吧?链,在这一拼里已经断了------明天 Day 22 专治这个。

哨兵错误:给错误起个名字,让调用方用 == 认出来

错误光会打印还不够:id 不合法用户不存在在界面上要走完全不同的分支。办法是给"这一类错误"定义一个包级变量,业内叫哨兵错误(sentinel error):

Plaintext 复制代码
// 《100天精通Go基础入门》Day 21 · 平头哥 · 技术改变世界,勤奋改变人生
package main

import (
        "errors"
        "fmt"
)

// 哨兵错误:包级变量,给"这一类错误"起个全局认得的名字
var ErrNotFound = errors.New("用户不存在")
var ErrBadID = errors.New("id 不合法")

func findUser(id int) (string, error) {
        if id < 0 {
                return "", ErrBadID
        }
        if id == 404 {
                return "", ErrNotFound // 直接把哨兵本身返回出去
        }
        return fmt.Sprintf("user-%03d", id), nil
}

func main() {
        for _, id := range []int{7, 404, -1} {
                name, err := findUser(id)
                switch {
                case err == ErrNotFound: // 没被包装过,== 就能比中
                        fmt.Printf("id=%d 走了「不存在」分支,给用户提示新建\n", id)
                case err == ErrBadID:
                        fmt.Printf("id=%d 走了「参数错」分支,提示改参数\n", id)
                case err != nil:
                        fmt.Printf("id=%d 出现没预料到的错误: %v\n", id, err)
                default:
                        fmt.Printf("id=%d 正常返回: %s\n", id, name)
                }
        }
}

输出结果三行,验证符合预期:id=7 正常返回: user-007id=404 走了「不存在」分支,给用户提示新建id=-1 走了「参数错」分支,提示改参数,退出码 0。命名规矩你照抄标准库就行:大写 Err 开头,导出与否取决于要不要跨包比较。netiosql 包里全是这个味道,比如 io.EOF

== 现在够用,但它的适用前提得钉死

上面 == 能比中,前提只有一个:哨兵从底层到调用方一路"裸传",没被包装过 。回想第三节那条链,错误被拼成 启动失败: ... 之后再拿 == ErrNotFound 去比,永远不等------值已经不是同一个了。这就是明天 errors.Is 存在的理由。边界先记在这里,不算坑,算伏笔吧。

今天这套东西,最后用 go build 验一遍,确认它不是只在 go run 里活着的玩具:

Plaintext 复制代码
// 把最后一个示例(switch 哨兵 3 分支版本)编译成独立 exe
go build -o day21.exe
// go build 成功零输出,追一条列目录命令,把产物体积落进截图当凭据
powershell -NoProfile -Command "Get-ChildItem day21.exe | Format-Table Name,Length"

命令会话图里两条命令的退出码都是 0:go build 安安静静产出 day21.exe,大小约 2 MB 级(精确字节数以截图为准)。这一步顺带把"错误即值"从 demo 升格成了能分发的程序:哨兵、三层链、switch 分支,全在二进制里,不需要 Go 源码也能跑。

为什么 Go 选了"错误即值",没选异常那套

Java 派会问:为什么不用 try/catch?Go 团队的答案很工程化:异常会跳栈 ,从深处直接飞到不知哪一层 catch,阅读者看不出中间状态由谁收尾;而 error 作返回值,每一步的分支都摆在明面上 ,代价是多敲几行 if err != nil。说白了,Go 把"错误的流动"从语言黑魔法降级成了普通数据流,和 Day 19 你用 comma-ok 显式检查断言结果是一个哲学------编译器不替你做主,你就饿不死,但也别指望它救你。

panic 就一无是处?不,它和 error 有分工

panic 管"程序自己的 bug"(数组越界、空指针解引用、Day 19 那个赌错的断言),error 管"运行世界会发生的意外"(文件没了、网络超时、参数不对)。判断口诀:这个失败,是代码写错了,还是世界本来就脏?世界脏,return error;代码错了,才轮到 panic,而且 Day 23 会告诉你它兜底的唯一正当位置。

xerrors 那张旧船票,今天不用了

顺手交代个时间线:Go 1.13 之前,想给错误加"链"要用扩展库 golang.org/x/xerrors;1.13 把 Is/As/Unwrap 合进了标准库 errors,xerrors 就此成了过时的替代方案,如今新代码不再推荐引它,直接 import "errors" 即可。这套接口的机制从 1.13 稳定到今天,长期有效哈。

先拿张表把今天的三种"造错误"手段对齐:

手段 你得到什么 程序会停吗 用在哪
errors.New("...") 一个 error 值(哨兵或一次性) 不停,继续走 造错误、定义哨兵
fmt.Errorf("...: %s", err) 拼了上下文的 error 值(链断) 不停 今天先用,明天换 %w
panic("...") 崩溃 + 一坨栈 直接停 程序自身 bug,不是业务失败

回头看,三句话定调"错误处理"这件事

第一句,error 是普通值:接口两格(类型格 + 值格)照旧适用于它,errors.New 给你 *errors.errorString,fmt.Errorf 给你格式化后的同款,能赋值能上抛。第二句,错误沿调用栈向上抛,每层加一句上下文,只有"有信息做决定"的那层(通常是 main 或 handler)才处理------这一条把 Day 20 的签名教训(*MyErr 别装进 error)也串上了:返回类型直接写 error,成功就裸 return nil。第三句,要调用方分支的错,给它名字:哨兵变量 + == 在未包装时有效,包装后的事明天讲。

把这三句连起来看,其实是接口三部曲的终章:Day 19 学会从接口里 值(comma-ok / type switch),Day 20 学会判断接口是不是 nil ,今天你终于把接口用起来处理错误了。我 review 代码的必查清单也从三行扩到了四行,多出来的那条就是"函数里看见裸 panic 先标黄"。

动手改一改:三处 panic 换成 error 返回

三个必改项,都在 D:\go\day21 里做。第一,把今天 bug-code 那个 loadUser 改成 func loadUser(id int) (string, error),非法 id 返回 ErrBadID 风格的哨兵,main 打印后继续收尾,确认退出码回到 0。第二,仿照第三节的三层链,自己定义 readFile → parse → load 的形状,每层给错误加一句你这层才知道的 上下文------别抄我的文案。第三,故意把哨兵 ErrNotFound 在中间层拼进字符串再抛出,在 main 里用 == 比一下,亲眼看看它怎么失灵。这个现象留个悬念,明天第一件事就是修它。

明天见:明天用 %w 把今天断掉的链接上

今天你手工拼出来的 启动失败: 解析配置失败: 第 3 行不是数字,读着挺全,链却是断的:errors.Is 对着它就装不认识。明天 Day 22 讲 %w:同一个 fmt.Errorf,换个动词,链就活着;再讲 Unwraperrors.Iserrors.As 三件套怎么顺链摸瓜。睡前想一道题:fmt.Errorf("x: %v", err)fmt.Errorf("x: %w", err),打印出来的字符串一模一样,差在哪?

参考资料

相关推荐
写后端的胖头鱼2 小时前
一文讲懂JVM与调优
jvm·后端·算法·架构·jvm调优
步行cgn2 小时前
Spring Boot 指定数据来源详解
spring boot·后端·python
ECT-OS-JiuHuaShan2 小时前
哲学是迭代学,数学是拓扑学
开发语言·人工智能·学习·算法·机器学习·php·拓扑学
leo_messi942 小时前
Mysql学习(十二) -- SQL执行到底做了什么事?
sql·学习·mysql
敲代码的嘎仔2 小时前
从零实现视频续播 + 学习进度统计:前端心跳、条件更新、GROUP BY 统计全链路拆解
java·前端·数据库·学习·面试·职场和发展·音视频
一个有温度的技术博主2 小时前
深入理解 Spring Boot 自动装配
java·spring boot·后端
IT_陈寒3 小时前
我TM竟然被Java的空指针坑了第三次!
前端·人工智能·后端
xcLeigh3 小时前
AI 编程学习路线图:一份覆盖前端、后端、全栈的系统学习计划
前端·人工智能·学习
小灰灰搞电子3 小时前
Rust suppaftp 库详解:基于 FTP 客户端实战指南
开发语言·后端·rust