2.3 函数、多返回值与 defer
上一节的派发器把所有逻辑都堆在 main 里,add 只打印一行、toggle 只数了个数。这种写法在原型阶段没问题,但只要逻辑长一点就会失控。本节把三条命令各自抽成一个函数,顺带把 Go 函数相关的特性讲透——尤其是多返回值和 defer,这两个是 Go 代码里出现频率最高、也最容易理解偏差的地方。
本节把 TaskAPI 推进到「add/list/toggle 三个函数成型」:
add返回([]Task, Task, error)三值、list用命名返回值拼出展示字符串、toggle就地翻转完成状态。派发器从「一堆内联语句」变成「调用三个函数」,为第 4 章给Task加方法做好准备。
2.3.1 函数签名
Go 的函数声明格式固定:
func 函数名(参数名 类型, ...) (返回值类型, ...) {
// 函数体
}
几点与其它语言不同的地方:
- 类型写在参数名之后:
func add(title string)而不是func add(string title)。 - 相邻同类型参数可合并:
func sum(a, b int)等价于func sum(a int, b int)。 - 没有默认参数,也没有函数重载。需要「可选行为」时用可变参数或选项结构体。
- 函数是一等值,可以赋给变量、作为参数、作为返回值。
一个最简单的例子:
func greet(name string) string {
return "你好," + name
}
调用它:greet("TaskAPI") 返回 "你好,TaskAPI"。
2.3.2 多返回值:Go 的错误传递方式
Go 函数可以返回多个值,这不是语法糖,而是错误处理模型的基础。惯例是把 error 放在最后一个返回值:
func add(tasks []Task, title string) ([]Task, Task, error) {
if title == "" {
return tasks, Task{}, errors.New("title 不能为空")
}
t := Task{ID: nextID, Title: title}
nextID++
return append(tasks, t), t, nil
}
调用方用「逗号 + ok」的形式同时接住值:
tasks, t, err := add(nil, "写第一章")
if err != nil {
fmt.Println("err:", err)
return
}
新增: {ID:1 Title:写第一章 Done:false}
注意出错时的 return tasks, Task{}, nil——返回了一个「零值 Task」和一个非 nil 的 error。调用方只要检查 err != nil 就知道这次调用失败,不需要去猜 Task{} 是否有效。这套「值 + 错误」的双通道约定,让 Go 在没有异常机制的前提下依然能清晰表达失败。
if _, _, err := add(tasks, ""); err != nil {
fmt.Println("空标题被拒绝:", err)
}
空标题被拒绝: title 不能为空
用 _ 丢掉不关心的返回值是常见写法。这里连 tasks 也丢掉了,因为我们只关心有没有报错。
2.3.3 命名返回值
返回值可以在签名里就起好名字:
func list(tasks []Task) (out []string) {
for _, t := range tasks {
status := "未完成"
if t.Done {
status = "已完成"
}
out = append(out, fmt.Sprintf("#%d %s [%s]", t.ID, t.Title, status))
}
return
}
#1 写第一章 [未完成]
#2 写第二章 [未完成]
#3 写第三章 [未完成]
命名返回值有两个作用:一是让签名自解释(读签名就知道返回的是字符串切片),二是可以在函数体里直接给 out 追加而不必声明局部变量,最后写一个裸 return 即可。
但要克制使用。命名返回值的代价是可读性下降——return 那行看不出到底返回了什么。更隐蔽的坑是:如果命名返回值是 error,函数内不小心给它赋了值又写了裸 return,可能返回非预期的错误。社区共识是:返回值少(一两个)且含义明显时可以用,多了就别用。
2.3.4 可变参数
参数类型前加 ... 表示接收任意个该类型的值:
func sumAll(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
sumAll: 6
在函数内部,nums 就是一个普通的 []int。调用时可以直接传多个值(sumAll(1, 2, 3)),也可以把切片展开传(sumAll(items...),注意那三个点)。
约束只有两条:可变参数必须是最后一个参数,且一个函数只能有一个。标准库里的 fmt.Println、append 都是这个形态。
2.3.5 函数是值
函数在 Go 里是一等公民,可以赋值、传递、返回:
var render func(Task) string = func(t Task) string {
return t.Title
}
fmt.Println(render(Task{Title: "写第一章"}))
更常见的是把函数当参数传给另一个函数,也就是高阶函数。标准库的 slices.SortFunc 就是典型:
slices.SortFunc(tasks, func(a, b Task) int {
return int(a.ID - b.ID)
})
这里传进去的匿名函数就是闭包,它能捕获外层的变量。闭包加上函数值,让 Go 不需要写太多样板类就能表达「策略」——第 9 章用 slices/maps 重写查询时会大量使用。
需要注意的是:闭包捕获的是变量本身而非当时的值,所以延迟执行的闭包会看到变量的最新状态。这一点在 defer 里尤其容易踩坑,下一小节就是。
2.3.6 defer:顺序与求值时机
defer 把一个函数调用推迟到当前函数返回前执行。多个 defer 按 LIFO(后进先出) 顺序执行:
func trace() {
for i := 1; i <= 3; i++ {
defer fmt.Println("defer", i)
}
fmt.Println("trace 函数体结束")
}
trace 函数体结束
defer 3
defer 2
defer 1
「函数体结束」先打印,随后是 3、2、1——后注册的先执行。这个顺序保证了「先申请的资源后释放」,正好匹配嵌套资源的释放需求。
第二个必须记住的规则是:defer 的参数在注册时就求值,函数体则在返回时才执行。看这段对比:
func deferredArgs() {
i := 0
defer fmt.Println("参数立即求值 i =", i)
defer func() { fmt.Println("闭包延迟求值 i =", i) }()
i = 2
}
闭包延迟求值 i = 2
参数立即求值 i = 0
两行输出都是 i,结果却不同。fmt.Println("参数立即求值 i =", i) 的参数在 defer 那一行就被算成 0 并冻结;而闭包 func() { ... } 没有参数,函数体直到真正执行时才去读外层的 i,此时已经是 2。所以想让 defer 看到当时的值,就把值当参数传进去;想让它看到最终的值,就用闭包。
defer 最典型的用途是资源清理:
func readFileLike() error {
f, err := os.Open("no-such-file.txt")
if err != nil {
return fmt.Errorf("打开文件: %w", err)
}
defer f.Close()
return nil
}
打开文件: open no-such-file.txt: no such file or directory
注意 defer f.Close() 写在错误检查之后——如果文件根本没打开成功,f 是 nil,此时 defer 一个 nil 上的方法调用会在返回时 panic。这是新手常犯的顺序错误。另外 f.Close() 自己也会返回 error,但清理阶段的错误通常被忽略;如果必须处理,可以写成 defer func() { _ = f.Close() }() 或改用命名返回值合并错误(第 6 章展开)。
2.3.7 把三个命令写成函数
现在把 TaskAPI 的命令实现完整重写。先看 toggle,它演示了「要修改切片元素必须用索引」:
func toggle(tasks []Task, id int64) bool {
for i := range tasks {
if tasks[i].ID == id {
tasks[i].Done = !tasks[i].Done
return true
}
}
return false
}
toggle #2 -> true
#2 写第二章 [已完成]
for i := range tasks 拿到的是索引,tasks[i].Done = !tasks[i].Done 直接改原切片的元素;如果用 for _, t := range tasks,改的就是副本,原切片纹丝不动。
三个函数合起来运行:
package main
import (
"errors"
"fmt"
)
type Task struct {
ID int64
Title string
Done bool
}
var nextID int64 = 1
func add(tasks []Task, title string) ([]Task, Task, error) {
if title == "" {
return tasks, Task{}, errors.New("title 不能为空")
}
t := Task{ID: nextID, Title: title}
nextID++
return append(tasks, t), t, nil
}
func list(tasks []Task) (out []string) {
for _, t := range tasks {
status := "未完成"
if t.Done {
status = "已完成"
}
out = append(out, fmt.Sprintf("#%d %s [%s]", t.ID, t.Title, status))
}
return
}
func toggle(tasks []Task, id int64) bool {
for i := range tasks {
if tasks[i].ID == id {
tasks[i].Done = !tasks[i].Done
return true
}
}
return false
}
func main() {
tasks, t, err := add(nil, "写第一章")
if err != nil {
fmt.Println("err:", err)
return
}
fmt.Printf("新增: %+v\n", t)
tasks, _, _ = add(tasks, "写第二章")
tasks, _, _ = add(tasks, "写第三章")
for _, line := range list(tasks) {
fmt.Println(line)
}
fmt.Println("toggle #2 ->", toggle(tasks, 2))
fmt.Println(list(tasks)[1])
}
新增: {ID:1 Title:写第一章 Done:false}
#1 写第一章 [未完成]
#2 写第二章 [未完成]
#3 写第三章 [未完成]
toggle #2 -> true
#2 写第二章 [已完成]
注意 nextID 是一个包级变量,被 add 直接修改。这种「函数依赖外部可变状态」的写法在真实项目里是需要警惕的——它让函数不再纯粹,测试时也会互相干扰。第 5 章引入 TaskStore 接口后,这个计数器会被收进 store 里管理。
小结
- Go 函数把类型写在参数名之后,同类型参数可合并;没有默认参数、没有重载。
- 多返回值是 Go 错误传递的基础,惯例是
error放最后;失败时同时返回零值与非 nil error,调用方只查err != nil。 - 命名返回值让签名自解释、可用裸
return,但返回值多时反而降低可读性,要克制。 - 可变参数
...T必须是最后一个参数,函数内就是普通切片;传切片时用slice...展开。 - 函数是一等值,闭包捕获的是变量本身而非当时的值。
defer按 LIFO 执行;参数在注册时求值,想要延迟取值就用闭包。defer f.Close()必须写在错误检查之后,否则可能 defer 一个 nil。- 修改切片元素必须用索引遍历(
for i := range),for _, t := range拿到的是副本。
三个函数写好了,但数据还存在一个临时变量里,nextID 还是包级全局状态。下一章 3.1 数组、切片与扩容
会正式用 []Task 承载任务列表,讲清 len、cap、append 扩容与切片共享底层数组的坑;紧接着 3.2 map 与集合惯用法
会加上 map[int64]Task 索引,让按 ID 查找不再需要遍历。想复习派发器是怎么写的,见 2.2 控制流与循环
。
阅读导航:上一节:2.2 控制流与循环 · 下一节:3.1 数组、切片与扩容 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。