golang的泛型使用

Go 泛型(Generics)是一种编程范式,它允许我们在定义函数、方法或类型时,使用一个或多个 “ 类型占位符 “(Type Parameter),而不是具体的类型。这些 “ 占位符 “ 在实际调用时,会被具体的类型(如 intstringstruct 等)替换,从而让一份代码能够安全、高效地处理多种不同的数据类型。

Go 泛型就像一个万能的厨房模具,你不需要为心形、星形、圆形饼干分别准备三个模具,只需要一个可以调整形状的 “ 可变形 “ 模具,就能制作出所有形状的饼干。这个 “ 可变形模具 “ 就是你的泛型函数或泛型类型,” 形状 “ 就是你传入的具体类型。

Go 泛型的出现,完美地解决了这两个核心痛点。它让你能够:

  1. 编写可复用的代码:一套逻辑,多处使用,极大提高开发效率。
  2. 保证类型安全:在编译期间就进行类型检查,把错误消灭在萌芽状态,而不是等到运行时才 panic
  3. 提升性能:泛型代码在编译时会进行 “ 具象化 “(Instantiation),生成针对特定类型的原生代码,避免了 interface{} 带来的装箱(boxing)和拆箱(unboxing)开销。

1. 组成部分

Go 泛型主要由三部分组成:类型参数(Type Parameters)、类型约束(Constraints) 和 泛型代码(Generic Functions/Types)。

类型参数(Type Parameters)

  • 语法:在函数名或类型名后,用方括号 [] 声明。例如 [T any],这里的 T 就是一个类型参数,any 是它的约束。T 只是一个约定俗成的名字,你也可以用 VK 或任何你喜欢的名字。
  • 伪代码:
1
2
3
4
// T 是一个类型占位符
func PrintSlice[T any](s []T) {
// ...
}

类型约束 (Constraints)

  • 作用:它定义了类型参数 T 必须满足的条件。它就像一个 “ 准入规则 “,告诉编译器:” 任何想替换 T 的类型,都必须具备某些特征(比如支持 +> 运算,或者实现了某个方法)”。
  • 定义:约束本质上是一个接口(interface)。你可以使用预定义的约束(如 anycomparable),也可以自定义。
  • 预定义约束 any: 它是 interface{} 的别名,代表 “ 任何类型 “,是最宽松的约束。
  • 预定义约束 comparable: 代表该类型支持 == 和 != 运算,如 intstringpointerstruct(如果其所有字段都可比较)。注意,slicemapfunc 类型不满足 comparable
  • 自定义约束:
1
2
3
4
5
6
7
8
9
10
// 定义一个名为 Number 的约束
// 它要求类型必须是 int 或 float64
type Number interface {
int | float64 // 使用 | 来联合多种类型
}

// T 必须是 int 或 float64
func SumNumbers[T Number](numbers []T) T {
// ...
}

泛型代码 (Generic Functions/Types)

  • 泛型函数: 一个使用了类型参数的函数。
1
2
3
4
5
6
7
// 一个可以翻转任何类型切片的泛型函数
func Reverse[T any](s []T) {
l := len(s)
for i := 0; i < l/2; i++ {
s[i], s[l-1-i] = s[l-1-i], s[i]
}
}
  • 泛型类型: 一个使用了类型参数的 struct 或 interface
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 一个可以存放任何类型数据的泛型栈
type Stack[T any] struct {
items []T
}

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

func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T // 关键点:泛型类型的零值
return zero, false
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}

2. 基础介绍

2.1 泛型 Vs interface{}

泛型的出现并非要完全取代 interface{},它们各有战场。

特性泛型 (Generics)interface{} (空接口)
类型安全编译时检查。如果传入的类型不满足约束,代码无法编译。运行时检查。需要类型断言 (v.(int)),如果断言失败,会在运行时 panic
性能高性能。编译时生成专用代码,无运行时开销。通常与手写的特定类型函数性能相当。有性能开销。涉及类型装箱 (boxing) 和拆箱 (unboxing),以及动态派发,比原生调用慢。
代码意图非常明确。通过约束,函数的签名清晰地说明了它能接受什么类型的参数,以及能对这些参数做什么操作。模糊不清。函数签名 func(v interface{}) 没提供任何信息,你需要阅读文档或源码才能知道它期望什么类型。
适用场景1. 实现通用数据结构(如树、栈、队列)。
2. 编写对不同数据类型执行相同算法的函数(如 MapFilterReduce)。
3. 要求编译时类型安全和高性能的场景。
1. 处理异构集合(一个切片中同时包含 intstringstruct)。
2. 需要与不了解泛型的旧代码或反射库交互。
3. 当行为由方法定义,而不是由具体类型操作定义时(典型的接口用法)。

