2022年2月12日

Go 泛型初步

摘  要

Go 1.18 版本之后正式引入泛型,它被称作类型参数(type parameters),本文初步介绍 Go 中泛型的使用。
关键词: go 泛型 类型参数

1. Go 的泛型

长期以来 go 都没有泛型的概念,只有接口 interface 偶尔类似的充当泛型的作用,然而接口终究无法满足一些基本的泛型需求,比如

(1). 函数体内需要对参数做运算而不是使用接口方法,如下的写法连编译都不可行。

// Sum 函数尝试对输入的任意多个参数求和。
// 然而 interface{} 不可以做加法,这段代码是不能编译的
func Sum(values ...interface{}) interface{} {
	var sum interface{}
	for _, v := range values {
		sum += v
	}
	return sum
}

(2). 使用接口常常存在极其令人厌恶的接口转换,一个例子是标准库 container/heap。Pop 方法返回值几乎总是需要在逻辑上再转换为 Push 时传入的类型,这使得代码不仅丑陋而且低效(曾经因为 interface{} 实际是 int 类型,但是因为类型转换导致大量的内存分配次数)

// Push pushes the element x onto the heap.
// The complexity is O(log n) where n = h.Len().
func Push(h Interface, x interface{}) {
	// ...
}

// Pop removes and returns the minimum element (according to Less) from the heap.
// The complexity is O(log n) where n = h.Len().
// Pop is equivalent to Remove(h, 0).
func Pop(h Interface) interface{} {
	// ...
}

因为没有泛型而带来的其他问题就不一一列举,相信许多开发者都有遇到需要泛型的场景。从 go 1.18 版本开始,将正式引入泛型,官方称谓叫做类型参数 type parameter,由于各种原因,现阶段的泛型比起一些流行语言中的泛型功能上还是差很多,不过总比没有好了。目前泛型主要使用的方式有两类:函数的类型参数,类型的类型参数。

2. 安装 go 1.18 以上的版本

在 go1.18 尚未正式发布时可以通过如下命令安装 beta 版本体验

go install golang.org/dl/go1.18beta2@latest
go1.18beta2 download

此后可以使用 go1.18beta2 命令取代原来的 go 命令编译支持泛型的代码。

3. 函数类型参数

3.1. 泛型版本的求和函数

仍以求和函数为例,泛型版本的写法如下:

import (
	"golang.org/x/exp/constraints"
)

func Sum[T constraints.Integer](values ...T) T {
	var sum T
	for _, v := range values {
		sum += v
	}
	return sum
}

constraints 原本是放在标准库的包,但是近期被移除了,改到了 x/exp 中,参见 #50792

这个版本实现了对任意多个同类型的整数求和。Sum 后面的中括号 [] 内就是定义类型参数的地方,其中 T 为类型参数名,constraints.Integer 是对该类型参数的约束,即 T 应该满足的条件,在这里我们要求 T 是一个整数。剩下的代码就和普通没有泛型的代码一致了,只不过后面 T 可以当作一个类型来使用。

go 的泛型参数为什么不使用其他流行语言的 < > 定义泛型?这个主要是会引起语法上的歧义,比如下面这一段代码

x, y := a < b, c > (d)

如果没有足够的类型信息,将无法判定是两个比较表达式还是一个范型函数调用,导致语法树分析阶段出现歧义。

现在可以来使用一下刚才定义的 Sum 方法:

func main() {
	fmt.Println(Sum(1, 2, 3))

	var ints = []int{1, 2,3}
	fmt.Println(Sum(ints...))

	var int32s = []int32{-1, 2,3}
	fmt.Println(Sum(int32s...))

	var uint32s = []uint32{1, 2,3}
	fmt.Println(Sum(uint32s...))

	// 调用 Sum 函数时也可以将类型参数带上,只是经常都能够通过实际参数
	// 类型推断类型参数,所以常常省略
	fmt.Println(Sum[uint32](uint32s...))
}

这个版本仍有一些问题,比如可以做加法的不止整数啊,还有浮点数,甚至是复数。修改类型参数 T 的约束来支持浮点数和复数:

