Go语言泛型设计

2021-12-05 ⏳14.7分钟(5.9千字) 🕸️

从18年转向Go语言之后,一直关注其发展。在众多缺失的特性当中,泛型无疑是呼声最高的那个。经过几年的努力,泛型特性终于要跟随 1.18 版本发布了。这是一个里程碑。考虑到Go泛型的原始设计文档比较艰涩,而且结构比较杂乱,今天就把自己的理解整理成文,分享给大家。因为内容很多,我自己对英文设计文档的理解也有限(尤其是类型推导部分),错误再所难免。欢迎各位读者通过留言批评指正。本文是Go泛型系列的第一篇,后续还会发表更多相关内容,也欢迎关注。

我之前写过Go语言泛型的进化,着重介绍了泛型语法设计的演化,欢迎阅读。

类型参数(Type parameters)

Go语言中把泛型(Generic)叫做类型参数。我们知道,Go语言是静态强类型语言。我们在写代码的时候需要为变量指定明确的类型。而泛型编程则恰恰是编写可以适应于不同类型的代码,所以我们需要一种描述不同类型的方法。

下面是Go语言泛型设计方案中的示例:

// Print 打印切片 s 的所有元素。
// 此方法可以打印任意类型的切片成员。
func Print(s []T) {
	for _, v := range s {
		fmt.Println(v)
	}
}

T表是切片成员的类型,但T实际类型在定义Print()的时候是不确定的,需要在调用该函数的时候指明。也就是说,我们在调用Print()函数的时候需要额外传入一个特殊参数来指定T的具体类型。这种特殊的参数就叫类型参数。

既然函数Print()需要接收类型参数,那它就得声明自已所需要的类型参数。于是便有了类型参数的声明语法:

// Print 打印切片 s 的所有元素。
// 该函数定义了一个类型参数 T,并且使用 T 作为入参切片 s 的元素类型
func Print[T any](s []T) {
	// 同上
}

Go语言在原来的函数名和函数参数列表之间插入了一组方括号,来表示类型参数。跟函数参数一样,我们需要为每一个类型参数指定「类型」,这种类型的类型Go语言称之为约束。我们在下文会详细分析。现在,大家只需要知道any是一种特殊的约束,表示对应的类型参数可以接受任意类型,也就是没有约束。

所有的类型参数需要在函数调用的时候指定,所以我们需要另一种语法,示例如下:

// 调用 Print 打印 []int{1,2,3}
// 因为切片 s 的成员类型为 int,所以需要指定 T 的值为 int
Print[int]([]int{1,2,3})
// 输出
// 1
// 2
// 3

在调用函数Print()的时候,Go语言规定在函数名和参数列表之前插入方括号,在方括号里指名类型参数的实际类型。在上面的例子中,因为入参 s 的实际类型是[]int,所以需要给类型参数T传入类型int。如果想打印浮点数切片,则可以:

Print[float64]([]float64{0.1,0.2,0.3})
// 输出
// 0.1
// 0.2
// 0.3

类型参数不光可以用于声明函数入参的类型,可以用于声明出参类型,比如:

// Pointer 返回任意参数的指针。
Pointer[T any](t T) *T {
	return &t
}

使用方式如下:

Pointer[int](1) // 返回 *int 类型,指向的值为 1
Pointer[float64](0.1) // 返回 *float64 类型,指向的值为 0.1

泛型函数可以声明多个类型参数,比如:

// Must2 接受 t1, t2 和 err 三个参数,如果 err 不为空,则 panic
// 否则返回 t1 和 t2。
// 多类型参数的约束语法跟普通函数的参数类型相同。
func Must2[T1, T2 any](t1 T1, t2 T2, err error) (T1, T2) {
	if err != nil {
		panic(err)
	}
	return t1, t2
}
// 假设我们需要调用 foo 函数
func foo() (int, float64, error)
// 如果 foo 函数返回 err 则会 panic。
i, f := Must2[int, float64](foo())

除了泛型函数外,Go语言还支持在类型定义中声明类型参数。例如:

// Vector 是一个切片,其元素的类型由类型参数 T 确定。
// T 的实际类型需要在声明 Vector 对象的时候指定。
type Vector[T any] []T

如果我们需要保存int元素,可以这样定义:

var v Vector[int] // v 的类型为 []int

声明了类型参数的类型定义我们称之为泛型类型(Generic Types)。对于泛型类型,我们还可以定义泛型方法,比如:

// Push 向容器尾部追加新元素
func (v *Vector[T]) Push(x T) { *v = append(*v, x) }

因为类型参数已经在声明v的时候指定了,所以调用函数Push()就不需要再传入类型参数:

var v Vector[int]
v.Push(1) // 不需要指定类型参数
fmt.Println(v) // 输出 [1]

无论是泛型函数还是泛型类型,都需要在使用的时候传入具体的类型参数。这很自然,却也很繁琐。比如:

Print[int]([]int{1,2,3})
Print[float64]([]float64{0.1,0.2,0.3})

为了方便使用泛型函数或者类型的开发者,Go语言支持通过实际传入的参数类型推导(inference)参数的实际类型!

所以,上述函数调用可以简写为:

Print([]int{1,2,3}) // 推导出 T 的实际类型为 int
Println([]float64{0.1,0.2,0.3}) // 推导出 T 的实际类型为 float64
Pointer(0.1) // 推导出 T 的实际类型为 float64

泛型类型也可以推导:

v := Vector{1} // 推导出 T 的实际类型为 float64

通过泛型推导,开发者可以像使用普通函数或者类型一样使用泛型函数和泛型类型,简单清晰。这是了不起的设计。但泛型推导非常复杂,我们将在本文的后半部分详细介绍。

到现在,我们已经介绍完Go语言最主要的泛型语法。简单总结如下:

下面我们详细讨论泛型的约束和泛型推导。

泛型约束(Constraints)

我们在前一节讲过,所有类型参数都需要指定类型约束。我们还介绍说any表示一种特殊的约束,可以接受所有类型。

那为什么类型参数需要约束呢?看下面的例子:

// Stringify 将任意类型的切片转化成对应的字符串切片
func Stringify[T any](s []T) (ret []string) {
	for _, v := range s {
		ret = append(ret, v.String()) // 错误
	}
	return ret
}

这里类型参数T的约束为any,所以切片s的元素可以是任何类型。也就是说s的元素可以就没有String()方法。比如我们尝试执行:

Stringify[int]([]int{1,2,3})

此时T的实际类型为int,所以s的类型为[]int,进而v的类型为int。显然,int没有String()方法,必然会报错!

所以说,对于Stringify函数,我们需要对类型参数T的范围加以限制。具体来说,我们只能给T传入那些带有String()方法的类型。

再举一个例子:

// Max 返回两者中比较大的值
func Max[T any](a, b T) T {
	if a > b { // 错误
		return a
	}
	return b
}

如果我们想确定两个整数中的最大值,可以

m := Max(1, 2) // 返回 2

但如果我们想比较两个复数呢?

m := Max(1+2i, 3+4i)

这样的调用会报错。为什么呢?因为复数不能比较大小,所以在Go语言中complex(64|128)不支持比较运算符!

所以说,对于Max函数,我们同样需要限制T的取值范围。具体来说,我们只能给T传入支持>运算的类型。

以上就是需要给泛型指定约束的原因。我们需要泛型约束来限定类型参数所支持的函数和运算符。

但有一点需要明确,加了泛型约束并不意味着不再报编译错误。如果在调用泛型函数的时候传错类型仍然会报错,但编译器会在最开始调用的时候明确报错,而不是要到了对应的函数体内再报错。如果没有约束,那编译报错层级可能非常深,对开发者排查错误极不友好(参考C++的模板报错)。

如果某类型约束不限制类型实现的函数,同时也不限制类型支持的运算符,那就意味着对应的类型参数可以接受任意类型。这种特殊的约束就是any

Go语言已经可以使用interface来限制对应所需要实现的方法。如果想支持所有对象,则可以使用臭名昭著的interface{}。因为interface{}又臭又长,这样Go官方引入了any关键字来替换它。而且,我们以后可以在非泛型代码中使用any来替换interface{}了,是不是很期待。

但是Go语言的接口无法限制对象支持的运算符。Go团队综合考虑多种因素,最终决定对现有的interface进行扩展,使其在用作泛型约束的时候支持限制运算符。具体的语法我们会在下面的章节讨论。

在深入讨论泛型约束之前,我们有必要说说这个any约束。

any约束

因为对类型没有限制,我们在写代码的时候只能使用所有类型都支持的语法:

所以any也并非可以随心所欲,没有绝对的自由。

