跳到正文

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

把一门技术讲透到既懂也会用