golang 泛型

泛型允许程序员在强类型程序设计语言中编写代码时使用一些以后才指定的类型,在实例化时作为参数再指明这些类型。ーー换句话说,在编写某些代码或数据结构时,在声明的时候先不提供值的类型(或提供一个可能的类型范围),而在实例化 new时才确定它的类型。使用泛型可以编写出适用于一组类型中的任何一种的函数和类型。

泛型的作用

假设我们需要实现一个反转切片的功能:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func reverse(s []int) []int {
	l := len(s)
	r := make([]int, l)

    for i, e := range s {
		r[l-i-1] = e
	}
	return r
}

fmt.Println(reverse([]int{1, 2, 3, 4}))  // [4 3 2 1]

它可以很好的实现这个功能,但是它只能传入int切片类型,如果是float64切片类型就需要重新写一个逻辑一模一样,只有参数和返回值类型不同的函数。如果要传入更多不同类型就需要实现相对于的函数

1
2
3
4
5
6
7
8
9
func reverseFloat64Slice(s []float64) []float64 {
	l := len(s)
	r := make([]float64, l)

	for i, e := range s {
		r[l-i-1] = e
	}
	return r
}

一遍遍的写相同逻辑的代码是低效的,实际上这个反转切片的函数并不需要知道切片中元素的类型,但为了适用不同的类型我们把一段代码重复了很多遍。

在Go1.18之前我们可以尝试使用反射去解决上述问题,但是使用反射在运行期间获取变量类型会降低代码的执行效率,并且失去了编译期的类型检查,它会导致出现意想不到的panic,同时大量的反射代码也会让程序变得晦涩难懂。

从Go1.18开始,使用泛型就能够编写出适用所有元素类型的“普适版”reverse函数。

1
2
3
4
5
6
7
8
9
func reverseWithGenerics[T any](s []T) []T {
	l := len(s)
	r := make([]T, l)

	for i, e := range s {
		r[l-i-1] = e
	}
	return r
}

语法

泛型为Go语言添加了三个新的重要特性:

  1. 函数和类型的类型参数。
  2. 将接口类型定义为类型集,包括没有方法的类型。
  3. 类型推断,它允许在调用函数时在许多情况下省略类型参数。

我们都知道参数有形参和实参区别,而新版中对函数和类型增加了类型参数。类型参数列表看起来像普通的参数列表,只不过它使用方括号([])而不是圆括号(())。

1
2
3
4
5
// 类型形参
func reverseWithGenerics[T int | float64, B any](s []T, b B) []T

// 类型实参
reverseWithGenerics[int,string]([]int{},"")

int | float64 就是类型形参的类型约束

实例化

借助泛型,我们可以声明一个适用于 一组类型 的 min 函数

1
2
3
4
5
6
func min[T int | float64](a, b T) T {
	if a <= b {
		return a
	}
	return b
}

它同时支持 intfloat64两种类型,当调用 min函数时,我们既可以传入 int类型的参数:

1
m1 := min[int](1, 2)  // 1

也可以传入 float64类型的参数。

1
m2 := min[float64](-0.1, -0.2)  // -0.2

在调用函数时提供的类型参数称为实例化( instantiation ),它就是在调用时确定传入函数的参数到底是什么类型。

类型实例化分两步进行:

  1. 首先,编译器在整个泛型函数或类型中将所有类型形参(type parameters)替换为它们各自的类型实参(type arguments)。
  2. 其次,编译器验证每个类型参数是否满足相应的约束。

在实例化之后,我们将得到一个非泛型函数(已经确定参数类型),它可以像任何其他函数一样被调用:

1
2
fmin := min[float64] // 类型实例化,编译器生成T=float64的min函数
m2 = fmin(1.2, 2.3)  // 1.2

而如果使用 实例化并调用 的语法时,参数类型是可以省略的,这属于是语法糖,go会根据实际传的参数获取其具体类型。

1
m2 := min(3.14, 4)	// min[float64](3.14, 4)

类型参数的使用

除了函数中支持使用类型参数列表外,类型也可以使用类型参数列表。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Slice[T int | string] []T

type Map[K int | string, V float32 | float64] map[K]V

type Tree[T interface{}] struct {
	left, right *Tree[T]
	value       T
}

