go泛型介绍

什么是泛型 #

泛型是一种编程特性,允许你编写更通用的代码。 泛型可以让你编写一个函数或类型,而不是针对特定的数据类型。 这样,你可以使用相同的函数或类型处理不同的数据类型,而无需为每种数据类型编写重复的代码,在python和其他语言中很早就被支持了,但是在go中直到1.18版本之后才被支持。

为什么需要泛型 #

假如我们需要计算两数之和

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

此时,如果我们需要去计算其他类型的,比如浮点或者字符串的和,就需要新建方法去实现

func AddFloat32(a float32, b float32) float32 {
    return a + b
}
func AddString(a string, b string) string {
    return a + b
}

我们也可以使用反射去解决问题,但是使用反射在运行期间获取变量类型会降低代码的执行效率并且失去编译的类型检查,同时大量的反射代码也会让程序变得复杂。如果将传入的确定的类型转换成一个类型集合,这样就只需要定义一个方法就能实现上述需求

// 假设 T 是类型形参,在定义函数时它的类型是不确定的,类似占位符
func Add[T string|float64](a T, b T) T { 
    return a + b
}

泛型语法 #

借助上面的例子,我们对于go泛型编程有了最基本的认识,对于泛型go还有很多的新的概念

类型形参、类型实参 #

现在go语言中的函数和类型支持类型参数。类型参数列表看起来像普通的参数列表,只不过它使用方括号([])而不是圆括号(())。

// 其中int | float64 代表类型约束
// 类型形参
func min[T int | float64](a, b T) T{
    if a <= b {
        return a
    }
    return b
}
// 类型实参
min(20, 10)

实例化 #

上面定义的min函数就同时支持intfloat64两种类型,也就是说当调用min函数时,我们既可以传入int类型的参数也可以传入float64类型。

m1 := min[int](1, 2)  // 1
m2 := min[float64](-0.1, -0.2) // -0.2

min 函数提供类型参数称为实例化( instantiation )。

类型实例化分为两个步骤

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

在成功实例化之后,我们会得到一个非泛型函数,它可以向其他函数一样被调用

fmin := min[float64] // 类型实例化,生成T=float64的min函数
m2 = fmin(1.2, 2.3)  // 1.2

类型参数的使用 #

除了函数中支持的使用类型参数列表之外,类型也可以使用类型参数列表

type Slice[T int | string] []T

type Map[K int | string, V float32 | float64] map[K]V

type Tree[T interface{}] struct {
	left, right *Tree[T]
	value       T
}

在上述泛型类型中,TKV都属于类型形参,类型形参后面是类型约束,类型实参需要满足对应的类型约束。

泛型类型可以定义方法,例如为上面的Tree实现一个查找元素的方法。

func (t *Tree[T]) Lookup(x T) *Tree[T] { 
}
var stringTree Tree[string]

要使用泛型类型,必须进行实例化。Tree[string]是使用类型实参string实例化 Tree 的示例。

类型约束 #

普通函数中的每个参数都有一个类型,例如,我们上面定义的非泛型函数minFloat64那样,声明了参数的类型为float64,那么在函数调用时允许传入的实际参数就必须是可以用float64类型表示的浮点数值。

类似于参数列表中每个参数都有对应的参数类型,类型参数列表中每个类型参数都有一个类型约束。类型约束定义了一个类型集,只有在这个类型集中的类型才能用作类型实参。

Go语言中的类型约束是接口类型。

// 类型约束字面量,通常外层interface{}可省略
func min[T interface{ int | float64 }](a, b T) T {
	if a <= b {
		return a
	}
	return b
}
// 作为类型约束使用的接口类型可以事先定义并支持复用。
// 事先定义好的类型约束类型
type Value interface {
	int | float64
}
func min[T Value](a, b T) T {
	if a <= b {
		return a
	}
	return b
}

// 如果去掉interface{}会引起歧义则不可省略
type IntPtrSlice [T *int] []T  //  Invalid array bound 'T *int', must be a constant expression

type IntPtrSlice[T *int,] []T  // 可以添加`,`
type IntPtrSlice[T interface{ *int }] []T // 使用interface{}包裹

类型集 #

Go语言扩展了接口类型的语法,让我们能够向接口中添加类型。例如

type V interface {
	int | string | bool
}
// 上面的代码就定义了一个包含 int、 string 和 bool 类型的类型集。

当用作类型约束时,由接口定义的类型集精确地指定允许作为相应类型参数的类型。

|符号

T1 | T2表示类型约束为T1和T2这两个类型的并集,例如下面的Integer类型表示由intstring组成。

type Integer interface {
	int | string
}

~符号

~T表示所以底层类型是T的类型,例如~string表示所有底层类型是string的类型集合。

type MyString string  // MyString的底层类型是string

// 使用
type Integer interface {
	~string
}
// 注意:~符号后面只能是基本类型。

类型推断 #

从某些方面来说,类型推断是语言中最复杂的变化,但它很重要,因为它能让人们在编写调用泛型函数的代码时更自然。

对于类型参数,需要传递类型参数,这可能导致代码冗长。回到我们通用的 min函数:

func min[T int | float64](a, b T) T {
	if a <= b {
		return a
	}
	return b
}

类型形参T用于指定ab的类型。我们可以使用显式类型实参调用它:

var a, b, c float64
c = min[float64](a, b) // 显式指定类型实参

在许多情况下,编译器可以从普通参数推断 T 的类型实参。这使得代码更短,同时保持清晰。

var a, b, m float64

m = min(a, b) // 无需指定类型实参

这种从实参的类型推断出函数的类型实参的推断称为函数实参类型推断

参考文档 #

示例 #

type Filter[T AllowedData] map[T]bool

type AllowedData interface {
	constraints.Ordered
}

func Test_fx(t *testing.T) {
	data := []int{1, 3, 4, 4, 5, 8, 7, 3, 2}     // sample array
	data32 := []int32{1, 3, 4, 4, 5, 8, 7, 3, 2} // sample array
	data64 := []int64{1, 3, 4, 4, 5, 8, 7, 3, 2}
	fmt.Printf("Duplicate found %t\n", FindDuplicate(data))
	fmt.Printf("Duplicate found %t\n", FindDuplicate(data32))
	fmt.Printf("Duplicate found %t\n", FindDuplicate(data64))

}

func (r Filter[T]) add(datum T) {
	r[datum] = true
}

func (r Filter[T]) has(datum T) bool {
	_, ok := r[datum]
	return ok
}

func FindDuplicate[T AllowedData](data []T) bool {
	inArray := Filter[T]{}
	for _, datum := range data {
		if inArray.has(datum) {
			return true
		}
		inArray.add(datum)
	}
	return false
}

Slice元素查找 #

在Golang中,我们通常使用for循环来遍历一个Slice,并进行一些操作。例如,我们要查找一个Slice中是否存在某个元素,可以使用以下代码:

func FindString(slice []string, elem string) bool {
    for _, v := range slice {
        if v == elem {
            return true
        }
    }
    return false
}

func main() {
    slice := []string{"foo", "bar", "baz"}
    elem := "bar"
    fmt.Println("Is", elem, "in the slice?", FindString(slice, elem))
}

这段代码很简单,但是如果我们想查找的是其他类型的Slice,我们又需要写一个相同的函数。而使用泛型机制,我们只需要写一个函数即可实现对任意类型的Slice元素的查找。

func Find[T comparable](slice []T, elem T) bool {
    for _, v := range slice {
        if v == elem {
            return true
        }
    }
    return false
}

func main() {
    slice := []string{"foo", "bar", "baz"}
    elem := "baz"
    fmt.Println("Is", elem, "in the slice?", Find(slice, elem))

    ints := []int{1, 2, 3, 4, 5}
    num := 2
    fmt.Println("Is", num, "in the slice?", Find(ints, num))
}

泛型版的Find函数可以接受任意可比较类型的Slice,并能够正确地查找元素。

Map操作 #

与Slice类似,Map也是Golang中常用的数据类型之一。在处理Map时,我们通常需要对Map中的每个键值对进行操作,例如计算总和或查找最大值。以下是非泛型版的代码:

func FindMax(m map[string]int) (string, int) {
    maxKey := ""
    maxVal := 0

    for k, v := range m {
        if v > maxVal {
            maxKey = k
            maxVal = v
        }
    }

    return maxKey, maxVal
}

func main() {
    aMap := map[string]int{"foo": 1, "bar": 2, "baz": 3}
    key, val := FindMax(aMap)
    fmt.Println("Max in aMap is", key, val)

    bMap := map[int]string{1: "foo", 2: "bar", 3: "baz"}
    key2, val2 := FindMax(bMap)
    fmt.Println("Max in bMap is", key2, val2)
}

在这个例子中,我们针对不同类型的Map,需要编写多个具体实现的函数。使用泛型技术,我们只需要编写一个通用的函数即可解决问题:

type ordered interface {
	int | string
}

func TheMax[T comparable, U ordered](m map[T]U) (T, U) {
    var maxKey T
    var maxVal U

    first := true
    for k, v := range m {
        if first || v > maxVal {
            maxKey = k
            maxVal = v
            first = false
        }
    }

    return maxKey, maxVal
}

func main() {
    aMap := map[string]int{"foo": 1, "bar": 2, "baz": 3}
    key, val := TheMax(aMap)
    fmt.Println("Max in aMap is", key, val)

    bMap := map[int]string{1: "foo", 2: "bar", 3: "baz"}
    key2, val2 := TheMax(bMap)
    fmt.Println("Max in bMap is", key2, val2)
}

在泛型版的代码中,我们只需要一个函数即可处理不同类型的Map,相对于非泛型版代码更加简洁和易懂。

实战:并发安全的栈 #

在实际开发中,我们通常需要使用栈数据结构。在多线程编程时,使用未同步的栈会引起并发安全问题。仅使用一些原始数据类型,例如int或string,还可以避免类型转换的问题。我们可以使用以下泛型元素的栈实现,这里使用sync.Mutex进行同步:

type Stack[T any] struct {
    mu    sync.Mutex
    stack []T
}

func (s *Stack[T]) Push(v T) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.stack = append(s.stack, v)
}

func (s *Stack[T]) Pop() (T, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()
    if len(s.stack) == 0 {
        return zeroVal(), false
    }
    index := len(s.stack) - 1
    res := s.stack[index]
    s.stack = s.stack[:index]
    return res, true
}

func zeroVal[T any]() T {
    return reflect.Zero(reflect.TypeOf(T{})).Interface().(T)
}

func main() {
    s := &Stack[int]{}
    s.Push(1)
    s.Push(2)
    s.Push(3)
    s.Push(4)
    s.Push(5)

    for {
        n, ok := s.Pop()
        if !ok {
            break
        }
        fmt.Println(n)
    }
}

在这个例子中,我们创建了一个Stack的通用类型,可以使用任何类型的元素。Push和Pop函数负责将元素压入和弹出Stack,并且使用sync.Mutex来保证线程安全。zeroVal函数用于创建零值,以便Pop在栈为空时返回默认值。在最后,我们对整个Stack做了一次Pop并打印结果。

总结 #

在Golang泛型机制的介绍中,我们展示了一些实际应用。从这些例子中可以看出,泛型机制可以极大地提高代码的可读性和可维护性,同时也增加了代码的灵活性和可重用性。尽管Golang的泛型机制与其他语言不太相同,但只要掌握了其中的约束类型和类型参数,就可以轻松应对各种类型的数据结构操作。