39 驯服泛型:了解类型参数 你好,我是Tony Bai。

在专栏的结束语中,我曾承诺要补充“泛型篇”,帮助你入门Go泛型语法。在经历了2022年3月Go 1.18版本的泛型落地以及8月份Go 1.19对泛型问题的一轮修复后,我认为是时候开讲Go泛型篇了

虽说目前的Go泛型实现和最后一版的泛型设计方案相比还有差距,依旧不是完全版,还有一些特性没有加入,还有问题亟待解决,但对于入门Go泛型语法来说,我认为已经是足够了。

不过在正式开讲之前,我还有一些友情提示:和支持泛型的主流编程语言之间的泛型设计与实现存在差异一样,Go的泛型与其他主流编程语言的泛型也是不同的。我希望你在学习之前,先看一下Go泛型设计方案已经明确不支持的若干特性,比如:

  • 不支持泛型特化(specialization),即不支持编写一个泛型函数针对某个具体类型的特殊版本;
  • 不支持元编程(metaprogramming),即不支持编写在编译时执行的代码来生成在运行时执行的代码;
  • 不支持操作符方法(operator method),即只能用普通的方法(method)操作类型实例(比如:getIndex(k)),而不能将操作符视为方法并自定义其实现,比如一个容器类型的下标访问c[k];
  • 不支持变长的类型参数(type parameters)
  • … …

这些特性如今不支持,后续大概率也不会支持。所以小伙伴们,尤其是来自Java、C++等语言阵营的小伙伴,在进入Go泛型语法学习之前,你一定要先了解Go团队的这些设计决策。

泛型篇的内容共有三讲,我们将从泛型的基本语法,也就是类型参数(type parameter)开启驯服泛型之旅,接下来再搞定泛型的难点定义约束(constraints),最后我们再来谈谈Go泛型的使用时机。如果你还想Go泛型的演化简史,请移步《加餐|聊聊最近大热的Go泛型》,这里我也做了详细分析。

那么,今天这泛型篇的第一讲,我们就来聚焦Go泛型的基本语法,类型参数。下面我们通过一个最常见的泛型应用场景来开启今天的学习之旅。一个小提醒需要你注意,泛型篇这三讲的所有示例代码均基于Go 1.19.1版本。

例子:返回切片中值最大的元素

正如小标题写的那样,我们这个例子要实现一个函数,该函数接受一个切片作为输入参数,然后返回该切片中值最大的那个元素。题目并没有明确使用什么元素类型的切片,我们就先以最常见的整型切片为例,实现一个maxInt函数: // max_int.go func maxInt(sl []int) int { if len(sl) == 0 { panic(“slice is empty”) } max := sl[0] for _, v := range sl[1:] { if v > max { max = v } } return max } func main() { fmt.Println(maxInt([]int{1, 2, -4, -6, 7, 0})) // 输出:7 }

maxInt的逻辑十分简单。我们使用第一个元素值(max := sl[0])作为max变量初值,然后与切片后面的元素(sl[1:])进行逐一比较,如果后面的元素大于max,则将其值赋给max,这样到切片遍历结束,我们就得到了这个切片中值最大的那个元素(即变量max)。

我们现在给它加一个新需求:能否针对元素为string类型的切片返回其最大(按字典序)的元素值呢?

答案肯定是能!我们来实现这个maxString函数: // max_string.go func maxString(sl []string) string { if len(sl) == 0 { panic(“slice is empty”) } max := sl[0] for _, v := range sl[1:] { if v > max { max = v } } return max } func main() { fmt.Println(maxString([]string{“11”, “22”, “44”, “66”, “77”, “10”})) // 输出:77 }

maxString实现了返回string切片中值最大元素的需求。不过从实现上来看,maxString与maxInt异曲同工,只是切片元素类型不同罢了。这时如果让你参考上述maxInt或maxString实现一个返回浮点类型切片中最大值的函数maxFloat,你肯定“秒秒钟”就可以给出一个正确的实现:

// max_float.go func maxFloat(sl []float64) float64 { if len(sl) == 0 { panic(“slice is empty”) } max := sl[0] for _, v := range sl[1:] { if v > max { max = v } } return max } func main() { fmt.Println(maxFloat([]float64{1.01, 2.02, 3.03, 5.05, 7.07, 0.01})) // 输出:7.07 }

问题来了!有代码洁癖的同学肯定已经嗅到了上面三个函数散发的“糟糕味道”:代码重复。上面三个函数除了切片的元素类型不同,其他逻辑都一样。

那么能否实现一个“通用”的函数,可以处理上面三种元素类型的切片呢?提到“通用”,你一定想到了Go语言提供的any(interface{}的别名),我们来试试: // max_any.go func maxAny(sl []any) any { if len(sl) == 0 { panic(“slice is empty”) } max := sl[0] for _, v := range sl[1:] { switch v.(type) { case int: if v.(int) > max.(int) { max = v } case string: if v.(string) > max.(string) { max = v } case float64: if v.(float64) > max.(float64) { max = v } } } return max } func main() { i := maxAny([]any{1, 2, -4, -6, 7, 0}) m := i.(int) fmt.Println(m) // 输出:7 fmt.Println(maxAny([]any{“11”, “22”, “44”, “66”, “77”, “10”})) // 输出:77 fmt.Println(maxAny([]any{1.01, 2.02, 3.03, 5.05, 7.07, 0.01})) // 输出:7.07 }

我们看到,maxAny利用any、type switch和类型断言(type assertion)实现了我们预期的目标。不过这个实现并不理想,它至少有如下几个问题:

  • 若要支持其他元素类型的切片,我们需对该函数进行修改;
  • maxAny的返回值类型为any(interface{}),要得到其实际类型的值还需要通过类型断言转换;
  • 使用any(interface{})作为输入参数的元素类型和返回值的类型,由于存在装箱和拆箱操作,其性能与maxInt等比起来要逊色不少,实测数据如下: // max_test.go func BenchmarkMaxInt(b /testing.B) { sl := []int{1, 2, 3, 4, 7, 8, 9, 0} for i := 0; i < b.N; i++ { maxInt(sl) } } func BenchmarkMaxAny(b /testing.B) { sl := []any{1, 2, 3, 4, 7, 8, 9, 0} for i := 0; i < b.N; i++ { maxAny(sl) } } $go test -v -bench . ./max_test.go max_any.go max_int.go goos: darwin goarch: amd64 … … BenchmarkMaxInt BenchmarkMaxInt-8 398996863 2.982 ns/op BenchmarkMaxAny BenchmarkMaxAny-8 85883875 13.91 ns/op PASS ok command-line-arguments 2.710s

我们看到,基于any(interface{})实现的maxAny其执行性能要比像maxInt这样的函数慢上数倍。

在Go 1.18版本之前,Go的确没有比较理想的解决类似上述“通用”问题的手段,直到Go 1.18版本泛型落地后,我们可以用泛型语法实现maxGenerics函数: // max_generics.go type ordered interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | ~float32 | ~float64 | ~string } func maxGenericsT ordered T { if len(sl) == 0 { panic(“slice is empty”) } max := sl[0] for _, v := range sl[1:] { if v > max { max = v } } return max } type myString string func main() { var m int = maxGenerics([]int{1, 2, -4, -6, 7, 0}) fmt.Println(m) // 输出:7 fmt.Println(maxGenerics([]string{“11”, “22”, “44”, “66”, “77”, “10”})) // 输出:77 fmt.Println(maxGenerics([]float64{1.01, 2.02, 3.03, 5.05, 7.07, 0.01})) // 输出:7.07 fmt.Println(maxGenerics([]int8{1, 2, -4, -6, 7, 0})) // 输出:7 fmt.Println(maxGenerics([]myString{“11”, “22”, “44”, “66”, “77”, “10”})) // 输出:77 }

我们看到,从功能角度看,泛型版本的maxGenerics实现了预期的特性,对于ordered接口中声明的那些原生类型以及以这些原生类型为底层类型(underlying type)的类型(比如示例中的myString),maxGenerics都可以无缝支持。并且,maxGenerics返回的类型与传入的切片的元素类型一致,调用者也无需通过类型断言做转换。

