likes
comments
collection
share

Go泛型:让你的代码更加优雅和高效

作者站长头像
站长
· 阅读数 9

欢迎小伙伴们观看我的文章,若文中有不正确的地方,小伙伴们可以在评论区处指出,希望能够与小伙伴们一起成长与进步,谢谢!

一、泛型介绍

泛型编程是计算机科学中一个相当重要的概念,广泛应用于各种编程语言和框架中。泛型允许在强类型编程语言在设计代码时,能够在实例化时通过指定类型作为参来指明使用哪些数据类型,有助于提高代码复用性,增加类型的安全性,在某种情况下还能够达到性能优化的效果。

Go语言的泛型是在Go 1.18版本中引入的一个新特性,它允许开发者编写可以处理不同数据类型的代码,而无需为每种数据类型都编写重复的代码。

泛型的引入使得Go语言在编写一些通用功能时更加方便,在开发时能够通过指定数据类型来处理不同数据类型的代码,而无需为每种数据类型都编写重复的代码,从而提高了代码的复用性和可读性。

举个例子,如下是一个实现反转切片的函数如下:

func Reverse(s []int) []int {
    n := len(s)
    for i, j := 0, n-1; i < j; i, j = i+1, j-1 {
       s[i], s[j] = s[j], s[i]
    }
    return s
}

func main() {
    intSlice := []int{1, 2, 3, 4, 5}
    fmt.Println("Reversed int slice:", Reverse(intSlice)) // Reversed int slice: [5 4 3 2 1] 
}

上述Reverse函数接收一个[]int类型的参数,但如果想要将一个[]float64类型的切片实现反转,则无法调用Reverse函数,可能导致需要重新定义一个接收参数类型[]float64类型的参数,这样就导致相同逻辑的函数却因为类型不同而重复定义。

func ReverseFloat64Slice(s []float64) []float64 {
    n := len(s)
    for i, j := 0, n-1; i < j; i, j = i+1, j-1 {
       s[i], s[j] = s[j], s[i]
    }
    return s
}

如果还想实现其他类型的切片反转,则需要持续一遍遍重复的定义相同的功能,十分的低效。

事实上,反转切片的函数并不需要知道切片中元素的类型,包括其他的一些工具类方法,也有可能同样不需要确定其参数的数据类型。

Go1.18提出泛型前,可以尝试使用反射去解决上述问题重复定义相同逻辑函数的问题,但是使用反射带来的影响也同样不小,在运行期间获取变量类型会降低代码的执行效率并且失去编译期的类型检查,同时大量的反射代码也会让程序变得晦涩难懂。

而在Go 1.18引入泛型后,类似这种工具函数的实现通过泛型化,能够让其变得更加普适且通用。

func Reverse[T any](s []T) []T {
    n := len(s)
    for i, j := 0, n-1; i < j; i, j = i+1, j-1 {
       s[i], s[j] = s[j], s[i]
    }
    return s
}

二、泛型的意义

1、类型安全

弱类型的弊端

在没有提供泛型语法时,Go语言中的由于interface{}的特性,经常被用作于通用类型,通过使用interface{}可以接收任何类型的参数,但带来的坏处则是失去了编译器类型检查的好处。

func Add(x, y interface{}) interface{} {
    return x.(int) + y.(int) // 需要使用类型断言,且不安全
}

上述Add()函数使用类型断言将x与y转换成int类型。这种类型断言的不安全主要体现在两方面:

类型断言失败

如果传入的xy并非int类型,则在进行类型断言时会发生运行时错误,导致程序崩溃,产生一个可控的错误响应,例如传入float64类型,则会报panic: interface conversion: interface {} is float64, not int错误。

为了处理这种情况,需要在x.(int)之前,先检查x是否实现了int类型的方法。如果类型不正确,可以返回一个错误或者提示信息。

类型转换错误

即使类型断言成功,但如果传入的参数值在转换成目标类型后发生溢出或下溢,也有可能导致程序崩溃。例如当两个int类型的数相加导致整数溢出时。

为了避免这种情况,需要在进行类型转换之前检查参数值是否在目标类型的范围内。

强类型的优势

通过使用泛型,可以在编译器进行类型检查,从而解决上述的问题。

type Addable interface {
    int | float64
}

func Add[T Addable](x, y T) T {
    return x + y
}

上述代码定义了一个名为Addable的接口,Addable接口使用了类型断言(int | float64),指定了实现该接口的类型必须是intfloat64Addable实际上是一个类型约束,只允许那些满足某些条件的类型(比如,可以进行加法操作的类型)作为泛型参数。

Add函数中,通过使用泛型,让其接收两个类型为Addable的参数xy,并返回它们的和。

