Skip to content

Go 函数:类型、闭包、方法值、泛型与迭代器

面向有 Java 经验的开发者,基于 Go 1.26。

Go 函数不只是“把一段代码封装起来”。它有自己的静态类型,可以赋值、传参、返回并与 nil 比较;闭包能够捕获变量;方法可以转换成方法值或方法表达式;泛型函数可以把同一套算法用于一族类型。从 Go 1.23 开始,特定签名的函数甚至直接构成了 range 的迭代协议。

要把这些行为串起来,需要同时观察三个层次:源码中的声明与调用、类型系统中的签名,以及运行时的栈帧与闭包环境。后文也会沿着这三个层次展开。

目录

1. 先建立心智模型

先把几个容易混在一起的概念拆开:

  • 函数声明在包级别定义一个有名字的函数;
  • 函数字面量在表达式位置创建匿名函数;
  • 函数类型由参数列表和结果列表决定;
  • 闭包是函数值加上它所引用的外部变量环境;
  • 方法是带 receiver 的函数声明;
  • 调用会先求出被调用函数和实参,再建立本次调用的参数变量。
go
func Add(a, b int) int { // 函数声明
	return a + b
}

type Operation func(int, int) int // 定义函数类型

var op Operation = Add // 函数值

offset := 10
addOffset := func(n int) int { // 函数字面量;同时形成闭包
	return n + offset
}

函数类型的零值是 nil。函数值只能拿来与 nil 比较,彼此之间不能比较,也不能作为 map key。

go
var fn func(int) int
fmt.Println(fn == nil) // true
// fn(1)               // panic:调用 nil 函数

2. 函数声明与签名

2.1 基本语法

go
func Divide(dividend int, divisor int) (int, int) {
	return dividend / divisor, dividend % divisor
}

相邻参数类型相同时可合并:

go
func Divide(dividend, divisor int) (quotient, remainder int) {
	return dividend / divisor, dividend % divisor
}

函数签名包含:

  • 参数的数量、顺序和类型;
  • 结果的数量、顺序和类型;
  • 是否为可变参数;
  • 若是泛型函数,还包括类型参数列表。

参数名和结果名不属于函数类型:

go
var f func(int, int) int
f = func(left, right int) int { return left + right }
f = func(x, y int) int { return x * y }

2.2 无参数、无结果

go
func Flush() {
	// ...
}

Go 没有 void 类型。无结果函数就是结果列表为空,不能把调用结果赋值。

2.3 参数声明顺序

Go 写“名字在前、类型在后”,复杂类型从左向右仍容易读:

go
func Transform(
	ctx context.Context,
	input []byte,
	apply func(context.Context, []byte) ([]byte, error),
) ([]byte, error)

回调签名如果在多个 API 中出现,应定义命名类型:

go
type Transformer func(context.Context, []byte) ([]byte, error)

命名类型可以承载文档,也可以定义方法;不过,它与底层相同的未命名函数类型在赋值规则上仍有细节。设计 API 时,不必为了“看起来正式”制造大量只用一次的名字。

3. 参数传递是值传递

Go 的参数一律按值传递。代码中表现出的“引用效果”,来自被复制的值内部仍然指向共享数据。

3.1 普通值

go
func Increment(n int) {
	n++
}

n := 1
Increment(n)
fmt.Println(n) // 1

3.2 指针

go
func Increment(n *int) {
	(*n)++
}

n := 1
Increment(&n)
fmt.Println(n) // 2

传入函数的仍是指针值的副本,只是这个副本也能访问同一目标。函数重新给自己的指针参数赋值,并不会改变调用者变量中保存的指针。

3.3 slice

slice 值包含指向底层数组的指针、长度和容量。传参会复制这三个字段:

go
func SetFirst(values []int) {
	values[0] = 99 // 修改共享底层数组
}

func AppendLocal(values []int) {
	values = append(values, 4) // 只更新局部 slice header
}

若函数要让调用者看到新长度,返回 slice:

go
func AppendValue(values []int, value int) []int {
	return append(values, value)
}

values = AppendValue(values, 4)

3.4 map、channel、function 和 interface

这些值复制后通常仍引用同一个运行时对象或动态值,但仍然是值传递。把 map 参数赋成新 map 不会替换调用者变量;向 map 写 entry 会修改共享表。

