卷 VI · 工程CH 22深度 22/24

泛型:Go 忍了十年才加的东西

本章卸下型变 in/out「泛型越强越好」

第 1 章那张减法账本里,泛型是唯一一样「你熟的东西 Go 后来也补上了」。但这个「补」本身就是一个极好的 Go 性格样本:它从 2012 年 1.0 一直忍到 2022 年 1.18,忍了整整十年才加泛型,而且加得异常克制。这一章讲清 Go 泛型怎么用、它故意做什么,以及一条你必须听进去的建议——什么时候用泛型。

泛型类型参数约束comparable克制

没有泛型的十年,是怎么过的

先体会一下「没有泛型」有多憋屈。在 1.18 之前,你想写一个「求两个数里较大的那个」的通用函数,做不到 —— 你得给每种类型各写一遍 MaxIntMaxFloat64…… 或者用 any + 类型断言(丢类型安全、还慢)。集合类库全靠 []any 硬撑。这确实是 Go 早期一个实打实的痛点,社区吵了十年。

Go 团队不是不知道痛,而是迟迟没找到一个他们满意的设计。他们的顾虑很「Go」:泛型是复杂度的巨大来源(看看 C++ 模板、Java 泛型的擦除和通配符),加一个设计不好的泛型,会毁掉 Go「简单、一眼看懂」的立身之本。所以他们宁可忍受「没有泛型」的痛,也不肯草率加一个会让语言变臃肿的泛型 —— 直到想清楚了才动手。这就是第 1 章说的那个「把不加当功能」的性格,最有代表性的一次。

泛型长什么样

1.18 的泛型,语法是在函数 / 类型名后加一个方括号,声明类型参数

// [T any] 声明一个类型参数 T,any 是它的约束(任意类型)
func Max[T int | float64](a, b T) T {
    if a > b {
        return a
    }
    return b
}

Max(3, 5)        // T 推断为 int
Max(2.7, 1.1)    // T 推断为 float64,一份代码通吃

方括号里的 T int | float64约束(constraint)—— 它规定 T 只能是 int 或 float64(因为 > 只对这些类型有意义)。约束是 Go 泛型的核心创新,它本质上就是一个接口(回想卷 III!),只不过这个接口不光能列方法,还能列允许的类型集合。泛型类型也一样:

// 一个泛型栈,装任意类型
type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) Push(x T) { s.items = append(s.items, x) }

var s Stack[string]   // 装 string 的栈
s.Push("hi")

约束就是接口,comparable 是内置的

把约束理解成「加强版接口」,泛型就通了。你可以自定义约束,也可以用内置的:

// 自定义约束:一个能列类型、也能列方法的接口
type Number interface {
    ~int | ~int64 | ~float64   // ~ 表示「底层类型是这些的」也算
}

func Sum[T Number](nums []T) T { ... }

// comparable:内置约束,表示「能用 == 比较的类型」
func Contains[T comparable](s []T, x T) bool {
    for _, v := range s {
        if v == x { return true }   // 因为 T 是 comparable,能用 ==
    }
    return false
}

comparable 是个特别有用的内置约束 —— 它保证 T 能用 ==(回想第 6 章「可比较的类型」),于是泛型的 Contains、map 的 key、集合去重都能写了。~int 里的波浪号表示「底层类型是 int 的都算」(这样你的 type Celsius float64 也能满足 ~float64 约束)。这套「约束 = 接口」的设计,让泛型和 Go 原有的接口体系无缝长在了一起,而不是硬塞进来一套新东西 —— 这正是他们忍十年要等的那个「自洽的设计」。

Go 泛型故意不做的事

Go 泛型的克制,体现在它刻意砍掉了别家泛型有、但会带来巨大复杂度的特性:

别家泛型有Go 故意没有
型变(Java 的 ? extends、Kotlin 的 in/out没有型变 —— Stack[Cat]Stack[Animal] 毫无关系
方法上的类型参数方法不能有自己的类型参数(只有函数和类型能)
类型参数的默认值、特化、元编程全都没有

其中「没有型变」你最会绊一下:Kotlin 里 List<Cat>List<out Animal> 的子类型(协变),你能把一堆猫当一堆动物传。Go 里 []Cat[]Animal完全无关的两个类型,泛型也不给你 in/out 去建立这种关系。Go 认为型变是泛型里最烧脑、最容易让人搞错的部分,干脆不要。要「一堆能干某事的东西」,你回到老办法 —— 用接口切片 []Animal(元素是满足 Animal 接口的任意具体类型)。接口负责多态,泛型负责「同一份代码套不同类型」,两者分工,不重叠。

◆ 最重要的一条:别急着用泛型

Go 团队和社区反复强调一件事,我原样传给你:「先别用泛型。」大多数你以为需要泛型的地方,其实用接口更好、更 Go。泛型只在一种情况下真正划算 —— 你在对不同类型结构上完全相同的操作,而且这些类型没有共同的方法可以抽成接口(比如 Max、通用容器、Map / Filter 这类)。

反面:如果这些类型有共同行为,用接口;如果你只是想「少写点重复代码」但类型其实就一两种,直接写具体的更清楚。官方建议:写代码时用具体类型和接口,等你真的发现自己在为好几个类型复制粘贴同一段逻辑时,再回头抽成泛型。不要一上来就泛型化 —— 那是把 C++/Java 的过度抽象习惯带进 Go,正是这门语言想让你戒掉的。

Kotlin 对照

⇄ 成熟丰富的泛型 vs 克制年轻的泛型

Kotlin 的泛型成熟、丰富:声明处型变(in/out)、使用处型变、reified 具体化类型参数、where 多约束、类型参数上的复杂边界。表达力很强,你能编码出很精巧的类型关系。代价是学习曲线陡(型变把无数人绕晕过)、以及 JVM 的类型擦除带来的一堆边角坑。

Go 的泛型年轻、克制:2022 年才有,只有类型参数 + 约束(约束就是接口),没有型变、没有方法类型参数、没有 reified。表达力明显更窄,你会撞上「这个在 Kotlin 一行就能表达」的墙。但它换来的是简单 —— Go 泛型基本没有你需要专门学的烧脑概念(因为最烧脑的型变被删了),读一眼就懂。

迁移时要压住两个 Kotlin 惯性:一是找 in/out —— Go 没有,需要「一组同类东西」时用接口切片,别指望协变。二是别把泛型当默认工具。你在 Kotlin 里可能习惯了给什么都加类型参数,Go 的文化明确反对 —— 先接口、后具体、真重复了才泛型。记住 Go 加泛型忍了十年的那份克制,把它也用在你自己写泛型的克制上。

这一章的一句话

Go 忍了十年才加泛型、加得极克制:类型参数 + 约束(约束就是能列类型的接口)、comparable 内置,但故意没有型变、没有方法类型参数——接口管多态、泛型管「同一份代码套不同类型」,两者分工;最重要的建议是「先别用泛型」,真的在为多个无共同方法的类型复制同一段逻辑时才抽它。

下一章我们讲 Go 工程化里最舒服的部分:测试、基准、工具链和部署 —— 它们全是语言内建的,而 Go 「一个静态二进制走天下」的部署体验,会让你重新认识「发布一个程序」这件事有多简单。