需要预先了解:


1. 函数

Go 的函数有两个显著特点:参数和返回值的类型都写在名字后面,以及支持多返回值。除此之外,函数在 Go 里是一等公民——可以赋值给变量、作为参数传递、作为返回值,这也让闭包成为日常写法。这篇还会讲清楚一个容易被忽略的事实:Go 的参数传递永远是值拷贝。


1.1 定义与调用

func add(a int, b int) int {
	return a + b
}
  • func 是关键字;
  • add 是函数名;
  • 参数列表里,名字在前、类型在后;相邻参数类型相同时可以合并写成 a, b int;
  • 返回值类型写在参数列表之后。

没有返回值时省略返回类型:

func greet(name string) {
	fmt.Println("你好,", name)
}

1.2 多返回值

函数可以返回多个值,这是 Go 里处理"结果 + 错误"的标准形式:

func divmod(a, b int) (int, int) {
	return a / b, a % b
}
q, r := divmod(17, 5)
fmt.Println(q, r) // 3 2

不需要的返回值用 _ 丢弃:

n, _ := strconv.Atoi("42")
fmt.Println(n) // 42

多返回值配合逗号 ok 惯例,就构成了 Go 错误处理的基础形态(详见 错误处理)。


1.3 命名返回值

返回值可以起名字,起名后它们就是函数内已声明的变量:

func split(sum int) (x, y int) {
	x = sum * 4 / 9
	y = sum - x
	return // 裸 return,返回当前 x、y 的值
}
x, y := split(17)
fmt.Println(x, y) // 7 10

裸 return(不写返回值)只能用在有命名返回值的函数里。命名返回值有两个实际用途:

  1. 在文档里说明每个返回值的含义;
  2. 配合 defer 修改返回值:
func double() (result int) {
	defer func() { result *= 2 }()
	return 10
}
fmt.Println("double() =", double()) // 20

return 10 会先把 10 赋给 result,再执行 defer,defer 里把 result 改成 20,函数最终返回 20。这个机制在 错误处理 里会完整展开。

注:返回值短的时候用命名返回值可以提升可读性,但函数体很长时,命名返回值容易让人忘记"这个变量最后会被返回",反而增加心智负担。


1.4 可变参数

参数类型前加 ... 表示可变参数,函数内它是一个切片:

func sum(nums ...int) int {
	total := 0
	for _, n := range nums {
		total += n
	}
	return total
}

调用时可以逐个传,也可以把切片展开:

fmt.Println(sum(1, 2, 3))     // 6
nums := []int{1, 2, 3, 4}
fmt.Println(sum(nums...))     // 10
fmt.Println(sum(empty...))    // 0
fmt.Println(sum())            // 0

可变参数只能有一个,且必须放在参数列表最后:

func logf(level string, args ...any) {
	fmt.Print(level, ": ")
	fmt.Println(args...)
}

any 是 interface{} 的别名(Go 1.18+),可以接收任意类型的值。


1.5 参数传递:一切都是值拷贝

Go 只有值传递,没有引用传递。但"值"的含义取决于类型:结构体拷的是字段,切片拷的是表头,指针拷的是地址。三类实验放在一起看就清楚了。

type Person struct {
	Name string
	Age  int
}

func modifyStruct(p Person) { p.Name = "函数内改名" }   // 改副本
func modifyPtr(p *Person)   { p.Name = "函数内改名" }   // 改指向的对象
func modifySliceElem(s []int) { s[0] = 100 }            // 改共享的底层数组
func appendInFunc(s []int)    { s = append(s, 999) }    // 只改副本的表头
p := Person{Name: "一撮灰", Age: 3}
modifyStruct(p)
fmt.Println("传值后:", p)     // {一撮灰 3}

modifyPtr(&p)
fmt.Println("传指针后:", p)   // {函数内改名 3}

s := []int{1, 2, 3}
modifySliceElem(s)
fmt.Println("改元素后:", s)   // [100 2 3]

appendInFunc(s)
fmt.Println("函数内 append 后:", s, len(s)) // [100 2 3] 3

结论:

  • 结构体传参:拷贝整个结构体,函数内怎么改都不影响调用方;
  • 指针传参:拷贝的是地址,通过它修改指向的对象,调用方能看到;
  • 切片传参:拷贝的是 24 字节的表头,底层数组共享,所以改元素有效,但函数内 append 只改了副本的 len。