通过使用泛型:

  • 可以确保只有满足接口要求的类型(即intfloat64)才能被用于Add函数。这有助于在编译时检查类型错误,提高代码的安全性。
  • 泛型函数Add可以处理满足Addable接口要求的任何类型,无需为每种类型编写重复的代码,使得代码更加简洁,易于维护。
  • 若后续需要支持其他类型进行加法操作的类型,只需确保这些类型实现了Addable接口,即可在Add函数中使用。这使得代码具有很好的可扩展性。
func main() {
    add1 := Add[int](1, 2)
    add2 := Add[float64](3.14, 4.25)
    fmt.Println(add1) // 3
    fmt.Println(add2) // 7.390000000000001
}

2、代码复用

在没有泛型的情况下,如果说想为不同的类型实现相同的逻辑,通常需要多写几个几乎相同的函数。例如上述的Add函数,若没有泛型,则需要为int类型与float64类型分别定义相加函数。

func AddInt(x, y int) int {
    return x + y
}

func AddFloat64(x, y float64) float64 {
    return x + y
}

有了泛型后,通过使用泛型,可以写出更加普适且通用的函数,无需担心类型安全问题。

func Add[T Addable](x, y T) T {
    return x + y
}

3、性能优化

一般而言,泛型代码由于其高度抽象,可能会让使用者担心性能损失。但在Go语言中,泛型的实现方式是在编译期间生成特定类型的代码,因此性能损失通常是可控的。

由于Go编译器在编译期会为每个泛型参数生成具体的实现,因此,运行时不需要进行额外的类型检查或转换,这有助于优化性能。

// 编译期生成以下代码
func Add_int(x, y int) int {
    return x + y
}

func Add_float64(x, y float64) float64 {
    return x + y
}

三、泛型语法

Go语言泛型包括类型参数类型约束泛型函数泛型结构体等应用。

类型参数

在Go中,泛型的类型参数通常使用[]方括号来声明,方括号紧随函数名称或结构体名称。例如:

func Max[T int | float64](x, y T) T {
    if x >= y {
        return x
    }
    return y
}

上述代码中,定义了一个泛型函数,接受两个类型为T的参数xyT是一个类型参数,表示可以是intfloat64类型。int | float64表示类型约束,允许T可以是intfloat64类型。

func main() {
    maxInt := Max[int](1, 2)
    maxFloat64 := Max[float64](1.1, 2.2)
    fmt.Println(maxInt)     // 2
    fmt.Println(maxFloat64) // 2.2
}

上述main函数中,在调用Max函数时,可以指定传入int类型来调用函数,也可以传入float64类型的参数。向 Max 函数提供类型参数(在本例中为intfloat64)称为实例化(instantiation)

类型实例化分两步进行:

  • 首先,编译器在整个泛型函数或类型中将所有类型形参(type parameters)替换为它们各自的类型实参(type arguments)
  • 其次,编译器验证每个类型参数是否满足相应的约束。

在成功实例化之后,即可得到一个非泛型函数,它可以像任何其他正常函数一样被调用。例如:

func main() {
    max := Max[int]
    fmt.Println(max(1, 2)) // 2
}

类型约束

内置约束

Go泛型中,内置了几种类型约束。例如any,表示任何类型都可以作为参数。

func Add[T any](x, y T) T {
    return x + y
}

上述代码中,T表示一个类型参数,该类型参数使用了any约束,any代表该类型参数可以是任意类型。

Go泛型不仅支持单一的类型参数,同样也支持定义多个类型参数

func ReturnData[T, V any](x T, y V) (T, V) {
    return x, y
}

自定义约束

类型约束定义了一个数据类型集,只有在这个数据类型集中的数据类型,才能用作泛型的类型参数。

除了内置约束外,也同样支持自定义约束。自定义约束通常是通过定义interface接口来实现的。

func Add[T interface{ int | float64 }](x, y T) T {
    return x + y
}

上述Add函数中,类型约束接口可以直接在类型参数列表中使用。规定了支持的数据类型为intfloat64类型。Go泛型允许使用类型组合,在一个约束中指定多种允许的类型。

通常情况下,外层interface{}是可以省略的。

func Add[T int | float64](x, y T) T {
    return x + y
}

同样,类型约束接口可以实现定义并加以复用。

type Addable interface {
    int | float64
}

func Add[T Addable](x, y T) T {
    return x + y
}

上述通过定义Addable接口来定义一个自定义类型约束接口。在不同的函数中,可以复用该类型约束接口。

泛型函数

上述的例子中都属于泛型函数,泛型函数可以允许在不同类型上执行相同的函数逻辑。

func Max[T int | float64](x, y T) T {
    if x >= y {
        return x
    }
    return y
}

