Go 泛型入门:从类型参数到泛型链表

Go 早期没有泛型。

这句话听起来很普通,但它曾经深刻影响过 Go 程序员写代码的方式。你想写一个 Index 函数,在切片里找某个值的位置。如果是 []int,你写一个 IndexInt;如果是 []string,你再写一个 IndexString;如果是 []float64,你又得写一个 IndexFloat64

代码逻辑几乎一样,差别只是类型。

泛型解决的正是这个问题:

当一段逻辑与具体类型无关时,让类型也成为参数。

这篇文章从 Go Tour 里的两个例子出发,讲清楚 Go 泛型里最重要的两件事:

  • 泛型函数:func Index[T comparable](...)
  • 泛型类型:type List[T any] struct { ... }

读完以后,你应该能看懂 Go 里的类型参数、约束、anycomparable,也能自己写一个简单的泛型链表。


1. 没有泛型之前:重复的函数

假设我们要在 []int 里找一个数字的位置:

复制代码
func IndexInt(s []int, x int) int {
	for i, v := range s {
		if v == x {
			return i
		}
	}
	return -1
}

这个函数很简单:

  • 遍历切片
  • 如果当前值等于目标值,就返回下标
  • 如果找不到,就返回 -1

现在我们想支持 []string

复制代码
func IndexString(s []string, x string) int {
	for i, v := range s {
		if v == x {
			return i
		}
	}
	return -1
}

你会发现,除了 int 变成 string,代码完全一样。

如果再支持 []float64 呢?

复制代码
func IndexFloat64(s []float64, x float64) int {
	for i, v := range s {
		if v == x {
			return i
		}
	}
	return -1
}

这就是泛型出现的地方。

我们真正想表达的不是:

我要写一个 int 版本,一个 string 版本,一个 float64 版本。

而是:

我要写一个函数,它可以处理某一种类型的切片,只要这个类型能比较。


2. 泛型函数:把类型变成参数

Go 的泛型函数长这样:

复制代码
func Index[T comparable](s []T, x T) int {
	for i, v := range s {
		if v == x {
			return i
		}
	}
	return -1
}

最关键的是这一段:

复制代码
[T comparable]

它写在函数名后面、普通参数列表前面。

完整结构是:

复制代码
func 函数名[类型参数 约束](普通参数) 返回值 {
	函数体
}

所以:

复制代码
func Index[T comparable](s []T, x T) int

可以读成:

Index 有一个类型参数 T,这个 T 必须满足 comparable 约束。函数接收一个 []T 类型的切片 s,以及一个 T 类型的值 x,最后返回一个 int

换成人话:

给我一组同类型的值,再给我一个同类型的目标值,我帮你找它的位置。


4. comparable 是什么?

comparable 是 Go 内置的一个约束。

它表示:

这个类型的值可以使用 ==!= 比较。

为什么 Index 需要 comparable

因为函数内部写了这一句:

复制代码
if v == x {
	return i
}

如果一个类型不能使用 ==,这段代码就无法编译。

比如下面这些类型可以比较:

复制代码
int
string
bool
float64
uintptr
指针
channel
接口值
只包含可比较字段的 struct
元素类型可比较的 array

而下面这些类型不能直接比较:

复制代码
slice
map
func
包含不可比较字段的 struct

例如:

复制代码
type User struct {
	Name string
	Tags []string
}

User 不能用 == 比较,因为它里面有一个 []string 字段,而 slice 不能比较。

所以如果你这样调用:

复制代码
users := []User{
	{Name: "Alice", Tags: []string{"go"}},
}

fmt.Println(Index(users, User{Name: "Alice"}))

会编译失败。

这不是泛型故意刁难你,而是 Go 在保护你:既然函数里要用 ==,那传进来的类型就必须真的支持 ==


5. 为什么不用 any?

你可能会想,既然泛型是为了支持很多类型,那能不能这样写?