想让函数内的 append 生效,就要返回新切片由调用方接收:

func push(s []int, v int) []int {
	return append(s, v)
}

1.6 函数是一等公民

函数类型由参数和返回值决定,func(int) int 和 func(int) string 是不同类型。可以用 type 给函数类型起名:

type IntOp func(int) int

函数可以赋值给变量、作为参数、作为返回值:

func apply(nums []int, f IntOp) []int {
	out := make([]int, 0, len(nums))
	for _, n := range nums {
		out = append(out, f(n))
	}
	return out
}

func filter(nums []int, pred func(int) bool) []int { /* ... */ }

func compose(f, g IntOp) IntOp {
	return func(n int) int { return f(g(n)) }
}
nums := []int{1, 2, 3, 4, 5}

double := func(n int) int { return n * 2 }
fmt.Println(apply(nums, double))                       // [2 4 6 8 10]
fmt.Println(filter(nums, func(n int) bool { return n%2 == 0 })) // [2 4]

inc := func(n int) int { return n + 1 }
fmt.Println(compose(double, inc)(10))                  // 22

需要给不同类型的切片做同样的操作时,从 Go 1.18 起可以写泛型函数,比"为每种类型写一遍"或"用 any 加类型断言"更清晰,见 泛型。


1.7 匿名函数与闭包

匿名函数是没有名字的函数,可以直接定义并调用,也可以赋给变量:

double := func(n int) int { return n * 2 }
fmt.Println(double(21)) // 42

闭包(closure)是引用了外部变量的函数。它捕获的是变量本身,不是变量在某一时刻的快照:

func makeCounter() func() int {
	count := 0
	return func() int {
		count++
		return count
	}
}
counter := makeCounter()
fmt.Println(counter(), counter(), counter()) // 1 2 3

count 在 makeCounter 返回后依然存活,因为它被闭包引用着(这种情况下 count 会分配在堆上,见 内存分配与逃逸分析)。

多个闭包共享同一个变量时,一个改、另一个能读到:

x := 0
add := func() { x++ }
show := func() int { return x }
add()
add()
fmt.Println("x =", show()) // x = 2

循环变量捕获

在 Go 1.22 之前,for 的循环变量在整个循环中只有一个实例,闭包捕获到的是同一个变量,所有闭包最后都会看到循环结束时的值。Go 1.22 起,每轮迭代都会创建新的循环变量,所以现在这段代码会输出 0 1 2:

funcs := make([]func(), 0, 3)
for i := 0; i < 3; i++ {
	funcs = append(funcs, func() { fmt.Print(i, " ") })
}
for _, f := range funcs {
	f()
}
0 1 2

在 Go 1.22 之前的版本里,同样的代码会输出 3 3 3。这个差异在并发场景里影响更大,详见 并发设计模式与陷阱总结。


1.8 defer

defer 把一次函数调用推迟到当前函数返回前执行,常用于关闭资源:

f, err := os.Open("data.txt")
if err != nil {
	return err
}
defer f.Close()

它有两条必须记住的规则。

1. 多个 defer 按 LIFO(后进先出)顺序执行。

for i := 1; i <= 3; i++ {
	defer fmt.Println("defer", i)
}
defer 3
defer 2
defer 1

2. defer 后面调用的参数在 defer 语句执行时立即求值。

i := 1
defer fmt.Println("捕获的值:", i) // 这里就把 i 复制成 1
i = 100
defer func() { fmt.Println("闭包看到的值:", i) }() // 闭包捕获变量,看到最新值
fmt.Println("main 结束前 i =", i)
main 结束前 i = 100
闭包看到的值: 100
捕获的值: 1

注意输出顺序:先打印 main 里的语句,再按 LIFO 执行两个 defer,所以闭包那个(后注册)先执行。

注意:defer 在循环里使用时,注册的调用会累积到函数返回时才执行。循环里打开大量文件并 defer Close,会导致文件描述符一直占用到函数结束,这种情况下应该把循环体抽成独立函数。


1.9 练习

  1. 写一个 reduce 高阶函数,签名是 func reduce(nums []int, init int, f func(int, int) int) int,用它分别实现求和与求最大值。
  2. 用闭包实现一个 next 函数,每次调用返回斐波那契数列的下一个数。
  3. 写一个函数,用 defer 记录函数耗时(配合 time.Now()),并验证 defer 的参数求值时机对结果的影响。