Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第8章 现代类型系统与泛型(重点)

本章基于 Go 1.26。泛型语法自 Go 1.18 可用,range-over-function 与 iter 自 Go 1.23 正式可用,泛型类型别名自 Go 1.24 可用。

Go 的泛型不是一套独立于类型系统的模板语言。类型参数、约束和普通 interface 共用同一套类型集合模型。掌握这套模型的关键,不是记住方括号语法,而是分清命名类型、底层类型、方法集和类型集合

8.1 命名类型、别名与底层类型

下面三种声明含义不同:

type UserID int       // 新的命名类型
type RawID = int      // int 的别名,不产生新类型
type IDs []UserID     // 新的命名切片类型
  • UserIDint 是不同类型,需要显式转换;它可以定义自己的方法。
  • RawIDint 完全相同,不能为别名额外定义方法。
  • IDs 的底层类型是 []UserID,但 IDs 本身是命名类型。

底层类型决定哪些复合操作可用。约束中的 ~T 表示“底层类型是 T 的所有类型”:

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

func Double[T Integer](v T) T { return v + v }

type Score int

var _ = Double(Score(21)) // Score 的底层类型是 int

若把 ~int 写成 intScore 就不在约束的类型集合中。

8.2 类型参数与类型集合

类型参数写在函数名或类型名后的方括号里:

func Sum[S ~[]E, E ~int | ~int64 | ~float64](values S) E {
    var total E
    for _, value := range values {
        total += value
    }
    return total
}

这里有两个参数:

  • S 的类型集合是所有底层类型为 []E 的类型。
  • E 的类型集合是底层类型为三种数字类型之一的类型。

约束 interface 可包含三类元素:

type OrderedText interface {
    ~string             // 类型项
    fmt.Stringer        // 方法项
}

满足它的类型必须同时以 string 为底层类型,并实现 String() string。多行是交集,| 是并集。

包含类型项的 interface 只能用作约束,不能用作普通变量类型:

type Number interface{ ~int | ~float64 }

// var n Number // 编译错误:接口包含类型约束,只能用于类型参数

any 等价于 interface{}comparable 表示可使用 ==!= 的类型集合,常用于 map key:

type Set[T comparable] map[T]struct{}

func (s Set[T]) Add(value T) { s[value] = struct{}{} }

comparable 只保证操作合法,不保证它适合作为业务 key。例如浮点数中的 NaN 与自身不相等,通常不适合作为 key。

Go 1.20 起,“可比较但非严格可比较”的类型也满足 comparable:普通接口类型(如 anyerror)语法上支持 ==,因此可以实例化 comparable 约束的类型参数。代价是比较被推迟到运行时——若动态类型不可比较(slice、map、func),== 或 map 插入会 panic:

type Set[T comparable] map[T]struct{}

s := Set[any]{}            // Go 1.20+ 合法
s[[]int{1}] = struct{}{}   // 运行时 panic: hash of unhashable type []int

comparable 因此表达的是“编译期保证 == 语法合法”,而非“运行时保证比较不 panic”。业务 key 应优先使用严格可比较的具体类型。

8.3 类型推断

调用泛型函数时,编译器通常能从实参推断类型:

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

value, ok := First([]string{"a", "b"}) // 推断 S=[]string, E=string

推断只使用编译期类型关系,不执行运行时搜索。返回值上下文并不总能提供足够信息:

func Zero[T any]() T {
    var zero T
    return zero
}

name := Zero[string]() // 没有实参可供推断,必须写 string

API 设计时把容易从实参推断的参数放在前面,能减少调用点的显式类型参数。

8.4 泛型类型、方法与别名

泛型类型在实例化后才成为具体类型:

type Pair[A, B any] struct {
    First  A
    Second B
}

func (p Pair[A, B]) Swap() Pair[B, A] {
    return Pair[B, A]{First: p.Second, Second: p.First}
}

方法不能声明接收者类型之外的新类型参数。需要额外参数时使用泛型函数:

func MapPair[A, B, C any](p Pair[A, B], f func(A, B) C) C {
    return f(p.First, p.Second)
}

Go 1.24 起支持泛型类型别名:

type StringMap[V any] = map[string]V

别名用于迁移包路径或暴露已有类型,不适合用来创建新的领域抽象;需要方法和不变量时应声明新的命名类型。

Go 1.26 解除了“泛型类型不得在自己的类型参数列表中引用自身”的限制:约束现在可以引用正被约束的泛型类型本身,从而表达 F-bounded 风格的约束:

type Adder[A Adder[A]] interface {
    Add(A) A
}

type Vector[A Adder[A]] []A

在 Go 1.25 及更早版本,第一行的 Adder[A] 自引用是编译错误。这不是“泛型类型可以无限递归实例化”,而是仅解除了类型参数列表中的自引用限制(类型定义体内引用自身,如 type Node[T any] struct{ next *Node[T] },一直都是合法的)。这种 API 理解成本较高,只在确实需要“操作返回同类型”的抽象时使用。

