章节

12.4 练习

本篇通过若干分级练习巩固 error、panic 与 recover,并能在函数调用、配置校验和并发任务中写出更稳健的异常处理代码。

练习

学完本章你应该掌握

  • 能把普通失败设计成 error 返回值,并在调用方先判断 err 再决定继续、提示、返回还是中断当前流程。
  • 能使用 fmt.Errorf("...: %w", err) 为底层错误补充上下文,让调用方知道错误发生在哪一层。
  • 能区分哪些场景应该返回 error,哪些场景更适合使用 panic 表示程序无法继续执行的严重问题。
  • 能使用 defer + recover() 在合适的边界兜住 panic,并把崩溃改造成日志、提示或错误结果。
  • 能理解 recover 只对当前 goroutine 生效,不会回到 panic 发生点继续执行,也不会替你跨 goroutine 捕获异常。
  • 能把本章知识自然放进前面学过的函数、结构体、切片、map、输入校验、goroutine 和 channel 场景里。

简单

第 1 题:编写返回 error 的分数解析函数

第 1 题 简单

编写一个 Go 程序,完成下面要求:

  • 编写函数 parseScore(text string) (int, error)
  • 使用 strconv.Atoi 把字符串分数转成整数
  • 如果转换失败,直接把错误返回给调用方
  • 如果分数不在 0 ~ 100 之间,返回错误 score out of range
  • main 中至少测试两次: 一次传入 "88" 一次传入 "abc"
  • 调用方必须先判断 err
查看参考答案
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package main

import (
	"errors"
	"fmt"
	"strconv"
)

func parseScore(text string) (int, error) {
	score, err := strconv.Atoi(text)
	if err != nil {
		return 0, err
	}

	if score < 0 || score > 100 {
		return 0, errors.New("score out of range")
	}

	return score, nil
}

func main() {
	score, err := parseScore("88")
	if err != nil {
		fmt.Println("parse error:", err)
	} else {
		fmt.Println(score)
	}

	score, err = parseScore("abc")
	if err != nil {
		fmt.Println("parse error:", err)
		return
	}

	fmt.Println(score)
}

一种可能的输出结果:

1
2
88
parse error: strconv.Atoi: parsing "abc": invalid syntax

说明:这道题的重点不是 Atoi 本身,而是让你把“失败也算结果的一部分”真正写进函数签名里。

第 2 题:补全错误包装语句

第 2 题 简单

补全下面代码中的空白,让底层错误在向上返回时带上 load age 这层上下文:

1
2
3
4
5
6
7
func loadAge(text string) (int, error) {
	age, err := strconv.Atoi(text)
	if err != nil {
		return 0, _______________________
	}
	return age, nil
}
查看参考答案

应补全为:

1
fmt.Errorf("load age: %w", err)

完整函数如下:

1
2
3
4
5
6
7
func loadAge(text string) (int, error) {
	age, err := strconv.Atoi(text)
	if err != nil {
		return 0, fmt.Errorf("load age: %w", err)
	}
	return age, nil
}

说明:%w 的作用是把底层错误包进新的错误里,这样调用方既能看到当前这层上下文,也不会丢失原始错误信息。

第 3 题:修正把普通输入错误写成 panic 的程序

第 3 题 简单

下面程序把普通用户输入错误直接写成了 panic
请改正代码,让它在输入有误时给出提示并结束当前流程,而不是直接崩溃程序。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
	"fmt"
	"strconv"
)

func main() {
	var ageText string

	fmt.Print("请输入年龄:")
	fmt.Scan(&ageText)

	age, err := strconv.Atoi(ageText)
	if err != nil {
		panic(err)
	}

	if age < 0 || age > 120 {
		panic("bad age")
	}

	fmt.Println(age + 1)
}
查看参考答案

修正后的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
	"fmt"
	"strconv"
)

func main() {
	var ageText string

	fmt.Print("请输入年龄:")
	_, err := fmt.Scan(&ageText)
	if err != nil {
		fmt.Println("输入有误:", err)
		return
	}

	age, err := strconv.Atoi(ageText)
	if err != nil {
		fmt.Println("年龄必须是整数")
		return
	}

	if age < 0 || age > 120 {
		fmt.Println("年龄范围不合法")
		return
	}

	fmt.Println(age + 1)
}

关键点:

  • 用户输入错误属于普通业务失败,优先返回提示或结束当前流程,不要动不动就 panic
  • panic 更适合“程序根本无法继续”的场景,而不是“这次输入不合法”

第 4 题:编写必须加载配置函数

第 4 题 简单

编写一个 Go 程序,完成下面要求:

  • 定义结构体 Config,字段为 DBURL string
  • 编写函数 mustLoadConfig(cfg Config)
  • 如果 cfg.DBURL == "",触发 panic("missing DB_URL")
  • main 中故意传入一个空配置,观察程序中断

关键输出应类似这样:

1
panic: missing DB_URL
查看参考答案
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package main

type Config struct {
	DBURL string
}

func mustLoadConfig(cfg Config) {
	if cfg.DBURL == "" {
		panic("missing DB_URL")
	}
}

func main() {
	cfg := Config{}
	mustLoadConfig(cfg)
}

说明:这里的配置缺失会导致程序启动后根本无法继续工作,所以比起返回普通 error,更适合用 panic 直接中断。

第 5 题:使用 recover 让主流程继续

第 5 题 简单

编写一个 Go 程序,完成下面要求:

  • 编写函数 safeRun()
  • safeRun 中先输出 safeRun start
  • 然后触发 panic("lesson broken")
  • 使用 defer + recover() 捕获这次 panic,并输出 recovered: lesson broken
  • main 中调用 safeRun() 后,再输出 main continues
查看参考答案
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package main

import "fmt"

func safeRun() {
	defer func() {
		if err := recover(); err != nil {
			fmt.Println("recovered:", err)
		}
	}()

	fmt.Println("safeRun start")
	panic("lesson broken")
}

func main() {
	safeRun()
	fmt.Println("main continues")
}

输出结果:

1
2
3
safeRun start
recovered: lesson broken
main continues

说明:所谓“恢复”,不是回到 panic 那一行后面继续执行,而是把原本要继续向上传播的 panic 截住,让外层流程还能往下走。

一般

第 6 题:封装资料卡生成函数并向上抛错

第 6 题 一般

编写一个 Go 程序,完成下面要求:

  • 编写函数 parseAge(text string) (int, error)
  • 在这个函数里使用 strconv.Atoi
  • 如果转换失败,返回 fmt.Errorf("parse age: %w", err)
  • 再编写函数 buildProfile(name, ageText string) (string, error)
  • 如果 name == "",返回错误 name is empty
  • 如果年龄解析失败,继续向上包装成 fmt.Errorf("build profile: %w", err)
  • 成功时返回:
1
姓名:小王,年龄:18
  • main 中至少测试一次错误场景
查看参考答案
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

import (
	"errors"
	"fmt"
	"strconv"
)

func parseAge(text string) (int, error) {
	age, err := strconv.Atoi(text)
	if err != nil {
		return 0, fmt.Errorf("parse age: %w", err)
	}
	return age, nil
}

func buildProfile(name, ageText string) (string, error) {
	if name == "" {
		return "", errors.New("name is empty")
	}

	age, err := parseAge(ageText)
	if err != nil {
		return "", fmt.Errorf("build profile: %w", err)
	}

	return fmt.Sprintf("姓名:%s,年龄:%d", name, age), nil
}

func main() {
	profile, err := buildProfile("小王", "abc")
	if err != nil {
		fmt.Println(err)
		return
	}

	fmt.Println(profile)
}

输出结果:

1
build profile: parse age: strconv.Atoi: parsing "abc": invalid syntax

说明:这道题真正要练的是“每一层只补充自己知道的上下文”,而不是一出错就把底层细节丢掉。

第 7 题:修正放错位置的 recover

第 7 题 一般

下面程序想捕获 panic,但现在写法不对。
请改正代码,让它最终输出:

1
2
3
start
recovered: boom
main continues
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package main

import "fmt"

func safeRun() {
	fmt.Println("start")
	panic("boom")

	if err := recover(); err != nil {
		fmt.Println("recovered:", err)
	}
}

func main() {
	safeRun()
	fmt.Println("main continues")
}
查看参考答案

修正后的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package main

import "fmt"

func safeRun() {
	defer func() {
		if err := recover(); err != nil {
			fmt.Println("recovered:", err)
		}
	}()

	fmt.Println("start")
	panic("boom")
}

func main() {
	safeRun()
	fmt.Println("main continues")
}

原因说明:

  • recover() 必须在 defer 调用的函数中直接使用才有效
  • 原程序把 recover() 放在普通流程里,而且 panic 发生后那几行本来就不会继续执行

