Go 泛型:深入探究

1. 不用泛型
在引入泛型之前,有几种方法可以实现支持不同数据类型的泛型函数:
**方法 1:为每种数据类型实现一个函数**
这种方式会导致代码冗余度极大,维护成本极高,任何修改都需要对所有函数进行相同的操作,而且由于Go语言不支持同名函数重载,将这些函数暴露给外部模块调用也很不方便。
**方法 2:使用范围最大的数据类型**
为了避免代码冗余,另一种方法是使用范围最大的数据类型,即方法2。一个典型的例子是 math.Max,它返回两个数字中较大的一个。为了能够比较各种类型的数据,math.Max 使用 Go 中数值类型中范围最大的数据类型 float64 作为输入和输出参数,从而避免了精度损失。虽然这在一定程度上解决了代码冗余的问题,但是任何类型的数据都需要先转换为 float64 类型。例如,在比较 int 与 int 时,仍然需要进行类型转换,这不仅会降低性能,而且看起来也不自然。
**方法 3:使用 interface{} 类型**
使用 `interface{}` 类型可以有效解决上述问题。但是 `interface{}` 类型会引入一定的运行时开销,因为它需要在运行时进行类型断言或类型判断,这可能会导致一定的性能下降。另外,在使用 `interface{}` 类型时,编译器无法进行静态类型检查,因此某些类型错误可能只能在运行时才被发现。
2.泛型的优点
Go 1.18 引入了对泛型的支持,这是 Go 语言开源以来的重大变化。
泛型是编程语言的一个特性,它允许程序员在编程时使用泛型类型来替代实际类型,然后在实际调用时通过显式传递或者自动推导来替换泛型类型,达到代码复用的目的。在使用泛型的过程中,需要操作的数据类型被指定为参数,在类、接口和方法中,这样的参数类型分别称为泛型类、泛型接口和泛型方法。
泛型的主要优势在于提升代码的可重用性和类型安全性。相比于传统的形参,泛型使得编写通用代码更加简洁灵活,提供了处理不同类型数据的能力,进一步提升了 Go 语言的表达力和可重用性。同时,由于泛型的具体类型是在编译时确定的,因此可以提供类型检查,避免类型转换错误。
3. 泛型和 interface{} 的区别
在Go语言中,interface{}和泛型都是处理多种数据类型的工具,要讨论它们的区别,我们先来看一下interface{}和泛型的实现原理。
3.1 interface{} 实现原理
`interface{}` 是一个空接口,接口类型中没有方法。由于所有类型都实现了 `interface{}`,因此可以使用它来创建可以接受任何类型的函数、方法或数据结构。`interface{}` 在运行时的底层结构表示为 `eface`,其结构如下所示,主要包含 `_type` 和 `data` 两个字段。
type eface struct { _type *_type data unsafe.Pointer } type type struct { Size uintptr PtrBytes uintptr // number of (prefix) bytes in the type that can contain pointers Hash uint32 // hash of type; avoids computation in hash tables TFlag TFlag // extra type information flags Align_ uint8 // alignment of variable with this type FieldAlign_ uint8 // alignment of struct field with this type Kind_ uint8 // enumeration for C // function for comparing objects of this type // (ptr to object A, ptr to object B) -> ==? Equal func(unsafe.Pointer, unsafe.Pointer) bool // GCData stores the GC type data for the garbage collector. // If the KindGCProg bit is set in kind, GCData is a GC program. // Otherwise it is a ptrmask bitmap. See mbitmap.go for details. GCData *byte Str NameOff // string form PtrToThis TypeOff // type for pointer to this type, may be zero }
`_type` 是指向 `_type` 结构的指针,其中包含实际值的大小、类型、哈希函数和字符串表示等信息。`data` 是指向实际数据的指针。如果实际数据的大小小于或等于指针的大小,则数据将直接存储在 `data` 字段中;否则,`data` 字段将存储指向实际数据的指针。
当将特定类型的对象赋值给 `interface{}` 类型的变量时,Go 语言会隐式地执行 `eface` 的装箱操作,将 `_type` 字段设置为值的类型,将 `data` 字段设置为值的数据。例如,当执行语句 `var i interface{} = 123` 时,Go 会创建一个 `eface` 结构,其中 `_type` 字段代表 `int` 类型,`data` 字段代表值 123。
当从 `interface{}` 中取出存储的值时,会发生一个拆箱过程,即类型断言或类型判断。这个过程需要显式指定预期的类型。如果 `interface{}` 中存储的值的类型与预期类型匹配,则类型断言将成功,可以取出该值。否则,类型断言将失败,针对这种情况需要进行额外的处理。
var i interface{} = "hello" s, ok := i.(string) if ok { fmt.Println(s) // Output "hello" } else { fmt.Println("not a string") }
可以看出,interface{}通过运行时的装箱、拆箱操作,支持对多种数据类型的操作。
3.2 泛型实现原理
Go核心团队在评估Go泛型的实现方案时非常谨慎,一共提交了三种实现方案:
Stenciling 方案也是 C++、Rust 等语言实现泛型所采用的实现方案。它的实现原理是在编译期间根据泛型函数调用时的具体类型参数或者约束中的类型元素,为每一个类型实参生成一个独立的泛型函数实现,以保证类型安全和最优的性能。但是这种方式会拖慢编译器的速度,因为当调用的数据类型较多时,泛型函数需要为每一种数据类型都生成独立的函数,这可能会导致编译后的文件非常大。同时由于 CPU 缓存未命中、指令分支预测等问题,生成的代码可能无法高效运行。
Dictionaries 方案只为泛型函数生成一个函数逻辑,但会为函数增加一个参数 `dict` 作为第一个参数。`dict` 参数在调用泛型函数时保存类型实参的类型相关信息,并在函数调用过程中使用 AX 寄存器(AMD)传递字典信息。该方案的好处是减少了编译阶段的开销,不增加二进制文件的大小。但是增加了运行时开销,无法在编译阶段进行函数优化,存在字典递归等问题。
type Op interface{ int|float } func Add[T Op](m, n T) T { return m + n } // After generation => const dict = map[type] typeInfo{ int : intInfo{ newFunc, lessFucn, //...... }, float : floatInfo } func Add(dict[T], m, n T) T{}
Go 最终整合了上述两种方案,并提出了通用实现的 GC Shape Stenciling 方案。它以类型的 GC Shape 为单位生成函数代码。具有相同 GC Shape 的类型复用相同的代码(类型的 GC Shape 指的是它在 Go 内存分配器/垃圾收集器中的表示)。所有指针类型都复用 `*uint8` 类型。对于具有相同 GC Shape 的类型,使用共享的实例化函数代码。该方案还自动为每个实例化函数代码添加一个 `dict` 参数,以区分具有相同 GC Shape 的不同类型。
type V interface{ int|float|*int|*float } func F[T V](m, n T) {} // 1. Generate templates for regular types int/float func F[go.shape.int_0](m, n int){} func F[go.shape.float_0](m, n int){} // 2. Pointer types reuse the same template func F[go.shape.*uint8_0](m, n int){} // 3. Add dictionary passing during the call const dict = map[type] typeInfo{ int : intInfo{}, float : floatInfo{} } func F[go.shape.int_0](dict[int],m, n int){}
3.3 差异
从 interface{} 和泛型的底层实现原理可以发现,它们的主要区别在于 interface{} 支持在运行时处理不同的数据类型,而泛型支持在编译阶段静态地处理不同的数据类型。实际使用上主要有以下几点区别:
(1)性能差异:在将不同类型的数据赋值给 `interface{}` 或从中获取数据时执行的装箱和拆箱操作成本高昂,会引入额外的开销。相比之下,泛型不需要装箱和拆箱操作,并且泛型生成的代码针对特定类型进行了优化,避免了运行时性能开销。
(2)类型安全:使用 interface{} 类型时,编译器无法进行静态类型检查,只能在运行时进行类型断言。因此,一些类型错误可能只能在运行时才能发现。而 Go 的泛型代码是在编译时生成的,因此泛型代码在编译时就可以获取类型信息,保证了类型安全。
4. 泛型的使用场景
4.1 应用场景
4.2 不适用场景
5. 泛型中的陷阱
5.1 nil 比较
在 Go 语言中,类型参数是不允许直接和 nil 进行比较的,因为类型参数在编译时会进行类型检查,而 nil 在运行时是一个特殊值。由于类型参数的底层类型在编译时是未知的,编译器无法判断类型参数的底层类型是否支持和 nil 进行比较。因此,为了维护类型安全,避免潜在的运行时错误,Go 语言不允许类型参数和 nil 直接比较。
// Wrong example func ZeroValue0[T any](v T) bool { return v == nil } // Correct example 1 func Zero1[T any]() T { return *new(T) } // Correct example 2 func Zero2[T any]() T { var t T return t } // Correct example 3 func Zero3[T any]() (t T) { return }
5.2 无效的底层元素
The type `T` of the underlying element must be a base type and cannot be an interface type.
// Wrong definition! type MyInt int type I0 interface { ~MyInt // Wrong! MyInt is not a base type, int is ~error // Wrong! error is an interface }
5.3 Invalid Union Type Elements
Union type elements cannot be type parameters, and non-interface elements must be pairwise disjoint. If there is more than one element, it cannot contain an interface type with non-empty methods, nor can it be `comparable` or embed `comparable`.
func I1[K any, V interface{ K }]() { // Wrong, K in interface{ K } is a type parameter } type MyInt int func I5[K any, V interface{ int | MyInt }]() { // Correct } func I6[K any, V interface{ int | ~MyInt }]() { // Wrong! The intersection of int and ~MyInt is int } type MyInt2 = int func I7[K any, V interface{ int | MyInt2 }]() { // Wrong! int and MyInt2 are the same type, they intersect } // Wrong! Because there are more than one union elements and cannot be comparable func I13[K comparable | int]() { } // Wrong! Because there are more than one union elements and elements cannot embed comparable func I14[K interface{ comparable } | int]() { }
5.4 Interface Types Cannot Be Recursively Embedded
// Wrong! Cannot embed itself type Node interface { Node } // Wrong! Tree cannot embed itself through TreeNode type Tree interface { TreeNode } type TreeNode interface { Tree }
6. Best Practices
To make good use of generics, the following points should be noted during use:
func Set[T *int|*uint](ptr T) { *ptr = 1 } func main() { i := 0 Set(&i) fmt.Println(i) // Report an error: invalid operation }
The above code will report an error: `invalid operation: pointers of ptr (variable of type T constrained by *int | *uint) must have identical base types`. The reason for this error is that `T` is a type parameter, and the type parameter is not a pointer and does not support the dereference operation. This can be solved by changing the definition to the following:
func Set[T int|uint](ptr *T) { *ptr = 1 }
Summary
Overall, the benefits of generics can be summarized in three aspects:
Leapcell: The Advanced Platform for Go Web Hosting, Async Tasks, and Redis

Finally, let me introduce Leapcell, the most suitable platform for deploying Go services.
1. Multi-Language Support
2. Deploy unlimited projects for free
3. Unbeatable Cost Efficiency
4. Streamlined Developer Experience
5. Effortless Scalability and High Performance
Explore more in the documentation!
Leapcell Twitter: https://x.com/LeapcellHQ