CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Go 语言入门/课时/错误处理
RawBlame
课时目录12 节

Go 语言入门

  1. 01Go 是什么
  2. 02基础语法与类型
  3. 03数组、切片与 map
  4. 04结构体与接口
  5. 05模块与包管理
  6. 06并发模型
  7. 07错误处理
  8. 08泛型入门
  9. 09I/O 与 CLI
  10. 10Web 与数据库
  11. 11测试与基准
  12. 12综合练习

错误处理

error interface、wrap、panic/recover、sentinel errors。

30 分钟

错误处理

这一课你将能做到

  1. 用 errors.New / fmt.Errorf 创建错误,自定义错误类型,并按 (T, error) 规范层层返回
  2. 用 %w 包装错误形成错误链,并用 errors.Is / errors.As 精准判断
  3. 说清 panic/recover 的适用边界,写出能 recover 的防御代码

为什么要学

Go 没有 try/catch。错误是普通的返回值:函数显式返回 error,调用方显式处理。好处是控制流全部写在明面上——哪一行可能失败、失败后走哪条路,一目了然;代价是满屏 if err != nil,你需要一套规范让它不变成灾难。

这套规范就是本课内容:创建、包装、判断、兜底。写服务时它是日常;面试时 errors.Is/As、wrap 链、panic 边界是固定考题。

先建立图像

错误像快递单在调用链上逐层「贴单」:

readConfig()          "读取配置失败: %w"
  └── readFile()      "打开文件失败: %w"
        └── os.Open   open config.yaml: no such file or directory  ← 根因

错误链(wrap 链):
"读取配置失败: 打开文件失败: open config.yaml: no such file or directory"
 ↑ 最外层是当下语境           ↑ 中间层              ↑ 最内层是原始根因

errors.Is(err, fs.ErrNotExist)  → 顺着链找根因,true
errors.As(err, &pathErr)        → 顺着链找某类型的错误对象

每一层只负责两件事:决定自己处理还是上抛;上抛时用 %w 附加上下文。

跟着做

步骤 1:创建错误——两种基本姿势

package main

import (
	"errors"
	"fmt"
)

func sqrtGuess(n float64) (float64, error) {
	if n < 0 {
		// errors.New:静态固定文案
		return 0, errors.New("n 不能为负数")
	}
	// 这里的近似算法只作演示
	guess := n
	for i := 0; i < 10; i++ {
		guess = (guess + n/guess) / 2
	}
	return guess, nil
}

func main() {
	v, err := sqrtGuess(-4)
	if err != nil {
		fmt.Println("出错了:", err)
		return
	}
	fmt.Println(v)
}

需要动态信息时用 fmt.Errorf(本质是先格式化再 errors.New):

// fmt.Errorf("用户 %d 不存在", id) → "用户 42 不存在"

步骤 2:自定义错误类型——携带结构化信息

error 只是接口,任何类型实现 Error() string 都能当错误用:

package main

import (
	"errors"
	"fmt"
)

// 自定义错误:错误码、字段名都结构化保留,调用方可以按需取用
type ValidationError struct {
	Field string
	Msg   string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("字段 %s 校验失败: %s", e.Field, e.Msg)
}

func checkAge(age int) error {
	if age < 0 || age > 150 {
		return &ValidationError{Field: "age", Msg: "超出合理范围"}
	}
	return nil
}

func main() {
	err := checkAge(-1)
	fmt.Println(err) // 字段 age 校验失败: 超出合理范围

	var ve *ValidationError
	if errors.As(err, &ve) { // 还原成具体类型,拿到结构化字段
		fmt.Println("出错字段:", ve.Field)
	}

	var e2 error = errors.New("普通错误")
	fmt.Println(errors.As(e2, &ve)) // false:类型不匹配,安全返回
}

步骤 3:哨兵错误(sentinel error)与 errors.Is

预先定义包级错误变量,作为「可编程的错误值」供调用方比较:

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("记录不存在") // 惯例命名:Err 前缀

func findUser(id int) (string, error) {
	users := map[int]string{1: "张三"}
	name, ok := users[id]
	if !ok {
		return "", ErrNotFound
	}
	return name, nil
}

func main() {
	_, err := findUser(99)
	if err != nil {
		if errors.Is(err, ErrNotFound) { // 判断错误链上是否有这个哨兵
			fmt.Println("查无此人,返回 404")
			return
		}
		fmt.Println("其他错误:", err)
	}
}

步骤 4:%w 包装——错误链的正确打开方式

package main

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

var ErrConfig = errors.New("配置错误")

func loadConfig(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		// %w 包装:保留原错误,附加当下语境
		return fmt.Errorf("读取配置 %s 失败: %w", path, err)
	}
	if len(data) == 0 {
		// 可以同时包装哨兵,实现「即有语境又可判断」
		return fmt.Errorf("配置为空: %w", ErrConfig)
	}
	return nil
}

func main() {
	err := loadConfig("not-exist.yaml")
	fmt.Println(err)
	// 输出:读取配置 not-exist.yaml 失败: open not-exist.yaml: no such file or directory

	fmt.Println(errors.Is(err, os.ErrNotExist))  // true,链上有根因
	fmt.Println(errors.Is(err, ErrConfig))       // false

	err2 := loadConfig("/dev/null") // 空文件场景
	fmt.Println(errors.Is(err2, ErrConfig))      // true

	// 注意:%v 也会拼接文案,但会「剪断」错误链,Is/As 全部失效
	broken := fmt.Errorf("读取配置失败: %v", err)
	fmt.Println(errors.Is(broken, os.ErrNotExist)) // false!
}

一句话记住:转发错误一律 %w,除非有明确理由断链。

步骤 5:错误处理三原则(示例贯穿)

package main

import (
	"errors"
	"fmt"
	"log"
)

var ErrAuth = errors.New("鉴权失败")

func parseToken(token string) error {
	if token == "" {
		return fmt.Errorf("token 为空: %w", ErrAuth)
	}
	return nil
}

// 原则一:要么处理,要么上抛,不允许吞掉
func handleRequest(token string) error {
	if err := parseToken(token); err != nil {
		return fmt.Errorf("解析 token: %w", err) // 原则二:上抛时加上下文
	}
	return nil
}

func main() {
	err := handleRequest("")
	if err != nil {
		if errors.Is(err, ErrAuth) {
			fmt.Println("返回 401")
		}
		log.Printf("请求失败: %v", err) // 原则三:日志只在最外层记一次
	}
}

步骤 6:panic 与 recover——最后的保险丝

package main

import "fmt"

func safeDiv(a, b int) (result int) {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("捕获 panic:", r)
			result = 0 // 兜底值
		}
	}()
	return a / b // b=0 触发 runtime panic
}

func main() {
	fmt.Println(safeDiv(10, 0)) // 捕获后返回 0,程序继续
	fmt.Println("程序还活着")
}

边界要清楚:

  • panic 用于程序 bug(数组越界、nil 解引用、初始化失败),不是业务错误——用户输错密码返回 error,不该 panic。
  • recover 只在 defer 中有效,且只应在最外层兜底(如 HTTP server 每请求一个 goroutine,标准库已内置 recover),业务代码滥用 recover 会把 bug 藏起来。

讲清楚要点

  1. 错误是值:它就是函数的一个普通返回值,没有隐藏的控制流,所见即所得。
  2. (T, error) 签名约定:err 非 nil 时 T 必须是零值,调用方才能放心只看 err;判断顺序永远是 if err != nil 在前。
  3. %w 才包装,%v 会断链:Is/As 只认 %w 建立的链。
  4. Is 判值,As 判型:errors.Is(err, ErrXxx) 对哨兵值;errors.As(err, &target) 提取具体错误类型的字段。
  5. 哨兵错误是公共 API:命名 ErrXxx、放包级、写进文档;一旦发布就成为契约,轻易不能改语义。
  6. 日志只记一次:中间层只包装不打印,最外层统一落日志,避免同一错误刷三遍。