总结: 用泛型处理同构(homogeneous)数据,用 interface{} 处理异构(heterogeneous)数据。

2.2 冷门知识

  1. 泛型的历史:Go 社区关于是否引入泛型的讨论持续了近十年。核心团队曾多次拒绝,担心它会使 Go 变得过于复杂,违背 “ 大道至简 “ 的哲学。最终的设计方案(由 Ian Lance Taylor 和 Robert Griesemer 主导)因其与现有接口系统结合良好、不引入新运行时概念而胜出。
  2. “ 字典 “ 与 “ 模板 “ 之争:编译器实现泛型主要有两种方式。一种是 C++ 的模板(Stenciling),为每个类型实例生成一份独立代码,这导致编译出的二进制文件变大,但运行快。另一种是 Java 的擦除(Erasure)和 C# 的字典(Dictionary),在运行时处理类型信息,二进制文件小巧,但有运行时开销。Go 选择了 Stenciling(模板)方式,优先保证了运行时性能。
  3. 类型联合中的 ~ (tilde) 操作符:这是个强大的特性。type MyInt int 定义了一个新类型 MyInt,它和 int 是不同的。如果你的约束是 interface { int }MyInt 就不满足。但如果你的约束是 interface { ~int },那么任何底层类型是 int 的类型(包括 int 和 MyInt)都满足这个约束。
  4. 泛型方法不能用于具体类型:你可以在泛型类型上定义方法,但不能在一个非泛型类型上定义一个泛型方法
1
2
3
4
5
6
7
// 正确:在泛型类型上定义方法
type MySlice[T any] []T
func (s MySlice[T]) PrettyPrint() { /*...*/ }

// 错误:在非泛型类型上定义泛型方法
// type MyInt int
// func (i MyInt) ConvertTo[T any]() T { /*...*/ } // 这是不允许的

2.3 注意事项

  • 克制使用,勿滥用:当一个函数的逻辑确实与具体类型无关时(例如,操作容器、数据结构、排序、查找等),泛型是最佳选择。但如果函数的核心逻辑强依赖于具体类型(例如,格式化一个 time.Time 对象),那么传统的函数就足够了。” 当且仅当泛型能显著减少代码重复并保持类型安全时,才使用它。

  • 让类型推断为你工作:大多数情况下,Go 编译器可以根据你传入的参数自动推断出类型参数,你不需要显式指定。

1
2
3
// 不必写成 Reverse[int](myIntSlice)
// 编译器能自动推断 T 是 int
Reverse(myIntSlice)
  • 约束要尽可能小:设计约束时,只包含你真正需要的方法或类型。更小的约束意味着你的泛型函数有更广的适用范围。例如,如果你只需要比较大小,使用 constraints.Ordered 就比自定义一个包含很多不必要方法的接口要好。

    • comparable 约束只保证类型支持 == 和 != 操作。
    • 对于 <、<=、>、>= 这类排序操作,我们需要一个更强的约束 Ordered
  • 清晰命名类型参数:虽然 T 是惯例,但在有多个类型参数时,请使用有意义的名字,例如 [K comparable, V any] 用在 map 相关函数中,K 代表 Key,V 代表 Value。

  • 对泛型类型执行非法操作

    • 错误:func Add[T any](a T, b T) T { return a + b }
    • 原因:any 约束太宽泛了,它不保证类型 T 支持 + 运算符。一个 struct 类型就不支持。
    • 如何避免:使用更严格的约束。你可以定义一个 type Summable interface { int | int8 | ... | float64 | string } 来指定所有支持 + 的类型。
  • 泛型类型的零值 (Zero Value)

    • 问题:在一个泛型函数或方法中,当你声明 var zero T 时,zero 的值是什么?
    • 答案:它是该类型具象化后的零值。如果 T 是 intzero 就是 0。如果 T 是 *stringzero 就是 nil。如果 T 是 struct{}zero 就是 {}
    • 陷阱:你不能假设零值是 nil。例如,在前面的 Stack.Pop 方法中,如果栈为空,我们返回 var zero T。如果 T 是 int,调用者会收到 0,这可能是一个合法的栈内元素,从而导致逻辑错误。这就是为什么 Pop 方法需要返回一个额外的 bool 值来明确表示操作是否成功。
  • 无法直接比较的问题

    • 错误:func AreEqual[T any](a T, b T) bool { return a == b }
    • 原因:any 不保证类型是可比较的。[]int 和 map[string]int 就不能用 == 比较。
    • 如何避免:使用 comparable 约束:func AreEqual[T comparable](a T, b T) bool { return a == b }