type User[T int | float64] struct {
	Bance T
}

在上述泛型类型中,TKV都属于类型形参,类型形参后面是类型约束,类型实参需要满足对应的类型约束。

泛型类型可以有方法,例如为上面的 Tree实现一个查找元素的 Lookup方法。

1
func (t *Tree[T]) Lookup(x T) *Tree[T] { ... }

而如果要使用泛型类型,就必须先进行实例化,确定其泛型的具体类型。

1
2
var stringTree Tree[string]
var user User[float64]

类型约束

普通函数中的每个参数都有一个类型; 该类型定义一系列值的集合。比如非泛型函数 minFloat64声明了参数的类型为 float64,那么在函数调用时允许传入的实参就必须是 float64类型的浮点数值。

类似于参数都有对应的参数类型,类型参数也有一个类型约束。类型约束定义了一个类型集,只有在这个类型集中的类型才能用作类型实参。

Go语言中的类型约束是接口类型。

类型约束有两种定义方式:

  • 类型约束接口可以直接在类型参数列表中使用。
1
2
// 类型约束字面量,通常外层interface{}可省略
func min[T interface{ int | float64 }](a, b T) T
  • 作为类型约束使用的接口类型可以事先定义并支持复用。
1
2
3
4
5
// 事先定义好的类型约束类型
type Value interface {
	int | float64
}
func min[T Value](a, b T) T

在使用类型约束时,如果省略了外层的 interface{}会引起歧义,那么就不能省略。例如:

1
2
3
4
type IntPtrSlice [T *int] []T  // 会产生歧义,被当成T 乘以 int,编译都过不了

type IntPtrSlice[T *int,] []T  // 只有一个类型约束时可以添加`,`消除歧义
type IntPtrSlice[T interface{ *int }] []T // 使用interface{}包裹

类型集

Go1.18开始接口类型的定义发生了改变,由过去的接口类型定义方法集(method set,定义有哪些方法)变成了接口类型定义类型集(type set,即可以定义方法又可以进行类型约束,但是不能在一个interface中定义类型约束同时又定义方法,因为类型约束和接口是两个不同的概念,可以分两个接口,一个定义方法一个类型约束即可)。

把接口类型当做类型集相较于方法集有一个优势: 我们可以显式地向集合添加类型,从而以新的方式控制类型集。

Go语言扩展了接口类型的语法,让我们能够向接口中添加类型。

1
2
3
type V interface {
	int | string | bool
}

一个接口不仅可以嵌入其他接口,还可以嵌入任何类型、类型的联合或共享相同底层类型的无限类型集合。

当用作类型约束时,由接口定义的类型集可以精确地指定允许作为相应类型参数的类型。

  • | 符号

    T1 | T2 表示类型约束为T1和T2这两个类型的并集(OR),例如下面的 Integer类型表示可以是 SignedUnsigned类型。

    1
    2
    3
    
    type Integer interface {
    	Signed | Unsigned
    }
    
  • ~ 符号

    ~T 表示所有底层类型是T的类型,例如 ~string表示所有底层类型是 string的类型集合。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    type Slice[T int | int32] []T
    type MyInt int
    var a Slice[MyInt] // 错误
    
    // MyInt底层类型是int,但其本身并不是int,所以不能用于Slice[T int | int32]的实例化。
    // 可以使用 ~int 的写法,表示所有以int为底层类型的类型都可以用于实例化。
    type Slice[T ~int | ~int32] []T
    type MyInt int
    var a Slice[MyInt] // 正确
    

    注意: ~符号后面只能是基本类型。要注意的是切片也是基础类型,如果非要使用结构体之类的复合类型就需要设置成切片

接口作为类型集是一种强大的新机制,是使类型约束能够生效的关键。

any 接口

由于 interface{} 的定义发生了变更,Go 1.18提供了新的等价关键词any。且Go官方推荐所有使用空接口的地方都使用any替换。

1
2
3
// src/builtin/builtin.go

type any = interface{}

Go 1.18内置了comparable约束,它表示所有 可以用 != 和 == 进行对比的类型。

1
2
3
4
5
6
// comparable is an interface that is implemented by all comparable types
// (booleans, numbers, strings, pointers, channels, arrays of comparable types,
// structs whose fields are all comparable types).
// The comparable interface may only be used as a type parameter constraint,
// not as the type of a variable.
type comparable interface{ comparable }

https://pkg.go.dev/golang.org/x/exp/constraints 包提供了一些常用类型。

类型推断

函数参数类型推断

对于类型参数,需要传递类型参数,这可能导致代码冗长。

1
func min[T int | float64](a, b T) T 

类型形参 T用于指定 ab的类型。我们可以使用显式类型实参调用它:

1
2
var a, b, m float64
m = min[float64](a, b) // 显式指定类型实参

在许多情况下,编译器可以从普通参数推断 T 的类型实参。这使得代码更短,同时保持清晰。

1
2
var a, b, m float64
m = min(a, b) // 无需指定类型实参

这种从实参的类型推断出函数的类型实参的推断称为函数实参类型推断。函数实参类型推断只适用于函数参数中使用的类型参数,而不适用于仅在函数返回值结果中或仅在函数体中使用的类型参数。例如,它不适用于像 MakeT [ T any ]() T 这样的函数,因为它只使用 T 表示结果,这种就必须在调用时指定T的具体类型。

1
2
3
4
5
func Test[T int | float32]() T {
	return T(0)
}

m := Test[float32]()

约束类型推断

Go 语言支持另一种类型推断,即 约束类型推断

1
2
3
4
5
6
7
8
// Scale 返回切片中每个元素都乘c的副本切片
func Scale[E constraints.Integer](s []E, c E) []E {
    r := make([]E, len(s))
    for i, v := range s {
        r[i] = v * c
    }
    return r
}

这是一个泛型函数,它适用于任何整数类型的切片。

假设我们有一个多维坐标的 Point 类型,其中每个 Point 是一个给出点坐标的整数列表。这种类型通常会实现一些业务方法,这里假设它有一个 String方法。

1
2
3
4
5
6
type Point []int32

func (p Point) String() string {
    b, _ := json.Marshal(p)
    return string(b)
}

由于一个 Point其实就是一个整数切片,我们可以使用前面编写的 Scale函数:

1
2
3
4
func ScaleAndPrint(p Point) {
    r := Scale(p, 2)
    fmt.Println(r.String()) // 编译失败
}

