Golang 泛型实现原理


泛型(Generics)是 Go 语言在较早版本缺失的一个特性,直到 Go 1.18 版本中才引入了泛型。泛型提供了一种更灵活、更通用的方式来编写函数和数据结构,以处理不同类型的数据,而不必针对每种类型编写重复的代码。

1.什么是泛型?

假设我们有一个实现两个数相加的功能函数:

func Add(a int, b int) int {
    return a + b
}

通过传入 int 类型的 a 和 b,就可以返回 a 和 b 相加后的结果。如果 a 和 b 是 float 类型呢?

如果要解决上述问题,通常有两种解决方法:

(1)增加一个函数。

func AddFloat(a, b float32) float32 {
    return a + b
}

(2)利用反射,运行时进行类型判断。

func Add(a interface{}, b interface{}) interface{} {
    switch a.(type) {
    case int:
        return a.(int) + b.(int)
    case float32:
        return a.(float32) + b.(float32)
    default:
        return nil
    }
}

这两个方法有什么缺点吗?

方法1:会引入新的函数,如果还有其他类型的a,b需要相加的话,就需要再增加更多的函数。

方法2:使用了反射,性能会有影响。

如果不想增加一个新的功能逻辑一模一样的函数,同时也不想使用有性能问题的反射的话。就可以使用泛型。

func Add[T int | float32 | float64](a, b T) T {
    return a + b
}

泛型是一种编程范式,允许程序员在强类型程序设计语言中编写模板代码来适应任意类型。

通俗一点的描述,泛型是一种相同代码逻辑使用不同类型用的方法,而不需要一遍又一遍地复制和粘贴相同的函数,只是类型发生了变化。

2.有 interface{} 为什么还要有泛型?

虽然 Go 中的空接口 interface{} 允许存储任何类型的值,但它是一种动态类型的机制,并且在使用时需要进行类型断言。相比之下,泛型(Generics)提供了一种静态类型的通用解决方案,使得代码可以在不失去类型安全性的前提下处理多种数据类型。

使用泛型可以带来如下好处:

  • 类型安全

泛型允许开发者在编译时指定代码的通用类型,为类型参数定义一个类型约束,而不需要使用空接口进行运行时类型断言。这提供了更强的类型安全性,因为在编译时就能够发现类型错误。

  • 性能优化

在某些情况下,使用泛型可以带来性能优势。由于泛型代码是在编译时生成的,而不是在运行时进行类型断言,因此它可以更好地进行优化。

  • 代码重用和抽象

泛型允许编写通用的、与具体数据类型无关的代码,从而提高代码的重用性和抽象性。不再需要为每种数据类型都编写相似的代码,避免违反 DRY 原则(Don’t Repeat Yourself)。

3.泛型有哪些特性?

Go 语言泛型实现采用了一种基于类型参数的方式实现更加通用和类型安全的代码,而不是通过接口(像空接口 interface{})和类型断言来实现动态类型的处理。

以下是 Go 泛型实现的基本特性:

3.1 类型参数

Go 的泛型使用类型参数来实现通用性。在定义函数、类型或方法时,可以声明一个或多个类型参数。这些类型参数允许你在代码中引用并操作不同数据类型的对象。

泛型函数

泛型函数允许你编写能够处理不同类型的数据的通用函数,而不必为每种类型编写重复的代码。例如,可以创建一个泛型的排序函数,适用于不同类型的切片。

func Swap[T any](a, b T) (T, T) {
    return b, a
}

在上面的例子中,T 是一个类型参数,它表示一个占位符,可以代表任意类型。在函数体内,可以使用 T 来表示参数和返回值的类型。

泛型类型

泛型也可以用于创建通用的类型,如泛型切片、泛型映射等。这样可以更灵活地处理不同类型的数据。

type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) Push(item T) {
    s.items = append(s.items, item)
}

func (s *Stack[T]) Pop() T {
    if len(s.items) == 0 {
        panic("Stack is empty")
    }
    item := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return item
}

上述例子中,Stack 是一个泛型的堆栈数据结构,可以处理任意类型的元素。

3.2 类型约束

为了确保类型安全性,Go 引入了类型约束(type constraints)。