第 8 题:把 panic 转成 error 返回

第 8 题 一般

编写一个 Go 程序,完成下面要求:

  • 编写函数 runJob(name string) (err error)
  • 如果 name == "",触发 panic("job name empty")
  • 使用 defer + recover() 捕获这次 panic
  • 捕获后把它转换成错误返回给调用方,例如:
1
run job: job name empty
  • 如果 name 不为空,输出 run ok: 任务名
  • main 中至少测试一次正常场景和一次失败场景
查看参考答案
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import "fmt"

func runJob(name string) (err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("run job: %v", r)
		}
	}()

	if name == "" {
		panic("job name empty")
	}

	fmt.Println("run ok:", name)
	return nil
}

func main() {
	if err := runJob("parse report"); err != nil {
		fmt.Println(err)
	}

	if err := runJob(""); err != nil {
		fmt.Println(err)
	}
}

一种可能的输出结果:

1
2
run ok: parse report
run job: job name empty

说明:这类写法常见于程序边界层。内部出现了 panic,但边界层不想让整个程序直接崩掉,而是希望把它整理成一个可处理的错误结果。

第 9 题:判断哪些场景该用 error,哪些该用 panic

第 9 题 一般

下面四种情况里,哪些更适合返回 error,哪些更适合使用 panic?请分别写出判断,并简要说明原因。

1
2
3
4
A. 用户输入的年龄不是数字
B. 程序启动时 DB_URL 为空,服务根本无法工作
C. 订单确认等待超过 100ms
D. 代码把 nums[len(nums)] 写成了数组访问
查看参考答案

一种合理的判断如下:

1
2
3
4
A -> error
B -> panic
C -> error
D -> panic

原因说明:

  • A 属于普通业务输入错误,应该交给调用方提示用户或重新输入
  • B 属于启动阶段的致命配置问题,程序无法继续正常工作,更适合直接中断
  • C 是可预期的业务失败或超时结果,通常应作为 error 或失败状态返回
  • D 更像代码 bug 或运行时严重错误,这类问题通常会触发 panic,而不是正常业务分支

第 10 题:为高阶执行器加上恢复保护

第 10 题 一般

编写一个 Go 程序,完成下面要求:

  • 编写函数 executeSafely(fn func()) error
  • 在函数内部执行传入的 fn
  • 如果 fn 发生 panic,使用 recover 捕获,并返回:
1
execute failed: bad task
  • main 中至少测试两次: 一次传入正常函数 一次传入会 panic("bad task") 的函数
查看参考答案
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import "fmt"

func executeSafely(fn func()) (err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("execute failed: %v", r)
		}
	}()

	fn()
	return nil
}

func main() {
	err := executeSafely(func() {
		fmt.Println("task 1 ok")
	})
	if err != nil {
		fmt.Println(err)
	}

	err = executeSafely(func() {
		panic("bad task")
	})
	if err != nil {
		fmt.Println(err)
	}
}

一种可能的输出结果:

1
2
task 1 ok
execute failed: bad task

说明:这道题顺手复习了上一章的高阶函数。executeSafely 接收的不是普通数据,而是一个“待执行的行为”。

进阶

下面四题会把异常处理和前面学过的结构体、函数、切片、goroutine、channel 串起来,重点考察你能不能在更真实一点的程序边界里区分 errorpanicrecover

第 11 题:解析成绩表并补充学生上下文

第 11 题 进阶

编写一个 Go 程序,完成下面要求:

  • 定义结构体 StudentInput,字段包括: Name string ScoreText string
  • 编写函数 parseOneScore(text string) (int, error)
  • 再编写函数 buildScoreMap(inputs []StudentInput) (map[string]int, error)
  • buildScoreMap 需要遍历输入切片,把 "文本分数" 转成真正的整数分数
  • 如果某个学生解析失败,要返回带学生名上下文的错误,例如:
1
student 小王: strconv.Atoi: parsing "abc": invalid syntax
  • 成功时返回 map[string]int

测试数据可以使用:

1
2
3
4
5
inputs := []StudentInput{
	{Name: "小李", ScoreText: "92"},
	{Name: "小王", ScoreText: "abc"},
	{Name: "小张", ScoreText: "76"},
}
查看参考答案
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package main

import (
	"errors"
	"fmt"
	"strconv"
)

type StudentInput struct {
	Name      string
	ScoreText string
}