此外,通过下面的性能基准测试我们也可以看出,与maxAny相比,泛型版本的maxGenerics性能要好很多,但与原生版函数如maxInt等还有差距。关于泛型的运行时性能损耗问题,我们在泛型篇第三讲中会有说明。性能测试如下: $go test -v -bench . ./max_test.go max_any.go max_int.go max_generics.go goos: darwin goarch: amd64 BenchmarkMaxInt BenchmarkMaxInt-8 400910706 2.983 ns/op BenchmarkMaxAny BenchmarkMaxAny-8 85257433 14.04 ns/op BenchmarkMaxGenerics BenchmarkMaxGenerics-8 209468593 5.701 ns/op PASS ok command-line-arguments 4.492s

通过这个例子,我们也可以看到Go泛型十分适合实现一些操作容器类型(比如切片、map等)的算法,这也是Go官方推荐的第一种泛型应用场景,此类容器算法的泛型实现使得容器算法与容器内元素类型彻底解耦!

不过看到这里,很多同学可能会抱怨:看不懂maxGenerics的语法!别急,接下来,我们就来基于这个例子进行泛型基本语法的学习。

类型参数(type parameters)

根据官方说法,由于泛型(generic)一词在Go社区中被广泛使用,所以官方也就接纳了这一说法。但Go泛型方案的实质是对类型参数(type parameter)的支持,包括:

  • 泛型函数(generic function):带有类型参数的函数;
  • 泛型类型(generic type):带有类型参数的自定义类型;
  • 泛型方法(generic method):泛型类型的方法。

下面我们先以泛型函数为例来具体说明一下什么是类型参数。

泛型函数