所以,“复制后仍然引用同一个运行时对象”比“Go 有些类型按引用传递”更准确。后者很容易让人误判重新赋值、nil 和并发行为。

4. 多返回值与错误

4.1 多返回值不是 tuple 对象

go
func Lookup(key string) (Value, bool)
func Parse(text string) (Value, error)

调用时多重赋值:

go
value, ok := Lookup("language")
if !ok {
	return ErrNotFound
}

多返回值只能用于特定语法位置,不能作为一个可传递的 tuple 值:

go
// pair := Lookup("language") // 编译错误:多值不能赋给单个变量

需要长期保存或跨 API 传递,就定义 struct:

go
type LookupResult struct {
	Value Value
	Found bool
}

4.2 error 通常放最后

go
func ReadConfig(path string) (Config, error)

成功返回可用值和 nil,失败返回类型零值与非 nil error:

go
func ParsePort(text string) (uint16, error) {
	n, err := strconv.ParseUint(text, 10, 16)
	if err != nil {
		return 0, fmt.Errorf("解析端口 %q: %w", text, err)
	}
	return uint16(n), nil
}

有时即使 error 非 nil,前面的结果仍有意义,例如 io.Reader.Read 可能同时返回 n > 0io.EOF。是否能使用部分结果由具体 API 契约决定,不能机械套“err 非 nil 就丢掉结果”。

4.3 直接返回与增加上下文

go
if err := repository.Save(user); err != nil {
	return fmt.Errorf("保存用户 %q: %w", user.ID, err)
}
return nil

只在当前层能增加有用上下文时包装。每层都重复“failed to”会形成冗长而无信息的错误链。

5. 命名返回值

结果可以命名:

go
func Split(total int) (left, right int) {
	left = total / 2
	right = total - left
	return
}

命名结果在函数入口处声明并初始化为零值,可以被 defer 读取和修改。裸 return 返回它们当前的值。

5.1 适合的场景

  • 返回值含义无法从类型看出,如 (width, height int)
  • defer 需要合并关闭错误;
  • 短函数中名称显著改善文档。
go
func WriteFile(path string, data []byte) (err error) {
	file, err := os.Create(path)
	if err != nil {
		return err
	}
	defer func() {
		err = errors.Join(err, file.Close())
	}()

	_, err = file.Write(data)
	return err
}

5.2 不适合的场景

长函数里大量修改命名结果,再用裸 return,会让读者难以确认实际返回值:

go
func calculate(...) (result Result, err error) {
	// 两百行后 return,result 和 err 在哪里被改过很难追踪
	return
}

可以保留结果名用于文档,但显式返回:

go
return result, err

结果名与局部变量同处函数体作用域,短声明时还要防止遮蔽。

6. 函数是一等值

6.1 赋值与传参

go
type Predicate[T any] func(T) bool

func Filter[T any](values []T, keep Predicate[T]) []T {
	result := make([]T, 0, len(values))
	for _, value := range values {
		if keep(value) {
			result = append(result, value)
		}
	}
	return result
}

调用:

go
positive := Filter(numbers, func(n int) bool {
	return n > 0
})

回调适合表达局部策略。跨多个方法、带状态和生命周期的能力更适合接口。

6.2 返回函数

go
func Multiplier(factor int) func(int) int {
	return func(value int) int {
		return value * factor
	}
}

double := Multiplier(2)
fmt.Println(double(21)) // 42

返回的闭包在 Multiplier 返回后仍能使用 factor。编译器会确保被捕获状态的生命周期足够长,必要时让它逃逸到堆。

6.3 函数值的限制

  • 只能与 nil 比较;
  • 不能作为 map key;
  • 调用 nil 函数会 panic;
  • 函数值可能携带闭包环境,不应假设它只是一个机器代码地址;
  • 标准库没有稳定方式取得函数值的语义身份。

需要注册和注销回调时,应返回显式 token 或 cancel 函数,而不是比较函数值:

go
cancel := bus.Subscribe(handler)
defer cancel()

7. 匿名函数与闭包

7.1 立即调用

go
result := func(a, b int) int {
	return a + b
}(20, 22)

立即调用可以建立局部作用域,但常规流程若不需要隔离 defer 或复杂初始化,直接写普通代码更简单。

7.2 捕获变量,不是快照

go
count := 0
next := func() int {
	count++
	return count
}

fmt.Println(next()) // 1
fmt.Println(next()) // 2

闭包引用变量本身,后续赋值可见:

