golang的泛型已经出来了一年多了,从提案被接受开始我就在关注泛型了,如今不管是在生产环境还是开源项目里我都写了不少泛型代码,是时候全面得回顾下golang泛型的使用体验了。

先说说结论,好用是好用,但问题也很多,有些问题比较影响使用体验,到了不吐不快的地步了。

这篇文章不会教你泛型的基础语法,并且要求你对golang的泛型使用有一定经验,如果你还是个泛型的新手,可以先阅读下官方的教程,然后再阅读本篇文章。

泛型的实现

实现泛型有很多种方法,常见的主流的是下面这些:

  1. 以c++为代表的,类型参数就是个占位符,最后实际上会替换成实际类型,然后以此为模板生成实际的代码,生成多份代码,每份的类型都不一样
  2. 以TypeScript和Java为代表的类型擦除,把类型参数泛化成一个满足类型约束的类型(Object或者某个interface),只生成一份代码
  3. 以c#为代表,代码里表现的像类型擦除,但运行的时候实际上和c++一样采用模板实例化对每个不同的类型都生成一份代码
gcshape
gcshapeshapeshapeshape
shape

看个简单例子:

func Output[T any]() {var t Tfmt.Printf("%#v\n", t)}type A struct {a,b,c,d,e,f,g int64h,i,j stringk []stringl, m, n map[string]uint64}type B Afunc main() {Output[string]()Output[int]()Output[uint]()Output[int64]()Output[uint64]() // 上面每个都underlying type都不同,尽管int64和uint64大小一样,所以生成5份不同的代码Output[*string]()Output[*int]()Output[*uint]()Output[*A]() // 所有指针都是同一个shape,所以共用一份代码Output[A]()Output[*B]()Output[B]() // B的underlying tyoe和A一样,所以和A共用代码Output[[]int]()Output[*[]int]()Output[map[int]string]()Output[*map[int]string]()Output[chan map[int]string]()}

验证也很简单,看看符号表即可:

为啥要这么做?按提案的说法,这么做是为了避免代码膨胀同时减轻gc的负担,看着是有那么点道理,有相同shape的内存布局是一样的,gc处理起来也更简单,生成的代码也确实减少了——如果我就是不用指针那生成的代码其实也没少多少。

尽管官方拿不出证据证明gcshape有什么性能优势,我们还是姑且认可它的动机吧。但这么实现泛型后导致了很多严重的问题:

  1. 性能不升反降
  2. 正常来说类型参数是可以当成普通的类型来用的,但golang里有很多时候不能

正因为有了gcshape,想在golang里用对泛型还挺难的。

性能问题

这一节先说说性能。看个例子:

type A struct {num uint64num1 int64}func (a *A) Add() {a.num++a.num1 = int64(a.num / 2)}type B struct {num1 uint64num2 int64}func (b *B) Add() {b.num1++b.num2 = int64(b.num1 / 2)}type Adder interface {Add()}func DoAdd[T Adder](t T) {t.Add()}func DoAddNoGeneric(a Adder) {a.Add()}func BenchmarkNoGenericA(b *testing.B) {obj := &A{}for i := 0; i < b.N; i++ {obj.Add()}}func BenchmarkNoGenericB(b *testing.B) {obj := &B{}for i := 0; i < b.N; i++ {obj.Add()}}func BenchmarkGenericA(b *testing.B) {obj := &A{}for i := 0; i < b.N; i++ {DoAdd(obj)}}func BenchmarkGenericB(b *testing.B) {obj := &B{}for i := 0; i < b.N; i++ {DoAdd(obj)}}func BenchmarkGenericInterfaceA(b *testing.B) {var obj Adder = &A{}for i := 0; i < b.N; i++ {DoAdd(obj)}}func BenchmarkGenericInterfaceB(b *testing.B) {var obj Adder = &B{}for i := 0; i < b.N; i++ {DoAdd(obj)}}func BenchmarkDoAddNoGeneric(b *testing.B) {var obj Adder = &A{}for i := 0; i < b.N; i++ {DoAddNoGeneric(obj)}}

猜猜结果,是不是觉得引入了泛型可以解决很多性能问题?答案揭晓:

哈哈,纯泛型和正常代码比有不到10%的差异,而接口+泛型就慢了接近100%。直接用接口是这里最快的,不过这是因为接口被编译器优化了,原因参加这篇。

你说谁会这么写代码啊,没事,我再举个更常见的例子:

func Search[T Equaler[T]](slice []T, target T) int {index := -1for i := range slice {if slice[i].Equal(target) {index = i}}return index}type MyInt intfunc (m MyInt) Equal(rhs MyInt) bool {return int(m) == int(rhs)}type Equaler[T any] interface {Equal(T) bool}func SearchMyInt(slice []MyInt, target MyInt) int {index := -1for i := range slice {if slice[i].Equal(target) {index = i}}return index}func SearchInterface(slice []Equaler[MyInt], target MyInt) int {index := -1for i := range slice {if slice[i].Equal(target) {index = i}}return index}var slice []MyIntvar interfaces []Equaler[MyInt]func init() {slice = make([]MyInt, 100)interfaces = make([]Equaler[MyInt], 100)for i := 0; i < 100; i++ {slice[i] = MyInt(i*i + 1)interfaces[i] = slice[i]}}func BenchmarkSearch(b *testing.B) {for i := 0; i < b.N; i++ {Search(slice, 99*99)}}func BenchmarkInterface(b *testing.B) {for i := 0; i < b.N; i++ {SearchInterface(interfaces, 99*99)}}func BenchmarkSearchInt(b *testing.B) {for i := 0; i < b.N; i++ {SearchMyInt(slice, 99*99)}}

这是结果:

泛型代码和使用接口的代码相差无几,比普通代码慢了整整六倍

Ttype dictMyInt
type dict

所以想靠泛型大幅提升性能的人还是洗洗睡吧,只有一种情况泛型的性能不会更差:在类型参数上只使用内置的运算符比如加减乘除,不调用任何方法。

但也不该因噎废食,首先泛型struct和泛型interface受到的影响很小,其次如我所说,如果不使用类型约束上的方法,那性能损耗几乎没有,所以像lo、mo这样的工具库还是能放心用的。

这个问题1.18就有人提出来了,然而gcshape的实现在这点上太拉胯,小修小补解决不了问题,官方也没改进的动力,所以哪怕到了1.21还是能复现同样的问题。

不过噩梦才刚刚开始,更劲爆的还在后面呢。

如何创建对象

T{}make(T, 0)
T
func F[T any]() T {var ret T// 如果需要指针,可以用new(T),但有注意事项,下面会说return ret}

So far, so good。那么我要把T的类型约束换成一个有方法的interface呢?

type A struct {i int}func (*A)Hello() {fmt.Println("Hello from A!")}func (a *A) Set(i int) {a.i = i}type B struct{i int}func (*B)Hello(){fmt.Println("Hello from B!")}func (b *B) Set(i int) {b.i = i}type API interface {Hello()Set(int)}func SayHello[PT API](a PT) {a.Hello()var b PTb.Hello()b.Set(222222)fmt.Println(a, b)}func main() {a := new(A)a.Set(111)fmt.Println(a)SayHello(&A{})SayHello(&B{})}

运行结果是啥?啥都不是,运行时会奖励你一个大大的panic:

你懵了,如果T的约束是any的时候就是好的,虽然不能调用方法,怎么到这调Set就空指针错误了呢?

type parameter

理解了这点你的问题就迎刃而解了,因为它类似下面的代码:

var a APIa.Set(1)
SayHellobb.Hello()
new(T)type parameter
~int[]int

怎么解决?没法解决,当然不排除是我不会用golang的泛型,如果你知道在不使用unsafe或者给T添加创建实例的新方法的前提下满足需求的解法,欢迎告诉我。

目前为止这还不是大问题,一般不需要在泛型代码里创建实例,大部分需要的情况也可以在函数外创建后传入。而且golang本身没有构造函数的概念,怎么创建类型的实例并不是类型的一部分,这点上不支持还是可以理解的。

但下面这个问题就很难找到合理的借口了。

把指针传递给类型参数

最佳实践:永远不要把指针类型作为类型参数,就像永远不要获取interface的指针一样。

为啥,看看下面的例子就行:

func Set[T *int|*uint](ptr T) {*ptr = 1}func main() {i := 0j := uint(0)Set(&i)Set(&j)fmt.Println(i, j)}

输出是啥,是编译错误:

$ go build a.go# command-line-arguments./a.go:6:3: invalid operation: pointers of ptr (variable of type T constrained by *int | *uint) must have identical base types
type parameter
type parametertype parameter

不过比起前一个问题,这个是有解决办法的,而且办法很多,第一种,明确表明ptr是个指针:

func Set[T int|uint](ptr *T) {*ptr = 1}

第二种,投机取巧:

func Set[T int|uint, PT interface{*T}](ptr PT) {*ptr = 1}
type parameter

但我永远只推荐你用第一种方法,别给自己找麻烦

泛型和类型的方法集

先看一段代码:

type A struct {i int}func (*A)Hello() {fmt.Println("Hello from A!")}type B struct{i int}func (*B)Hello(){fmt.Println("Hello from B!")}func SayHello[T ~*A|~*B](a T) {a.Hello()}func main() {SayHello(&A{})SayHello(&B{})}

输出是啥?又是编译错误:

$ go build a.go# command-line-arguments./a.go:17:4: a.Hello undefined (type T has no field or method Hello)

你猜到了,因为T是类型参数,而不是(*A),所以没有对应的方法存在。所以你这么改了:

func SayHello[T A|B](a *T) {a.Hello()}

这时候输出又变了:

$ go build a.go# command-line-arguments./a.go:17:4: a.Hello undefined (type *T is pointer to type parameter, not type parameter)

这个报错好像挺眼熟啊,这不就是取了interface的指针之后在指针上调用方法时报的那个错吗?

对,两个错误都差不多,因为type parameter有自己的数据结构,而它没有任何方法,所以通过指针指向type parameter后再调用方法会报一模一样的错。

难道我们只能建个interface里面放上Hello这个方法了吗?虽然我推荐你这么做,但还有别的办法,我们可以利用上一节的PT,但需要给它加点method:

func SayHello[T A|B, PT interface{*T; Hello()}](a PT) {a.Hello()}

原理是一样的,但现在a还同时支持指针的操作。

interface{Hello()}

如何复制一个对象

b := a

对于指针就比较复杂了,因为type parameter的存在,我们得特殊处理:

type A struct {i int}func (*A)Hello() {fmt.Println("Hello from A!")}func (a *A) Set(i int) {a.i = i}type B struct{i int/*j*/}func (*B)Hello(){fmt.Println("Hello from B!")}func (b *B) Set(i int) {b.i = i}type API[T any] interface {*TSet(int)}func DoCopy[T any, PT API[T]](a PT) {b := *a(PT(&b)).Set(222222) // 依旧是浅拷贝fmt.Println(a, b)}

PT是指针类型,所以可以解引用得到T的值,然后再赋值给b,完成了一次浅拷贝。

*TPT
CloneAble[T any] interface{Clone() T}

总结

这一年多来我遇到的令人不爽的问题就是这些,其中大部分是和指针相关的,偶尔还要外加一个性能问题。

一些最佳实践:

*TT[]Tmap[T1]T2T
T*

而我们的golang呢?虽然不支持,但给的报错却是一个代码一个样,对golang的类型系统和泛型实现细节没点了解还真不知道该怎么处理呢。

我的建议是,在golang想办法改进这些问题之前,只用别人写的泛型库,只用泛型处理slice和map。其他的杂技我们就别玩了,容易摔着。