Go 函数可以返回多个值,可以作为闭包带着变量走,还能用 defer 安排清理工作。本文逐一讲解,包括延迟调用的执行顺序,以及参数在什么时候确定。
Go 的函数乍看和别的语言没什么两样,直到你注意到三件事。函数可以返回不止一个值。函数本身就是值,可以存起来、传来传去。还有 defer,让函数安排一些工作,在它退出的那一刻执行。
本文把这三点都讲一遍,也讲讲把函数当作值时自然产生的闭包。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果粘贴而来。
声明函数
Go 函数列参数时,类型写在参数名后面,结果类型写在参数列表后面。相邻的两个参数类型相同时,类型可以只写一次。
package main
import (
"fmt"
"strconv"
)
func add(a, b int) int {
return a + b
}
func minMax(nums []int) (int, int) {
lo, hi := nums[0], nums[0]
for _, n := range nums[1:] {
lo = min(lo, n)
hi = max(hi, n)
}
return lo, hi
}
func main() {
fmt.Println(add(2, 3))
lo, hi := minMax([]int{7, 2, 9, 4})
fmt.Println(lo, hi)
n, err := strconv.Atoi("42")
fmt.Println(n, err)
n, err = strconv.Atoi("forty-two")
fmt.Println(n, err)
}
输出:
5
2 9
42 <nil>
0 strconv.Atoi: parsing "forty-two": invalid syntax
add(a, b int) 是 add(a int, b int) 的简写。minMax 返回两个值,所以结果类型要放进括号,调用方用 lo, hi := 接收。(min 和 max 从 Go 1.21 起是内置函数。)
strconv.Atoi 展示了 Go 里最常见的形式:一个值加一个错误。转换成功时,err 是 nil。转换失败时,你拿到零值,外加一个说明哪里出错的错误。Go 不用异常处理这种情况。错误就是最后一个返回值,用 if err != nil 检查。讲错误的那一部分会深入讨论。
第二个结果不会被你无意中忽略
返回两个值的函数,没法塞进一个变量:
package main
import (
"fmt"
"strconv"
)
func main() {
n := strconv.Atoi("42")
fmt.Println(n)
}
编译器报错:
./main.go:9:7: assignment mismatch: 1 variable but strconv.Atoi returns 2 values
如果你确实不想要这个错误,就得用空白标识符明说:n, _ := strconv.Atoi("42")。这个 _ 在代码评审时一眼就能看到,要的就是这个效果。丢掉错误是一个别人看得见的决定。
命名返回值
Go 允许给结果值起名字,和给参数起名字一样。命名返回值在函数开头声明,初始是零值,一个不带值的 return 会把它们当前的值返回。
package main
import (
"fmt"
"strings"
)
func splitPair(s string) (key, value string, ok bool) {
i := strings.Index(s, "=")
if i < 0 {
return
}
key = s[:i]
value = s[i+1:]
ok = true
return
}
func main() {
k, v, ok := splitPair("host=example.com")
fmt.Printf("%q %q %v\n", k, v, ok)
k, v, ok = splitPair("no equals sign")
fmt.Printf("%q %q %v\n", k, v, ok)
}
输出:
"host" "example.com" true
"" "" false
提前的那个 return 返回 ""、"" 和 false,因为这时还什么都没赋值,这些正是零值。(标准库里已经有这个函数,叫 strings.Cut。手写一遍是为了看清原理。)
名字最大的用处是充当文档。(key, value string, ok bool) 告诉读者返回的是什么。(string, string, bool) 就得让人猜哪个字符串是哪个。
像这样不带值的 return 叫作裸返回(naked return)。在这么短的函数里没问题。长函数里就别用了。读者得往上翻才知道返回的是什么,而且内层代码块里声明的变量可能悄悄遮住某个返回值。最糟的那种情况,Go 会拦下来:
package main
import (
"fmt"
"strconv"
)
func parsePort(s string) (port int, err error) {
if s != "" {
port, err := strconv.Atoi(s)
if err != nil {
return
}
fmt.Println("parsed", port)
}
return
}
func main() {
fmt.Println(parsePort("8080"))
}
编译器报错:
./main.go:12:4: result parameter port not in scope at return
./main.go:12:4: result parameter err not in scope at return
在 if 里,:= 声明了全新的 port 和 err,遮住了命名返回值。在这里裸返回,返回的会是外层那两个从没被赋值的变量,所以 Go 拒绝编译。显式写成 return port, err,代码才表达了本意。
可变参数函数
可变参数函数接受任意个同一类型的参数。在最后一个参数上标 ...,在函数内部它就是一个切片。
package main
import "fmt"
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func main() {
fmt.Println(sum())
fmt.Println(sum(1, 2, 3))
scores := []int{10, 20, 30}
fmt.Println(sum(scores...))
}
输出:
0
6
60
sum() 拿到的 nums 是空的,所以总和是 0。sum(1, 2, 3) 拿到的是含三个元素的切片。
如果手里已经有一个切片,不能写成 sum(scores),因为 []int 不是 int。写成 scores... 就会把它展开到调用里。这样做时 Go 不会复制元素。函数里的 nums 和 scores 是同一个切片,共用同一个数组。讲切片的那一部分会说明这为什么重要。
其实你一直在调用可变参数函数。fmt.Println 的声明是 func Println(a ...any) (n int, err error)。
函数是值
Go 函数是有类型的值,就像 int 或字符串一样。你可以把它赋给变量,传给另一个函数,也可以从函数里返回它。
package main
import (
"fmt"
"strings"
)
func apply(words []string, f func(string) string) []string {
out := make([]string, 0, len(words))
for _, w := range words {
out = append(out, f(w))
}
return out
}
func exclaim(s string) string {
return s + "!"
}
func main() {
words := []string{"go", "is", "fun"}
shout := strings.ToUpper
fmt.Println(shout("hello"))
fmt.Println(apply(words, exclaim))
fmt.Println(apply(words, strings.ToUpper))
fmt.Println(apply(words, func(s string) string {
return "<" + s + ">"
}))
fmt.Printf("%T\n", exclaim)
}
输出:
HELLO
[go! is! fun!]
[GO IS FUN]
[<go> <is> <fun>]
func(string) string
shout := strings.ToUpper 里 ToUpper 后面没有括号,所以不是调用函数,而是把函数存起来。apply 接受任何 func(string) string 类型的函数,对每个单词调用一次。你自己写的 exclaim、库里的 strings.ToUpper、直接写在调用处的函数字面量,都能传进去,因为它们都是这个类型。
最后一行打印的是类型本身。函数的类型由参数类型和结果类型组成,名字不算在内。
闭包
函数字面量可以使用外层函数里的变量。这样用的时候,它就叫闭包,只要闭包还在,这些变量就一直活着。
package main
import "fmt"
func newCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
a := newCounter()
b := newCounter()
fmt.Println(a(), a(), a())
fmt.Println(b())
fmt.Println(a())
}
输出:
1 2 3
1
4
newCounter 返回了,按理说它的局部变量 count 应该随之消失。但它返回的函数还在用 count,所以 count 留了下来。每调用一次 a,它就加一。
b 来自另一次 newCounter 调用,所以有自己的 count,从 0 开始。这就是 b() 输出 1、之后 a 接着数到 4 的原因。两个计数器没有任何共享。
用十岁孩子能懂的话说
把闭包想象成一个背着书包的函数。
newCounter 做出那个小小的计数函数时,先把 count 放进它的书包,再把它送出去。函数走到哪儿,书包就跟到哪儿。每次调用它,它就打开书包,把里面的数字加一,然后告诉你新的数字。
再调用一次 newCounter,你会得到第二个函数,背着第二个书包。一个书包里装的东西,和另一个毫无关系。
准确的说法
函数字面量引用外层函数的变量时,捕获的是变量本身,而不是它的值的副本。闭包和外层函数共用这一个变量。任何一方改了它,另一方都能看到。
因为闭包可能比声明这个变量的那次调用活得更久,编译器通常会把这样的变量放到堆上,只要还有东西引用它,它就一直存在。讲内存的那一部分会看看编译器是怎么做这个判断的。
这个比喻的局限:书包听起来像是函数做出来那一刻打包好的私人副本。其实不是副本。如果外层函数在创建闭包之后又改了 count,闭包看到的是新值,因为它们共用一个变量。
循环里的闭包
这种共享曾经让循环变量出过一个有名的 bug。从 Go 1.22 起,for 循环的每次迭代都有一个新变量,所以在循环里创建的闭包捕获的是当次迭代的值:
package main
import "fmt"
func main() {
var printers []func()
for i := range 3 {
printers = append(printers, func() {
fmt.Println("i is", i)
})
}
for _, p := range printers {
p()
}
}
输出:
i is 0
i is 1
i is 2
在 Go 1.22 之前,整个循环共用一个 i,这段代码会输出三次 i is 3。你还会在老代码里看到在循环里写一行 i := i 来绕过这个问题。在 Go 1.22 及以后,这行代码什么也不做,可以删掉。
defer:函数退出时再执行
defer 语句安排一次函数调用,在外层函数返回时执行,不会更早。有好几个时,按相反的顺序执行。
package main
import "fmt"
func main() {
fmt.Println("start")
defer fmt.Println("deferred 1")
defer fmt.Println("deferred 2")
defer fmt.Println("deferred 3")
fmt.Println("end of body")
}
输出:
start
end of body
deferred 3
deferred 2
deferred 1
函数体从上到下执行,输出 start 和 end of body。三个延迟调用在等着。main 返回时它们才执行,最后 defer 的最先执行。一步步来看:
上面程序的 defer 栈。函数体先输出 start,然后每个 defer 把一次调用压入栈中,但不执行。函数体输出 end of body,main 返回。之后延迟调用从栈顶逐个弹出,依次输出 deferred 3、deferred 2 和 deferred 1。
如果动画没有播放,下面用文字把这几步再说一遍:
- 函数体从头开始,输出
start。 - 每一行
defer都把它的调用放到栈上。先放deferred 1,再放deferred 2,deferred 3在最上面。什么都没输出。 - 函数体最后一行输出
end of body。 main返回。栈顶的调用deferred 3先执行。- 接着执行
deferred 2。 - 最后执行
deferred 1,它是第一个 defer 的。栈空了,main结束。
用十岁孩子能懂的话说
想象水池边有一摞盘子。每说一次 defer,你就在一个盘子上写下一件事,放到这摞盘子的最上面。这件事先不做,你接着忙手头的事。
等你忙完、准备离开厨房时,再去做盘子上的事。你只能拿最上面的盘子,所以最后写下的事最先做。
准确的说法
每次执行 defer 语句时,Go 当场求值函数值和它的参数,然后把这次调用保存起来。函数返回时,不管是通过 return 语句、执行到右花括号,还是因为 panic,保存的调用都按后进先出的顺序执行。它们在结果值设定之后、函数真正把控制权交还给调用方之前执行。
循环里的 defer 每次迭代保存一个调用,而且要等整个函数返回才会执行。在一个要跑一百万次的循环里用 defer 之前,最好先知道这一点。
这个比喻的局限:在盘子上写事情时,你会先用文字记下来,细节以后再想。Go 却是在你说 defer 的那一刻就把参数算好了,下一节会演示这一点。
参数在 defer 时求值,而不是在调用执行时
延迟调用会等到函数退出,但它的参数不等。参数在 defer 那一行就算好了,大多数人第一次遇到都会吃一惊。
package main
import "fmt"
func main() {
x := 1
defer fmt.Println("deferred sees x =", x)
defer func() {
fmt.Println("closure sees x =", x)
}()
x = 2
fmt.Println("main sets x =", x)
}
输出:
main sets x = 2
closure sees x = 2
deferred sees x = 1
第一个 defer 当场对 x 求值,那时它还是 1,于是把 1 作为参数存了下来。之后再改 x,对它没有影响。
第二个 defer 保存的是一个不带参数的闭包。没有什么需要提前求值。等它终于执行时,才去读 x,而那时 x 已经是 2 了。它和外层共用这个变量,跟前面的计数器一模一样。
所以,想让延迟调用看到最新的值,就 defer 一个闭包。想把值固定下来,就把它作为参数传进去。
日常用法:清理
真实 Go 代码里的 defer 语句,大多是在获取某样东西后立刻安排释放它:关闭文件、关闭响应体、解锁互斥锁。把清理代码写在紧接着的下一行,以后不管加了哪条返回路径,都不会忘记清理。
package main
import (
"errors"
"fmt"
)
type resource struct {
name string
}
func open(name string) *resource {
fmt.Println("open", name)
return &resource{name: name}
}
func (r *resource) Close() error {
fmt.Println("close", r.name)
return nil
}
func process(name string) error {
r := open(name)
defer r.Close()
if name == "broken.txt" {
fmt.Println("bad data, returning early")
return errors.New("bad data")
}
fmt.Println("processing", name)
return nil
}
func main() {
fmt.Println(process("notes.txt"))
fmt.Println(process("broken.txt"))
}
输出:
open notes.txt
processing notes.txt
close notes.txt
<nil>
open broken.txt
bad data, returning early
close broken.txt
bad data
这里的 resource 代替文件,这样程序不用碰磁盘。它的 Close 方法只是打印一行。(方法会有单独的一部分来讲。现在先把 r.Close() 读作“对 r 调用 Close”。)
两次调用都关闭了资源,而且都是在 process 把错误交还给 main 之前关闭的。提前返回的那条路径不需要单独写 Close,因为 defer 覆盖了函数的每一个出口。
同样的写法也用来保护锁。学到并发时,你会经常写这两行:
mu.Lock()
defer mu.Unlock()
上面那个程序悄悄跳过了一件事:Close 会返回错误,而 defer r.Close() 把它扔掉了。只读的文件这样没问题。写过的文件关闭失败,可能意味着数据丢失。下一节给出一种捕获它的办法。
用延迟执行的闭包修改结果
延迟执行的闭包在返回值设定之后运行,如果返回值是命名的,它就能读取并修改。只有在这里,命名返回值才不只是文档。
package main
import (
"errors"
"fmt"
)
func save(name string) (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("save %q: %w", name, err)
}
}()
if name == "" {
return errors.New("empty name")
}
return nil
}
func main() {
fmt.Println(save("notes"))
fmt.Println(save(""))
}
输出:
<nil>
save "": empty name
return errors.New("empty name") 设置了 err。接着延迟执行的闭包运行,发现 err 不是 nil,就换成一个加上了文件名的包装错误。调用方拿到的是替换后的值。
如果返回值没有命名,闭包就没有名字可以用来访问返回值,也就做不到这一点。同样的技巧能让延迟执行的 Close 报告它的错误:if cerr := f.Close(); cerr != nil && err == nil { err = cerr }。
函数 panic 时 defer 也会执行,recover 正是靠这一点才能工作。两者都会在讲错误的那一部分里详细讲。
要点
- 同类型的参数可以共用一个类型名,函数可以返回多个值。最后一个通常是
error,用if err != nil检查。 - 命名返回值从零值开始,并说明返回的是什么。裸返回只在短函数里用。
- 可变参数
...T在函数内部是切片。用s...把已有的切片展开传进去。 - 函数是有类型的值,类型形如
func(string) string。可以存储、传递和返回。 - 闭包和外层函数共用它用到的变量,并让这些变量一直存活。从 Go 1.22 起,每次循环迭代都有自己的变量。
defer在函数退出时执行调用,后进先出。它的参数在defer那一行求值。延迟执行的闭包在运行时才读取变量,还能修改命名返回值。
需要清理的东西,清理代码就写在它的下一行。