以下全文大部分翻译自go官方blog: Why Generics?。但是由于原文的泛型示例已经过时,所以译者在 最新的泛型示例 章节中尽量使用新的泛型语法重写了原作的例子,对不合适的示例进行了替换。

Why generics?

添加泛型意味着什么,我们为什么需要它呢?

套用Jazayeri等人的话:泛型编程支持以泛型形式表示函数和数据结构,并考虑类型。

什么意思?我们举一个简单的例子:反转slice中的元素。这不是很多程序需要做的事情,但也不是那么不寻常。

下面是int slice反转:

1
2
3
4
5
6
7
8
9
func ReverseInts(s []int) {
    first := 0
    last := len(s) - 1
    for first < last {
        s[first], s[last] = s[last], s[first]
        first++
        last--
    }
}

然后是string slice反转:

1
2
3
4
5
6
7
8
9
func ReverseStrings(s []string) {
    first := 0
    last := len(s) - 1
    for first < last {
        s[first], s[last] = s[last], s[first]
        first++
        last--
    }
}

可以看到ReverseInts和ReverseStrings几乎一摸一样,除了参数类型。对于这种情况gopher们肯定已经习以为常了。

然而对于gopher新人,会发现没有办法为各种类型的slice写一个Reverse方法。这在大部分类型的编程语言是可以做到的。

在动态类型语言如python和javascript中,实现这个方法很简单,根本不用关心参数类型。这样在go中是行不通的,因为go是静态类型语言,必须指定参数的类型。

大部分静态类型语言,如c++或者java、rust、swift等,支持泛型来解决这类问题。

当前go编程中的泛型

所以大家在go中如何编写这类代码?

我们可以编写一个接受接口类型参数的方法,然后为各种类型的slice添加接口约定的方法。标准库中的sort.Sort就是这么做的。换句话说,go中的接口也是泛型编程的一种方式,它允许我们提取不同类型的公共点并使用方法表达。我们可以编写使用那些接口类型作为参数的方法,任何实现了该接口的类型都可以使用这个方法。

但是这种方法没有达到我们的要求。使用接口,我们必须为各种类型编写接口约定的方法。为了一个简单的Reverse方法,我们需要为各种类型的slice编写接口约定的方法,而且这些方法内基本都是一样的代码。所以我们并没有完全解决重复代码的问题。尽管接口是泛型编程的一种方式,但是还不是完整的泛型。

另外一种使用接口实现泛型的方法,可以解决需要我们自己编写接口方法的问题,但是需要语言内置了相应的方法。目前go并不支持这些内置方法,我们假设go为各种类型的slice内置了Index方法,Index接受一个下标参数,返回slice中的元素。如果想要使用这个内置方法,这个内置方法就必须返回一个interface{}类型,这又使得go失去了静态语言的优势。

使用反射也能够写一个通用的Reverse方法,但是反射不但写起来繁琐,而且还会影响运行性能。

另外我们也可以通过代码生成,为不同类型的slice生成各自的Reverse方法。但是这使得构建工作变得复杂了,每个引用Reverse的包都需要对生成代码进行编译,生成代码的bug修复,需要重新编译所有引用方,这些引用方可能存在于不同的项目中。

对于Reverse这种简单的方法,以上那些方式还不如我们开头写的那样,为不同类型slice单独编写一个Reverse。但是我们需要为这些不同的Reverse编写单独的单元测试,然后挨个运行这些测试。

那么就没有办法解决了吗?当然不是!我们需要真正的泛型。

泛型可以为go带来些什么

首要的事情就是泛型可以让我们编写类似Reverse这样的方法,而且我们不需要关心slice中元素的类型。这样我们就可以解决重复代码、重复单元测试的问题,“write once, use anywhere”。

go中的泛型与C++的模版不完全相同,c++的模版支持的东西更多一些。

