Zoe
Zoe
返回博客

试试 Golang 的泛型

12 分钟阅读
Golang泛型

最近Golang发布了1.18版本,其中一个很大的新功能就是泛型(generics)的支持, 这个也算是社区中呼唤的比较久的功能了。官方称早在2010年就在尝试支持吃, Proposal应该是在2021年1月正式提出:Type Parameters Proposal

有时候在用了一段Rust或TypeScript后,再回来写Golang代码时,对于泛型的需求会更加强烈。

何为泛型?

简单来说,定义(函数、结构体等)时用类型占位,在使用时确定具体类型。 泛型能让代码的复用性提高很多,而且对于有利于逻辑的抽象。

尝试一用

假设我们现在需要实现一个函数,用来返回数组中的最大值,其中数组的值类型可能是所有能够比较大小的类型。 没有泛型的支持下,在Golang中可能是以下的几种函数签名,

  1. 分别定义函数
func maxIntFrom(items []int) int {}
func maxCharFrom(items []char) char {}
// ...
// 如果需要支持其他值类型,则需要分别定义
  1. 使用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个数组值类型可以是不同类型的,因为泛型不同T1T2, 而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 &#x26;&#x26; !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 := &#x26;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 &#x3C; 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 &#x26;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 &#x26;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注意这不代表T1T2是同一个泛型

类型约束有一下3种,

  • 声明类型,int | byte
  • 接口,Stringer
  • 任意类型,interface{}

当然了,当前Go的泛型还是有诸多的限制,想要了解具体的设计细节可以看看 Type Parameters Proposal推荐看看,里面很多使用的细节。

Zoe

Zoe

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

https://zoe.im

评论