import (
	"golang.org/x/exp/constraints"
)

func Sum[T constraints.Integer | constraints.Float | constraints.Complex](values ...T) T {
	var sum T
	for _, v := range values {
		sum += v
	}
	return sum
}

func main() {
	fmt.Println(Sum(1.0, 2.0, 3.5))
}

通过符号 | 连接多个约束表示 T 只需满足其中任意一个。

Sum 函数的例子只用了一个类型参数,go 的类型参数也支持多个,这个定义和函数参数的格式类似。

func FuncA[T, U any]() {
	// ...
}

func FuncB[T any, U, V comparable]() {
	// ...
}

接下来通过几个简单的例子熟练一下泛型函数的使用。

3.2. 使用泛型实现一个类似脚本语言(比如 javascript)的或运算

这个例子用于判定 a 是否为 zero 值,如果是则返回 b,反之返回 a。

func Or[T comparable](a, b T) T {
	var zero T
	if a == zero {
		return b
	}
	return a
}

func doSomething(x int, y, z string) {
	fmt.Println(Or(x, 1))
	fmt.Println(Or(y, "default"))
	fmt.Println(Or(z, createString()))
}

func createString() string {
	return "hello"
}

func main() {
	doSomething(0, "", "")
	doSomething(12, "y", "z")
}

不过不同于一般的或运算,这里 Or(a, b) 时 b 的值已经确定,如果 b 是一个函数调用,那么当 a 不是 zero 值时,b 的函数调用完全浪费了。

javascript 中的 a || b() 不同于此处的 Or(a, b()),前者在 a 非空时不会调用函数 b

可以再实现一个延迟函数调用的版本 OrNew 处理这种情况:

func Or[T comparable](a, b T) T {
	var zero T
	if a == zero {
		return b
	}
	return a
}

func OrNew[T comparable](a T, new func()T) T {
	var zero T
	if a == zero {
		return new()
	}
	return a
}

func doSomething(x int, y, z string) {
	fmt.Println(Or(x, 1))
	fmt.Println(Or(y, "default"))
	fmt.Println(OrNew(z, createString))
}

func createString() string {
	return "hello"
}

func main() {
	doSomething(0, "", "")
	doSomething(12, "y", "z")
}

3.3. 使用泛型实现三元条件运算

go 语言不存在三元条件运算符 <condition>? value1 : value2,导致经常存在需要这种场景时只好用 if 写好几行的代码,不过现在可以通过泛型实现一个条件运算了。

func If[T any](yes bool, a, b T) T {
	if yes {
		return a
	}
	return b
}

func IfNew[T any](yes bool, a, b func() T) T {
	if yes {
		return a()
	}
	return b()
}

func createA() string { return "a" }
func createB() string { return "b" }

func main() {
	var a = true
	var b = false
	fmt.Println(If(a, 1, 2))
	fmt.Println(IfNew(b, createA, createB))
}

4. 类型泛型

4.1. 类型泛型的基本使用方法

以一个 c++ 的 std::pair 为例,来说明 go 的类型泛型的使用。pair 包含 first 和 second 两个成员,并且每一个都有独立的类型,所以我们需要两个类型参数,先看代码:

type Pair[T1, T2 any] struct {
	First  T1
	Second T2
}

func MakePair[T1, T2 any](first T1, second T2) Pair[T1, T2] {
	return Pair[T1, T2]{First: first, Second: second}
}

func (pair Pair[T1, T2]) Elements() (T1, T2) {
	return pair.First, pair.Second
}

在定义 Pair 时在类型名称之后使用 [T1, T2 any] 定义了类型参数,即 T1, T2 都可以是任意类型。

然后定义了泛型函数 MakePair 用于创建 Pair 对象,函数的返回值类型为 Pair[T1, T2]。

最后实现了 Pair 的成员方法 Elements 返回两个成员值,这个函数看起来很无聊,似乎没什么用,就是用来展示如何定义泛型类型的成员方法。和一般的类型的成员方法的定义的区别在于类型 Pair 之后必须要使用声明 Pair 类型时定义的类型参数(就是这里的 [T1, T2])。

另外 go 的泛型目前不支持给成员方法声明新的类型参数,比如这种成员方法的定义就不允许:

// Bad: 成员方法后面不能声明类型参数
func (pair Pair[T1, T2]) Something[T any]() {}

除了 struct 之外,interface 的定义也支持类型参数(但是它的接口方法不支持类型参数),但是 type alias 不支持类型参数

type Interface[T any] interface {
	// ...
}

type User interface {
	// ...
}

// 自己定义的接口 User 可用作类型参数的约束
type InterfaceTwo[T any, U User] interface {
	// ...
}

type IntPair Pair[int, int]

type Slice[T any] []T

// Bad: 这个不允许
type Vector[T any] = []T

类型约束除了内置的 any, comparable 以及 golang.org/x/exp/constraints 中定义的之外,也可以使用自己定义的任意接口用作约束,就像上例中的 User。另外现在除了以前概念中的 interface 定义之外,还有一种纯粹只能用于类型参数约束的 interface。像这类使用了基础类型或者 | 运算的接口。

// 实数约束 Real 只能用于类型参数约束,而不能作为普通参数或变量类型。
type Real interface {
	constraints.Integer | constraints.Float
}

// Number 包含一个只能用于约束的接口,所以也只能用于类型参数的约束了
type Number interface {
	Real
	Cat()
}

type Float interface {
	~float32 | ~float64
}

type String interface {
	~string
}

type PureString interface {
	string
}

// Name 满足 String 约束,但是不满足 PureString
type Name string

go 1.18 开始引入一个新的符号 ~ 用于约束前缀,这表示该约束包含 underlying 为该类型的参数。比如上面的 Name 类型的 underlying 是 string,所以 Name 也满足 String 约束,但是不满足 PureString 约束。

4.2. 实现一个通用的事件系统

有了类型泛型可以实现一个比较实用的功能:事件派发系统。

首先我们需要定义一个事件接口:

// Event 是一个事件接口,类型参数 T 表示事件类别的数据类型,比如可以使用
//
//	string
//	int
//	...
//
// 该接口定义 Type 方法获取事件类别
type Event[T comparable] interface {
	Type() T
}

然后定一个事件处理接口 Listener,同时为了使用方便实现一个内置的 listener

// Listener 接口用于处理被触发的事件
type Listener[T comparable] interface {
	EventType() T
	Handle(Event[T])
}

// Listen 创建一个 Listener 对象
func Listen[T comparable, E Event[T]](eventType T, handler func(E)) Listener[T] {
	return listenerFunc[T, E]{eventType, handler}
}

type listenerFunc[T comparable, E Event[T]] struct {
	eventType T
	handler   func(E)
}

func (h listenerFunc[T, E]) EventType() T {
	return h.eventType
}

func (h listenerFunc[T, E]) Handle(event Event[T]) {
	if e, ok := event.(E); ok {
		h.handler(e)
	} else {
		panic(fmt.Sprintf("unexpected event %T for type %v", event, event.Type()))
	}
}

上面这段代码需要特别说明一下 Listen 函数,该函数有 2 个类型参数 T 和 E,前者是事件类别的类型参数,后者是事件类型参数,而 E 的约束 Event[T] 中依赖了前一个泛型参数,这样一来事件处理函数 handler 的参数就不再是 Event 接口而是一个泛型参数了,这避免了每次在回调函数中进行一次类型转换(因为已经统一在 listenerFunc.Handle 中转换了)。比如以前经常是这样写回调函数

func onSomething(event Event) error {
	somethingEvent, ok := event.(*SomethingEvent)
	if !ok {
		return errors.New("unexpected event type")
	}
	// doSomething with somethingEvent
}

而现在回调函数就可以避免每次手动转换类型了

func onSomething(event *SomethingEvent) error {
	// doSomething with event
}

接下来实现事件派发管理器 Dispatcher。Dispatcher 需要实现事件注册(Add),删除(Remove),检查(Has)和派发(Dispatch) 方法。

// Dispatcher 管理事件注册与派发
type Dispatcher[T comparable] struct {
	nextid    int
	listeners map[T][]Pair[int, Listener[T]]
	mapping   map[int]Pair[T, int]
}