泛型结构体

Go泛型不仅支持泛型函数,同样也支持泛型结构体,例如:

type Number[T int | float64] struct {
    num1 T
    num2 int
    num3 float64
}

上述定义了一个Number的泛型结构体,结构体内的成员变量声明为T类型。

泛型方法

在使用泛型定义了泛型结构体后,同样也可以使用为泛型结构体定义方法。

func (n Number[T]) returnNum1() T {
    return n.num1
}

四、泛型应用

泛型接口

在Go中,同样可以在接口中定义包含泛型的方法,例如:

package main

import "fmt"

type Number[T int | float64] struct {
    num T
}

type OperatorContainer[T int | float64] interface {
    Add(x T) T
    Sub(x T) T
}

func (n Number[T]) Add(element T) T {
    return n.num + element
}

func (n Number[T]) Sub(element T) T {
    return n.num - element
}

func main() {
    var operator1 OperatorContainer[int]
    operator1 = Number[int]{num: 10}
    fmt.Println(operator1.Add(20)) // 30
    fmt.Println(operator1.Sub(5))  // 5

    var operator2 OperatorContainer[float64]
    operator2 = Number[float64]{num: 10.5}
    fmt.Println(operator2.Add(20.5)) // 31
    fmt.Println(operator2.Sub(4.5))  // 6
}

上述代码中:

  • 定义了一个泛型类型Number[T],其中T可以是整数(int)或浮点数(float64)。

  • 定义了一个泛型接口OperatorContainer[T],包含两个方法:Add(x T) TSub(x T) T,类型约束规定其能使用intfloat64类型。

  • 然后,为Number结构体实现了OperatorContainer[T]接口的方法。Add方法将两个数值相加,Sub方法将两个数值相减。

  • main函数中,创建了两个泛型变量operator1operator2,分别用于存储intfloat64的运算器。同时为两个变量分别创建了一个Number实例,并调用AddSub方法进行加法和减法运算。

通过泛型类型和接口可以编写更通用、更灵活的代码。在这个例子中,使用泛型实现了一个简单的数值计算器,可以处理intfloat64的加法和减法操作。

泛型数据结构

在实际应用场景中,可以通过泛型来为不同的数据类型定义通用的数据结构,例如栈、队列和堆等。

例如,使用泛型实现一个栈

package stack

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

// Push 入栈操作
func (s *Stack[T]) Push(element T) {
    if s == nil {
       panic("stack is nil")
    }
    s.stack = append(s.stack, element)
}

// Pop 出栈操作
func (s *Stack[T]) Pop() T {
    if len(s.stack) == 0 {
       panic("stack is empty")
    }
    element := s.stack[len(s.stack)-1]
    s.stack = s.stack[:len(s.stack)-1]
    return element
}

泛型构建简单缓存系统

缓存系统通常需要能够存储任意的数据类型,并且能够在给定的时间内根据特定的条件去检索到想获取到的值。可以通过泛型的特性以及Go内置的map类型来实现一个简单的缓存系统。

首先,可以通过any类型约束来定义泛型结构体,设置其内的map类型的成员变量可以存放任意的类型。

type Cache[T any] struct {
    cache map[string]T
}

可以为Cache结构体,实现简单的Set方法与Get方法来为操作缓存。

func (c *Cache[T]) Set(key string, value T) {
    c.cache[key] = value
}

func (c *Cache[T]) Get(key string) (T, bool) {
    value, exists := c.cache[key]
    return value, exists
}

是某些场景下,可能需要缓存字符串。

func main() {
    c := &Cache[string]{
       cache: make(map[string]string),
    }
    c.Set("name", "serena")
    value, exists := c.Get("name")
    if exists {
       fmt.Println(value) // serena
    } else {
       fmt.Println("不存在")
    }
}

五、总结

Go泛型是Go中一个极其强大以及灵活的特性,不仅解决了类型安全的问题,同时还带了代码复用以及可维护的强大能力。

泛型不仅仅是一种编程语言的功能或者一个语法糖,它更是一种编程范式的体现。

适当而精妙地应用泛型可以极大地提升代码质量,减少错误,并加速开发过程。

特别是在构建大型、复杂的系统时,泛型能够帮助更好地组织代码结构,降低模块之间的耦合度,提高系统的可维护性和可扩展性。

Go泛型没有引入过多复杂的规则和特性,而是集中解决最广泛和最实际的问题。在大多数场景下,Go泛型都能提供清晰、直观和高效的解决方案。

通过深入理解和应用Go的泛型特性,不仅能成为更高效的Go开发者,也能更好地理解泛型编程这一通用的编程范式,从而在更广泛的编程任务和问题解决中受益。