Interface、defer、panic、reflection 和 generics 并不是同一类特性,但它们经常出现在同一批“代码看起来没问题,运行结果却不对”的 Bug 里。

这篇不背面试结论,只讲我在代码评审中会检查的语义边界。示例以 Go 1.26.1 为准。

Interface 同时保存类型和值

一个 interface 值可以理解为“动态类型 + 动态值”。在 Go 1.26.1 runtime 中,空接口和带方法接口的内部表示可概括为:

type eface struct {
    _type *_type
    data  unsafe.Pointer
}

type iface struct {
    tab  *itab
    data unsafe.Pointer
}

这两个结构是 runtime 实现,不是应用代码应该依赖的 API。它们对理解 typed nil 很有帮助。

typed nil 为什么不等于 nil

Interface 只有在动态类型和动态值都为 nil 时才等于 nil。

func lookup() error {
    var err *os.PathError
    return err
}

func main() {
    err := lookup()
    fmt.Println(err == nil) // false
}

lookup 返回时,error 接口的动态类型是 *os.PathError,动态值才是 nil。修复方式是在没有错误时直接返回 nil:

func lookup() error {
    var err *os.PathError
    if err == nil {
        return nil
    }
    return err
}

这个问题不只出现在 error。任何将 nil 指针、nil map 或 nil slice 放进 interface 的代码都要注意。

方法集决定谁实现了接口

如果方法的 receiver 是 TT*T 的方法集都包含该方法。如果 receiver 是 *T,只有 *T 的方法集包含它。

type Counter int

func (c *Counter) Reset() { *c = 0 }

type Resetter interface {
    Reset()
}

var _ Resetter = (*Counter)(nil) // 通过
// var _ Resetter = Counter(0)   // 不通过

编译器能在 v.Reset() 时自动取地址,不代表值类型 Counter 因此实现了 Resetter

defer 的三条硬规则

参数在声明 defer 时求值

x := 1
defer fmt.Println(x)
x = 2
// 输出 1

fmt.Println(x) 的参数在执行 defer 语句时已经求值。闭包中的变量则在延迟函数实际执行时读取:

x := 1
defer func() { fmt.Println(x) }()
x = 2
// 输出 2

多个 defer 按后进先出执行

defer fmt.Println("first")
defer fmt.Println("second")
// second
// first

这对“先加锁,再启动跟踪,最后注册资源关闭”这类嵌套清理很重要。不要只看声明顺序猜执行顺序。

defer 可以读写命名返回值

func addOne() (n int) {
    defer func() { n++ }()
    return 41
}

fmt.Println(addOne()) // 42

return 41 先将 41 赋给命名返回值 n,然后执行 defer,最后返回。这种写法可以用来统一包装错误,但很容易藏副作用。如果 defer 修改返回值,我会要求它保持短小并有测试。

循环中的 defer 要考虑生命周期

defer 在当前函数返回时执行,不是当前循环迭代结束时执行。处理大量文件时,把单次逻辑抽成小函数:

func processFile(name string) error {
    f, err := os.Open(name)
    if err != nil {
        return err
    }
    defer f.Close()
    return consume(f)
}

panic 是当前 goroutine 的栈展开

panic 开始后,runtime 沿当前 goroutine 的调用栈向上执行 defer。如果没有被 recover,程序会输出 panic 和栈信息并终止。

recover 只在正在展开同一个 goroutine 的 deferred function 中有效。另一个 goroutine 无法跨过 goroutine 边界 recover。

func protect(handler func()) {
    defer func() {
        if v := recover(); v != nil {
            log.Printf("handler panic: %v\n%s", v, debug.Stack())
        }
    }()
    handler()
}

recover 适合放在 HTTP handler、任务执行器或插件边界,目标是隔离一个任务的崩溃。不应该给每个 goroutine 都加一个吞掉 panic 的 recover,那会让编程错误继续污染状态。

还有几个边界:

  • runtime.Goexit 会执行 defer,但它不是 panic,recover 抓不到它。
  • 某些 runtime fatal error 会直接终止程序,不应把 recover 当作进程级安全网。
  • Go 1.21 起 panic(nil) 在 recover 时也会得到非 nil 的 *runtime.PanicNilError,除非通过 GODEBUG=panicnil=1 恢复旧行为。

Reflection:Type 说明它是什么,Value 指向具体值

reflect.TypeOf 返回动态类型,reflect.ValueOf 返回可操作的反射值。

type User struct {
    Name string
}

u := User{Name: "Alice"}
t := reflect.TypeOf(u)
v := reflect.ValueOf(u)

fmt.Println(t.Name())          // User
fmt.Println(v.Field(0).String()) // Alice

修改值时必须传入指针并取 Elem,否则拿到的只是不可设置的副本:

v := reflect.ValueOf(&u).Elem()
field := v.FieldByName("Name")
if field.IsValid() && field.CanSet() {
    field.SetString("Bob")
}

反射代码最常见的 panic 来自三处:对 invalid Value 调用不支持的方法,对不可设置字段 Set,或在类型不匹配时做 Interface().(T)。反射库应该在边界校验 Kind、可修改性和转换关系,并把错误返回给调用者。

Generics:表达一组类型共享的算法

Generics 适合集合、算法和类型化容器,而 interface 适合表达运行时行为。一个实用的判断方式是:如果代码只关心“这个值能做什么”,使用 interface;如果算法需要保留输入和输出的类型关系,使用类型参数。

type Number interface {
    ~int | ~int64 | ~float64
}

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

~int 包含底层类型为 int 的自定义类型。如果只写 inttype UserID int 就不在类型集内。

不要把编译器的泛型实现当作语言保证。GC shape、dictionary 传递、单态化和内联策略都可以随版本调整。性能敏感代码应该通过 benchmark 和编译器输出判断,不是靠“泛型一定比 interface 快”的口号。

range-over-func:迭代器也是语言机制

Go 1.23 起,range 可以遍历特定形式的函数。标准库 iter.Seq[V] 本质上是:

type Seq[V any] func(yield func(V) bool)

它让集合类型提供不暴露底层表示的遍历方式:

func Count(n int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := 0; i < n; i++ {
            if !yield(i) {
                return
            }
        }
    }
}

for i := range Count(3) {
    fmt.Println(i)
}

Iterator 更适合惰性遍历。如果数据已经是 slice,直接返回 slice 往往更简单。

代码评审清单

  • 返回 interface 时,确认没有把 typed nil 装入接口。
  • 看到 defer 时,检查参数求值时机、执行顺序和资源生命周期。
  • recover 只放在有故障隔离语义的边界,并记录栈信息。
  • 反射操作在边界检查 IsValidKindCanSet 和类型转换。
  • 只在存在明确类型关系时引入泛型,避免为了抽象而抽象。

参考