Go 函数、闭包与泛型

# Go 函数、闭包与泛型

本篇目标

掌握函数值、闭包、可变参数、多返回值、defer 和泛型的实际用法,并能判断什么时候该使用接口、什么时候该使用类型参数。

把函数作为值理解闭包捕获看懂 defer正确使用泛型

# 先记住一句话

函数值用于注入一段行为,闭包是函数与它引用的外部变量,泛型通过类型参数让同一份代码安全处理多种具体类型。

需要复用一段逻辑
├─ 只替换一个行为步骤 → 传入函数值
├─ 行为还要记住少量配置 → 使用闭包
├─ 同一套处理逻辑要适配多种类型 → 使用泛型
└─ 调用方只关心对象能力 → 定义小接口
1
2
3
4
5

# 函数也是一种值

函数可以赋值给变量、作为参数传入,也可以从另一个函数返回。真实项目中的 HTTP 中间件、重试回调、排序规则和依赖注入都大量使用这个特性。

// 文件位置:internal/note/filter.go
package note

type Note struct {
	ID        string // 笔记的唯一标识。
	Published bool   // 是否已经发布,true 表示允许筛选结果保留它。
}

// type 可以为函数签名命名;Predicate 描述 “输入一篇笔记,返回是否保留” 的行为契约。
type Predicate func(Note) bool

// keep 是调用方传入的筛选函数;它接收一篇 Note,并返回是否保留。
func Filter(notes []Note, keep Predicate) []Note {
	// make 创建长度为 0、容量预留为 len(notes) 的切片,减少 append 扩容。
	result := make([]Note, 0, len(notes))
	// range 遍历切片;_ 明确忽略索引,只把当前元素赋给 current。
	for _, current := range notes {
		// keep(current) 为 true 时保留当前笔记,为 false 时跳过。
		if keep(current) {
			result = append(result, current)
		}
	}
	return result
}

