Go 泛型:深入探究

Image description

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泛型的实现方案时非常谨慎,一共提交了三种实现方案:

  • 模板方案
  • 字典方案
  • GC 形状模板方案
  • 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 应用场景

  • 实现通用数据结构时:使用泛型,只需编写一次代码,即可在不同数据类型上重复使用。这减少了代码重复,提高了代码的可维护性和可扩展性。
  • 在操作Go原生的容器类型时:如果函数使用了Go内置的容器类型(例如slice、map或者channel)的参数,并且函数代码没有对容器中的元素类型做任何特定的假设,那么使用泛型可以将容器算法和容器中的元素类型完全解耦。在没有泛型语法之前,通常使用反射来实现,但是反射使得代码的可读性变差,无法进行静态类型检查,并且大大增加了程序的运行时开销。
  • 当不同数据类型实现的方法逻辑相同时:当不同数据类型的方法功能逻辑相同,唯一不同之处只是输入参数的数据类型不同时,可以使用泛型来减少代码冗余。
  • 4.2 不适用场景

  • 不要用类型参数代替接口类型:接口支持一定意义上的泛型编程,如果对某些类型的变量的操作只调用该类型的方法,则直接使用接口类型即可,无需使用泛型。例如 io.Reader 使用接口从文件和随机数生成器中读取各种类型的数据。io.Reader 从代码角度看易于读取,效率高,函数执行效率几乎没有差异,因此无需使用类型参数。
  • 当不同数据类型的方法实现细节不同时:如果每种类型的方法实现不同,则应该使用接口类型,而不是泛型。
  • 在运行时动态性较强的场景下:比如使用switch进行类型判断的场景下,直接使用interface{}会有更好的效果。
  • 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:

  • Avoid over-generalizing. Generics are not suitable for all scenarios, and it is necessary to carefully consider in which scenarios they are appropriate. Reflection can be used when appropriate: Go has runtime reflection. The reflection mechanism supports a certain sense of generic programming. If certain operations need to support the following scenarios, reflection can be considered: (1) Operating on types without methods, where the interface type is not applicable. (2) When the operation logic for each type is different, generics are not applicable. An example is the implementation of the encoding/json package. Since it is not desired that each type to be encoded implements the MarshalJson method, the interface type cannot be used. And because the encoding logic for different types is different, generics should not be used.
  • Clearly use *T, []T and map[T1]T2 instead of letting T represent pointer types, slices, or maps. Different from the fact that type parameters in C++ are placeholders and will be replaced with real types, the type of the type parameter T in Go is the type parameter itself. Therefore, representing it as pointer, slice, map, and other data types will lead to many unexpected situations during use, as shown below:
  • 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:

  • Types are determined during the compilation period, ensuring type safety. What is put in is what is taken out.
  • Readability is improved. The actual data type is explicitly known from the coding stage.
  • Generics merge the processing code for the same type, improving the code reuse rate and increasing the general flexibility of the program. However, generics are not a necessity for general data types. It is still necessary to carefully consider whether to use generics according to the actual usage situation.
  • Leapcell: The Advanced Platform for Go Web Hosting, Async Tasks, and Redis

    Image description

    Finally, let me introduce Leapcell, the most suitable platform for deploying Go services.

    1. Multi-Language Support

  • Develop with JavaScript, Python, Go, or Rust.
  • 2. Deploy unlimited projects for free

  • pay only for usage — no requests, no charges.
  • 3. Unbeatable Cost Efficiency

  • Pay-as-you-go with no idle charges.
  • Example: $25 supports 6.94M requests at a 60ms average response time.
  • 4. Streamlined Developer Experience

  • Intuitive UI for effortless setup.
  • Fully automated CI/CD pipelines and GitOps integration.
  • Real-time metrics and logging for actionable insights.
  • 5. Effortless Scalability and High Performance

  • Auto-scaling to handle high concurrency with ease.
  • Zero operational overhead — just focus on building.
  • Explore more in the documentation!

    Leapcell Twitter: https://x.com/LeapcellHQ