func parseOneScore(text string) (int, error) {
	score, err := strconv.Atoi(text)
	if err != nil {
		return 0, err
	}

	if score < 0 || score > 100 {
		return 0, errors.New("score out of range")
	}

	return score, nil
}

func buildScoreMap(inputs []StudentInput) (map[string]int, error) {
	result := make(map[string]int)

	for _, input := range inputs {
		score, err := parseOneScore(input.ScoreText)
		if err != nil {
			return nil, fmt.Errorf("student %s: %w", input.Name, err)
		}

		result[input.Name] = score
	}

	return result, nil
}

func main() {
	inputs := []StudentInput{
		{Name: "小李", ScoreText: "92"},
		{Name: "小王", ScoreText: "abc"},
		{Name: "小张", ScoreText: "76"},
	}

	scores, err := buildScoreMap(inputs)
	if err != nil {
		fmt.Println(err)
		return
	}

	fmt.Println(scores)
}

说明:这道题把结构体、切片、map、函数和错误包装放到了一起。真正要练的是“错误不只要往上抛,还要带着足够的定位信息一起抛”。

第 12 题:在 goroutine 中各自 recover 任务 panic

第 12 题 进阶

编写一个 Go 程序,完成下面要求:

  • 定义结构体 Task,字段包括: ID int Name string
  • 编写函数 worker(task Task, resultCh chan string, wg *sync.WaitGroup)
  • worker 中使用 defer + recover()
  • 如果 task.Name == "",触发 panic("task name empty")
  • 如果没有 panic,就发送:
1
task 1 done: 复习 error
  • 如果发生 panic,就发送:
1
task 2 failed: task name empty
  • main 中准备 3 个任务,其中 1 个任务名为空
  • 使用 goroutine 启动所有任务
  • 使用 WaitGroup 等待全部完成后关闭 resultCh
  • 使用 for range 输出所有结果

说明:输出顺序不固定。

查看参考答案
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package main

import (
	"fmt"
	"sync"
)

type Task struct {
	ID   int
	Name string
}

func worker(task Task, resultCh chan string, wg *sync.WaitGroup) {
	defer wg.Done()

	defer func() {
		if err := recover(); err != nil {
			resultCh <- fmt.Sprintf("task %d failed: %v", task.ID, err)
		}
	}()

	if task.Name == "" {
		panic("task name empty")
	}

	resultCh <- fmt.Sprintf("task %d done: %s", task.ID, task.Name)
}

func main() {
	tasks := []Task{
		{ID: 1, Name: "复习 error"},
		{ID: 2, Name: ""},
		{ID: 3, Name: "练习 recover"},
	}

	resultCh := make(chan string)
	var wg sync.WaitGroup

	for _, task := range tasks {
		wg.Add(1)
		go worker(task, resultCh, &wg)
	}

	go func() {
		wg.Wait()
		close(resultCh)
	}()

	for result := range resultCh {
		fmt.Println(result)
	}
}

说明:这道题最关键的点不是 WaitGroupchannel,而是体会“每个 goroutine 要自己 recover 自己的 panic”。

第 13 题:修正试图在 main 中 recover 子 goroutine 的代码

第 13 题 进阶

下面程序想在 main 中统一 recover 一个子 goroutine 里的 panic,但它现在做不到。
请改正代码,让它稳定输出:

1
2
worker recovered: worker broken
main end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
	"fmt"
	"time"
)

func main() {
	defer func() {
		if err := recover(); err != nil {
			fmt.Println("main recovered:", err)
		}
	}()

	go func() {
		panic("worker broken")
	}()

	time.Sleep(100 * time.Millisecond)
	fmt.Println("main end")
}
查看参考答案

修正后的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	wg.Add(1)
	go func() {
		defer wg.Done()

		defer func() {
			if err := recover(); err != nil {
				fmt.Println("worker recovered:", err)
			}
		}()

		panic("worker broken")
	}()

	wg.Wait()
	fmt.Println("main end")
}

原因说明:

  • main 里的 recover 只能处理 main 这个 goroutine 自己的 panic
  • 子 goroutine 里的 panic,必须在那个 goroutine 自己的 defer 里恢复
  • 这里顺手把 time.Sleep 换成了 WaitGroup,等待方式更稳定

第 14 题:实现一个并发批改中心

第 14 题 进阶