// AddEventListener 注册事件回调
func (dispatcher *Dispatcher[T]) AddEventListener(listener Listener[T]) int {
	if dispatcher.listeners == nil {
		dispatcher.listeners = make(map[T][]Pair[int, Listener[T]])
		dispatcher.mapping = make(map[int]Pair[T, int])
	}
	dispatcher.nextid++
	var id = dispatcher.nextid
	var eventType = listener.EventType()
	var listeners = dispatcher.listeners[eventType]
	var index = len(listeners)
	dispatcher.listeners[eventType] = append(listeners, MakePair(id, listener))
	dispatcher.mapping[id] = MakePair(eventType, index)
	return id
}

// HasEventListener 判定是否存在事件回调
func (dispatcher *Dispatcher[T]) HasEventListener(id int) bool {
	if dispatcher.mapping == nil {
		return false
	}
	_, ok := dispatcher.mapping[id]
	return ok
}

// RemoveEventListener 删除事件回调
func (dispatcher *Dispatcher[T]) RemoveEventListener(id int) bool {
	if dispatcher.listeners == nil {
		return false
	}
	index, ok := dispatcher.mapping[id]
	if !ok {
		return false
	}
	var eventType = index.First
	var listeners = dispatcher.listeners[eventType]
	var last = len(listeners) - 1
	if index.Second != last {
		listeners[index.Second] = listeners[last]
		var newId = listeners[index.Second].First
		dispatcher.mapping[newId] = MakePair(eventType, index.Second)
	}
	listeners[last].Second = nil
	dispatcher.listeners[eventType] = listeners[:last]
	delete(dispatcher.mapping, id)
	return true
}

// DispatchEvent 派发事件
func (dispatcher *Dispatcher[T]) DispatchEvent(event Event[T]) bool {
	if dispatcher.listeners == nil {
		return false
	}
	listeners, ok := dispatcher.listeners[event.Type()]
	if !ok || len(listeners) == 0 {
		return false
	}
	for i := range listeners {
		listeners[i].Second.Handle(event)
	}
	return true
}

至此,一个基本的事件系统就完成了,接下来看看如何使用。

// 这个例子中事件的 Type 使用 string 类型
type testEventA struct {}
type testEventB struct {}

func (testEventA) Type() string { return "A" }
func (testEventB) Type() string { return "B" }

func main() {
	var dispatcher Dispatcher[string]

	// 注册事件,listener 通过 Listen 方法构建
	dispatcher.AddEventListener(Listen("A", func(e testEventA) {
		fmt.Println("test event 'A' fired")
	}))
	dispatcher.AddEventListener(Listen("B", func(e *testEventB) {
		fmt.Println("test event 'B' fired")
	}))

	// 派发事件,注意由于通过 Listen 注册的时候回调函数的参数
	// 没有使用指针,所以这里派发事件时也不能用 testEvent 的指针。
	// 这两者的类型必须要一致
	dispatcher.DispatchEvent(testEventA{})

	// 事件 B 的类型就需要指针了,因为注册时使用了指针。
	dispatcher.DispatchEvent(new(testEventB))
}

除了这个例子中的使用 string 作为事件类别的类型外,还可以使用整数,或其他任意可比较的类型。

在 go1.18beta 版本中还可以使用 reflect.Type 当作可比较类型,然而 go1.18 正式版已经不可以使用 reflect.Type 了。

事件系统的完整代码可参见 github.com/gopherd/doge/blob/main/event/event.go

5. 结语

总体来说,go 的泛型功能还是较少的,使用限制较多。另外 go 1.18 版本的泛型存在一个严重的性能问题:范型参数存在不必要的内存逃逸,而且执行速度低下,在 go 1.19 的 Milestone 中已经有提交来修正这个问题了(#50182)。然而内存逃逸的问题修复了,性能却仍然比非范型的版本差。

目前建议只在满足以下条件之一的时候使用范型:

  • 普通基础类型用作类型参数约束
  • 参数类型约束没有成员方被调用
  • 对性能没有极致要求