类型约束规定了类型参数必须满足的条件,以便进行合法的操作。例如,可以使用空接口、特定的接口类型或基本类型约束类型参数。

func Print[T fmt.Stringer](value T) {
    fmt.Println(value.String())
}

在上述例子中,T 被约束为实现了 fmt.Stringer 接口的类型。

3.3 类型集合

类型集合(Type Set)表示一堆类型的集合,用来在泛型中约束类型参数的范围。

在 Go1.18 之前,Go 官方对接口的定义是:接口是一个方法集(Method Set)。而 Go1.18 开始将接口的定义正式更改为了类型集。

Go 1.18 引入了一种新的接口语法,可以嵌入其他数据类型,构成类型集合。

type Numeric interface {
    int | float32 | float64
}

这意味着一个接口不仅可以定义一组方法,还可以定义一组类型。使用 Numeric 接口作为类型约束,意味着值可以是整数或浮点数。

type Node[T Numeric] struct {
    value T
}

3.4 约束元素

任意类型约束元素

在类型集合中,任意类型均可作为类型参数的约束元素。

// 其中 int 为基础类型
type Integer interface { int } 

近似约束元素

实际编码时,可能会有很多的类型别名,例如:

type (
    orderStatus   int32
    sendStatus    int32
    receiveStatus int32
    ...
)

Go1.18 中扩展了近似约束元素(Approximation Constraint Element)这个概念,以上述例子来说,即:基础类型为 int32 的类型。语法表现为:

type AnyStatus interface{ ~int32 }

如果我们需要对上述自定义的 status 做一个翻译,就可以使用以下方式:

// 使用定义的类型集
func translateStatus[T AnyStatus](status T) string {
    switch status {
    case 1:
        return "成功"
    case -1:
        return "失败"
    default:
        return "未知"
    }
}

// 或者不使用类型集
func translateStatus[T ~int32](status T) string {
    switch status {
    case 1:
        return "成功"
    case -1:
        return "失败"
    default:
        return "未知"
    }
}

联合约束元素

联合元素,写成一系列由竖线 (|) 分隔的约束元素。

这里给所有有符号的整数类型添加一个通用的求和方法。

type Integer interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64
}

func addInteger[T Integer](a, b T) T {
    return a + b
}

约束中的可比类型

Go1.18 中内置了一个类型约束 comparable约束,comparable约束的类型集是所有可比较类型的集合。这允许你对该类型的值进行比较操作。

func indexSlice[T comparable](s []T, x T) int {
    for i, v := range s {
        if v == x {
            return i
        }
    }
    return -1
}

3.5 类型推断

在许多情况下,可以使用类型推断来避免必须显式写出部分或全部类型参数。可以利用函数调用使用的实参类型推断出类型参数。

func Print[T any](s T) {
    fmt.Println(s)
}

s := []int{1, 2, 3}

// 显示指定参数类型
Print[[]int](s)
// 推断参数类型
Print(s)

4.实现原理

要了解 Go 如何实现泛型,首先需要了解一下实现泛型的最常用方法。

4.1 类型擦除

在编译器中实现泛型的一种方法是类型擦除(Type erasure)。

对 Java 来说统一的数据类型就是 Object,在编译阶段做完类型检查后就将类型信息通过转换成 Object 进行擦除,这样只需要生成一份泛型函数的副本即可。类型擦除保证了泛型函数生成的字节码和非泛型函数的是相同的,也符合 Java 对兼容性的要求。不过类型擦除也给 Java 的泛型带来了很多的限制,比如:

  • 基本类型不支持泛型化

Java 的泛型只能应用于类和对象,无法直接用于基本数据类型(如 int、char)。这导致在使用泛型时需要使用包装类(Wrapper Classes),如 Integer 代替 int。

  • 不能创建参数化类型的数组

例如 List<String>[] array = new ArrayList<String>[10]; 是非法的。这是因为 Java 泛型系统中的数组是具体化的,而泛型是擦除的,二者不兼容。

  • 无法创建参数化类型的实例

不能直接实例化泛型类型。例如,不能使用 new T() 创建泛型类的实例,因为在类型擦除后无法确定 T 的具体类型。

虚方法表

对 Java 来说通过类型擦除结合「虚方法表」(Virtual Method Table)来实现泛型的效果:运行时同样的数据类型 Object,却能调用原始类型的方法。

