golang的泛型已经出来了一年多了,从提案被接受开始我就在关注泛型了,如今不管是在生产环境还是开源项目里我都写了不少泛型代码,是时候全面得回顾下golang泛型的使用体验了。
先说说结论,好用是好用,但问题也很多,有些问题比较影响使用体验,到了不吐不快的地步了。
这篇文章不会教你泛型的基础语法,并且要求你对golang的泛型使用有一定经验,如果你还是个泛型的新手,可以先阅读下官方的教程,然后再阅读本篇文章。
泛型的实现
实现泛型有很多种方法,常见的主流的是下面这些:
- 以c++为代表的,类型参数就是个占位符,最后实际上会替换成实际类型,然后以此为模板生成实际的代码,生成多份代码,每份的类型都不一样
- 以TypeScript和Java为代表的类型擦除,把类型参数泛化成一个满足类型约束的类型(Object或者某个interface),只生成一份代码
- 以c#为代表,代码里表现的像类型擦除,但运行的时候实际上和c++一样采用模板实例化对每个不同的类型都生成一份代码
那么golang用的哪种呢?哪种都不是,golang有自己的想法:gcshape
。
什么是gcshape
?简单得说,所有拥有相同undelyring type的类型都算同一种shape
,所有的指针都算一种shape
,除此之外就算两个类型大小相同甚至字段的类型相同也不算同一个shape
。
那么这个shape
又是什么呢?gc编译器会根据每个shape生成一份代码,拥有相同shape的类型会共用同一份代码。
看个简单例子:
func Output[T any]() {
var t T
fmt.Printf("%#v\n", t)
}
type A struct {
a,b,c,d,e,f,g int64
h,i,j string
k []string
l, m, n map[string]uint64
}
type B A
func 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有什么性能优势,我们还是姑且认可它的动机吧。但这么实现泛型后导致了很多严重的问题:
- 性能不升反降
- 正常来说类型参数是可以当成普通的类型来用的,但golang里有很多时候不能
正因为有了gcshape,想在golang里用对泛型还挺难的。
性能问题
这一节先说说性能。看个例子:
type A struct {
num uint64
num1 int64
}
func (a *A) Add() {
a.num++
a.num1 = int64(a.num / 2)
}
type B struct {
num1 uint64
num2 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 := -1
for i := range slice {
if slice[i].Equal(target) {
index = i
}
}
return index
}
type MyInt int
func (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 := -1
for i := range slice {
if slice[i].Equa