除了上面一直用作例子的Reverse,还有其他很多方法可以泛化,如下:

  1. 查找slice中最大、最小元素
  2. 计算slice中元素的平均值和标准差
  3. 计算map的交并集
  4. 查找图中的最短路径
  5. slice、map的转换方法

以上这些都是其他支持泛型语言常见的列子,还有一些与go并发编程相关的用途:

  1. 编写带有超时读取channel的方法
  2. 将两个channel合并为一个
  3. 并发调用函数列表,返回一个结果slice
  4. 使用Context调用函数列表,返回完成的第一个函数的结果,取消并清除其他的goroutine

像以上这些方法需要为不同的数据类型编写不同的方法。虽然并不难编写,但是如果可以复用同一个高效的方法就更好了。

上面说到的都是方法,数据结构也可以使用泛型。

Go在语言中内置了两种通用的泛型数据结构:slice和map。slice和map可以保存任何数据类型的value,这些value在保存和检索的时候使用静态类型检测。这些value存储为自身,并非interface类型。也就是[]int,这个slice中存储的就是int值,而不是转换为interface后的值。

slice和map是最有用的泛型数据结构,但他们不是唯一的。下面是一些其他的例子:

  1. Sets
  2. 平衡树
  3. Multimaps
  4. 支持并发读写的hashmap

如果我们可以使用泛型,我们就可以定义上面这些数据结构的泛型结构,具有和slice、map同样优势的类型检测:编译器可以静态地对这些结构中的value进行类型检测,value可以保存为自身,而非interface类型。

也可以将前面提到的算法,使用到泛型数据结构中。

这些示例都应该像Reverse:泛型方法和数据结构在一个包中编写一次,并在需要时重用。它们应该像slice和map一样工作,它们不应该存储接口类型的值,而应该存储特定类型,并且应该在编译时检查这些类型。

这就是go可以从泛型中获得的。泛型可以为我们提供强大的构建块,让我们更容易地共享代码和构建程序。

最新的泛型示例

以下示例使用go1.17运行,运行方法:在run或者build时增加-G=3编译参数,例如go run -gcflags=-G=3 main.go

首先我们看以下Reverse的泛型方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func reverse[T any] (s []T) {
	first := 0
	last := len(s) - 1
	for first < last {
		s[first], s[last] = s[last], s[first]
		first++
		last--
	}
}

func ReverseAndPrint(s []int) {
	reverse(s)
	fmt.Println(s)
}

可以看到方法体内是完全一样的,只是方法的签名不同了。切片的元素类型已经从具体的数据类型变为了T,也就是在泛型中所说的类型参数,它不再是slice参数类型的一部分,而是一个单独的、附加的类型参数。调用时只需要将slice类型的变量直接传入reverse方法即可,编译器可以自动识别slice中的元素类型。

除了slice参数的元素类型变为了T,方法名之后还增加了[T any],any是对类型参数的约束,any代表T可以是任意数据类型。

类型参数约束(constraints)

由于go是静态类型语言,我们不得不讨论类型参数的类型。这个元类型告诉编译器泛型方法允许什么样的参数,以及泛型方法可以对类型参数的值执行哪些操作。

reverse方法允许任何类型的数据,因为它只是进行赋值操作,任何类型的数据都支持。我们看一个有些不同的方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func find[T any] (s []T, v T) int {
    for i, e := range s {
		if v == e {
			return i
		}
	} 
	return -1
}

func FindAndPrint(s []int, v int) {
	i := find(s, v)
	fmt.Println(i)
}

当我们运行时会发现编译报错:nvalid operation: cannot compare v == e (operator == not defined on T),这是由于find方法中的类型参数约束是any,但是并不是所有数据类型都支持相等运算。将any改为comparable,万事大吉。comparable是go的内置约束类型,代表了可以做相等运算的数据类型(这与map中可以做key的数据类型相似,key也需要做相等运算)。

