主题
03 函数、方法与接口
一句话:函数是一等值,方法把行为绑定到类型,接口用「隐式实现 + 类型/数据二元组」做运行时多态;这套抽象很轻,代价是接口调用的装箱和 nil 判断必须分清「接口 nil」与「数据 nil」。
适用 Go 1.22+:for 循环变量从 1.22 起每次迭代一份。泛型见 05。
为什么需要
Go 没有继承、没有重载、没有默认参数,复用靠组合、函数值和接口。接口是解耦的核心:调用方定义自己需要的方法,实现方不用声明「我实现了谁」,依赖因此可以倒置。但接口值在运行时是「类型 + 数据」两段,任何一段为 nil 都会产生和直觉不同的结果。
核心机制
多返回值与命名返回值
go
func divmod(a, b int) (q, r int) { return a / b, a % b }多返回值支撑了 (value, error) 和 (value, ok) 两种惯例。命名返回值能让签名自解释,也能被 defer 修改;但裸 return 在长函数里难读,超过几行就显式写出返回值。返回值本身是复制,返回大 struct 是否高效交给编译器判断,不要过早改成指针。
闭包与循环变量
go
func counter() func() int {
n := 0
return func() int { n++; return n }
}闭包捕获的是变量本身,不是创建时的值;n 会逃逸到堆,多次调用共享同一份状态。循环里的闭包更容易出错:Go 1.22 起每次迭代新建循环变量,捕获到的是当次值;1.21 及更早共用一个变量,常见修法是 v := v 或把变量作为参数传入。闭包还会延长被捕获变量的生命周期,不要把整个大对象拖进去。
defer:注册时求值,返回前逆序执行
go
func f() (out []int) {
for i := 0; i < 3; i++ {
defer func() { out = append(out, i) }()
}
return
}
// Go 1.22+ 输出 [2 1 0]defer 的参数和接收者在注册时求值,闭包体在真正执行时才读变量。多个 defer 后进先出,命名返回值可以被 defer 改写。defer 放在循环里会累积到函数返回才执行;循环内要释放资源,就把循环体包进一个函数,或显式 Close。
方法:接收者决定方法集
go
type Dog struct{ Name string }
func (d Dog) Speak() string { return d.Name }
func (d *Dog) Rename(n string) { d.Name = n }| 接收者 | T 的方法集 | *T 的方法集 |
|---|---|---|
| 值接收者 | 包含 | 包含 |
| 指针接收者 | 不包含 | 包含 |
T 只有值接收者方法,*T 同时拥有值和指针接收者方法。接口赋值时这条规则决定 T 能否满足接口:接口方法若由指针接收者实现,只有 *T 满足。可寻址的 T 变量调用指针方法会自动取地址;map 元素、接口里的值不可寻址,不能直接调用指针方法。需要修改字段、struct 较大、含同步原语时用指针接收者;小不可变值和基础类型包装可以用值,同一类型尽量统一。
接口:隐式实现,值是「类型 + 数据」
- 接口不声明实现关系,方法集包含接口方法就满足;编译期断言写成
var _ Speaker = (*Dog)(nil)。 - 接口值通常由两个机器字表示:动态类型加数据。调用方法先查类型的方法表再跳转;把具体值放进接口常常发生装箱分配。
var s Speaker是 nil 接口;var d *Dog; s = d是非 nil 接口,动态类型是*Dog、数据是 nil。于是s == nil为 false,调用值接收者方法会解引用 nil 而 panic。- 判空要分清:接口判
s == nil,指针判d == nil;不要把 typed nil 当成空接口。 v, ok := i.(T)是安全断言,v := i.(T)失败会 panic;type switch 按动态类型分支,case nil只匹配 nil 接口。any是interface{}的别名(1.18+),表示空接口,不是泛型的替代品。
最小上手
go
package main
import "fmt"
type Speaker interface{ Speak() string }
type Dog struct{ Name string }
func (d Dog) Speak() string { return d.Name + ": woof" }
func (d *Dog) Rename(n string) { d.Name = n }
func counter() func() int {
n := 0
return func() int { n++; return n }
}
func deferOrder() (out []int) {
for i := 0; i < 3; i++ {
defer func() { out = append(out, i) }()
}
return
}
func describe(v any) string {
switch x := v.(type) {
case nil:
return "nil"
case int:
return fmt.Sprintf("int %d", x)
default:
return fmt.Sprintf("%T", x)
}
}
func main() {
c := counter()
fmt.Println(c(), c()) // 1 2
var fs []func() int
for i := 0; i < 3; i++ {
fs = append(fs, func() int { return i })
}
fmt.Println(fs[0](), fs[1](), fs[2]()) // Go 1.22+:0 1 2
fmt.Println(deferOrder()) // [2 1 0]
var s Speaker = Dog{Name: "Max"}
fmt.Println(s.Speak())
var d *Dog
s = d
fmt.Println(s == nil, describe(s)) // false *main.Dog
}完整版本见 examples/03_functions_interfaces/main.go。运行:go run examples/03_functions_interfaces/main.go。
生产动作
- 接口定义在调用方,越小越好,一到三个方法;不要给每个 struct 预建接口,也不要为了 mock 把一切都接口化。
- 返回具体类型,接收接口;编译期断言
var _ Iface = (*T)(nil),依赖升级时能提前发现方法集变化。 - 避免 typed nil:返回
error或接口前检查具体指针是否为 nil;不要把(*MyError)(nil)当普通 error 返回。 - 接收者统一:含
sync.Mutex的类型用指针接收者,避免复制锁;值接收者方法不要修改字段。 - 检查:
go vet的 copylocks、staticcheck、go test -race;热路径用 benchmark 看接口装箱的allocs/op。
边界
- 接口不是泛型:接口擦除具体类型,取不回字段;需要保留类型时用类型参数(05)。
- 接口值比较要求动态类型可比较;动态类型是 slice/map/func 时
==会 panic。 - 没有重载、默认参数和继承;用组合、函数选项、小接口替代。
- 闭包捕获大对象会阻止回收;长期存活的闭包只捕获必要字段。
- defer 不是零成本;1.14+ 的开放编码降低了开销,但循环里累积仍然存在。
- 指针接收者方法可以在方法内处理 nil,值接收者方法调用前就会解引用;nil 语义要写进类型约定。
讲清楚
Go 用函数、方法和接口代替继承体系。函数是一等值,闭包捕获变量本身,所以循环里的闭包要小心版本差异:1.22 起每次迭代一份变量,之前共用一份。defer 在注册时求值、返回前逆序执行,能改命名返回值,但放在循环里会累积。方法由接收者决定方法集:值接收者属于 T,指针接收者只属于 *T,接口赋值时这条规则决定谁满足接口。接口是隐式实现,接口值由动态类型和数据两部分组成;typed nil 会让接口不等于 nil,这是最常见的坑。理解「接口值 = 类型 + 数据」,方法调用、断言和 nil 判断就都能推出来。
适用 Go 1.22+ | 初稿 2026-10