3. 具体使用

3.1 使用示例

  • slice 的 demo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type StringSlice []string
type Float32Slie []float32
type Float64Slice []float64

// 代替上面的
type Slice[T string|float32|float64 ] []T


// 具体使用
type Slice[T string | int] []T

func main() {
var a Slice[int] = []int{1, 2, 3}
fmt.Println("a type", reflect.TypeOf(a))

var b Slice[string] = []string{"Hello", "World"}
fmt.Println("b type", reflect.TypeOf(b))
}

/*
a type main.Slice[int]
b type main.Slice[string]
*/
  • map 的 demo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
"fmt"
"reflect"
)

type Map[K string | float32, V any] map[K]V // 注意这里的key不能为any,想一想为什么

func main() {
a := Map[string, int]{"a": 10, "b": 12}
//b := Map[int32, string]{} // 不能编译
c := Map[float32, float32]{10: 10}
fmt.Println("a", reflect.TypeOf(a))
fmt.Println("c", reflect.TypeOf(c))
}

/*
a main.Map[string,int]
c main.Map[float32,float32]
*/

3.2 类型嵌套

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 类型嵌套,注意 S 这里是引用了 T
type WowStruct[T int | float32, S []T] struct {
Data S
MaxValue T
MinValue T
}


// 先定义个泛型类型 Slice[T]
type Slice[T int|string|float32|float64] []T
// ✗ 错误。泛型类型Slice[T]的类型约束中不包含uint, uint8
type UintSlice[T uint|uint8] Slice[T]
// ✓ 正确。基于泛型类型Slice[T]定义了新的泛型类型 FloatSlice[T] 。FloatSlice[T]只接受float32和float64两种类型
type FloatSlice[T float32|float64] Slice[T]

3.3 类型约束的两种写法

这两种写法和实现的功能其实是差不多的,实例化之后结构体相同。但是用第一种更好,扩展性更强。

1
2
3
4
5
6
7
8
9
type WowStruct[T int|string] struct {
Name string
Data []T
}

type WowStruct2[T []int|[]string] struct {
Name string
Data T
}

3.4 方法集接口和约束接口

传统接口 (方法集接口)

定义一个行为契约 (Contract of Behavior),它只能规定 “能做什么“,无法规定 “是什么“。

1
2
3
4
5
// 招聘“可序列化”的岗位
type Serializable interface {
ToJSON() ([]byte, error)
FromJSON([]byte) error
}

泛型接口 (约束接口)

Go 1.18 中为了配合泛型而扩展的新能力。它的主要用途是作为泛型的类型约束 (Constraint)。定义一个类型身份契约 (Contract of Type Identity),接口内包含一个由 | 连接的类型列表(Type Union)。

1
2
3
4
// "数字"派对的宾客名单
type Number interface {
int | int64 | float32 | float64
}

只能用作泛型约束:这是一个非常重要的限制!你不能像传统接口那样用它来声明一个变量。

1
2
3
4
5
// 正确:作为泛型约束
func SumNumbers[T Number](nums []T) T { ... }

// 错误:不能作为变量类型!
// var myNumber Number // 这行代码无法通过编译

现代混合接口 (方法集 + 约束)

同时定义行为契约和类型身份契约。这是一个极其严格的派对入场规则。它要求 “ 你必须是宾客名单上的人(比如,int 或 int64),并且你还得会说秘密暗号(比如,实现一个 Hash() string 方法)”。

  • 限制:和泛型接口一样,只要接口内包含了类型列表,它就只能用作泛型约束,不能用于变量声明。
