《Go 语言编程入门》2.3 函数、多返回值与 defer

本节把 TaskAPI 的 add、list、toggle 抽成真正的函数:先讲函数签名与多返回值为何是 Go 的错误传递方式,再讲命名返回值、可变参数与闭包;随后重点拆解 defer 的 LIFO 顺序和「参数立即求值」这一最容易踩的坑,最后用三个函数重写命令派发器并实测运行。

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 数组、切片与扩容 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练