函数约束(Functions Constraints)

回到上面的Stringify泛型函数

// Stringify 将任意类型的切片转化成对应的字符串切片
func Stringify[T any](s []T) (ret []string) {
	// 同上
}

我们希望类型参数T所有的取值都实现String() string函数,所以我们可以:

// Stringer 是泛型类型约束,要求所有类型都需要实现 String 方法。
// 所以在泛型代码中可以调用该类型变量的 String 方法。
// String 方法返回变量的字符串表示。
type Stringer interface {
	String() string
}

从形式到内容都跟普通的接口没有任何差别。然后我们可以修改Stringify函数:

// Stringify 将任意类型的切片转化成对应的字符串切片
func Stringify[T Stringer](s []T) (ret []string) {
	for _, v := range s {
		ret = append(ret, v.String()) // 正确
	}
	return ret
}

这个时候,下述代码就会报编译错误:

Stringify([]int{1,2,3})

因为int类型没有实现String() string方法,所以不能传给类型参数T

而下面的方法则会正常运行:

type MyInt int
func (i MyInt) String() string { return strconv.Itoa() }

Stringify(MyInt{1,2,3}) // 返回 []string{"1","2","3"}

我们也可以不单独声明Stringer接口,而是写成:

func Stringify[T inference{ String() string }](s []T) (ret []string) {
	// 同上
}

效果是一样的。如果我们不想对T的范围做任何限制,可以写成:

func Stringify[T interface{}](s []T) { ... }

是不是比func Stringify[T any](s []T) { ... }要难看很多呢?

当然,我们也可以使用别人定义好的接口限制类型参数,比如:

func Stringify[T fmt.Stringer](s []T) (ret []string) {
	// 同上
}

以上就是函数约束的主要内容,下面我们讨论运算符约束。

运算符约束(Operators Constraints)

回到前面的例子:

// Max 返回两者中比较大的值
func Max[T any](a, b T) T {
	if a > b { // 错误
		return a
	}
	return b
}

函数Max要求所有的T都需要支持>运算符。如何表达这种约束呢?一种办法是把所有运算符操作都转化成函数调用,这样我们就可以使用接口来限制运算符操作。但这种方式实现起来非常复杂。最终Go语言官方选择了一种不那么优雅但实现起来非常简单的办法:类型集合。

Go语言不允许重载运算符。也就是说,只能Go语言内置的对象才支持运算符操作。我们不能自已声明一个struct然后尝试使用>比较对应变量的大小。这就让限制对象的运算符这个事变得比较简单。因为内置类型有限,我们完全可以把所有支持的类型都枚举出来。

如果想限制类型参数的范围是所有Go语言内置的有符号整型,我们可以:

// PredeclaredSignedInteger 只能匹配内置的有符号整数类型
type PredeclaredSignedInteger interface {
	int | int8 | int16 | int32 | int64
}

所以说PredeclaredSignedInteger只允许传入内置的五种有符号整数类型,传入其他类型都会报编译错误。

我们知道,Go语言允许重新定义自已的类型,比如:

type MyInt8 int8

虽然MyInt8的底层类型还是int8,但MyInt8没法匹配PredeclaredSignedInteger约束。但是MyInt8所支持的运算符操作又跟int8完全相同,所以我们需要一种语法来指明可以同时匹配int8MyInt8。于是Go语言引入了近似约束(Approximation Constraint)的概念,语法如下:

// Int8 匹配所有底层类型为 int8 的类型
type Int8 interface {
	~int8
}

注意,这里在int8之前添加~表示近似匹配。只要底层类型为int8就可以。所以MyInt8可以匹配Int8约束。

有了近似约束,我们的表达能力一下子就上了一个台阶。所有支持比较运算符的类型可以写成如下约束:

// Ordered 限制所有支持比较运算符的类型。
// 也就是说符合条件的类型都支持 <, <=, >, 和 >= 运算符。
type Ordered interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64 |
		~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
		~float32 | ~float64 |
		~string
}

我们可以把上面的Max函数改写成:

// Max 返回两者中比较大的值
func Max[T Ordered](a, b T) T {
	if a > b { // 正确
		return a
	}
	return b
}

这个时候我们再执行Max(1+2i, 3+4i)就会触发编译错误。

复合约束

在泛型约束中,我们不但可以通过|枚举可能的基本类型或者近似类型,还可以枚举其他约束。我个人称之为复合约束。