你可能会懵的地方

  • 满屏 if err != nil,为什么 Go 不引入 try/catch? 官方观点:异常把错误路径藏进「快乐路径」之外,控制流不透明;Go 把每个可能失败的调用摆到台面上。社区已有 ? 类提案多次被拒,短期内不会变。

  • err 非 nil 时第一个返回值返回什么? 零值(0、""、nil)。不要返回 -1、-999 这类魔数,调用方不该被迫去猜「这个值能不能信」。

  • errors.Is 和 == 判断错误有什么区别? == 只比较单个对象;Is 会顺着 wrap 链逐层解包比较,还支持类型自定义 Is 方法。包装过的错误用 == 会漏判,永远用 Is。

  • recover 写在普通函数体里有效吗? 无效。recover 必须被 defer 的函数直接调用才生效。而且它只能接 panic,接不住 fatal error(如并发写 map、栈耗尽)。

  • 一个 fmt.Errorf 能包多个 %w 吗? Go 1.20 起支持多个 %w,Is/As 会检查所有包装项;但为了可读性,惯例仍是一条链一层 %w。

错法对照

错法问题正解
_ = doSomething() 吞错误失败无感知,bug 延后爆发显式处理,或注释说明为什么可忽略
fmt.Errorf("xx: %v", err)链被剪断,Is/As 失效用 %w 包装
if err == ErrNotFound包装后不相等,漏判errors.Is(err, ErrNotFound)
返回 nil, errors.New(...) 之外还返回 -1调用方困惑该信哪个值错误时返回类型零值
中间层每层都 log.Printf同一错误日志刷屏只在最外层记一次
用 panic 表达「用户输入不合法」业务错误炸掉整个进程返回 error,panic 留给程序 bug

卡点急救

  1. err 明明有值却没进 if:检查是不是在多返回值时把 err 接给了 _;或 if 初始化变量作用域出了块。
  2. errors.Is 一直是 false:链上有 %v 断层,逐层排查 fmt.Errorf 的动词。
  3. panic: runtime error: invalid memory address:nil 指针解引用。追根因:哪个函数返回了没检查的 nil(map 取值、类型断言、出错的构造函数)。
  4. recover 后程序行为诡异:recover 吞掉了不该吞的 panic,把兜底范围缩小到明确边界,或干脆去掉。

面试常问

  • 谈谈 Go 的错误处理哲学,和 try/catch 的对比? 错误是显式返回值,控制流可见、强制逐层决策;异常机制捕获点与抛出点分离,容易漏接与滥用。代价是样板代码多,收益是可靠性与可读性,且可用 wrap 链 + Is/As 补足表达力。

  • errors.Is / errors.As / errors.Unwrap 的区别? Is 判断链上是否存在某个「值」(哨兵);As 判断链上是否存在某「类型」并提取到变量;Unwrap 是底层机制,返回上一层错误,Is/As 就是沿着它遍历的。

  • 什么时候用 panic? 程序级不可恢复错误:初始化阶段配置缺失、不变量被破坏、数组越界等 bug。业务错误一律 error;最外层(如 HTTP server、定时任务)做统一 recover 兜底即可。

  • 如何设计一个库的错误 API? 导出哨兵错误(ErrXxx)供 Is 判断;复杂场景定义错误结构体(携带字段)供 As 提取;内部错误一律 %w 包装保链;文档写明会返回哪些哨兵。

自测三问

  1. %v 和 %w 包装错误的本质区别是什么?各写一行代码演示。
  2. 想拿到错误链里 *PathError 的 Path 字段,用 Is 还是 As?写出代码骨架。
  3. recover 写在函数中间某一行会生效吗?为什么?

小结一句话

Go 的错误处理 = 错误是值 + 一律 %w 包装保链 + Is 判值 / As 判型 + 最外层记一次日志;panic 只留给 bug,recover 只放在边界。

延伸

  • 官方博客:Error handling and Go
  • errors 包文档
  • 下一课:泛型入门——Go 1.18 之后,处理「任意类型」终于不必靠 any 硬扛。
加载本节练习…
← 上一课并发模型下一课 →泛型入门