func PublishedOnly(current Note) bool {
	// 直接返回 Published 字段,使这个函数满足 Predicate 的函数签名。
	return current.Published
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

调用方可以传入命名函数 PublishedOnly,也可以传匿名函数。参数和返回值共同构成函数类型的一部分;两个签名不同的函数不能直接互换。

# 闭包会捕获外部变量

// 文件位置:internal/note/access.go
package note

// OwnerFilter 返回的函数会记住 ownerID,因此它是一个闭包。
func OwnerFilter(ownerID string) func(string) bool {
	// 返回一个匿名函数;即使 OwnerFilter 已经执行结束,匿名函数仍会记住 ownerID。
	return func(candidateOwnerID string) bool {
		// candidateOwnerID 是以后调用闭包时传入的值,ownerID 是创建闭包时保存的值。
		return candidateOwnerID == ownerID
	}
}
1
2
3
4
5
6
7
8
9
10
11

闭包常用于包装配置、依赖或局部状态,但被捕获变量的生命周期可能延长。多个 Goroutine 同时读写同一个被捕获变量时,它仍是共享内存,必须使用锁、原子操作或重新设计所有权。

不要用闭包偷偷制造全局状态

闭包让变量看起来很局部,但只要返回的函数仍被持有,被捕获的数据就仍然存活。缓存、连接和可变 Map 应有明确所有者与关闭方式,不能仅因为写在函数内部就默认安全。

# 多返回值、可变参数和 defer

Go 常用 (value, error) 返回结果,也可以用第二个布尔值表达查询是否命中。可变参数在函数内部表现为切片。

// 文件位置:examples/function_features.go
package functionfeatures

import (
	"errors"  // 创建带说明文字的普通错误。
	"strings" // 提供字符串拼接等工具函数。
)

// ...string 是可变参数,调用方可传任意数量的字符串,函数内把它当作 []string。
func JoinNonEmpty(separator string, parts ...string) string {
	// 预留与 parts 相同的容量;实际长度先设为 0,因为空字符串不会加入结果。
	filtered := make([]string, 0, len(parts))
	// 逐个检查调用方传入的字符串。
	for _, part := range parts {
		if part != "" {
			filtered = append(filtered, part) // 只保留非空字符串。
		}
	}
	// Join 使用 separator 连接筛选后的字符串,例如分隔符为 "," 时得到 "a,b"。
	return strings.Join(filtered, separator)
}

// First 返回切片中的第一个字符串;切片为空时返回错误,避免访问 parts[0] 发生越界 panic。
func First(parts []string) (string, error) {
	if len(parts) == 0 {
		// Go 常用零值与 error 一起返回;nil 表示没有错误。
		return "", errors.New("parts cannot be empty")
	}
	return parts[0], nil // 成功时返回第一个元素;nil 表示没有错误。
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

# defer 到底推迟了什么

defer 常用于解锁、关闭文件和结束 Trace。一个函数中有多个 defer 时,它们会在函数返回前按照后进先出的顺序执行。

需要特别区分两个时点:执行 defer 语句时,立即计算并保存被调用的函数和参数;等当前函数即将返回时,才真正调用这个函数。

// 文件位置:examples/defer_arguments.go
package functionfeatures

import "fmt"

func PrintSnapshot() {
	value := 1

	// 此时不会打印,但会立即读取 value,并把参数值 1 保存下来。
	defer fmt.Println("defer:", value)

	value = 2
	fmt.Println("函数内部:", value)
	// 函数即将结束时,执行之前推迟的 fmt.Println("defer:", 1)。
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

执行顺序是:

value := 1
    ↓
遇到 defer,保存参数值 1,但暂时不调用 Println
    ↓
value = 2
    ↓
先打印 “函数内部: 2”
    ↓
函数即将返回,再打印 “defer: 1”
1
2
3
4
5
6
7
8
9

因此输出为:

函数内部: 2
defer: 1
1
2

如果希望函数返回时再读取变量的最新值,可以使用闭包:

func PrintLatest() {
	value := 1 // 这是闭包将要捕获的外部变量。

	// defer 推迟匿名函数的执行;匿名函数没有普通参数,所以此处不会保存 value 的当前值。
	defer func() {
		fmt.Println("defer:", value) // 真正执行闭包时才读取 value,因此打印 2。
	}()

	value = 2 // 修改的是闭包捕获的同一个变量。
}
1
2
3
4
5
6
7
8
9
10

可以简单记成:defer 推迟的是调用动作,不是普通参数的计算;普通参数立即保存,闭包捕获的变量会在闭包真正执行时读取。

# 泛型解决什么问题

泛型允许函数或类型声明类型参数。类型参数仍受约束,编译器会检查调用是否合法,而不是把所有值都退化成 any 再做运行时断言。

// 文件位置:internal/collection/map.go
package collection

// 函数名后面的 [Input any, Output any] 是泛型的类型参数列表。
// Input 和 Output 只是自定义名称,作用类似 TypeScript 泛型中常用的 T 和 R,并非 Go 固定写法。
// Input 代表输入元素的类型,Output 代表转换后元素的类型;any 是类型约束,表示不额外限制类型。
func MapSlice[Input any, Output any](
	// items 是待处理的输入切片,其中每个元素都是 Input 类型。
	items []Input,
	// transform 是调用方传入的转换函数:接收一个 Input,返回一个 Output。
	transform func(Input) Output,
) []Output {
	// 返回值需要与输入元素一一对应,因此创建相同长度的 Output 切片。
	result := make([]Output, len(items))

	// index 是元素位置,item 是当前位置的 Input 值。
	for index, item := range items {
		// 调用 transform 转换当前元素,再把结果放回相同位置,以保持原顺序。
		result[index] = transform(item)
	}

	// result 的类型是 []Output。
	return result
}

func LanguageNameLengths() []int {
	languages := []string{"Go", "Python"}

	// 调用时没有显式写 MapSlice[string, int],编译器会根据参数推断泛型类型:
	// Input 是 string,Output 是 int。
	// transform 接收一个语言名称,并返回它的长度,最终结果是 []int{2, 6}。
	return MapSlice(languages, func(name string) int {
		return len(name)
	})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

这次调用把 []string 转成了 []int。MapSlice 不关心具体类型和转换规则,只负责按照原顺序逐个取值、调用 transform,再收集结果;真正的转换规则由调用方传入的函数决定。

// 文件位置:internal/pagination/page.go
package pagination

// Page 用同一个分页结构承载不同领域类型,同时保留具体元素类型。
type Page[Item any] struct {
	// 反引号中的 json 标签控制字段编码名;omitempty 会在空值时省略字段。
	Items      []Item `json:"items"`                // 当前页的数据;元素类型由 Item 决定。
	Total      int    `json:"total"`                // 满足查询条件的数据总数。
	NextCursor string `json:"nextCursor,omitempty"` // 下一页游标;空字符串时不输出该 JSON 字段。
}
1
2
3
4
5
6
7
8
9
10

any 等价于 interface{},表示不限制类型集合。需要操作符时,要声明更具体的约束。

// 文件位置:internal/number/sum.go
package number

// Number 的 ~ 允许底层类型为这些整数或浮点数的自定义类型进入集合。
type Number interface {
	// | 表示类型集合的并集;~int 还允许底层类型为 int 的自定义类型。
	~int | ~int64 | ~float64
}

func Sum[Value Number](values []Value) Value {
	// Value 必须属于 Number 类型集合,因此 values 只能是允许的数字切片。
	var total Value // 未显式赋值时获得 Value 对应的零值,允许作为累加起点。
	for _, value := range values {
		total += value // Number 约束保证 + 运算合法。
	}
	return total // 返回类型仍是调用方传入的具体数字类型,不会退化成 any。
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

如果没有 ~,自定义类型 type Score int 不属于只写 int 的类型集合;~int 表示底层类型为 int 的所有类型。

# 泛型、接口和 any 怎样选择

需求 更合适的工具 例子
同一算法处理多种类型并保留类型关系 泛型 MapSlice[T, R]、Page[T]
调用方只需要对象具备某种行为 接口 io.Reader、Repository
值的类型本来就不固定 any 加显式检查 解码任意 JSON
运行时检查结构和标签 反射 JSON 编码器、依赖注入框架

泛型不应被用来消灭所有重复代码。如果两个函数只是代码长得像,但业务语义、错误处理和未来变化方向不同,强行抽成泛型会让接口更难理解。

# 一个完整的组合示例

下面把泛型函数和闭包组合起来:通用函数负责转换,闭包负责记住业务配置。

// 文件位置:examples/note_titles.go
package notetitles

import (
	"fmt"     // 按格式拼接标题前缀和正文。
	"strings" // 清理标题两侧的空白字符。
)

type Note struct {
	ID    string // 笔记唯一标识。
	Title string // 可能带有首尾空白的原始标题。
}

// MapSlice 把 []Input 中的每个元素转换为 Output,并按原顺序返回 []Output。
func MapSlice[Input any, Output any](items []Input, transform func(Input) Output) []Output {
	result := make([]Output, len(items)) // 提前创建与输入等长的结果切片。
	for index, item := range items {
		result[index] = transform(item) // 使用调用方传入的函数转换当前元素。
	}
	return result
}

// titleFormatter 根据 prefix 创建一个标题格式化函数。
func titleFormatter(prefix string) func(Note) string {
	return func(current Note) string {
		// 闭包记住 prefix,调用时只需要传入当前笔记。
		// TrimSpace 去掉标题首尾空白,Sprintf 再把前缀与清理后的标题拼起来。
		return fmt.Sprintf("%s%s", prefix, strings.TrimSpace(current.Title))
	}
}

// FormatTitles 把 Note 切片转换成格式化后的标题字符串切片。
func FormatTitles(notes []Note) []string {
	// titleFormatter("笔记:") 先创建闭包,再由 MapSlice 对每篇 Note 调用它。
	return MapSlice(notes, titleFormatter("笔记:"))
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

编译器通常能从 notes 和 titleFormatter 推断 Input 与 Output,所以调用时不必显式写成 MapSlice[Note, string](...)。

# 高频面试题与回答

1. 闭包是什么,有什么风险?参考答案

闭包是函数与它引用的外部变量。它适合保存少量配置并生成回调,但会延长被捕获变量的生命周期;多个 Goroutine 共享修改捕获变量时仍可能产生数据竞争。

2. defer 的参数什么时候求值?参考答案

执行到 defer 语句时就会计算被调用函数和参数,真正的函数调用则推迟到外层函数返回前,并按后进先出的顺序执行。

3. 泛型和接口有什么区别?参考答案

泛型主要表达多种类型之间的编译期关系,让同一算法保留具体类型;接口表达对象具备哪些行为,让调用方依赖能力而不是实现。真实项目可以同时使用,例如泛型容器中存放满足某个接口约束的类型。

4. 类型约束中的波浪号有什么作用?参考答案

~int 表示底层类型为 int 的所有类型,不仅包括预声明类型 int,也包括 type Score int 这样的自定义类型。它让约束按底层类型接受一组类型。

# 接下来学什么

下一篇学习 Go Struct、方法、接口与错误,把函数与类型的方法集、接口契约和错误链串起来。

# 参考资料

上次更新时间: 2026年09月18日 02:14:27