并集约束

比如匹配所有有符号整数的约束为:

// SignedInteger 匹配所有有符号整数类型
type SignedInteger interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64
}

匹配所有无符号整数的约束为:

// UnsignedInteger 匹配所有无符号整数类型
type UnsignedInteger interface {
	~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

所以匹配所有整数的约束可以简写成:

// Integer 匹配所有整数类型
type Integer interface {
	SignedInteger | UnsignedInteger
}

这里的本质是使用|表示并集的关系,其结果就是Integer可以匹配SignedIntegerUnsignedInteger匹配的所有结果的并集。

交集约束

我们可以表示两个或者多个约束的交集,比如:

// StringableInteger 匹配所有实现了 String() 方法的整数类型
type StringableInteger interface {
	Integer
	Stringer
}

这里我们在StringableInteger约束中嵌入了IntegerStringer两个约束,表示两者匹配结果的交集。符合该约束的类型不仅底层类型是整型,还要实现String() string方法。

我们也可以直接列出对应的类型列表和需要实现的函数列表,比如:

// StringableSignedInteger 匹配所有实现 String 方法的有符号整数类型
type StringableSignedInteger interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64
	String() string
}

对于一些简单的使用场景,我们甚至可以省略interface关键字。比如:

type Max[T interface{int|uint}](a, b T) { ... }

可以直接简化成:

type Max[T int|uint](a, b T) { ... }

泛型约束

Go语言还支持在约束中声明类型参数!比如:

// SliceConstraint 匹配所有类型为 T 的切片,但 T 的类型需要是使用的时候指定!
type SliceConstraint[T any] interface {
	[]T
}

我们在使用的时候需要为约束指定具体的类型参数,比如:

// Map 接受一个切片对象和一个转换函数。
// Map 声明了两个类型参数 S 和 E,其中 S 的约束为 SliceConstraint。
// SliceConstraint 声明了类型参数 T,Map 将 T 转成 E,最终 S 的实际约束为 []E。
func Map[S SliceConstraint[E], E any](s S, f func(E) E) S { ... }

这个例子看起来非常复杂,而且好像多此一举。完全可以写成这个样子嘛:

func Map[S []E, E any](s S, f func(E) E) S { ... }

其实不然,这涉及到泛型推导的问题,我们会在后文详细讲解。

在泛型约束中,我们还可以声明自已引用自已的约束。比如下面的例子:

// Equaler 限制类型必须实现 Equal 方法,但参数 T 需要在使用的时候指定。
type Equaler[T any] interface {
        Equal(v T) bool
}

// Index 从切片 s 中查找元素 e 的索引。
// 类型参数 T 的约束为 Equaler[T],需要实现 Equal(v T) bool 方法。
// 这里在声明 T 的约束的时候又用到了 T 本身。
func Index[T Equaler[T]](s []T, e T) int {
        for i, v := range s {
                if e.Equal(v) {
                        return i
                }
        }
        return -1
}

相互约束

Go语言不但支持在约束中定义类型参数,更支持约束相互引用。目的是解决实际中比较复杂的问题,比如图论问题。

以图论为例,如果我们想写一系列图论算法,那么我们需要EdgeNode两种类型:

一张图可以表示为[]Node。这就足够实现图论算法了。下面的代码比较烧脑,请对照注释仔细阅读:

// NodeConstraint 是一个简单的约束,要求被约束的类型一定要实现 Edges 方法。
// 但是 Edges 方法返回的 []Edge 类型为 Edge,没有确定,
// 需要在使用 NodeConstraint 的时候指明。
type NodeConstraint[E any] interface {
	Edges() []E
}

// EdgeConstraint 也是一个简单的约束,要求被约束的类型一定要实现 Nodes 方法。
// 同样 Nodes 方法返回的 from t to 类型为 Node,没有确定,
// 需要在使用 EdgeConstraint 的时候指明。
type EdgeConstraint[N any] interface {
	Nodes() (from, to N)
}

// Graph 为泛型类型,声明了两个类型变量为 Node 和 Edge。
// Node 类型必须满足 NodeConstraint 约束,并且指定了 NodeConstraint 中 E 的类型为 Edge。
// 所以 Node 类型必须实现 NodeConstraint 中规定的 Edges 方法,返回 []Edge。
// 同理,Edge 类型必须实现 EdgeConstraint 中规定的 Nodes 方法,返回 (from, to Node)。
type Graph[Node NodeConstraint[Edge], Edge EdgeConstraint[Node]] struct { ... }

