从一个实际例子开始
有这样一个需求:
实现两个输入求和的能力,输入可能是 int32、int64、float32、float64
在 Go 1.18 之前,你可能会这样写:
func AddInt32(a, b int32) int32 {
return a + b
}
func AddInt64(a, b int64) int64 {
return a + b
}
func AddFloat32(a, b float32) float32 {
return a + b
}
func Addfloat64(a, b float64) float64 {
return a + b
}
或者借助 reflect
:
func AddByReflect(a, b interface{}) (interface{}, error) {
aValue := reflect.ValueOf(a)
bValue := reflect.ValueOf(b)
if aValue.Type() != bValue.Type() {
return nil, errors.New("invalid error")
}
switch aValue.Kind() {
case reflect.Int32, reflect.Int64:
return aValue.Int() + bValue.Int(), nil
case reflect.Float32, reflect.Float64:
return aValue.Float() + bValue.Float(), nil
default:
return nil, errors.New("invalid error")
}
}
从上面的例子可以看出,为了达到同样的功能适配不同的参数类型的目的,我们或者会重复的制造类似的函数,或者基于 interface + reflect 在运行时识别具体类型在做处理:
- 前者导致代码实现臃肿,且不优雅,大量的重复实现还会影响编译速度
- 而后者引入的运行时开销,则对性能有一定的冲击。
而泛型编程则是为了解决这一问题,比较成熟的语言,如 C++(template) 、Java (generic)早已给开发者提供了相应的能力,golang 社区也一直在致力于解决这个问题,终于在 golang 1.18 支持:
Go 1.18 includes an implementation of generic features as described by the Type Parameters Proposal.
注释: Type Parameters Proposal 有泛型的完整阐释,详细的描述的golang 泛型设计过程中的一些思考与抉择,非常推荐阅读
上述例子在 Golang 1.18,则可以这么写:
func AddByGeneric[T int32| int64 | float32 | float64](a , b T) T{
return a + b
}
泛型函数
上面的例子中,我们使用的方式是泛型函数(generic function),AddByGeneric
即是一个泛型函数,我们先来看一些新的概念。
- T 是 type parameter,实质上是个占位符
- int32| int64 | float32 | float64 是 type constraint,约束了 T 的类型范围,使用时,传入的具体类型被称为 type argument
- 泛型函数要实例化(instantiations)后才可以使用
-
int32 | int64
是一种新的语法结构,叫做 union element
【TIP】type constraint 为什么选择使用 []?
1. () 函数入参和返回等都是圆括号,容易搞混
2. <> 容易和 <, > 容易搞混,实现时还要考虑兼容,成本也较高
3. 《》非 ASCII 码不考虑
泛型类型
除了泛型函数外,go 的泛型还支持泛型类型(generic type),再来看一个例子:
// Vector is a name for a slice of any element type.
type Vector[T any] []T
上面的例子中,Vector 即一个泛型类型,同泛型函数一样,基于 type parameter,使用时需要传入具体的 type argument 实例化,泛型类型也可以拥有方法(method):
type Vector[T any] []T
func (v *Vector[T]) Push(x T) {
*v = append(*v, x)
}
func (v *Vector[T]) PushList(x []T) {
*v = append(*v, x...)
}
func useVector(){
var v Vector[int64]
fmt.Println("before push:", v)
v.Push(1)
v.PushList([]int64{2,3,4})
fmt.Println("after push:", v)
}
但是,非泛型类型的的方法中不能使用 type parameter,eg:
Constraint
再看一个例子,以下使用方式在 golang 是不合法的:
在上面的代码中,T 有可能没有 String
方法,所以会存在问题,这是所有实现泛型的语言都要面对的一个问题,C++ 中「可以这么写的」,但是会在编译时报错,而且为了找到这个错误的根因要打印非常长的调用栈,也不怎么优雅。
Golang 没有采用类似的机制,原因是:
- One reason is the style of the language.
- Another reason is that Go is designed to support programming at scale.
这里提现了 golang 在设计泛型时的原则:
This is an important rule that we believe should apply to any attempt to define generic programming in Go: generic code can only use operations that its type arguments are known to implement.
Any
上面的例子中出现了一个新的关键字 any,其实际上是空接口的别名,也就是说在在 go1.18 以后,所有使用空接口的地方都可以使用 any 替换(后面会更详细的展开 interface 的讨论)。
// any is an alias for interface{} and is equivalent to interface{} in all ways.
type any = interface{}
Interface: Method Set -> Type Set
回顾上面的例子(使用 any 的例子,而 any 本质是个空 interface ),我们会使用 interface 做作为 constraint,而 1.18 前 interface 本质是a set of methods
,即一组方法的集合,这也就限定了我们使用任意 type 只能用来实现方法调用,但是方法调用并不能满足我们全部的变成场景,我们还会使用 operator,来看一个使用 operator 的例子:
为了解决以上问题,golang 引入了新概念 type set,即一组类型的集合,而 interface 的定义也悦然一新:
An interface type defines a type set (一个接口类型定义了一个类型集)
PS:其实从之前的定义来看(method set),也可以理解成 type set,即实现了这 method 的类型的集合
相应的,我们可以这样定义一个这样的 interface,在泛型编程时用作 constraint:
// SignedInteger is a constraint that matches any signed integer type.
type SignedInteger interface {
int | int8 | int16 | int32 | int64
}
func Smallest[T SignedInteger](s []T) T {
r := s[0] // panic if slice is empty
for _, v := range s[1:] {
if v < r {
r = v
}
}
return r
}
新符号 ~
假设我们定义了一个类型 type MyInt64 Int64,在上述Smallest
中是行不通的,因为 constraint 中只有 int64 而没有 MyInt64:
所以,go 1.18 提供了一个新的符号~
来描述所有底层都是这一基础类型的所有类型,新的SignedInteger
定义如下,这时 Smallest 方法就是接受 MyInt64 型的 argument了。
type SignedInteger interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
使用上有一些需要注意的地方:
- ~ 只支持基本类型(most predeclared types)
- 不支持 type parameter 或者是 interface type
comparable 和 ordered
Golang 在 1.18 还新增加了一个内置关键字comparable
,用来解决使用 「==」 和「!=」 这两种 operator 的场景
// comparable is an interface that is implemented by all comparable types
// (booleans, numbers, strings, pointers, channels, arrays of comparable types,
// structs whose fields are all comparable types).
// The comparable interface may only be used as a type parameter constraint,
// not as the type of a variable.
type comparable interface{ comparable }
comparable
只能被用作 type parameter 的 constraint,不能用来声明变量,这里是不是和 interface 之前的用法有些矛盾呢?确实,按1.18 以前的逻辑,这里是冲突的,所以在 1.18 后,为了兼容泛型的实现,golang 在 interface 上还有很多变化,不仅仅是 type set,下个段落我们详细展开 interface 聊聊。
注意,comparable 是不包含 「<」 、「+」这些 operator,对于这类operator,golang 也提供了一个额外的库(见后文)支持:
type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
type Unsigned interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
type Integer interface {
Signed | Unsigned
}
type Float interface {
~float32 | ~float64
}
type Ordered interface {
Integer | Float | ~string
}
回过头来再看 interface
完整官方说明:https://go.dev/ref/spec#Interface_types
上文我们提到 interface 变成了 type set,此外还有比较多的概念,首先是 interface 有不同的类型定义:
- Basic interfaces:这个比较好理解,即1.8 版本之前的接口,只包含 method
- Embedded interfaces:一个 interface T 中嵌入了另一个 interface E,即 interface 中包含其他 interface
type Reader interface {
Read(p []byte) (n int, err error)
Close() error
}
type Writer interface {
Write(p []byte) (n int, err error)
Close() error
}
// ReadWriter's methods are Read, Write, and Close.
type ReadWriter interface {
Reader // includes methods of Reader in ReadWriter's method set
Writer // includes methods of Writer in ReadWriter's method set
}
// 注意:嵌入接口时,同名 method 需要有相同的函数签名,否则不合法
type ReadCloser interface {
Reader // includes methods of Reader in ReadCloser's method set
Close() // illegal: signatures of Reader.Close and Close are different
}
- General interface:既包含任意的类型 T、或者 ~T、T1|T2|T3|...,同时包含 method,要注意都是,这种接口是不能用来定义变量的,只能在泛型场景使用。
interface 的 Implementing 语义也发生了变化,当满足以下条件时,我们可以说 类型 T 实现了接口 I ( type T implements interface I):
- T 不是接口时:类型 T 是接口 I 代表的类型集中的一个成员 (T is an element of the type set of I)
- T 是接口时: T 接口代表的类型集是 I 代表的类型集的子集(Type set of T is a subset of the type set of I)
官方提供的一些泛型库
golang.org/x/exp/constraints
Constraints that are useful for generic code, such as constraints.Ordered.golang.org/x/exp/slices
A collection of generic functions that operate on slices of any element type.golang.org/x/exp/maps
A collection of generic functions that operate on maps of any key or element type.
泛型的实现原理
根据Russ Cox的观察,实现泛型至少要面对下面三条困境之一,那还是在2009年:
- Leave them out(slow programmers):比如C语言,增加了程序员的负担,需要曲折的实现,但是不对增加语言的复杂性
- Compile-time specialization or macro expansion(slow compilers): 比如C++编程语言,增加了编译器的负担,可能会产生很多冗余的代码,重复的代码还需要编译器斟酌删除,编译的文件可能非常大(Rust的泛型也属于这一类)。
- Box everything implicitly(slow execution times):比如Java,将一些装箱成Object,进行类型擦除。虽然代码没啥冗余了,空间节省了,但是需要装箱拆箱操作,代码效率低。java 主要借助 “Type Erasure” 实现
在 type parameter 的提案中有提到,golang不会是 slow programmers
,所以会在slow compilers
和 slow execution times
中做选择
In other words, this design permits people to stop choosing slow programmers, and permits the implementation to decide between slow compilers (compile each set of type arguments separately) or slow execution times (use method calls for each operation on a value of a type argument).
找到一篇大佬的分析资料,golang 在1.18 中实际使用的是一种 GC Shape Stenciling 的方案,更多分析参考:https://colobu.com/2021/08/30/how-is-go-generic-implemented/
总结
- 泛型函数&泛型类型,以及 constraint
- 新符号 ~ 和 |
- 新的关键字 any、comparable
- 焕然一些的 Interface(type set)
- 官方库
- 实现原理
参考
- 官方提案:https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md
- 官方 interface 的说明:https://go.dev/ref/spec#Interface_types
- 一篇比较全面的介绍:https://segmentfault.com/a/1190000041634906
https://colobu.com/2021/08/30/how-is-go-generic-implemented/ - 分析golang 泛型实现原理都blog:https://colobu.com/2021/08/30/how-is-go-generic-implemented/
- 一篇 go 语言设计哲学的思考:https://golang3.eddycjy.com/posts/generics-history/