除了使用comparable等内置约束类型,我们也可以自定义约束类型,如下定义了Addable来约束可以做+运算的类型参数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
type Addable interface {
	type int , int8 , int16 , int32 , int64 , uint , uint8 , uint16 , uint32 , uint64 , 
	uintptr , float32 , float64 , complex64 , complex128 , string
}

func add[T Addable](a, b T) T {
	return a + b
}

func AddAndPrint(a, b int) {
	sum := add(a, b)
	fmt.Println(sum)

	sum1 := add("a", "b")
	fmt.Println(sum1)
}

在go源码master分支上已经创建了内置的constraints包,包括Signed、Unsigned、Integer、Float、Complex、Ordered等约束。

类型参数的方法约束

我们除了可以使用数据类型来定义约束,也可以使用方法来定义约束,如下面的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func join[T any](things []T) (result string) {
    for _, v := range things {
        result += v.String()
    }
    return result
}

func JoinAndPrint(s []int) {
	r := join(s)
	fmt.Println(r)
}

运行编译报错:v.String undefined (type bound for T has no method String),编译器告诉我们类型参数T没有String方法。我们定义一个需要String方法的约定Stringer,然后将类型参数的约定改为Stringer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type Stringer interface {
    String() string
}

func join[T Stringer](things []T) (result string) {
    for _, v := range things {
        result += v.String()
    }
    return result
}

func JoinAndPrint(s []int) {
	r := join(s)
	fmt.Println(r)
}

运行编译报错:int does not satisfy Stringer (missing method String),编译器告诉我们int没有String方法。我们为int定义一个新的类型并实现String方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
type Stringer interface {
    String() string
}

type INT int

func(i INT) String() string {
	return strconv.Itoa(int(i))
}

func join[T Stringer](things []T) (result string) {
    for _, v := range things {
        result += v.String()
    }
    return result
}

func JoinAndPrint(s []INT) {
	r := join(s)
	fmt.Println(r)
}

这和接口类型很像,虽然是否实现了接口也是在编译期确定,但是接口类型调用哪个方法是在运行期,而泛型则是在编译器就确定了。

泛型数据结构

最后我们看一个泛型数据结构的例子:二叉树。本示例中二叉树有一个比较方法属性,所以对二叉树中的元素数据类型没有要求:

1
2
3
4
5
6
7
8
9
type tree [T any] struct {
    root    *node[T]
    compare func(T, T) int
}

type node [T any] struct {
    val         T
    left, right *node[T]
}

下面是如何创建这个二叉树。new方法需要一个比较方法:

1
2
3
func new [T any] (cmp func(T, T) int) *tree[T] {
    return &tree[T]{compare: cmp}
}

find方法返回一个指针,指向包含v的位置,或者指向树中它应该放置的位置。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func (t *tree[T]) find(v T) **node[T] {
    pn := &t.root
    for *pn != nil {
        switch cmp := t.compare(v, (*pn).val); {
        case cmp < 0:
            pn = &(*pn).left
        case cmp > 0:
            pn = &(*pn).right
        default:
            return pn
        }
    }
    return pn
}

下面是检测树中是否包含指定元素的方法:

1
2
3
func (t *tree[T]) Contains(v T) bool {
    return *t.find(v) != nil
}

以下代码是插入一个元素的方法:

1
2
3
4
5
6
7
8
func (t *tree[T]) Insert(v T) bool {
    pn := t.find(v)
    if *pn != nil {
        return false
    }
    *pn = &node[T]{val: v}
    return true
}

可以看到,除了类型参数T需要出现在它要出现的位置,其他部分几乎与些普通的go代码一样。下面代码是使用这个二叉树:

1
2
3
4
5
6
7
8
var intTree = new(func(a, b int) int { return a - b })

func InsertAndCheck(v int) {
    intTree.Insert(v)
    if !intTree.Contains(v) {
        log.Fatalf("%d not found after insertion", v)
    }
}

写泛型数据结构比写泛型方法要困难一些,因为我们需要在各处显示地写出类型参数。不过这也只是一种特别的语法,多练习,熟悉后就好了。