// New 方法通过传入一组 []Node 构造 Graph 对象
func New[Node NodeConstraint[Edge], Edge EdgeConstraint[Node]] (nodes []Node) *Graph[Node, Edge] { ... }

// ShortestPath 查询图中两点之间的最短路径
func (g *Graph[Node, Edge]) ShortestPath(from, to Node) []Edge { ... }

以上只是声明部分,下面继续看调用部分。先定义具体的结构:

// Vertex 表示图的顶点。
type Vertex struct { ... }

// Edges 返回连接该顶点的所有边。
func (v *Vertex) Edges() []*FromTo { ... }

// FromTo 表示图的边。
type FromTo struct { ... }

// Nodes 返回边的两个端点
func (ft *FromTo) Nodes() (*Vertex, *Vertex) { ... }

然后调用图算法:

g := New[*Vertex, *FromTo]([]*Vertex{...})
edges := g.ShortestPath(a, b)

现在分析一下类型参数的初始化过程。

首先,Graph的类型参数NodeEdge分别被替换为*Vertex*FromTo。然后编译器开始检查类型约束。对于Node类型,它的约束为NodeConstraint[*FromTo],所以需要实现Edges() []*FromTo方法。而*Vertex也确实实现了Edges方法。对于Edge类型,它的约束为EdgeConstraint[*Vertex],所以需要实现Nodes() []*Vertex方法,显然*FromTo也实现了该方法。到此,约束检查结束。

所以,当我们调用g.ShortestPath(a, b)的时候,edges的类型为[]*FromTo

有同学可能会说这种写法太复杂,太烧脑,完全可以把NodeConstraintEdgeConstraint简化成:

type NodeInterface interface { Edges() []EdgeInterface }
type EdgeInterface interface { Nodes() (NodeInterface, NodeInterface) }

但这样一来就要求修改VertexFromTo的函数定义:

func (v *Vertex) Edges() []EdgeInterface { ... }
func (ft *FromTo) Nodes() (NodeInterface, NodeInterface) { ... }

但这样做的结果就是调用Edges函数返回的是一个抽象的[]EdgeInterface切片,而非具体的[]*FromTo列表。

内置约束

comparable

前面我们说Go语言仅支持对内置类型执行运算符操作。但有两个操作符例外,它们是==!=

这两个操作符允许比较用户自定义的 struct 对象,所以需要单独处理。

从这里可以看出Go语言设计的不一致性。一方面他们不希望引入运算符重载,这样会大大增加Go语言的复杂度;另一方面,他们又不得不支持 struct 类型的相等比较。为此,还是得在编译期间自动插入相等比较函数,然后「重载」==!=运算符。

为此,Go语言单独为这个两操作符引入了comparable约束。

也就是说,如果我们希望类型支持相等比较,我们可以写成这样:

// Index 查询元素 x 在切片 s 中的位置。
func Index[T comparable](s []T, x T) int {
	for i, v := range s {
		if v == x {
			return i
		}
	}
	return -1
}

constraints

为方便开发者,Go内置一个constraints包,提供常用的类型约束:

// Signed 有符号整数类型
type Signed interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64
}
// Unsigned 无符号整数类型
type Unsigned interface {
	~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
// Integer 整数类型
type Integer interface {
	Signed | Unsigned
}
// Float 浮点数类型
type Float interface {
	~float32 | ~float64
}
// Complex 复数类型
type Complex interface {
	~complex64 | ~complex128
}
// Ordered 支持排序的类型,即支持操作符:< <= >= >
type Ordered interface {
	Integer | Float | ~string
}

以上基本上涵盖了泛型约束的大部分内容,下面我们开始讨论泛型推导问题。

泛型推导(Type inference)

我们在前面已经简单介绍过泛型推导,其目的是尽量简化泛型函数/类型的使用,减少不必要的类型参数传递。

本节详细分析泛型推导的功能和设计。在开始之前,看一下泛型推导的效果。

// Map 对入参 s 切片中的每个元素执行函数 f,将结果保存到新的切片并返回。
// 类型参数 F 和 T 需要在调用的时候指定。
func Map[F, T any](s []F, f func(F) T) []T { ... }

Go语言支持如下几种情况的类型推导:

var s []int
f := func(i int) int64 { return int64(i) }
var r []int64

// 普通情形,指定全部类型参数,前文已经介绍过
r = Map[int, int64](s, f)

// 仅指定第一个类型参数,自动推导后面的类型参数
r = Map[int](s, f)

// 自动推导所有类型参数
r = Map(s, f)

如果在使用泛型函数/类型的时候未指明全部的类型参数,编译器就会尝试推导缺失的类型。如果推导失败则会报编译错误。

Go语言使用所谓的类型同化(Type unification)进行类型推导。但是原文写的比较抽象,所以本文主要从具体例子出发来说明类型推导的工作过程。

类型同化(Type unification)

所谓同化,就是比较两个类型是否等价。两个类型能否同价取决于:

比如,T1T2是类型参数,[]map[int]bool可以跟以下类型同化:

但是[]map[int]bool跟下面的类型不等价:

参数推导(Function argument type inference)

函数参数推导分两个阶段。

第一段跳过所有实参中无类型常量匹配一遍。如果还有类型参数没有确定,则将开始第二阶段。此时需要给所有无类型常量设置为对应的默认类型,然后再匹配一遍。同一个类型参数可能被匹配多次,如果多次匹配的类型结果不一致就会报编译错误。

回到我们前面说的例子:

func Print[T any](s []T) { ...}

可以简化为Print([]int{1,2,3})。因为没有指明T的类型,所以编译器会执行类型推导。

编译器比较实参类型[]int和形参类型[]T。根据同化的定义,T的类型只能是int,从而得出T的实际类型。

所以最终的函数调用为Print[int]([]int{1,2,3})

再分析一个更复杂的示例:

// Map 对入参 s 切片中的每个元素执行函数 f,将结果保存到新的切片并返回。
// 类型参数 F 和 T 需要在调用的时候指定。
func Map[F, T any](s []F, f func(F) T) []T { ... }

strs := Map([]int{1, 2, 3}, strconv.Itoa)

推导过程如下:

以上的入参都有明确的类型,下面讨论入参中有无类型常量的情况。

// NewPair 返回 Pair 对象指针,包含两个相同类型的值。
func NewPair[F any](f1, f2 F) *Pair[F] { ... }

对于NewPair(1,2)

第一阶段会跳过所有无类型常量,所以没有推导出T的类型。第二阶段给12设定默认类型为int。所以类型参数F对应int,该函数调用被推断为NewPair[int](1,2)

对于NewPair(1,int64(2))

第一阶段推导忽略无类型常量1。因为第二个参数的类型为int64,所以推断F的参数为int64。所以最终的函数调用为NewPair[int64](1,2)

对于NewPair(1,2.5)

第一阶段推导忽略无类型常量。第二阶段先将12.5设定默认类型为intfloat64。然后从左往右匹配。对于参数1确认Fint,对于参数2.5,确定Ffloat64。两次结果不相同,所以报错。

类型推导完成后,编译器依然会执行约束校验和参数类型检查。

约束推导(Constraint type inference)

我们前面说过类型参数约束也可以使用类型参数。对于这种结构化的约束,我们可以通过其他类型参数或者约束来推断其实际的约束。

推导算法也是比较啰嗦,这里先说几个例子。

元素类型约束示例

假设我们有如下函数:

// Double 返回新切片,每个元素都是 s 对应元素的两倍。
func Double[E constraints.Number](s []E) []E {
	r := make([]E, len(s))
	for i, v := range s {
		r[i] = v + v
	}
	return r
}

在上述定义下,如果像下面这样调用函数:

type MySlice []int
var v1 = Double(MySlice{1})

推导出来到的v1的类型其实是[]int,而不是我们想要的MySlice。因为编译器在比较MySlice[]E的时候把MySlice换成了底层类型[]int,所以推导出Eint

为了能让Double正常返回MySlice类型,我们将函数改写成:

// SC 限定类型必须为元素类型为 E 的切片。
type SC[E any] interface {
	[]E
}

func DoubleDefined[S SC[E], E constraints.Number](s S) S {
	r := make(S, len(s))
	for i, v := range s {
		r[i] = v + v
	}
}

调用代码需要改成这样:

var v2 = DoubleDefined[MySlice, int](MySlice{1})

我们也可以让编译器自动推导类型:

var v3 = DoubleDefined(MySlice{1})

首先,编译器执行函数参数类型推导。此时需要对比MySliceS,但S使用结构约束,所以需要推导其实际的约束类型。

为此,编译器构建一个映射表:

{S -> MySlice}

然后,编译器展开S约束,把SC[E]展开成[]E。因为我们之前记录了SMySlice的映射关系,所以可以对比[]EMySlice。又因为MySlice的底层类型为[]int,所以推导出E的类型为int

{S -> MySlice, E -> int}

然后,我们把约束中的E都换成int,看还有没有不确定的类型参数。没有了,所以推导结束。所以原来的调用被推导为:

var v3 = DoubleDefined[MySlice,int](MySlice{1})

返回的结果依然是MySlice

指针方法约束示例

假设我们希望把一组字符串转换成一组其他类型的数据:

// Setter 限制类型需要实现 Set 方法,通过 string 设置自身的值。
type Setter interface {
	Set(string)
}

// FromStrings 接受字符串切片,返回类型为 T 的切片。
// 返回切片中每个元素的值通过调用其 Set 方法设置。
func FromStrings[T Setter](s []string) []T {
	result := make([]T, len(s))
	for i, v := range s {
		result[i].Set(v)
	}
	return result
}

我们看一下调用代码(有问题,不能编译):

// Settable 是可以从字符串设置自身取值的整数类型。
type Settable int

// Set 将字符串解析成整数并赋给 *p。
func (p *Settable) Set(s string) {
	i, _ := strconv.Atoi(s) // 实际代码不能忽略报错
	*p = Settable(i)
}

// 调用函数,无法编译
nums := FromStrings[Settable]([]string{"1", "2"})

上面的代码不能正常编译。因为我们指定T的类型为Settable,但Settable类型并没有实现Set(string)方法。实现该方法的是类型*Settable

于是我们将调用代码改成:

// 调用函数,正常编译,但运行报错
nums := FromStrings[*Settable]([]string{"1", "2"})

这次可以正常编译,但运行代码又会报错。这是因为在FromStrings中,result[i]的类型为*Settable,值为nil,无法执行*p = *Settable(i)赋值。

所以说,上面定义的FromStrings,我们既不能将T定为Settable,这会导致编译错误,又不能定为*Settable,这会导致运行时错误。

为了实现FromStrings,我们需要同时指定Settable*Settable类型,这就需要结构化约束:

// Setter2 限制类型必须是 B 的指针而且要实现 Set 方法。
type Setter2[B any] interface {
	Set(string)
	*B
}

// FromStrings2 接受字符串切片,返回 T 切片。
//
// 这里定义了两个类型参数,所以才能在返回 T 切片的同时
// 调用 *T 也就是 PT 的方法。
// Setter2 约束可以确保 PT 是 T 的指针。
func FromStrings2[T any, PT Setter2[T]](s []string) []T {
	result := make([]T, len(s))
	for i, v := range s {
		// &result[i] 类型是 *T,也就是 Setter2 的类型。
		// 所以可以将其强转为 PT。
		p := PT(&result[i])
		// PT 实现了 Set 方法
		p.Set(v)
	}
	return result
}

调用代码如下:

nums := FromStrings2[Settable, *Settable]([]string{"1", "2"})

重复写两遍Settable感觉有点二,可以简化为:

// 因为函数入参中没有使用 T,所以无法进一步简化
nums := FromStrings2[Settable]([]string{"1", "2"})

整个编译器的推导过程是这样的。首先,根据已知类型构造映射表:

{T -> Settable}

然后用Settable替换T,展开所有结构化参数。所以PT的类型Setter2[T]被展开成*T,加入到映射表:

{T -> Settable, PT -> *T}

然后把所有T替换成Settable,最终得到:

{T -> Settable, PT -> *Settable}

到此推导结束,实际的函数调用为FromStrings2[Settable,*Settable]([]string{"1","2"})

推导后约束校验
// Unsettable 也是 int,但没有实现 Set 方法。
type Unsettable int

// 错误调用
nums := FromString2[Unsettable]([]string{"1", "2"})

在这个例子中,编译器可以推导出PT的类型为*Unsettable。推导结束之后,编译器会继续检查Setter2约束。但*Unsettable没有实现Set方法,所以会报编译错误。

总结

以上就是Go泛型设计的主要内容,要点如下:

因为内容太多,所以本文先到此结束。其他相关内容会在后续文章中介绍。