试试 Golang 的泛型
最近Golang发布了1.18版本,其中一个很大的新功能就是泛型(generics)的支持,
这个也算是社区中呼唤的比较久的功能了。官方称早在2010年就在尝试支持吃,
Proposal应该是在2021年1月正式提出:Type Parameters Proposal。
有时候在用了一段Rust或TypeScript后,再回来写Golang代码时,对于泛型的需求会更加强烈。
何为泛型?
简单来说,定义(函数、结构体等)时用类型占位,在使用时确定具体类型。 泛型能让代码的复用性提高很多,而且对于有利于逻辑的抽象。
尝试一用
假设我们现在需要实现一个函数,用来返回数组中的最大值,其中数组的值类型可能是所有能够比较大小的类型。 没有泛型的支持下,在Golang中可能是以下的几种函数签名,
- 分别定义函数
func maxIntFrom(items []int) int {}
func maxCharFrom(items []char) char {}
// ...
// 如果需要支持其他值类型,则需要分别定义
- 使用
interface{}
func maxFrom(items []interface{}) interface{} {}
可以看出,方法1是没办法统一调用函数的,而且不能复用判断逻辑。
方法2虽然看上去是统一了调用函数,但是其本质还是方法1。
而且引入了interface{},增加参数转换和运行时的类型判断。
另外,[]interface{}并不是严格预期,我们需要接收的参数是,
所有能比较大小类型的数组。当然了,由于Golang中Trait这类概念,
所以这一点是必然没办法达做到的。
在尝试用Golang的泛型之前,先看一下在Rust中是如何解决,
fn largest<T: PartialOrd>(items: &[T]) -> T {
其中T作为类型的占位符,在函数名后表明,然后参数中的数组的值类型是T,
函数返回值类型也是T,如此就可以了。至于T: PartialOrd是Rust中的对于
T的限定的表示,PartialOrd是能够进行大小比较的Trait。
完整的Rust函数代码参考下面,
fn largest<T: PartialOrd + Copy>(items: &[T]) -> T {
let mut one = items[0];
for &item in items {
if item > one {
one = item
}
}
one
}
使用时,如下这样,
fn main() {
println!("{}", largest::<i32>(&[1,2,3]));
println!("{}", largest(&['a', 'b', 'c']));
}
function_name::<具体类型>,在这里编译时,会把具体类型去做使用。
当然了,由于Rust可以自主推导类型,调用时也可以不用指明。
真正用于比较的逻辑,只需要实现一遍就可了。
对比着看Rust,我们回到Golang中来。我们发现一个问题,Trait在Golang可以理解为interface{}接口类型,
那么如何来对T进行限定呢?
其实也很简单,我们回到问题的本质是支持多种值类型的数组,只需要用多个类型来限定T就可以了。
按照上面的思路函数签名应该是,
func largest[T int | byte](items []T) T {}
完整的代码实现如下,
func largest[T int | byte](items []T) T {
one := items[0]
for _, item := range items {
if item > one {
one = item
}
}
return one
}
func main() {
println(largest[int]([]int{1,2,3}))
println(largest([]byte{'a','b','c'}))
}
可以看到Golang中使用时也是可以省略T的具体类型表示,由编译器自行推导处理。
🎉 看起来还不错。
这就Go(够)了吗?
只能每次都的类型约束都只能在一个个写吗?
这一点,Golang已经考虑到了,可以让我们定义类型约束,当然还是通过万能的interface{},
type Comparable interface {
int | byte
}
func largest[T Comparable](items []T) T {}
再来看看,已经和Rust很像啦。只不过,Comparable只是一个类型的集合而已。
能不能再进一步,做到和Rust一毛一样,用Trait(类型接口)来做类型约束?当然是可以的,
不然也太鸡肋了。
type Stringer interface {
String() string
}
func echo[T Stringer](s T) {}
这个和之前的接口类型参数用法有什么区别?
func echo(s Stringer) {}
我们可以这样来理解,之前需要自己去定义Stringer,然后让其他结构体来实现,
才能够满足函数的使用。对于其他外部的依赖,就不能传进来了。
那么现在这个可以说是,一个方法的集合,不管是外部的结构体(接口)还是自己实现的, 只要包含约束类型的方法就可了。
这样一来,就基本差不多了。
再看一看
现在回过头来结合Type Parameters Proposal看类型约束,
- 声明类型,
int | byte - 接口,
Stringer - 任意类型,
interface{}
我们再来看一个问题,
func Print1[T1, T2 any](s1 []T1, s2 []T2) {}
func Print2[T any](s1 []T, s2 []T) { ... }
Print1参数的2个数组值类型可以是不同类型的,因为泛型不同T1和T2,
而Print2则需要2个数组值类型则必须为统一类型,因为泛型都是泛型T
所以,类型是看泛型类型,而不是约束类型。这也是泛型的一大用处,用于约束不同位置的类型。
我有一个工具库函数是效仿Rust的迭代器(Iter)的,之前的由于没有泛型,
只能通过interface{}来支持不同的值类型,所以先断言成[]interface{}才能继续后面的操作。具体的代码是实现如下,
type Iter struct {
items []interface{}
// 实际实现均为 []
filters func(interface{}) bool
maps func(interface{}) interface{}
err error
}
func (i *Iter) Filter(f func(interface{}) bool) *Iter {
i.filters = f
return i
}
func (i *Iter) Map(f func(interface{}) interface{}) *Iter {
i.maps = f
return i
}
func (i *Iter) Collect() []interface{} {
// cache
res := []interface{}{}
for _, item := range i.items {
if i.filters != nil && !i.filters(item) {
continue
}
if i.maps != nil {
v := i.maps(item)
res = append(res, v)
} else {
res = append(res, item)
}
}
return res
}
func (i *Iter) Error() error {
return i.err
}
func NewIter(items interface{}) *Iter {
iter := &Iter{}
// ignore is not a slice or array
v := reflect.ValueOf(items)
if v.Kind() != reflect.Slice {
iter.err = errors.New("Iteror value not supported")
return iter
}
for i := 0; i < v.Len(); i++ {
iter.items = append(iter.items, v.Index(i).Interface())
}
return iter
}
那么,在泛型支持下,我们应该如何实现呢
type Iter[T any] struct {
items []T
cursor int
size int
}
// TODO
由于不能在方法上使用泛型,所以不能很好的实现。
我们再看个例子,还是我之前写的一个工具库中的例子,
由于Golang没有三元表达式或者Default,所以只能写if判断。
所以我写了一个判断链的用法,
type Value struct {
val interface{}
cond bool
}
// NewValue create the value for expression
func NewValue(v interface{}) *Value {
return &Value{
val: v,
cond: true,
}
}
// 省略各种类型的取值方法
// Interface ...
func (v *Value) Interface() interface{} {
return v.val
}
// Or Value(a).Or(-1)
func (v *Value) Or(r interface{}) *Value {
// check if is else
// check if val is nil or zero value
if !v.cond || v.val == nil || reflect.ValueOf(v.val).IsZero() {
v.val = r
}
return v
}
// If Value(a).If(a == 1).Or(0)
// can accept a func() bool
func (v *Value) If(r bool) *Value {
v.cond = r
return v
}
可以看出来,很不好用,需要用reflect,还需要手动取值类型。
那么有了泛型后实现会不会精简很多呢,我们直接看效果。
type Value[T comparable] struct {
val T
cond bool
zero T
}
// NewValue create the value for expression
func NewValue[T comparable](v T) *Value[T] {
return &Value[T]{
val: v,
cond: true,
}
}
func (v *Value[T]) Value() T {
return v.val
}
// Or Value(a).Or(-1)
func (v *Value[T]) Or(r T) *Value[T] {
// check if is else
// check if val is nil or zero value
if !v.cond || v.zero == v.val {
v.val = r
}
return v
}
// If Value(a).If(a == 1).Or(0)
// can accept a func() bool
func (v *Value[T]) If(r bool) *Value[T] {
v.cond = r
return v
}
看起来精简太多了。而且不用reflect调用,也不用各种取值方法。
顺便测试了一下性能,比用iterface{}的方式好约6倍,当然还是不及直接写快。
不过这并不是泛型带来的问题,而是这种封转必然会带来性能损耗。
goos: darwin
goarch: amd64
pkg: demo.zoe.im/go-generic
cpu: Intel(R) Core(TM) i7-4770HQ CPU @ 2.20GHz
BenchmarkXValue_Normal-8 1000000000 0.3282 ns/op 0 B/op 0 allocs/op
BenchmarkXValue_InterfaceCall-8 24450914 48.52 ns/op 0 B/op 0 allocs/op
BenchmarkXValue_GenericCall-8 144149385 8.310 ns/op 0 B/op 0 allocs/op
PASS
ok demo.zoe.im/go-generic 3.754s
总结
Golang的泛型是千呼万唤总出来,在1.18中正式发布。
如果有一些编程经验的话,会有比较明确的需求,特别是有其他语言的泛型经验。
和以前相比修改点主要是在函数定义和类型定义后。
- 分割符号是
[],不同于Rust的<> - 位置位于函数名或类型名之后
泛型写法和类型一致,
- 泛型名和约束之间用空格(
):T: int | string - 多个之间用
,进行分割:T1 int | int64, T2 string - 泛型约束可以合并:
T1, T2 any,注意这不代表T1和T2是同一个泛型
类型约束有一下3种,
- 声明类型,
int | byte - 接口,
Stringer - 任意类型,
interface{}
当然了,当前Go的泛型还是有诸多的限制,想要了解具体的设计细节可以看看 Type Parameters Proposal。推荐看看,里面很多使用的细节。

Zoe
全栈开发 · AI 工具制造者 · Go / Flutter / Rust · 开源偏执狂