不幸的是,这代码会编译失败,输出 r.String undefined (type []int32 has no field or method String的错误。

原因是 Scale函数返回类型为 []E的值,其中 E是参数切片的元素类型。使用 Point类型的值调用 Scale(其基础类型为[]int32)时,我们返回的是 []int32类型的值,而不是 Point类型。[]int32 并没有 String() 方法。

为了解决这个问题,我们必须更改 Scale 函数,以便为切片类型使用类型参数。

1
2
3
4
5
6
7
func Scale[S ~[]E, E constraints.Integer](s S, c E) S {
    r := make(S, len(s))
    for i, v := range s {
        r[i] = v * c
    }
    return r
}

引入了一个新的类型参数 S,它是切片参数的类型。我们对它进行了约束,使得基础类型是 S而不是 []E,函数返回的结果类型现在是 S。由于 E被约束为整数,因此效果与之前相同:第一个参数必须是某个整数类型的切片。对函数体的唯一更改是,现在我们在调用 make时传递 S,而不是 []E

现在这个 Scale函数,不仅支持传入普通整数切片参数,也支持传入 Point类型参数。

这样将p作为S传入时就不会强转成 []int32切片类型,因为在泛型定义中已经表示了 S ~[]E ,而定义是 type Point []int32。将其改成显式类型参数就能一目了然,Scale[Point,int32](p,2),S就是Point所以不会发生强转,返回值也直接就是S 即Point。

Scale 函数有两个类型参数——SE。在不传递任何类型参数的 Scale(p, 2) 调用中,函数参数类型推断让编译器推断 S 的类型参数是 Point。但是这个函数也有一个类型参数 E,它是乘法因子 c 的类型。相应的函数参数是 2,因为 2是一个非类型化的常量,函数参数类型推断不能推断出 E 的正确类型(它可以推断出 2的默认类型是 int,而这是错误的,因为Point 的基础类型是 []int32)。相反,编译器推断 E 的类型参数 就是 切片的元素类型 的过程称为 约束类型推断

约束类型推断 就是从 类型参数 的约束来推导其他类型参数。当一个类型参数 具有 另一个类型参数定义的约束,而其中一个类型参数已知其具体类型时,约束类型推断 就可以 以此推断出另一个类型参数的具体类型。

通常的情况是,当一个约束 对某种类型使用 ~type 形式时,该类型是使用其他类型参数编写的。我们在 Scale 的例子中看到了这一点。S~[]E,后面跟着一个用另一个类型参数写的类型 []E。如果我们知道了 S 的类型实参,我们就可以推断出 E的类型实参。S 是一个切片类型,而 E是该切片的元素类型。

使用场景

可以使用的场景

使用语言定义的容器类型时 (map、slice和channel)

当我们编写的是操作 Go 语言定义的特殊容器类型(slice、map和chennel)的函数。如果函数 具有包含这些容器类型的参数,并且函数的代码并不关心元素的类型,那么使用泛型可能是有用的。

比如返回任何类型map中所有的key:

1
2
3
4
5
6
7
8
// MapKeys 返回m中所有key组成的切片
func MapKeys[Key comparable, Val any](m map[Key]Val) []Key {
    s := make([]Key, 0, len(m))
    for k := range m {
        s = append(s, k)
    }
    return s
}

这段代码并不关注 map 中键的类型,也根本没有使用 map 值类型。它适用于任何map类型。

在引入泛型之前,想要实现类似功能通常是使用反射,但是使用反射实现通常是复杂的,并且在编译期间不会进行静态类型检查,在运行时通常速度也更慢。

通用数据结构 (二叉树、链表)

另一个适用场景就是用于通用数据结构。通用数据结构类似于slice或map,但不是内置在语言中的,例如链表或二叉树。

之前需要这种数据结构通常采用两种方法:使用特定的元素类型编写数据结构,或者使用接口类型。用泛型替换特定的元素类型可以生成更通用的数据结构,该数据结构可以在程序的其他部分或其他程序中使用。用泛型替换接口类型可以更有效地存储数据,节省内存资源;它还允许代码避免类型断言,并在构建时进行完全的类型检查。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Tree is a binary tree.
type Tree[T any] struct {
    cmp  func(T, T) int
    root *node[T]
}

// A node in a Tree.
type node[T any] struct {
    left, right  *node[T]
    val          T
}

// find returns a pointer to the node containing val,
// or, if val is not present, a pointer to where it
// would be placed if added.
func (bt *Tree[T]) find(val T) **node[T] {
    pl := &bt.root
    for *pl != nil {
        switch cmp := bt.cmp(val, (*pl).val); {
        case cmp < 0:
            pl = &(*pl).left
        case cmp > 0:
            pl = &(*pl).right
        default:
            return pl
        }
    }
    return pl
}

// Insert inserts val into bt if not already there,
// and reports whether it was inserted.
func (bt *Tree[T]) Insert(val T) bool {
    pl := bt.find(val)
    if *pl != nil {
        return false
    }
    *pl = &node[T]{val: val}
    return true
}

树中的每个节点都包含类型参数 T 的值。实例化树时,确定了具体类型的值将直接存储在节点中。它们不会再被存储为接口类型。

这是对泛型的合理使用,因为 Tree 数据结构(包括方法中的代码)在很大程度上与元素类型 T 无关。

Tree数据结构需要知道如何比较元素类型T的值;它为此使用在 Tree结构体中传入的比较函数cmp。除此之外,type参数根本不重要。

泛型优先选择函数而不是方法

Tree 示例说明了另一个一般原则:当你需要比较函数之类的东西时,最好使用函数而不是方法。

如果使用方法的形式,那么实例化此泛型时,其具体类型都需要实现对应的方法,如果Tree的T是int类型就必须要定义自己的整数类型,并编写自己的比较方法。而作为函数用结构体成员的方式进行传递就灵活且容易的多。

实现通用方法

泛型可能有用的另一种情况是,不同类型需要实现某些公共方法,而不同类型其方法的实现逻辑看起来都是相同的。

比如标准库的 sort.Interface,它要求类型实现三个方法: LenSwapLess

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// SliceFn 为T类型切片实现 sort.Interface
type SliceFn[T any] struct {
	sli  []T
	less func(T, T) bool
}

func (s SliceFn[T]) Len() int {
	return len(s.sli)
}
func (s SliceFn[T]) Swap(i, j int) {
	s.sli[i], s.sli[j] = s.sli[j], s.sli[i]
}
func (s SliceFn[T]) Less(i, j int) bool {
	return s.less(s.sli[i], s.sli[j])
}

对于任意切片类型,LenSwap 方法完全相同。Less 方法需要进行比较,因此将less作为结构体成员传递进去,而SliceFn已经实现了 sort.Interface的三个方法。

1
2
3
sort.Sort(SliceFn[int]{sli: sli,less: func(i1, i2 int) bool {
	return i1<i2
}})

这类似于标准库函数 sort.Slice,但比较函数是使用值而不是切片索引 (用到了反射) 编写的。

对这类代码使用泛型是合适的,因为所有切片类型的方法看起来完全相同。

不能使用的场景

不要用泛型替换接口类型(考虑可读性)

众所周知,Go有接口类型。接口类型允许一种通用编程。而泛型和接口是互补的关系。

例如,广泛使用的 io.Reader接口提供了一种通用机制,用于从任何包含信息(例如文件)或生成信息(例如随机数生成器)的值中读取数据。如果对某个类型的值所做的一切就是调用该值的方法,请使用接口类型,而不是类型参数。io.Reader易于阅读、高效且有效。不需要使用类型参数,通过调用read方法从值中读取数据。

1
2
3
func ReadSome(r io.Reader) ([]byte, error)

func ReadSome[T io.Reader](r T) ([]byte, error)

接口省略type参数使函数更容易编写,更容易读取,并且执行时间可能相同。

Go 1.18 中泛型的实现在许多情况下会将类型为类型参数的值视为类型为接口类型的值。这意味着使用类型参数通常不会比使用接口类型更快。因此,不要仅仅为了速度而从接口类型更改为类型参数,因为它可能不会运行得更快。更多的是要去考虑重复代码的问题。

如果方法实现不同,不要使用类型参数 (考虑重复代码问题)

在决定是使用类型参数还是接口类型时,请考虑方法的实现。前面我们说过,如果方法的实现对于所有类型都相同,则可以使用类型参数。相反,如果每种类型的实现都不同,则使用接口类型并编写不同的方法实现,不要使用类型参数。

就像 io.Reader接口,如果使用泛型就会发现很多对象的read方法逻辑都是不同的,甚至其他方法也有所不同,它不像 sort.Interface 一样有逻辑相同的方法,这会导致每次实例化时都需要用结构体成员的方式传递多个同名不同逻辑的函数,如果实例化相同类型,反而会多出许多重复代码,而接口则只需要在定义结构体时一次性写好需要实现的方法即可。

在适当的地方使用反射

Go具有运行时反射。反射允许一种通用编程,因为它允许你编写适用于任何类型的代码。

如果某些操作不能实现接口的所有方法(不能使用接口类型),并且每个类型的操作都不同(不能使用类型参数),请使用反射。

encoding/json包就是一个例子。我们不能要求代码中的每个类型都有MarshalJSON方法,所以不能使用接口类型。但对不同类型的编码又不相同,因此我们不应该使用类型参数。相反,该包使用反射。代码不简单,但它有效。

简单的指导方针

如果发现自己多次编写了完全相同的代码,而副本之间的唯一区别是代码使用了不同的类型,请考虑是否可以使用类型参数。

换句话说,除非注意到将要多次编写完全相同的代码,那么就应该避免使用类型参数。为单个类型设置泛型是得不偿失的行为。

总结

总之,如果你发现自己多次编写完全相同的代码,而这些代码之间的唯一区别就是使用的类型不同,这个时候你就应该考虑是否可以使用类型参数。

泛型和接口类型之间并不是替代关系,而是相辅相成的关系。泛型的引入是为了配合接口的使用,让我们能够编写更加类型安全的Go代码,并能有效地减少重复代码。

换句话说,将方法转换为函数要比将方法添加到类型中简单得多。因此,对于通用数据类型,最好使用函数作为结构体成员而不是编写需要的方法进行约束。

Licensed under CC BY-NC-SA 4.0