go
prefix := "dev"
format := func(name string) string {
	return prefix + ":" + name
}

prefix = "prod"
fmt.Println(format("api")) // prod:api

若需要创建时的快照,把值作为参数传入工厂函数,或复制到新的不会再修改的变量。

7.3 多个闭包共享状态

go
func Counter() (next func() int, reset func()) {
	count := 0
	return func() int {
			count++
			return count
	}, func() {
			count = 0
	}
}

两个闭包共享同一个 count。这是一种封装状态的方式,但并不自动并发安全。

8. 闭包捕获与并发

8.1 Go 1.22 的循环变量

对语言版本 Go 1.22 及以上,循环用 := 声明的变量每轮都是新变量:

go
for _, item := range items {
	go func() {
		process(item)
	}()
}

每个闭包捕获对应迭代的 item。但如果循环使用 = 写入外层变量,它仍是共享变量:

go
var item Item
for _, item = range items {
	go func() {
		process(item) // 共享外层 item
	}()
}

语义由模块语言版本决定,通常看 go.modgo 行。

8.2 捕获安全不等于数据安全

go
counter := 0
for range 10 {
	go func() {
		counter++ // 数据竞争
	}()
}

闭包共享 counter,必须同步。可用 mutex、atomic,或让一个 goroutine 独占状态。运行:

bash
go test -race ./...

8.3 生命周期和取消

启动 goroutine 的函数必须说明它何时结束:

go
func Start(ctx context.Context, interval time.Duration, fn func()) {
	go func() {
		ticker := time.NewTicker(interval)
		defer ticker.Stop()

		for {
			select {
			case <-ticker.C:
				fn()
			case <-ctx.Done():
				return
			}
		}
	}()
}

这里仍有两个 API 问题:调用者如何等待真正停止,以及 fn panic 怎么处理。工程接口可能返回 done <-chan struct{} 或 stop/wait 句柄。

9. 方法、方法值与方法表达式

9.1 方法声明

go
type Counter struct {
	value int
}

func (c *Counter) Add(delta int) int {
	c.value += delta
	return c.value
}

receiver 是一个额外的、写在函数名前的参数。方法属于定义在同一包中的非指针、非接口命名类型(以及符合规范的别名限制)。

9.2 方法值

go
counter := &Counter{}
add := counter.Add

fmt.Println(add(2))
fmt.Println(add(3))

counter.Add 会求值并保存 receiver,得到类型 func(int) int。以后调用 add 不再提供 receiver。

receiver 在创建方法值时求值。值 receiver 会被复制到函数值中:

go
type Point struct{ X int }

func (p Point) GetX() int { return p.X }

p := Point{X: 1}
get := p.GetX
p.X = 2
fmt.Println(get()) // 1

指针 receiver 保存的是指针副本,目标后续变化可见。

9.3 方法表达式

go
add := (*Counter).Add
counter := &Counter{}

fmt.Println(add(counter, 5))

(*Counter).Add 得到普通函数,receiver 成为第一个显式参数,类型为:

go
func(*Counter, int) int

值 receiver:

go
getX := Point.GetX
fmt.Println(getX(Point{X: 42}))

方法表达式适合把某个方法作为通用算法传递,而不绑定具体实例。

9.4 自动取地址的边界

对可寻址值调用指针 receiver 方法时,编译器可自动取地址:

go
var c Counter
c.Add(1) // 等价于 (&c).Add(1)

但 map 元素不可寻址:

go
counters := map[string]Counter{"a": {}}
// counters["a"].Add(1) // 编译错误

需要取出、修改、写回,或 map 保存指针。方法值的创建同样受可寻址性和 method set 约束。

10. 可变参数

10.1 声明与调用

最后一个参数可写成 ...T

go
func Sum(values ...int) int {
	total := 0
	for _, value := range values {
		total += value
	}
	return total
}

Sum()
Sum(1, 2, 3)

函数体中 values 的类型是 []int

已有 slice 可展开:

go
numbers := []int{1, 2, 3}
total := Sum(numbers...)

除特定内建函数外,不能把展开 slice 和额外同类型实参混写:

go
// Sum(0, numbers...) // 普通可变参数调用不允许这种混合
all := append([]int{0}, numbers...)
Sum(all...)

10.2 是否复制

调用 Sum(numbers...) 时,函数收到的 slice 会共享调用者的底层数组;函数修改元素可能影响调用者:

go
func Zero(values ...int) {
	for i := range values {
		values[i] = 0
	}
}

numbers := []int{1, 2, 3}
Zero(numbers...)
fmt.Println(numbers) // [0 0 0]

若 API 不应修改输入,文档说明并遵守;需要保存参数到调用结束之后时,考虑 slices.Clone,避免调用者后续修改。

10.3 ...any 的代价

go
func Log(values ...any)

它灵活,但失去元素类型约束,值装入接口可能带来转换和分配,也难以表达参数结构。结构化日志更适合明确的键值类型或属性类型;公共 API 不要把 ...any 当作重载替代品。

11. 递归

函数可以直接或间接调用自己:

go
func Factorial(n uint64) uint64 {
	if n < 2 {
		return 1
	}
	return n * Factorial(n-1)
}

递归必须有可到达的终止条件,并考虑数值溢出。Go 的整数溢出不会自动变成 error。

11.1 栈会增长,但不是无限

goroutine 栈从较小尺寸开始并可增长,但深度失控仍会耗尽内存或超过运行时上限。Go 不保证尾调用优化:

go
func SumTail(values []int, acc int) int {
	if len(values) == 0 {
		return acc
	}
	return SumTail(values[1:], acc+values[0])
}

不要假设它会变成常量栈。线性遍历优先写循环。

11.2 适合递归的结构

树、语法结构、分治算法用递归往往最清楚:

go
func Walk(node *Node, visit func(*Node) error) error {
	if node == nil {
		return nil
	}
	if err := visit(node); err != nil {
		return err
	}
	for _, child := range node.Children {
		if err := Walk(child, visit); err != nil {
			return err
		}
	}
	return nil
}

处理不可信深度的输入时,使用显式栈并设置节点、深度和内存上限,避免拒绝服务。

11.3 递归闭包

函数字面量要递归调用自己,先声明变量:

go
var visit func(*Node)
visit = func(node *Node) {
	if node == nil {
		return
	}
	fmt.Println(node.Value)
	for _, child := range node.Children {
		visit(child)
	}
}

直接写 visit := func(...) { visit(...) } 时,右侧初始化期间 visit 尚未进入作用域。

12. 泛型函数

12.1 类型参数与约束

go
func Max[T cmp.Ordered](a, b T) T {
	if a < b {
		return b
	}
	return a
}

T 是类型参数,cmp.Ordered 是约束。调用时通常能推断:

go
fmt.Println(Max(3, 5))
fmt.Println(Max("go", "java"))

无法从普通参数推断时显式提供类型实参:

go
value := Zero[time.Duration]()

12.2 anycomparable

go
func First[T any](values []T) (T, bool) {
	var zero T
	if len(values) == 0 {
		return zero, false
	}
	return values[0], true
}

func Contains[T comparable](values []T, target T) bool {
	for _, value := range values {
		if value == target {
			return true
		}
	}
	return false
}

any 不代表“运行时随便处理”,而是算法不需要对 T 做额外操作。要用 ==,约束必须允许比较;要用 <,需要相应类型集合。

12.3 底层类型近似

go
type Integer interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64
}

func Sum[T Integer](values []T) T {
	var total T
	for _, value := range values {
		total += value
	}
	return total
}

~int 包含底层类型为 int 的命名类型。没有 ~ 时只包含精确的预声明类型。

12.4 保留命名 slice 类型

go
func Map[S ~[]E, E any, R any](source S, fn func(E) R) []R {
	result := make([]R, len(source))
	for i, value := range source {
		result[i] = fn(value)
	}
	return result
}

如果输出元素仍是 E,可以让返回类型也是 S,从而保留调用者的命名 slice 类型:

go
func Clone[S ~[]E, E any](source S) S {
	return append(S(nil), source...)
}

API 设计要明确是否应该保留命名类型,而不是一味追求最复杂的签名。

12.5 何时不要用泛型

  • 操作依赖行为而不是类型运算时,用接口;
  • 只有一个具体类型的业务流程,不必泛化;
  • 各类型分支完全不同,泛型不会消除 type switch;
  • 复杂约束让调用点和错误信息比重复两段短代码更难懂。

泛型擅长容器、算法和类型安全的适配器,不是 Java 方法重载或继承体系的替代品。

13. 函数迭代器

Go 1.23 允许 range 三种函数签名:

go
func(func() bool)
func(func(V) bool)
func(func(K, V) bool)

标准库 iter 定义了:

go
type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)

13.1 一个可组合的迭代器

go
func MapSeq[A, B any](source iter.Seq[A], transform func(A) B) iter.Seq[B] {
	return func(yield func(B) bool) {
		for value := range source {
			if !yield(transform(value)) {
				return
			}
		}
	}
}

使用:

go
lengths := MapSeq(slices.Values(words), func(word string) int {
	return len(word)
})

for length := range lengths {
	fmt.Println(length)
}

13.2 yield 的协议

迭代器作者必须遵守:

  • 每次调用 yield 最多产生一组值;
  • yield 返回 true 才能继续;
  • yield 返回 false 后不得再次调用;
  • 迭代结束就返回;
  • 资源清理应覆盖调用者提前停止的路径。
go
func Lines(scanner *bufio.Scanner) iter.Seq2[string, error] {
	return func(yield func(string, error) bool) {
		for scanner.Scan() {
			if !yield(scanner.Text(), nil) {
				return
			}
		}
		if err := scanner.Err(); err != nil {
			yield("", err)
		}
	}
}

这个设计把错误作为最后一项,调用者容易忘记处理。实践中也可以让迭代器只产出值,并由拥有 scanner 的对象提供 Err();选择哪种形式应保持整个库一致。

13.3 iter.Pull

go
next, stop := iter.Pull(sequence)
defer stop()

value, ok := next()

Pull 形式适合 zip、归并和需要同时推进多个序列的算法。只要可能提前结束,就调用 stop。不要并发调用 next 或 stop,除非具体 API 文档明确允许。

13.4 迭代器与所有权

迭代器可能返回指向内部缓冲区的 slice 或指针,下一次迭代就覆盖。API 必须说明值是否稳定。调用者需要长期保存时应复制:

go
for record := range records {
	saved = append(saved, bytes.Clone(record))
}

懒迭代减少中间 slice,但增加生命周期和错误传播复杂度。小集合上直接返回 slice 往往更简单。

14. 逃逸分析、内联与性能

14.1 栈和堆由编译器决定

Go 不按 new& 或闭包语法简单决定分配位置。若一个值的生命周期可能超过当前调用,或分析无法证明它留在栈上,它可能逃逸到堆:

go
func NewCounter() *int {
	n := 0
	return &n
}

返回局部变量地址是安全的,编译器会保证它的生命周期。

查看诊断:

bash
go test -gcflags='all=-m=2' ./...
go build -gcflags='all=-m=2' ./...

这些输出属于编译器诊断,不是稳定 API,会随着版本和代码形态变化。

14.2 闭包可能分配,也可能不分配

立即调用且不逃逸的闭包通常可以在栈上处理;闭包被返回、存入堆对象或交给 goroutine 时,它的环境更可能逃逸。不要因为“闭包一定慢”就提前改写,先用:

bash
go test -bench=. -benchmem

观察 allocs/op,再结合 profile 和逃逸输出定位。

14.3 内联

编译器能把符合条件的小函数内联到调用点,减少调用开销,并为常量传播、边界检查消除创造机会。能否内联由编译器预算和实现决定,不能作为 API 契约。

清晰的小函数通常不会成为性能问题。为了避开一个可能早已被内联的调用而复制业务逻辑,损失的是确定的可维护性。只有 benchmark 确认这里是热点,才值得根据当前工具链的诊断继续优化。

14.4 函数值和接口调用

直接调用、函数值调用和接口方法调用的优化机会并不相同,编译器有时可以去虚拟化并内联,有时则不能。间接调用确实不利于分支预测和内联,但业务系统的主要成本往往在分配、锁和 I/O。最终仍要用真实负载测量。

14.5 参数大小

值传递大 struct 可能产生复制;改成指针可能减少复制,却带来:

  • nil 状态;
  • 可变共享与数据竞争;
  • 更复杂的所有权;
  • 逃逸和 GC 压力;
  • 较差的缓存局部性。

值还是指针,应当先由语义决定,再到真实热点中测量。小而不可变的值类型通常很适合值传递。

15. API 与工程设计

15.1 参数少而明确

参数持续增加,通常意味着缺少领域对象,或者函数承担了太多职责。不要把所有参数都塞进无类型的 map[string]any,更清楚的做法是定义配置 struct:

go
type ClientOptions struct {
	Timeout    time.Duration
	MaxRetries int
	Transport  http.RoundTripper
}

构造选项稳定且字段多时可以用 struct。functional options 适合可选配置和需要校验/默认值的构造器,但会增加间接层,不是每个构造器都需要。

15.2 接受接口,返回具体类型

这条经验的核心是让函数只依赖所需能力:

go
func Decode(r io.Reader) (*Document, error)

接口最好由使用方定义,并保持小。返回具体类型让调用者能使用完整能力;当隐藏实现或允许多个实现确实是 API 目标时,返回接口也合理。

15.3 context 的位置

按 Go 惯例,context.Context 是第一个参数,名为 ctx,不存入 struct,不作为可选 nil:

go
func Fetch(ctx context.Context, id string) (Record, error)

context 表达请求生命周期、取消和截止时间,不用于塞普通可选业务参数。

15.4 谁创建,谁负责清理

返回资源时明确所有权:

go
func Open(path string) (io.ReadCloser, error)

调用者看到 io.Closer 应知道要关闭。若函数内部只临时创建资源,就在函数内 defer。回调式 API 可以把资源生命周期限制在回调内,但要清楚回调不能保存资源供之后使用。

15.5 panic 边界

普通库函数用 error 报告可预期失败。panic 适合内部不变量被破坏、程序无法继续的情况。若 API 接受用户回调,要说明回调 panic 是否传播;默认传播通常比静默吞掉更易诊断。只有 goroutine 或插件边界确实需要隔离时才 recover,并转换成有上下文的失败。

16. 与 Java 的对照

JavaGo关键差异
方法 / static 方法函数 / 方法Go 包级函数不依附 class
overload无函数重载用不同名字、泛型或不同类型建模
checked exceptionerror 返回值错误是普通值
lambda函数字面量Go 闭包捕获变量,可读写
functional interface函数类型不需单方法接口即可传行为
method reference obj::m方法值 obj.Mreceiver 已绑定
method reference Type::m方法表达式 Type.Mreceiver 是首个参数
varargs T......T函数体中是 slice,可用 slice... 展开
generic method泛型函数/泛型类型的方法Go 方法不能自行声明额外类型参数
Streamiter.Seq + range更小的语言级 push 协议
JVM 栈可增长 goroutine 栈仍无尾调用优化保证

Go 没有默认参数和重载。为了模拟重载而使用 ...any 加 type switch,通常会损失编译期检查。优先给行为不同的函数不同名字,或用配置 struct、泛型表达真正统一的算法。

17. Go 1.22–1.26 相关变化

  • Go 1.22:按模块语言版本启用每轮独立的循环变量;闭包捕获 range/for 声明变量的常见问题得到语义层修复。加入整数 range。
  • Go 1.23:加入 range-over-function 和标准库 iter,函数签名正式成为语言迭代协议;slicesmaps 中多个 API 与迭代器组合。
  • Go 1.24–1.26:函数声明、闭包、方法值、可变参数和泛型函数的上述核心规则没有需要改写的语法变化。工具链的内联、逃逸和去虚拟化会持续改进,但这些属于实现,不应写成源码正确性依赖。

版本说明必须区分语言特性、标准库 API 和编译器优化。某段代码在 Go 1.26 分配更少,不代表规范保证未来始终如此。

18. 常见误区

  • 说 Go “有引用传递”:所有参数都是值传递;共享效果来自值中包含指针或运行时引用。
  • 修改 slice 参数的 header,期待调用者长度变化:返回新 slice 并赋回。
  • 把多返回值当 tuple 保存:需要保存就定义 struct。
  • 长函数使用裸 return:结果从哪里赋值难以追踪。
  • 比较两个函数值:函数只能与 nil 比较。
  • 调用 nil 函数:会 panic,回调可选时先判断。
  • 认为闭包捕获创建时快照:闭包捕获变量。
  • 认为 Go 1.22 后所有闭包并发都安全:共享对象和外层变量仍需同步。
  • 混淆方法值与方法表达式:前者绑定 receiver,后者把 receiver 变成首参。
  • 认为值 receiver 方法值会追踪原变量:创建时 receiver 值已复制。
  • 认为 slice... 一定复制:展开传递共享底层数组。
  • 假设尾递归优化:Go 不保证,应对深线性递归使用循环。
  • any 约束后执行 ==:需要 comparable
  • 迭代器在 yield false 后继续:违反协议。
  • 凭感觉优化闭包和小函数:用 benchmark、profile 与 -m=2

19. 速查表

go
func F(a int, b string) (Result, error)

func F(a, b int) int

func F(values ...int) int

func F[T comparable](a, b T) bool

type Handler func(context.Context, Request) (Response, error)

匿名函数和闭包:

go
fn := func(value int) int { return value * 2 }

func Factory(offset int) func(int) int {
	return func(value int) int { return value + offset }
}

方法值与方法表达式:

go
bound := object.Method // receiver 已绑定
bound(arg)

unbound := (*Type).Method
unbound(object, arg)

泛型:

go
func Contains[S ~[]E, E comparable](values S, target E) bool

迭代器:

go
func Values[T any](items []T) iter.Seq[T] {
	return func(yield func(T) bool) {
		for _, item := range items {
			if !yield(item) {
				return
			}
		}
	}
}

诊断:

bash
go test -race ./...
go test -bench=. -benchmem ./...
go build -gcflags='all=-m=2' ./...

20. 练习

  1. 实现 Map[S ~[]E, E, R any]Filter[S ~[]E, E any],思考返回值是否应保留命名 slice 类型。
  2. 写一个返回 nextvaluereset 三个闭包的计数器,然后用 race detector 证明它默认不适合并发;再加 mutex。
  3. 为一个值 receiver 和一个指针 receiver 分别创建方法值,创建后修改原变量,记录调用结果并解释 receiver 的求值时机。
  4. 实现可变参数函数 Concat(separator string, parts ...string),测试零参数、直接实参和 slice 展开。明确函数是否修改 parts
  5. 用递归遍历树,再用显式栈改写。加入最大深度限制,比较错误处理和遍历顺序。
  6. 实现 Reduce[S ~[]E, E, A any]。解释为什么累加器类型 A 可能与元素类型 E 不同。
  7. 实现 Take[T any](iter.Seq[T], n int) iter.Seq[T],确保够 n 项后停止上游,调用方提前 break 时也停止。
  8. iter.Pull 实现两个有序 Seq 的归并,保证所有返回路径调用 stop。
  9. 对直接循环、传入函数值、接口方法三种转换策略做 benchmark,并查看 -m=2;记录当前工具链结果,但不要把它当语言保证。
  10. 实现写文件函数,用命名 error 和 defer 合并 Close 错误。分别模拟写失败、关闭失败和两者同时失败。

21. 可运行示例

函数不仅是可复用代码块,也是可以捕获状态、绑定 receiver、接受类型参数的值。下面三个例子位于 examples/ch07

21.1 两个闭包共享一份词法状态

闭包保存的是可继续访问的词法环境。 外层函数返回后,局部变量为什么还能存在?不同闭包和不同函数调用之间怎样共享状态?

go
package main

import "fmt"

// counter 返回两个共享同一份局部状态的闭包。
// next 修改 value,current 只读取 value;只要闭包仍可达,这份状态就必须保持有效。
// 状态最终放在栈还是堆属于编译器实现细节,不是语言层面的承诺。
func counter(start int) (next func() int, current func() int) {
	value := start
	next = func() int {
		value++
		return value
	}
	current = func() int {
		return value
	}
	return next, current
}

func main() {
	next, current := counter(10)
	fmt.Printf("初始=%d\n", current())
	fmt.Printf("next=%d\n", next())
	fmt.Printf("next=%d\n", next())
	fmt.Printf("当前=%d\n", current())

	// 另一次调用拥有独立的词法环境,不会与上面的计数器共享状态。
	otherNext, _ := counter(100)
	fmt.Printf("另一个=%d\n", otherNext())
}

运行:

bash
go run ./examples/ch07/closure-state

预期输出:

text
初始=10
next=11
next=12
当前=12
另一个=101

两组计数器揭示了共享边界。

  1. nextcurrent 都引用同一次 counter 调用中的 value,因此一个闭包修改后,另一个能读到新值。
  2. 外层函数返回不代表 value 立即销毁;只要闭包仍可达,它捕获的环境就必须继续存活。
  3. 第二次调用 counter(100) 会创建独立环境,不与第一个计数器共享 value。
  4. 这份可变状态没有同步保护,多 goroutine 并发调用会数据竞争;闭包不会自动提供线程安全。

把共享扩展到并发场景。 再创建一个读取第一个计数器的闭包,验证共享;然后写两个 goroutine 调 next,用 go run -race 观察竞态诊断。