1
2
3
4
5
6
7
8
9
10
11
12
// "可哈希整数"派对规则
type HashableInteger interface {
~int | ~int64 // 宾客名单 (这里的 ~ 表示也接受底层类型为 int/int64 的自定义类型)
Hash() string // 秘密暗号 (行为要求)
}

func PrintHashes[T HashableInteger](vals []T) {
for _, v := range vals {
fmt.Println(v.Hash())
}
}

接口类型核心思想比喻能否用于变量声明? (var v I)
传统接口 (方法集)定义行为招聘启事可以
泛型接口 (类型列表)定义类型身份VIP 宾客名单不可以 (只能用作约束)
混合接口定义行为 + 类型身份带附加条件的 VIP 名单不可以 (只能用作约束)

4. 瞬间看懂复杂泛型的定

比喻:把复杂的泛型签名当成一道菜谱,看到这个菜谱名,别慌。我们三步把它看懂。(中括号,圆括号,返回值)

func TransformAndGroup[T any, K comparable, V any](items []T, transform func(T) (K, V)) map[K][]V

第一步:看 “ 配料种类 “ [ … ] (类型参数和约束)

这部分是 [T any, K comparable, V any]

  • 是什么:这是泛型的核心,它定义了这道菜谱里有哪些可以变化的 “ 基础食材种类 “。
  • 怎么读:
    • T any:我们需要一种叫 T 的食材,它可以是任何东西 (任何类型)。
    • K comparable:我们需要第二种叫 K 的食材,它必须是可比较的 (比如 intstring,可以作为 map 的键)。
    • V any:我们需要第三种叫 V 的食材,它也可以是任何东西。
  • 技巧:这是你理解泛型函数的第一站。先搞清楚它引入了哪几个 “ 代号 “,以及对这些 “ 代号 “ 有什么 “ 规矩 “。

第二步:看 “ 备菜清单 “ ( … ) (函数入参)

这部分是 (items []T, transform func(T) (K, V))

  • 是什么:这是告诉你要准备哪些具体的食材和工具才能开始做菜。
  • 怎么读:
    • items []T:你需要准备一大盘 T 这种食材 (一个 T 类型的切片)。
    • transform func(T) (K, V):你还需要一个 “ 魔法料理机 “(一个函数)。这个料理机很厉害,你给它一个 T,它能帮你加工成一个 K 和一个 V
  • 技巧:重点看这些 “ 代号 “ 是如何在输入参数中组合的。这揭示了它们之间的关系。 在这里,transform 函数是关键,它建立了从 T 到 K 和 V 的桥梁。

第三步:看 “ 成品展示 “ (返回结果)

这部分是 map[K][]V

  • 是什么:这是菜谱告诉你,按步骤操作后,最终会得到一道什么样的菜。
  • 怎么读:你会得到一个 “ 分类拼盘 “ (map)。盘子里的每个分类标签是 K 类型,每个分类下放着一堆 V 这种食材 ([]V)。
  • 技巧:返回值是你理解函数最终目的的钥匙。

三步合一,瞬间理解

func TransformAndGroup[T any, K comparable, V any](items []T, transform func(T) (K, V)) map[K][]V

现在我们把三步串起来,对 TransformAndGroup 这个函数进行 “ 同声传译 “:

  1. 它需要三种类型:输入类型 T,一个可作为 Key 的类型 K,和一个值类型 V
  2. 你需要给它:一个 T 类型的切片,以及一个能把每个 T 转换成 K 和 V 的转换函数。
  3. 它会还给你:一个 map。这个 map 会根据转换后得到的 K 作为键,把所有键相同的 V 组织在一起,形成 []V 作为值。

一句话总结函数功能:这是一个 “ 转换并分组 “ 的函数。它能将任意类型的切片,通过你指定的转换规则,转换成一个分组后的 map

掌握这个 “ 三步解码法 “,任何复杂的泛型定义在你面前都会变得清晰透明。先看 “ 种类 “,再看 “ 输入 “,最后看 “ 输出 “,逻辑链条自然就浮现了。

5. 参考资料