复制代码
func Index[T any](s []T, x T) int {
	for i, v := range s {
		if v == x {
			return i
		}
	}
	return -1
}

不行。

any 表示"任何类型"。

但不是任何类型都能用 ==

所以当约束是 any 时,编译器只允许你做所有类型都能做的事情。比如赋值、传参、返回、放进切片里,这些可以。但 ==+< 这类操作不一定可以。

这句话很重要:

类型参数能做什么,不取决于你希望它做什么,而取决于它的约束允许什么。

T comparable 允许比较。

T any 不保证可以比较。

所以 Index 必须用 comparable


6. 类型推断:通常不用手写 T

泛型函数可以显式写类型参数:

复制代码
Index[int]([]int{10, 20, 15}, 15)
Index[string]([]string{"foo", "bar"}, "bar")

但大多数时候不用写。

Go 可以根据普通参数推断出 T

复制代码
Index([]int{10, 20, 15}, 15)
Index([]string{"foo", "bar"}, "bar")

第一行里,s[]intxint,所以 Tint

第二行里,s[]stringxstring,所以 Tstring

但是类型必须一致。

复制代码
Index([]int{10, 20, 15}, "15")

这不行。

因为第一个参数说明 Tint,第二个参数却给了 string

泛型不是"放弃类型检查",正好相反,它让你在复用逻辑的同时保留静态类型安全。


7. 完整运行例子:泛型 Index

复制代码
package main

import "fmt"

func Index[T comparable](s []T, x T) int {
	for i, v := range s {
		if v == x {
			return i
		}
	}
	return -1
}

func main() {
	nums := []int{10, 20, 15, -10}
	fmt.Println(Index(nums, 15))

	words := []string{"foo", "bar", "baz"}
	fmt.Println(Index(words, "hello"))

	names := []string{"Alice", "Bob", "Carol"}
	fmt.Println(Index(names, "Bob"))
}

输出:

复制代码
2
-1
1

这个例子虽小,但已经体现了泛型函数的价值:

一份逻辑,多个类型,编译期保证安全。


8. 泛型类型:让结构体也带类型参数

除了函数可以泛型,类型也可以泛型。

Go Tour 里的例子是:

复制代码
type List[T any] struct {
	next *List[T]
	val  T
}

这是一个单向链表节点。

先看普通版本。如果只存 int,可以写:

复制代码
type IntList struct {
	next *IntList
	val  int
}

如果只存 string,可以写:

复制代码
type StringList struct {
	next *StringList
	val  string
}

问题又来了:结构一样,逻辑一样,只是值的类型不同。

于是我们可以写成:

复制代码
type List[T any] struct {
	next *List[T]
	val  T
}

这里的:

复制代码
[T any]

表示:

List 是一个泛型类型,它有一个类型参数 T,这个 T 可以是任何类型。

字段:

复制代码
val T

表示节点里保存一个 T 类型的值。

字段:

复制代码
next *List[T]

表示下一个节点,是同样元素类型的链表节点。

如果当前是:

复制代码
List[int]

那么 next 就是:

复制代码
*List[int]

如果当前是:

复制代码
List[string]

那么 next 就是:

复制代码
*List[string]

这样一个链表里就不会混进不一致的类型。


9. 初始化泛型类型

泛型类型使用时,要给出具体类型。

复制代码
n1 := &List[int]{
	val: 10,
}

n2 := &List[int]{
	val: 20,
}

n1.next = n2

这里的 List[int] 表示:

一个存 int 的链表。

字符串版本:

复制代码
w1 := &List[string]{
	val: "hello",
}

w2 := &List[string]{
	val: "world",
}

w1.next = w2

这里是:

复制代码
List[string]

表示:

一个存 string 的链表。

你不能把 *List[string] 放到 *List[int]next 里:

复制代码
nums := &List[int]{val: 10}
words := &List[string]{val: "hello"}

nums.next = words // 编译错误