泛型函数被修改成只接受统一数据类型 Object 作为参数。在调用实际类型对象的方法时,需要找到不通对象的方法。因此,需要一个可以查询方法的内存地址的表格:虚方法表。

虚方法表不仅可以用来实现泛型,还可以用来实现其他类型的多态性,比如 C++ 中的多态。然而,推导这些指针和调用虚拟函数要比直接调用函数慢,而且使用虚方法表会阻止编译器进行优化。

4.2 字典

编译器在编译泛型函数时只生成一份函数副本,通过新增一个字典参数来供调用方传递类型参数(Type Parameters),这样就可以用一个函数实例支持多种类型参数。

这种实现方式称为字典传递(Dictionary passing)。

Go 实现泛型的方式,就是在编译阶段,通过将类型信息以字典的方式传递给泛型函数。当然这个字典不仅包含了类型信息,还包含了此类型的内存操作函数,如 make/len/new 等。

同样的 Swift 也通过字典传递了一种名为 Witness table 的数据结构,这种数据结构包含着类型的大小以及类型的内存操作函数(移动、复制与释放)。
在这里插入图片描述
Swift 在编译阶段通过在函数入参中以字典的形式把 Witness table 注入,达到了以统一的方式处理任何类型,而不用对类型进行装箱操作。这样一来,Swift 就可以实现泛型,而且不需要单态化,虽然在运行时有动态查找的开销,但这种表结构节省了分配内存和缓存不一致性的成本。

4.3 单态化

单态化(Monomorphization)是一个实现泛型的常用方法,编译器为每个被调用的数据类型单独生成一个泛型函数副本,以确保类型安全和最佳性能。

func max[T Numeric](a, b T) T {
    // ...
}

larger := max(3, 5)

由于上面显示的 max 函数是用两个整数调用的,编译器在对代码进行单态化时将为 int 生成一个 max 的副本。

func maxInt(a, b int) int {
    // ...
}

larger := maxInt(3, 5)

单态化针对不同类型创建单独的函数副本,显而易见会增加编译时长,但是可以在编译期针对不同类型进行代码优化,且运行时没有类型相关的判断逻辑,性能较好。

模板(Template)

C++ 通过模板实现泛型类、方法和函数,这导致编译器为不同类型参数生成独立代码副本。这种方法的关键优势是没有运行时开销,它以增加编译时间和二进制文件大小为代价。

蜡印(Stenciling)

蜡印其实就是模版,也是一种代码生成技术。但 Go 除了使用字典传递实现装箱外,还采用了 GC Shape Stenciling 的技术。这种看起来很高级的名词简单来说是为了解决蜡印或模版的问题,因为在蜡印的过程中,编译器会为每一个实例化的类型参数生成一套独立的代码,这会有一个问题,请看下面的例子:

type a int
type b int

虽然 a 和 b 是两个自定义的类型,但它们底层都是 int 类型,但编译器会为这两个类型生成两套函数的版本(如对 max 泛型函数来说会生成 max_a 与 max_b 两个函数版本)。这是一种浪费,还会生成增加二进制文件大小。

GC Shape 这种技术通过对类型的底层内存布局(从内存分配器或垃圾回收器的视角)分组,对拥有相同内存布局的类型参数进行蜡印,比如不同类型的指针、不同类型的接口在内存中总是有相同的布局,编译器将为指针和接口的调用生成同一个泛型函数的副本,这样就可以避免生成大量重复的代码。

Go 实现泛型的方式混合了字典与蜡印技术,对于实例类型 Shape 相同的情况,只生成一份代码,使用字典区分类型的不同行为。最终的流程如下:

1.开始编译
2.寻找泛型函数调用
3.使用 GCShape 对类型分组
4.为类型生成实例化的函数
5.创建包含类型信息的字典
6.调用实例化的函数,传入实参与类型字典

总的来说 Go 在 1.18 实现泛型的方式和 Rust 类似,如果感兴趣可以看这篇 Rust 泛型的文章:透过Rust探索系统的本原:泛型

5.小结

对静态类型检查的编程语言,实现泛型的方式有很多,如:

  • C++:通过模版实现,相比 C 的宏,模版显然更强大灵活。
  • Java:通过类型擦除的装箱技术结合虚方法表实现,虽然类型擦除导致 Java 的泛型实现不如人意,但这种代价确保了兼容性。
  • Swift:通过字典传入的方式配合 Witness Table 的实现,这种巧妙的方式在编译速度与运行时速度之间取得了不错的平衡。
  • Go:通过字典传入的方式配合 GC Shape 的蜡印技术实现,这种方式在编译与运行时速率之间取得了不错的平衡。

虽然看起来方式多样,但实际上只有两种思路:

  • 编译时根据不同类型生成不同的代码。
  • 编译时只生成一份代码,运行时根据传入的实参类型信息执行相应的操作。

泛型是 Go 语言新增的一个重要特性,减少了重复代码的编写,提高了代码的可维护性和性能。

不同泛型实现方式在性能讨论中经常被忽略的是,所有这些好处和成本只涉及函数的调用。通常情况下,大部分的执行时间在函数内部。调用方法的开销可能不会成为性能瓶颈,所以要优先考虑函数的实现,再考虑调用开销。

Go 仍在不断改进泛型实现机制,所以本文会存在时效性问题。


参考wenxian

An Introduction To Generics
Type Parameters Proposal
泛型设计 - | Go 语言设计哲学- 煎鱼
golang拾遗:为什么我们需要泛型- apocelipes
简单易懂的Go 泛型使用和实现原理介绍- 万俊峰Kevin
编程语言是如何实现泛型的 - BMPI
Go泛型是怎么实现的? - 鸟窝

  • 23
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
### 回答1: 在Golang中,泛型接口是一种接口类型,可以用于处理不同类型的数据。泛型接口在编程中非常有用,因为它允许我们编写可重用、灵活的代码,而无需针对特定类型进行硬编码。 在Golang中,泛型接口可以通过使用空接口(`interface{}`)来实现。空接口是一个没有任何方法的接口,可以接受任何类型的值。通过使用空接口,可以实现泛型的功能,使得接口可以接收任何类型的数据。 使用泛型接口,我们可以在不改变接口定义的情况下,接受不同类型的参数。例如,我们可以定义一个泛型接口`Container`,用于表示一个可以容纳不同类型元素的容器。这个接口可以定义添加元素、删除元素以及获取元素等方法。 使用泛型接口的好处是可以编写灵活的代码,尽可能减少重复代码的编写。由于泛型接口可以处理多种类型的数据,我们可以将相同的逻辑应用于不同的数据类型,实现代码的重用。 然而,目前Golang没有原生支持泛型接口的功能,因此在实现泛型接口时可能需要一些额外的代码处理。一种常见的做法是使用类型断言来判断接口的实际类型,然后进行相应的操作。 总而言之,虽然Golang没有内置的泛型功能,但通过使用空接口和类型断言,我们可以实现泛型接口从而处理不同类型的数据,提高代码的重用性和灵活性。 ### 回答2: Go语言是一种静态类型的编程语言,其最近的版本Go 1.18中引入了泛型接口的概念。泛型指的是在编写代码时不指定具体类型,而是允许使用者在使用时根据自己的需求来指定具体的类型。 在传统的面向对象编程中,常用的接口表示方式是通过接口类型断言来判断对象是否实现了某个接口。但是这种方式在处理不同类型的数据时需要进行类型转换,不够灵活且有一定的性能损耗。 而泛型接口则可以在接口定义时使用类型参数,通过类型参数来指定接口的具体类型。这样一来,在使用时就可以直接将对应类型的数据传入接口中,无需进行类型转换。 泛型接口的引入为Go语言提供了更加灵活和高效的编程方式。通过泛型接口,我们可以编写更加通用和复用的代码。它还能帮助我们更好地约束函数和数据类型之间的关系,提高代码的健壮性和可读性。 不过需要注意的是,泛型接口的引入也会带来一定的复杂性。在使用泛型接口时,我们需要仔细考虑类型参数的合理性和边界条件,并且需要充分测试以确保代码的正确性。 总之,引入泛型接口是Go语言进一步发展的一大步。它提供了更多的编程方式,并且可以在一定程度上简化代码和提高效率。希望未来随着泛型接口的进一步成熟和普及,我们可以看到更多高质量、灵活和通用的Go代码。
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值