请完成一个综合练习:

  • 定义结构体 CheckTask,字段包括: Index int Name string ScoreText string
  • 定义结构体 CheckResult,字段包括: Index int Text string
  • 编写函数 parseScore(text string) (int, error) 使用 strconv.Atoi 如果解析失败,返回 fmt.Errorf("parse score: %w", err) 如果分数不在 0 ~ 100 范围内,返回 score out of range
  • 编写函数 levelOf(score int) stringA / B / C / D 返回等级
  • 编写 goroutine 函数 checkTask(task CheckTask, resultCh chan CheckResult, wg *sync.WaitGroup) 在里面使用 defer + recover() 如果 task.Name == "",触发 panic("missing student name") 如果文本分数解析失败,发送失败结果 如果成功,发送成功结果
  • main 中准备下面这组任务:
1
2
3
4
5
6
tasks := []CheckTask{
	{Index: 1, Name: "小李", ScoreText: "92"},
	{Index: 2, Name: "", ScoreText: "88"},
	{Index: 3, Name: "小王", ScoreText: "abc"},
	{Index: 4, Name: "小张", ScoreText: "59"},
}
  • 为每个任务启动一个 goroutine
  • 使用 channel + WaitGroup 收集结果
  • 最后按 Index 的原始顺序输出结果

期望输出类似这样:

1
2
3
4
任务1成功:小李 92 A
任务2失败:missing student name
任务3失败:parse score: strconv.Atoi: parsing "abc": invalid syntax
任务4成功:小张 59 D
查看参考答案
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
package main

import (
	"errors"
	"fmt"
	"strconv"
	"sync"
)

type CheckTask struct {
	Index     int
	Name      string
	ScoreText string
}

type CheckResult struct {
	Index int
	Text  string
}

func parseScore(text string) (int, error) {
	score, err := strconv.Atoi(text)
	if err != nil {
		return 0, fmt.Errorf("parse score: %w", err)
	}

	if score < 0 || score > 100 {
		return 0, errors.New("score out of range")
	}

	return score, nil
}

func levelOf(score int) string {
	switch {
	case score >= 90:
		return "A"
	case score >= 80:
		return "B"
	case score >= 60:
		return "C"
	default:
		return "D"
	}
}

func checkTask(task CheckTask, resultCh chan CheckResult, wg *sync.WaitGroup) {
	defer wg.Done()

	defer func() {
		if r := recover(); r != nil {
			resultCh <- CheckResult{
				Index: task.Index,
				Text:  fmt.Sprintf("任务%d失败:%v", task.Index, r),
			}
		}
	}()

	if task.Name == "" {
		panic("missing student name")
	}

	score, err := parseScore(task.ScoreText)
	if err != nil {
		resultCh <- CheckResult{
			Index: task.Index,
			Text:  fmt.Sprintf("任务%d失败:%v", task.Index, err),
		}
		return
	}

	resultCh <- CheckResult{
		Index: task.Index,
		Text:  fmt.Sprintf("任务%d成功:%s %d %s", task.Index, task.Name, score, levelOf(score)),
	}
}

func main() {
	tasks := []CheckTask{
		{Index: 1, Name: "小李", ScoreText: "92"},
		{Index: 2, Name: "", ScoreText: "88"},
		{Index: 3, Name: "小王", ScoreText: "abc"},
		{Index: 4, Name: "小张", ScoreText: "59"},
	}

	resultCh := make(chan CheckResult, len(tasks))
	results := make([]string, len(tasks))

	var wg sync.WaitGroup
	for _, task := range tasks {
		wg.Add(1)
		go checkTask(task, resultCh, &wg)
	}

	go func() {
		wg.Wait()
		close(resultCh)
	}()

	for result := range resultCh {
		results[result.Index-1] = result.Text
	}

	for _, text := range results {
		fmt.Println(text)
	}
}

说明:

  • 解析失败属于普通业务错误,所以这里用 error 返回
  • 学生名缺失被题目约定成不可继续的严重问题,所以这里用 panic
  • checkTask 再用 recover 把单个任务的崩溃兜住,避免整个批改中心一起退出
  • 这道题把 01-12 单元里学过的结构体、切片、函数、判断、goroutine、channel 和异常处理真正串起来了
本文禁止转载
使用 Hugo 构建
主题 StackJimmy 设计 由 Hobin 魔改
最近构建时间:2026-04-17 19:07:48 CST
载入天数...载入时分秒...
发表了 1 篇文章 · 发表了 152 篇笔记 · 总计 18 万 0 千字