Go 1.26 还允许内置 new 接收值表达式,直接返回指向该初始值的指针:

timeout := new(3 * time.Second) // 类型为 *time.Duration
enabled := new(true)            // 类型为 *bool

它主要减少 optional 字段的临时变量样板;指针是否导致堆分配仍由逃逸分析决定。

8.5 泛型与普通接口如何选

两者解决的问题不同:

需求更合适的工具
同一算法适用于一组编译期已知类型泛型
运行时持有不同实现并动态分派普通 interface
容器保留元素的具体类型泛型
API 只依赖一个小行为,如 io.Reader普通 interface
需要异构集合interface 或显式 tagged union

一个常见误区是为了“消除 interface”而把所有 API 泛型化。下面的接口已经足够精确:

func Decode(r io.Reader) error

改成 func Decode[R io.Reader](r R) error 通常不会增加类型安全,反而会让函数值、mock 和导出 API 更复杂。

8.6 cmpslicesmaps

标准库优先于自造通用算法:

names := []string{"c", "a", "b"}
slices.Sort(names)

clone := slices.Clone(names)
same := slices.Equal(names, clone)

counts := map[string]int{"a": 1}
copyOfCounts := maps.Clone(counts)

largest := max(10, 20)          // Go 1.21+ 内置函数,min 同理
ordering := cmp.Compare(10, 20) // -1

注意 cmp 包没有 Max/Min 函数:求最大最小值使用 Go 1.21 引入的内置 max/mincmp 提供的是 CompareLessOrOrdered 约束。这些泛型函数通常用 S ~[]EM ~map[K]V 保留调用方的命名类型。使用前仍要理解语义,例如 slices.Delete 可能修改底层数组,maps.Clone 是浅拷贝。

8.7 迭代器与 range-over-function

Go 1.23 允许 range 遍历三种函数签名,对应零个、一个或两个迭代值。标准库 iter 给后两种命名为 SeqSeq2

func Countdown(n int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for value := n; value >= 0; value-- {
            if !yield(value) {
                return
            }
        }
    }
}

for value := range Countdown(3) {
    if value == 1 {
        break // yield 返回 false,生产者必须停止
    }
    fmt.Println(value)
}

迭代器适合惰性序列和不暴露内部存储的容器。设计时遵守三条规则:

  1. yield 返回 false 后立即返回,不能再次调用。
  2. 不要在迭代结束后保存或异步调用 yield
  3. 若底层资源需要关闭,优先设计显式生命周期;不要把资源释放完全隐藏在迭代器中。

把 push iterator 转成 pull 风格时可用 iter.Pull,并始终调用返回的 stop

next, stop := iter.Pull(Countdown(3))
defer stop()

value, ok := next()
fmt.Println(value, ok)

8.8 编译器实现与性能边界

Go 规范不承诺“每个泛型实例都生成一份完全特化机器码”。当前编译器会综合使用:

  • GC shape stenciling:布局和指针形状相同的实例可共享机器码。
  • dictionary:调用点传入类型信息和部分操作入口。
  • 内联与去虚化:条件满足时消除部分抽象开销。

因此不能笼统声称泛型一定零装箱、一定等价于手写特化版本。约束方法调用、转换到 any、反射和逃逸仍可能产生间接调用或分配。判断性能必须使用 benchmark 和 -gcflags=-m=2

go test -bench=. -benchmem
go test -gcflags='-m=2' ./...

泛型的首要收益是类型安全和减少重复,性能收益是需要测量的结果,不是语法保证。

8.9 API 设计清单

  • 先写具体实现,确认重复与类型无关后再泛化。
  • 导出约束保持小而稳定;能在函数签名内写清时不必命名。
  • ~ 接受有相同底层类型的命名类型;不需要时不要扩大类型集合。
  • 返回零值时用 var zero T,不要假设所有 T 都能与 nil 比较。
  • 不用 any 绕过约束;那会把编译期问题推迟到运行时。
  • 泛型容器仍要写清并发安全、所有权、复制深度和零值语义。
  • 对热路径做基准测试,尤其关注字典调用、接口转换、逃逸与代码体积。

本章小结

  • 命名类型、别名和底层类型是理解 ~T 与类型推断的基础。
  • 约束 interface 描述类型集合;普通 interface 描述运行时行为,两者不能相互替代。
  • slicesmapscmpiter 是现代 Go 泛型 API 的主要范例。
  • Go 1.26 支持类型参数列表的自引用约束,也支持 new(value);二者都应在能减少真实复杂度时再使用。
  • 当前编译器使用 shape 与 dictionary 等策略,泛型性能不应靠“完全特化”推断。

进一步阅读: