Go 1.18发布了第一版的Go泛型之后,大家开始对Go泛型进行了深入的研究,今天翻译的这一篇,是加拿大的Xe Iaso刚出炉的一篇有趣的文章 ,对Go泛型的应用做了一些探索。
Go 1.18在语言中添加了泛型特性。允许您把你的类型作为参数,这样你可以创建复合类型(类型之外的类型)。这让你在使用Go的过程中可以有更多的表现力和清晰度。
然而,如果你正在寻找关于如何使用Go泛型的好主意,这篇文章不适合你。这里面满是坏主意。这篇文章全是不应该在生产环境中使用Go泛型的方法。不要将本文中的示例复制到生产中。通过阅读本文,您同意不将本文中的示例复制到生产中。
我已将本文的代码放在我的git服务器 上。我故意采取以下步骤以便代码难以在生产环境中使用:
我在一个名为internal
的gitea组织下创建了它。这将使您无法导入包,除非您是从我的gitea服务器上的repo使用它。该gitea服务器上的注册被禁用。有关内部包规则的更多信息,请参见此处 。
软件包文档包含一条神奇的注释,它会让staticcheck和其他linter 警告您正在使用的软件包已被弃用。
Queue[T]
首先,让我们展示一下计算机科学中一个难题。让我们创建一个MPMS(multiple producer, multiple subscriber,多生产者、多消费者)队列,这是我们并发编程常见的一种数据结构。
首先,我们需要一个struct来包装一切。它看起来是这样的:
1
2
3
type Queue[T any] struct {
data chan T
}
上面代码创建了一个名为Queue
的类型,它接受一个类型参数T
。这个T
可以是任何类型,但唯一要求是这个数据是一个Go类型。
您可以使用以下函数为Queue[T]
实例创建一个小小的构造函数:
1
2
3
4
5
func NewQueue[T any](size int ) Queue[T] {
return Queue[T]{
data: make (chan T, size),
}
}
现在,让我们在Queue
struct上创建一些方法,当然也就是压入和弹出操作了。它们可能是这样的:
1
2
3
4
5
6
7
func (q Queue[T]) Push(val T) {
q.data <- val
}
func (q Queue[T]) Pop() T {
return <-q.data
}
这些方法将允许您将数据放在队列的末尾,然后从头部取出数据。你可以这样使用它们:
1
2
3
4
5
6
q := NewQueue[string ](5 )
q.Push("hi there" )
str := q.Pop()
if str != "hi there" {
panic ("string is wrong" )
}
目前来说一切都好,但是有个小问题,当队列为空时,调用Pop
时调用者会被阻塞在那里,我们可以使用select - default
语句来实现非阻塞版本的TrpPop
方法:
1
2
3
4
5
6
7
8
9
func (q Queue[T]) TryPop() (T, bool ) {
select {
case val := <-q.data:
return val, true
default :
return nil , false
}
}
但是,不幸的事情发生了,上面的代码无法编译,出错信息如下:
1
cannot use nil as T value in return statement
在该代码中,T
可以是任何值,包括可能不为nil的值。我们可以利用var语句来解决这个问题,它生成一个新变量,并将其初始化为该类型的零值:
1
2
3
4
func Zero[T any]() T {
var zero T
return zero
}
我们可以像下面一样使用这个返回零值的函数:
1
2
log.Printf("%q" , Zero[string ]())
log.Printf("%v" , Zero[int ]())
输出的结果可能如下:
1
2
2009 /11/ 10 23 :00 :00 ""
2009 /11/ 10 23 :00 :00 0
现在我们就可以改造TryPop
的default
分支了:
1
2
3
4
5
6
7
8
9
func (q Queue[T]) TryPop() (T, bool ) {
select {
case val := <-q.data:
return val, true
default :
var zero T
return zero, false
}
}
最后我们写一个单元测试来测试它:
1
2
3
4
5
6
7
8
9
10
func TestQueue(t *testing.T) {
q := NewQueue[int ](5 )
for i := range make ([]struct {}, 5 ) {
q.Push(i)
}
for range make ([]struct {}, 5 ) {
t.Log(q.Pop())
}
}
Option[T]
使用Go时,人们会有很多原因使用指针值:
指针值可能为零,因此可以表示该值可能不存在。
指针值只在内存中存储偏移量,因此传递该值会导致只复制指针,而不用复制传递的值。
传递给函数的指针值允许您在传递中改变值。否则Go将复制该值,您就可以随心所欲地对其进行更改,但所做的更改不会持续到函数调用之后。你可以认为这是“不可变的”,但它不像在Rust中函数传递那样严格。
Optiion[T]
类型可以帮助我们对第一条约束创建一个容器:一个值可能不存在。我们可以这样定义:
1
2
3
type Option[T any] struct {
val *T
}
你需要为这个容器实现一组方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var ErrOptionIsNone = errors.New("gonads: Option[T] has no value" )
func (o Option[T]) Take() (T, error) {
if o.IsNone() {
var zero T
return zero, ErrOptionIsNone
}
return *o.val, nil
}
func (o *Option[T]) Set(val T) {
o.val = &val
}
func (o *Option[T]) Clear() {
o.val = nil
}
Some other functions that will be useful will be an IsSome function to tell if the Option contains a value. We can use this to also implement an IsNone function that will let you tell if that Option does not contain a value. They will look like this:
其他一些有用的函数包括IsSome
函数,用于判断Option
是否包含值。我们还可以为它实现一个IsNone
函数,该函数将让您判断该Option
是否不包含值。它们看起来是这样的:
1
2
3
4
5
6
7
func (o Option[T]) IsSome() bool {
return o.val != nil
}
func (o Option[T]) IsNone() bool {
return !o.IsSome()
}
当Option
没有保存某个值时,我们可以说Option
为空,我们可以使用IsSome
来实现IsNone
。
最后我们把这些都放在Yank
函数中,它类似Rust语言中的Option::unwrap()
:
1
2
3
4
5
6
7
func (o Option[T]) Yank() T {
if o.IsNone() {
panic ("gonads: Yank on None Option" )
}
return *o.val
}
写个Go单元测试校验它:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func TestOption(t *testing.T) {
o := NewOption[string ]()
val, err := o.Take()
if err == nil {
t.Fatalf("[unexpected] wanted no value out of Option[T], got: %v" , val)
}
o.Set("hello friendos" )
_, err = o.Take()
if err != nil {
t.Fatalf("[unexpected] wanted no value out of Option[T], got: %v" , err)
}
o.Clear()
if o.IsSome() {
t.Fatal("Option should have none, but has some" )
}
}
脱离本篇文章,我认为Option[T]
, 但是它需要更进一步的工作和泛化(才能更好的应用在生产环境),这应当是Go核心团队去做的事情,而不是第三方自己去实现。
Thunk[T]
在计算机科学中,我们通常程序分成数值(value)和计算(computation)。通常我们会处理其中一个,或者另一个。但是有时候计算也可以被视为值,但这是非常罕见的。更为罕见的是,将部分完成的计算用作一个值。
thunk
是一种存储为值的部分计算。为了了解我所说的内容,让我们考虑一下这个JavaScript函数:
1
2
const add = (x, y) => x + y;
console .log(add(2 , 2 ));
上面的代码实现了一个add
函数对象,需要两个参数,返回一个参数。很多场景下都会这么使用。但是如果我们只绑定一个参数,让另一个参数做变量,这会变得很困难。
我们可以这样实现add
:
1
2
const add = (x) => (y) => x + y;
console .log(add(2 )(2 ));
它需要我们部分的实现add
函数,比如addTwo
:
1
2
const addTwo = add(2 );
console .log(addTwo(3 ));
也可以用在不需要参数的函数中,这样实现了延迟计算:
1
2
const hypotenuse = (x, y) => Math .sqrt(x * x + y * y);
const thunk = () => hypot(3 , 4 );
你可以传递trunk
对象,而不必立即计算它,只有在需要的时候才去计算。
现在我们使用Go来实现这个类型:
1
2
3
type Thunk[T any] struct {
doer func () T
}
然后使用Force
函数强制Trunk
进行计算:
1
2
3
func (t Thunk[T]) Force() T {
return t.doer()
}
这是可行的,但是我们也可以比JavaScript示例更进一步。我们可以利用Thunk[T]
容器来缓存doer
函数的结果,这样多次调用它实际上只会返回一次相同的结果。
请记住,这只适用于纯函数,或不修改外部世界的函数。这也不仅仅是全局变量,而是任何可以在任何地方修改任何状态的函数,包括网络和文件系统IO。(如果你不理解纯函数,可以搜索pure function了解更多)
所以Trunk[T]
可以实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Thunk[T any] struct {
doer func () T
o *Option[T]
}
func (t *Thunk[T]) Force() T {
if t.o.IsSome() {
return t.o.Yank()
}
t.o.Set(t.doer())
return t.o.Yank()
}
func NewThunk[T any](doer func () T) *Thunk[T] {
return &Thunk[T]{
doer: doer,
o: NewOption[T](),
}
}
现在来一个复杂点的例子,我们用它实现斐波那契函数。斐波那契函数正规的例子如下:
1
2
3
4
5
6
7
func Fib(n int ) int {
if n <= 1 {
return n
}
return Fib(n-1 ) + Fib(n-2 )
}
我们写一个单元测试看看它的时间花费:
1
2
3
func TestRecurFib(t *testing.T) {
t.Log(Fib(40 ))
}
运行go test,结果如下:
1
2
3
4
$ go test -run RecurFib
thunk_test.go:15: 102334155
--- PASS: TestRecurFib (0.36s)
然而,我们可以使用刚才实现的Trunk[T]
实现它,不过更复杂了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func TestThunkFib(t *testing.T) {
cache := make ([]*Thunk[int ], 41 )
var fib func (int ) int
fib = func (n int ) int {
if cache[n].o.IsSome() {
return *cache[n].o.val
}
return fib(n-1 ) + fib(n-2 )
}
for i := range cache {
i := i
cache[i] = NewThunk(func () int { return fib(i) })
}
cache[0 ].o.Set(0 )
cache[1 ].o.Set(1 )
t.Log(cache[40 ].Force())
}
执行go test,.输出结果:
1
2
3
thunk_test.go:36: 102334155
--- PASS: TestThunkFib (0.60s)
奇怪不?为什么更慢了?我们不是缓存了中间计算的结果了么?下面的代码是我们常见的优化,我们的版本和下面不是一样的么?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func TestMemoizedFib(t *testing.T) {
mem := map [int ]int {
0 : 0 ,
1 : 1 ,
}
var fib func (int ) int
fib = func (n int ) int {
if result, ok := mem[n]; ok {
return result
}
result := fib(n-1 ) + fib(n-2 )
mem[n] = result
return result
}
t.Log(fib(40 ))
}
1
2
3
4
$ go test -run Memoized
thunk_test.go:35: 102334155
--- PASS: TestMemoizedFib (0.00s)
如果你把我们使用Trunk[T]
的斐波那契函数改动如下,也是能快速跑完的:
1
2
3
4
5
6
7
8
9
fib = func (n int ) int {
if cache[n].o.IsSome() {
return *cache[n].o.val
}
result := fib(n-1 ) + fib(n-2 )
cache[n].o.Set(result)
return result
}
1
2
3
thunk_test.go:59: 102334155
--- PASS: TestThunkFib (0.00s)
要明确的是,这不是Go泛型的错。我几乎可以肯定的是,我糟糕的代码导致了速度大大降低。
网友指出我的代码事实上是错误的,我的fib
实现应该如下:
1
2
3
fib = func (n int ) int {
return cache[n-1 ].Force() + cache[n-2 ].Force()
}
我很高兴Go语言添加了泛型。这肯定会让很多事情变得更容易,更富有表现力。我担心在学习Go泛型的过程会给人们带来很多麻烦,。泛型应在特定情况下使用,而不是被滥用。
我希望这是一个关于如何在Go中使用泛型的有趣研究,但请不要在生产中使用这些示例。