21.2 方法值何时绑定 receiver

receiver 在方法值创建时就会绑定。 object.Method 保存的究竟是对象快照还是对象引用?它与 Type.Method 有什么区别?

go
package main

import "fmt"

type Counter struct {
	Value int
}

func (counter Counter) Snapshot() int {
	return counter.Value
}

func (counter *Counter) Increment() int {
	counter.Value++
	return counter.Value
}

func main() {
	counter := Counter{Value: 10}

	// 创建方法值时 receiver 会被求值:
	// 值 receiver 复制当前 Counter;指针 receiver 保存 &counter。
	snapshot := counter.Snapshot
	increment := counter.Increment

	counter.Value = 20
	fmt.Printf("值 receiver 的快照=%d\n", snapshot())
	fmt.Printf("指针 receiver 的结果=%d\n", increment())
	fmt.Printf("原变量现在=%d\n", counter.Value)

	// 方法表达式不绑定 receiver,调用时必须显式传入。
	read := Counter.Snapshot
	fmt.Printf("方法表达式=%d\n", read(counter))
}

运行:

bash
go run ./examples/ch07/method-value

预期输出:

text
值 receiver 的快照=10
指针 receiver 的结果=21
原变量现在=21
方法表达式=21

值 receiver 与指针 receiver 从这里分开。

  1. 创建 snapshot := counter.Snapshot 时,值 receiver 被复制,所以后来把原变量改成 20,不影响快照里的 10。
  2. increment := counter.Increment 绑定的是指向原变量的指针,调用后原变量从 20 变成 21。
  3. Counter.Snapshot 是方法表达式,没有绑定任何对象,调用者必须显式传入 receiver。
  4. 把方法当回调传递前,要先确定希望冻结当下值,还是继续操作同一个可变对象。

改变绑定时机再比较。 在创建两个方法值之前先把 counter 改为 20,比较结果;再写 (*Counter).Increment 方法表达式并显式传 &counter

21.3 泛型函数分离元素与结果类型

泛型参数把输入、元素和结果分别建模。 怎样写一次 Map/Reduce,同时保留编译期类型检查,并允许命名切片类型作为输入?

go
package main

import "fmt"

// Map 让输入元素类型 E 和输出元素类型 R 相互独立。
// ~[]E 还允许 MySlice 这类底层类型为 []E 的命名切片作为输入。
func Map[S ~[]E, E, R any](source S, transform func(E) R) []R {
	result := make([]R, len(source))
	for index, value := range source {
		result[index] = transform(value)
	}
	return result
}

// Reduce 的累加器 A 不必与元素 E 同类型,例如可以把整数归约为字符串。
func Reduce[S ~[]E, E, A any](source S, initial A, combine func(A, E) A) A {
	accumulator := initial
	for _, value := range source {
		accumulator = combine(accumulator, value)
	}
	return accumulator
}

type Scores []int

func main() {
	scores := Scores{10, 20, 30}

	labels := Map(scores, func(score int) string {
		return fmt.Sprintf("%d分", score)
	})
	total := Reduce(scores, 0, func(sum, score int) int {
		return sum + score
	})

	fmt.Printf("labels=%v\n", labels)
	fmt.Printf("total=%d\n", total)
}

运行:

bash
go run ./examples/ch07/generic-functions

预期输出:

text
labels=[10分 20分 30分]
total=60

三个类型参数各有自己的职责。

  1. S ~[]E 表示 S 的底层类型是 []E,所以命名类型 Scores 也能传入。
  2. Map 的 E 与 R 独立,可以把 int 转换成 string,而不局限于同类型变换。
  3. Reduce 的累加器 A 与元素 E 独立;本例相同都是 int,但也可以把元素归约成结构体或文本。
  4. result 由函数新建并交给调用方,和 source 不共享底层数组,后续修改 labels 不会影响 scores。

换一种结果类型验证抽象。 用 Map 把分数映射为 bool(是否及格);再用 Reduce 把分数组合成逗号分隔文本,体会 A 与 E 不同的价值。

22. 官方资料

函数设计的核心不是把代码切成更多小块,而是让类型、所有权、失败方式和生命周期一眼可见。语法只是入口;真正稳定的 API 会让调用者很难误用。

以 Go 官方规范与标准库文档为准,示例面向 Go 1.26。