这就是静态类型安全。


10. 给泛型类型写方法

泛型类型也可以有方法。

比如给 List[T] 写一个 Values 方法,把链表转成切片:

复制代码
func (l *List[T]) Values() []T {
	var values []T

	for node := l; node != nil; node = node.next {
		values = append(values, node.val)
	}

	return values
}

注意 receiver:

复制代码
func (l *List[T]) Values() []T

这里不是:

复制代码
func (l *List) Values()

因为 List 是泛型类型,使用时必须带上类型参数:

复制代码
List[T]

完整例子:

复制代码
package main

import "fmt"

type List[T any] struct {
	next *List[T]
	val  T
}

func (l *List[T]) Values() []T {
	var values []T

	for node := l; node != nil; node = node.next {
		values = append(values, node.val)
	}

	return values
}

func main() {
	nums := &List[int]{
		val: 10,
		next: &List[int]{
			val: 20,
			next: &List[int]{
				val: 30,
			},
		},
	}

	fmt.Println(nums.Values())

	words := &List[string]{
		val: "hello",
		next: &List[string]{
			val: "world",
		},
	}

	fmt.Println(words.Values())
}

输出:

复制代码
[10 20 30]
[hello world]

同一个 Values 方法,既服务于 List[int],也服务于 List[string]


11. 给链表加一个 PushFront

链表常见操作是往头部插入一个值。

我们可以写一个函数:

复制代码
func PushFront[T any](head *List[T], val T) *List[T] {
	return &List[T]{
		val:  val,
		next: head,
	}
}

这个函数的意思是:

给我一个 *List[T] 作为旧头节点,再给我一个 T 类型的值,我返回一个新的头节点。

完整代码:

复制代码
package main

import "fmt"

type List[T any] struct {
	next *List[T]
	val  T
}

func PushFront[T any](head *List[T], val T) *List[T] {
	return &List[T]{
		val:  val,
		next: head,
	}
}

func (l *List[T]) Values() []T {
	var values []T

	for node := l; node != nil; node = node.next {
		values = append(values, node.val)
	}

	return values
}

func main() {
	var nums *List[int]

	nums = PushFront(nums, 30)
	nums = PushFront(nums, 20)
	nums = PushFront(nums, 10)

	fmt.Println(nums.Values())

	var words *List[string]

	words = PushFront(words, "world")
	words = PushFront(words, "hello")

	fmt.Println(words.Values())
}

输出:

复制代码
[10 20 30]
[hello world]

这里有一个很自然的点:

复制代码
var nums *List[int]

这是一个空链表,头节点是 nil

第一次插入:

复制代码
nums = PushFront(nums, 30)

得到:

复制代码
30

第二次插入:

复制代码
nums = PushFront(nums, 20)

得到:

复制代码
20 -> 30

第三次插入:

复制代码
nums = PushFront(nums, 10)

得到:

复制代码
10 -> 20 -> 30

所以最后转成切片就是:

复制代码
[10 20 30]

12. 用构造函数创建泛型链表

如果想一次性从多个值创建链表,可以写一个构造函数:

复制代码
func NewList[T any](values ...T) *List[T] {
	var head *List[T]

	for i := len(values) - 1; i >= 0; i-- {
		head = PushFront(head, values[i])
	}

	return head
}

为什么从后往前遍历?

因为 PushFront 是插到头部。

如果传入:

复制代码
NewList(10, 20, 30)

我们希望链表顺序是:

复制代码
10 -> 20 -> 30

所以要先插 30,再插 20,最后插 10

完整代码:

复制代码
package main

import "fmt"

type List[T any] struct {
	next *List[T]
	val  T
}

func PushFront[T any](head *List[T], val T) *List[T] {
	return &List[T]{
		val:  val,
		next: head,
	}
}

func NewList[T any](values ...T) *List[T] {
	var head *List[T]

	for i := len(values) - 1; i >= 0; i-- {
		head = PushFront(head, values[i])
	}

	return head
}

func (l *List[T]) Values() []T {
	var values []T

	for node := l; node != nil; node = node.next {
		values = append(values, node.val)
	}

	return values
}

func main() {
	nums := NewList(10, 20, 30)
	fmt.Println(nums.Values())

	words := NewList("go", "generic", "list")
	fmt.Println(words.Values())
}

输出:

复制代码
[10 20 30]
[go generic list]

你会发现这里没有写:

复制代码
NewList[int](10, 20, 30)

因为 Go 可以根据参数推断出 Tint

当然你也可以显式写:

复制代码
nums := NewList[int](10, 20, 30)
words := NewList[string]("go", "generic", "list")

13. any:最宽松的约束

any 是 Go 里最宽松的泛型约束。

它本质上等价于:

复制代码
interface{}

也就是说:

复制代码
type List[T any] struct {
	val T
}

和下面这种表达的意思接近:

复制代码
type List[T interface{}] struct {
	val T
}

只是 any 更短,也更符合泛型语境。

什么时候用 any

当你的代码不需要对 T 做特殊操作时。

比如链表只是保存值、返回值、遍历值:

复制代码
val T
[]T
append(values, node.val)

这些操作对所有类型都成立,所以 any 足够。

但如果你要比较:

复制代码
v == x

那就不能用 any,要用 comparable

如果你要做加法:

复制代码
a + b

any 也不够,需要一个允许加法的约束。


14. 自定义约束:让泛型更精确

有时候内置的 anycomparable 不够。

比如你想写一个 Min 函数:

复制代码
func Min[T ???](a T, b T) T {
	if a < b {
		return a
	}
	return b
}

这里需要使用 <

但不是所有类型都能用 <

我们可以定义一个约束:

复制代码
type Ordered interface {
	~int | ~int64 | ~float64 | ~string
}

然后:

复制代码
func Min[T Ordered](a T, b T) T {
	if a < b {
		return a
	}
	return b
}

完整例子:

复制代码
package main

import "fmt"

type Ordered interface {
	~int | ~int64 | ~float64 | ~string
}

func Min[T Ordered](a T, b T) T {
	if a < b {
		return a
	}
	return b
}

func main() {
	fmt.Println(Min(10, 20))
	fmt.Println(Min(3.14, 2.71))
	fmt.Println(Min("go", "java"))
}

输出:

复制代码
10
2.71
go

这里的 | 表示类型集合的并集:

复制代码
~int | ~int64 | ~float64 | ~string

意思是:

T 可以是这些类型之一。

前面的 ~ 表示允许底层类型是这个类型的自定义类型。

比如:

复制代码
type Age int

Age 的底层类型是 int

如果约束写成:

复制代码
int

Age 不一定能匹配。

如果写成:

复制代码
~int

Age 就可以匹配。

这就是 ~ 的作用:允许"底层类型为某类型"的自定义类型。


15. 泛型不是 interface{} 的换皮

在泛型出现之前,有些代码会用 interface{} 写通用逻辑。

例如:

复制代码
func PrintAll(values []interface{}) {
	for _, v := range values {
		fmt.Println(v)
	}
}

这看起来也能接收各种类型,但它有几个问题。

第一,[]int 不能直接当成 []interface{} 传入。

复制代码
nums := []int{1, 2, 3}
PrintAll(nums) // 不行

第二,取出来以后类型信息变弱了。

复制代码
func First(values []interface{}) interface{} {
	return values[0]
}

调用方拿到的是 interface{},还要类型断言。

泛型可以保留类型信息:

复制代码
func First[T any](values []T) T {
	return values[0]
}

调用:

复制代码
n := First([]int{1, 2, 3})
s := First([]string{"go", "lang"})

这里 n 自动是 ints 自动是 string

这就是泛型比 interface{} 更适合通用容器和通用算法的地方:

泛型复用代码,但不丢失类型。


16. 什么时候该用泛型?

泛型很好,但不是所有地方都该用。

适合用泛型的场景:

  • 写通用数据结构,比如栈、队列、链表、集合。
  • 写通用算法,比如 IndexMapFilterReduce
  • 多个函数逻辑相同,只是类型不同。
  • 你希望复用逻辑,同时保留静态类型。

不太适合用泛型的场景:

  • 只有一个具体类型在用。
  • 类型虽然不同,但业务含义完全不同。
  • 用接口能更自然地表达行为。
  • 泛型让代码变得更难读,而不是更清楚。

比如这个场景适合接口:

复制代码
type Reader interface {
	Read(p []byte) (n int, err error)
}

你关心的不是具体类型是什么,而是它有没有 Read 这个能力。

而这个场景适合泛型:

复制代码
func First[T any](values []T) T {
	return values[0]
}

你关心的是容器里的元素类型,并希望返回同样的类型。

一个简单判断是:

如果你要表达"能力",优先考虑接口。

如果你要表达"同一份逻辑作用于不同类型",考虑泛型。


17. 泛型代码里的几个常见误区

误区一:以为 any 什么都能做

复制代码
func Equal[T any](a T, b T) bool {
	return a == b
}

这不行。

any 表示任何类型,而任何类型并不都能比较。

应该写:

复制代码
func Equal[T comparable](a T, b T) bool {
	return a == b
}

误区二:类型参数越多越高级

下面这种代码看起来很通用:

复制代码
func Convert[A any, B any](a A) B {
	var b B
	return b
}

但它几乎没有实际意义。

泛型的目标不是让函数签名变复杂,而是消除真实的重复。

误区三:把泛型当成继承

泛型不是继承,也不是"任意类型乱传"。

复制代码
func Handle[T Animal](v T)

这不是说 T 继承了 Animal

在 Go 里,约束描述的是类型集合和可用操作。泛型关注的是:

这个类型参数允许是什么,以及在函数内部能做什么。

误区四:忘了 nil 链表

比如:

复制代码
func (l *List[T]) Values() []T {
	var values []T

	for node := l; node != nil; node = node.next {
		values = append(values, node.val)
	}

	return values
}

这个方法可以处理 nil receiver:

复制代码
var nums *List[int]
fmt.Println(nums.Values())

输出:

复制代码
[]

因为循环一开始 node := l,如果 lnil,循环不会进入。

这是链表方法里很常见也很舒服的写法。


18. 泛型的美感:少写重复,但不牺牲清楚

Go 的泛型不是为了让代码看起来更抽象。

它更像是一种克制的工具:

当你发现自己在写三份几乎一样的代码,只是 intstringfloat64 不同,它就出现了。

Index[T comparable] 说的是:

我不关心具体是 int 还是 string,我只要求它能比较。

List[T any] 说的是:

我不关心具体存什么,我只是负责把这些值串起来。

泛型真正好的地方,不是把所有类型混在一起,而是在保留类型安全的前提下,让重复的逻辑合并成一份。

它让代码更少,但不让类型更模糊。

它让抽象出现,但仍然站在编译器能检查的地面上。


结语

你可以这样记 Go 泛型:

复制代码
func Index[T comparable](s []T, x T) int

这里的 T 是类型参数,comparable 是约束。

复制代码
type List[T any] struct {
	next *List[T]
	val  T
}

这里的 List[T] 是泛型类型,T 决定链表里存什么。

any 表示什么类型都可以,但能做的操作很少。

comparable 表示可以用 ==!=

自定义约束可以表达更精确的类型集合。

类型推断让大多数泛型调用看起来和普通函数一样自然。

泛型不是 Go 里最日常的语法,但一旦你开始写通用容器和通用算法,它会变得非常顺手。

它像一把轻一点的刀:不炫目,但锋利;不强迫你抽象,但在重复出现时,给你一条干净的路。