我们回顾一下上面的示例,maxGenerics就是一个泛型函数,我们看一下maxGenerics的函数原型: func maxGenericsT ordered T { // … … }

我们看到,maxGenerics这个函数与我们之前学过的普通Go函数(ordinary function)相比,至少有两点不同:

  • maxGenerics函数在函数名称与函数参数列表之间多了一段由方括号括起的代码:[T ordered];
  • maxGenerics参数列表中的参数类型以及返回值列表中的返回值类型都是T,而不是某个具体的类型。

maxGenerics函数原型中多出的这段代码[T ordered]就是Go泛型的类型参数列表(type parameters list),示例中这个列表中仅有一个类型参数T,ordered为类型参数的类型约束(type constraint)。类型约束之于类型参数,就好比常规参数列表中的类型之于常规参数。关于类型约束,我们还会在下一讲中详细说明。

Go语言规范规定:函数的类型参数列表位于函数名与函数参数列表之间,由方括号括起的固定个数的、由逗号分隔的类型参数声明组成,其一般形式如下: func genericsFuncT1 constraint1, T2, constraint2, …, Tn constraintN (return values list)

函数一旦拥有类型参数,就可以用该参数作为常规参数列表和返回值列表中修饰参数和返回值的类型。我们继续maxGenerics泛型函数为例分析,它拥有一个类型参数T,在常规参数列表中,T被用作切片的元素类型;在返回值列表中,T被用作返回值的类型。

按Go惯例,类型参数名的首字母通常采用大写形式,并且类型参数必须是具名的,即便你在后续的函数参数列表、返回值列表和函数体中没有使用该类型参数,也是这样。比如下面例子中的类型参数T: func printT any { // 正确 } func printany { // 编译错误:all type parameters must be named }

和常规参数列表中的参数名唯一一样,在同一个类型参数列表中,类型参数名字也要唯一,下面这样的代码将会导致Go编译器报错:

func printT1 any, T1 comparable { // 编译错误:T1 redeclared in this block //… }

常规参数列表中的参数有其特定作用域,即从参数声明处开始到函数体结束。和常规参数类似,泛型函数中类型参数也有其作用域范围,这个范围从类型参数列表左侧的方括号[开始,一直持续到函数体结束,如下图所示:

图片

类型参数的作用域也决定了类型参数的声明顺序并不重要,也不会影响泛型函数的行为,于是下面的泛型函数声明与上图中的函数是等价的: func fooM map[E]T, T any, E comparable(E, T) { //… … }

到这里,泛型函数的结构我们已经了解完了,接下来我们来看一下如何调用泛型函数。

调用泛型函数

在前面的讲解中,我一直使用“类型参数”这个名称。但在学习调用泛型函数之前,我们需要对“类型参数”做一下细分。

和普通函数有形式参数与实际参数一样,类型参数也有类型形参(type parameter)和类型实参(type argument)之分。其中类型形参就是泛型函数声明中的类型参数,以前面示例中的maxGenerics泛型函数为例,如下面代码,maxGenerics的类型形参就是T,而类型实参则是在调用maxGenerics时实际传递的类型int: // 泛型函数声明:T为类型形参 func maxGenericsT ordered T // 调用泛型函数:int为类型实参 m := maxGenericsint

从上面这段代码我们也可以看出调用泛型函数与调用普通函数的区别。在调用泛型函数时,除了要传递普通参数列表对应的实参之外,还要显式传递类型实参,比如这里的int。并且,显式传递的类型实参要放在函数名和普通参数列表前的方括号中。

在反复揣摩上面代码和说明后,你可能会提出这样的一个问题:如果泛型函数的类型形参较多,那么逐一显式传入类型实参会让泛型函数的调用显得十分冗长,比如: foo[int, string, uint32, float64](1, “hello”, 17, 3.14)

这样的写法对开发者而言显然谈不上十分友好。其实不光大家想到了这个问题,Go团队的泛型实现者们也考虑了这个问题,并给出了解决方法:函数类型实参的自动推断(function argument type inference)

顾名思义,这个机制就是通过判断传递的函数实参的类型来推断出类型实参的类型,从而允许开发者不必显式提供类型实参,下面是以maxGenerics函数为例的类型实参推断过程示意图:

图片

我们看到,当maxGenerics函数传入的实际参数为[]int{…}时,Go编译器会将其类型[]int与泛型函数参数列表中对应参数的类型([]T)作比较,并推断出T == int这一结果。当然这个例子的推断过程较为简单,那些有难度的,甚至无法肉眼可见的就交给Go编译器去处理吧,我们没有必要过于深入。

不过,这个类型实参自动推断有一个前提,你一定要记牢,那就是它必须是函数的参数列表中使用了的类型形参,否则就会像下面的示例中的代码,编译器将报无法推断类型实参的错误: func fooT comparable, E any { } foo(5, “hello”) // 编译器错误:cannot infer T

在编译器无法推断出结果时,我们可以给予编译器“部分提示”,比如既然编译器无法推断出T的实参类型,那我们就显式告诉编译器T的实参类型,即在泛型函数调用时,在类型实参列表中显式传入T的实参类型,但E的实参类型依然由编译器自动推断,示例代码如下:

var s = “hello” fooint //ok fooint, //ok

那么,除了函数参数列表中的参数类型可以作为类型实参推断的依据外,函数返回值的类型是否也可以呢?我们看下面示例:

func fooT any T { var zero T return zero } var a int = foo(5) // 编译器错误:cannot infer T println(a)

我们看到,这个函数仅在返回值中使用了类型参数,但编译器没能推断出T的类型,所以我们切记:不能通过返回值类型来推断类型实参

有了函数类型实参推断后,在大多数情况下,我们调用泛型函数就无须显式传递类型实参了,开发者也因此获得了与普通函数调用几乎一致的体验。

其实泛型函数调用是一个不同于普通函数调用的过程,为了揭开其中的“奥秘”,接下来我们就把镜头放慢,看看泛型函数调用过程究竟发生了什么。

泛型函数实例化(instantiation)

我们还以maxGenerics为例来演示一下这个过程: maxGenerics([]int{1, 2, -4, -6, 7, 0})

上面代码是对maxGenerics泛型函数的一次调用,Go对这段泛型函数调用代码的处理分为两个阶段,如下图所示:

图片

我们看到,Go首先会对泛型函数进行实例化(instantiation),即根据自动推断出的类型实参生成一个新函数(当然这一过程是在编译阶段完成的,不会对运行时性能产生影响),然后才会调用这个新函数对输入的函数参数进行处理。

我们也可以用一种更形象的方式来描述上述泛型函数的实例化过程。实例化就好比一家生产“求最大值”机器的工厂,它会根据要比较大小的对象的类型将这样的机器生产出来。以上面的例子来说,整个实例化过程如下:

  • 工厂接单:调用maxGenerics([]int{…}),工厂师傅发现要比较大小的对象类型为int;
  • 模具检查与匹配:检查int类型是否满足模具的约束要求,即int是否满足ordered约束,如满足,则将其作为类型实参替换maxGenerics函数中的类型形参T,结果为maxGenerics[int]
  • 生产机器:将泛型函数maxGenerics实例化为一个新函数,这里将其起名为maxGenericsInt,其函数原型为func([]int)int。本质上maxGenericsInt := maxGenerics[int]

我们实际的Go代码也可以真实得到这台新生产出的“机器”,如下面代码所示: maxGenericsInt := maxGenerics[int] // 实例化后得到的新“机器”:maxGenericsInt fmt.Printf(“%T\n”, maxGenericsInt) // func([]int) int

一旦针对int对象的“求最大值”的机器被生产出来了,它就可以对目标对象进行处理了,这和普通的函数调用没有区别。这里就相当于调用如下代码:

maxGenericsInt([]int{1, 2, -4, -6, 7, 0}) // 输出:7

整个过程只需检查传入的函数实参([]int{1, 2, …})的类型与maxGenericsInt函数原型中的形参类型([]int)是否匹配即可。

另外要注意,当我们使用相同类型实参对泛型函数进行多次调用时,Go仅会做一次实例化,并复用实例化后的函数,比如: maxGenerics([]int{1, 2, -4, -6, 7, 0}) maxGenerics([]int{11, 12, 14, -36,27, 0}) // 复用第一次调用后生成的原型为func([]int) int的函数

好了,关于泛型函数的讲解就先告一段落,接下来我们再来看Go对类型参数的另一类支持:带有类型参数的自定义类型,即泛型类型。

泛型类型

所谓泛型类型,就是在类型声明中带有类型参数的Go类型,比如下面代码中的maxableSlice: // maxable_slice.go type maxableSlice[T ordered] struct { elems []T }

顾名思义,maxableSlice是一个自定义切片类型,这个类型的特点是总可以获取其内部元素的最大值,其唯一的要求是其内部元素是可排序的,它通过带有ordered约束的类型参数来明确这一要求。像这样在定义中带有类型参数的类型就被称为泛型类型(generic type)。

从例子中的maxableSlice类型声明中我们可以看到,在泛型类型中,类型参数列表放在类型名字后面的方括号中。和泛型函数一样,泛型类型可以有多个类型参数,类型参数名通常是首字母大写的,这些类型参数也必须是具名的,且命名唯一。其一般形式如下: type TypeName[T1 constraint1, T2 constraint2, …, Tn constraintN] TypeLiteral

和泛型函数中类型参数有其作用域一样,泛型类型中类型参数的作用域范围也是从类型参数列表左侧的方括号[开始,一直持续到类型定义结束的位置,如下图所示:

图片

这样的作用域将方便我们在各个字段中灵活使用类型参数,下面是一些自定义泛型类型的示例: type Set[T comparable] map[T]struct{} type sliceFn[T any] struct { s []T cmp func(T, T) bool } type Map[K, V any] struct { root /node[K, V] compare func(K, K) int } type element[T any] struct { next /element[T] val T } type Numeric interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | ~float32 | ~float64 | ~complex64 | ~complex128 } type NumericAbs[T Numeric] interface { Abs() T }

我们看到,泛型类型中的类型参数可以用来作为类型声明中字段的类型(比如上面的element类型)、复合类型的元素类型(比如上面的Set和Map类型)或方法的参数和返回值类型(如NumericAbs接口类型)等。

如果要在泛型类型声明的内部引用该类型名,必须要带上类型参数,如上面的element结构体中的next字段的类型:/element[T]。按照泛型设计方案,如果泛型类型有不止一个类型参数,那么在其声明内部引用该类型名时,不仅要带上所有类型参数,类型参数的顺序也要与声明中类型参数列表中的顺序一致,比如: type P[T1, T2 any] struct { F /P[T1, T2] // ok }

不过从实测结果来看,Go 1.19版本对于下面不符合技术方案的泛型类型声明也并未报错:

type P[T1, T2 any] struct { F /*P[T2, T1] // 不符合技术方案,但Go 1.19编译器并未报错 }

了解了如何声明一个泛型类型后,我们再来看看如何使用这些泛型类型。

使用泛型类型

和泛型函数一样,使用泛型类型时也会有一个实例化(instantiation)过程,比如: var sl = maxableSlice[int]{ elems: []int{1, 2, -4, -6, 7, 0}, }

Go会根据传入的类型实参(int)生成一个新的类型并创建该类型的变量实例,sl的类型等价于下面代码:

type maxableIntSlice struct { elems []int }

看到这里你可能会问:泛型类型是否可以像泛型函数那样实现类型实参的自动推断呢?很遗憾,目前的Go 1.19尚不支持,下面代码会遭到Go编译器的报错:

var sl = maxableSlice { elems: []int{1, 2, -4, -6, 7, 0}, // 编译器错误:cannot use generic type maxableSlice[T ordered] without instantiation }

不过这一特性在Go的未来版本中可能会得到支持。

既然涉及到了类型,你肯定会想到诸如类型别名、类型嵌入等Go语言机制,那么这些语言机制对泛型类型的支持情况又是如何呢?我们逐一来看一下。

  • 泛型类型与类型别名- 在专栏前面的讲解中,我们学习过类型别名(type alias)。我们知道类型别名与其绑定的原类型是完全等价的,但这仅限于原类型是一个直接类型,即可直接用于声明变量的类型。那么将类型别名与泛型类型绑定是否可行呢?我们来看一个示例: type foo[T1 any, T2 comparable] struct { a T1 b T2 } type fooAlias = foo // 编译器错误:cannot use generic type foo[T1 any, T2 comparable] without instantiation

在上述代码中,我们为泛型类型foo建立了类型别名fooAlias,但编译这段代码时,编译器还是报了错误!

这是因为,泛型类型只是一个生产真实类型的“工厂”,它自身在未实例化之前是不能直接用于声明变量的,因此不符合类型别名机制的要求。泛型类型只有实例化后才能得到一个真实类型,例如下面的代码就是合法的: type fooAlias = foo[int, string]

也就是说,我们只能为泛型类型实例化后的类型创建类型别名,实际上上述fooAlias等价于实例化后的类型fooInstantiation:

type fooInstantiation struct { a int b string }

  • 泛型类型与类型嵌入- 类型嵌入是运用Go组合设计哲学的一个重要手段。引入泛型类型之后,我们依然可以在泛型类型定义中嵌入普通类型,比如下面示例中Lockable类型中嵌入的sync.Mutex: type Lockable[T any] struct { t T sync.Mutex } func (l /Lockable[T]) Get() T { l.Lock() defer l.Unlock() return l.t } func (l /Lockable[T]) Set(v T) { l.Lock() defer l.Unlock() l.t = v }

在泛型类型定义中,我们也可以将其他泛型类型实例化后的类型作为成员。现在我们改写一下上面的Lockable,为其嵌入另外一个泛型类型实例化后的类型Slice[int]:

type Slice[T any] []T func (s Slice[T]) String() string { if len(s) == 0 { return “” } var result = fmt.Sprintf(“%v”, s[0]) for _, v := range s[1:] { result = fmt.Sprintf(“%v, %v”, result, v) } return result } type Lockable[T any] struct { t T Slice[int] sync.Mutex } func main() { n := Lockable[string]{ t: “hello”, Slice: []int{1, 2, 3}, } println(n.String()) // 输出:1, 2, 3 }

我们看到,代码使用泛型类型名(Slice)作为嵌入后的字段名,并且Slice[int]的方法String被提升为Lockable实例化后的类型的方法了。同理,在普通类型定义中,我们也可以使用实例化后的泛型类型作为成员,比如让上面的Slice[int]嵌入到一个普通类型Foo中,示例代码如下:

type Foo struct { Slice[int] } func main() { f := Foo{ Slice: []int{1, 2, 3}, } println(f.String()) // 输出:1, 2, 3 }

此外,Go泛型设计方案支持在泛型类型定义中嵌入类型参数作为成员,比如下面的泛型类型Lockable内嵌了一个类型T,且T恰为其类型参数:

type Lockable[T any] struct { T sync.Mutex }

不过,Go 1.19版本编译上述代码时会针对嵌入T的那一行报如下错误:

编译器报错:embedded field type cannot be a (pointer to a) type parameter

关于这个错误,Go官方在其issue中给出了临时的结论:暂不支持。

泛型方法

在专栏基础篇的学习中,我们知道Go类型可以拥有自己的方法(method),泛型类型也不例外,为泛型类型定义的方法称为泛型方法(generic method),接下来我们就来讲讲如何定义和使用泛型方法。

我们用一个示例,给maxableSlice泛型类型定义max方法,看一下泛型方法的结构: func (sl /*maxableSlice[T]) max() T { if len(sl.elems) == 0 { panic(“slice is empty”) } max := sl.elems[0] for _, v := range sl.elems[1:] { if v > max { max = v } } return max }

我们看到,在定义泛型类型的方法时,方法的receiver部分不仅要带上类型名称,还需要带上完整的类型形参列表(如maxableSlice[T]),这些类型形参后续可以用在方法的参数列表和返回值列表中。

不过在Go泛型目前的设计中,泛型方法自身不可以再支持类型参数了,不能像下面这样定义泛型方法: func (f /*foo[T]) M1E any T { // 编译器错误:syntax error: method must have no type parameters //… … }

关于泛型方法未来是否能支持类型参数,目前Go团队倾向于否,但最终结果Go团队还要根据Go社区在使用泛型过程中的反馈而定。

在泛型方法中,receiver中某个类型参数如果没有在方法参数列表和返回值中使用,可以用“”代替,但不能不写,比如: type foo[A comparable, B any] struct{} func (foo[A, B]) M1() { // ok } 或 func (foo[, _]) M1() { // ok } 或 func (foo[A, _]) M1() { // ok } 但 func (foo[]) M1() { // 错误:receiver部分缺少类型参数 }

另外,泛型方法中的receiver中类型参数名字可以与泛型类型中的类型形参名字不同,位置和数量对上即可。我们还以上面的泛型类型foo为例,可以为它添加下面方法:

type foo[A comparable, B any] struct{} func (foo[First, Second]) M1(a First, b Second) { // First对应类型参数A,Second对应类型参数B }

小结

好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。

在这一讲中,我们一起学习了Go泛型的基本语法:类型参数。类型参数是Go泛型方案的具体实现,通过类型参数,我们可以定义泛型函数、泛型类型以及对应的泛型方法。

泛型函数是带有类型参数的函数,在函数名称与参数列表之间声明的类型参数列表使得泛型函数的运行逻辑与参数/返回值类型解耦。调用泛型函数与普通函数略有不同,泛型函数需要进行实例化后才能生成真正执行的、带有类型信息的函数。同时,Go泛型支持的类型实参推断也使得开发者在大多数情况下无需显式传递类型实参,获得与普通函数调用几乎一致的体验。

泛型类型是带有类型参数的类型,泛型类型的类型参数放在类型名称后面的类型参数列表中声明,类型参数后续可以在泛型类型声明中用作成员字段的类型或复合类型成员元素的类型。不过目前(Go 1.19版本)Go尚不支持泛型类型的类型实参的自动推断,我们在泛型类型实例化时需要显式传入类型实参。

与泛型类型绑定的方法被称为泛型方法,泛型方法的参数列表和返回值列表中可以使用泛型类型的类型参数,但泛型方法目前尚不支持声明自己的类型参数列表。

Go泛型的引入,使得Go开发人员在interface{}之后又拥有了一种编写“通用代码”的手段,并且这种新手段因其更多在编译阶段的检查而变得更加安全,也因其减少了运行时的额外开销使得代码性能更好。

思考题

使用过其他编程语言泛型语法特性的小伙伴们可能会问:为什么Go在方括号“[]”中声明类型参数,而不是使用其他语言都用的尖括号“<>”呢?你可以思考一下。

欢迎在评论区写下你的想法,我们泛型篇的第二讲见。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/Tony%20Bai%20%c2%b7%20Go%e8%af%ad%e8%a8%80%e7%ac%ac%e4%b8%80%e8%af%be/39%20%e9%a9%af%e6%9c%8d%e6%b3%9b%e5%9e%8b%ef%bc%9a%e4%ba%86%e8%a7%a3%e7%b1%bb%e5%9e%8b%e5%8f%82%e6%95%b0.md