Go泛型的坏例子

Go 1.18发布了第一版的Go泛型之后,大家开始对Go泛型进行了深入的研究,今天翻译的这一篇,是加拿大的Xe Iaso刚出炉的一篇有趣的文章,对Go泛型的应用做了一些探索。

Go 1.18在语言中添加了泛型特性。允许您把你的类型作为参数,这样你可以创建复合类型(类型之外的类型)。这让你在使用Go的过程中可以有更多的表现力和清晰度。

然而,如果你正在寻找关于如何使用Go泛型的好主意,这篇文章不适合你。这里面满是坏主意。这篇文章全是不应该在生产环境中使用Go泛型的方法。不要将本文中的示例复制到生产中。通过阅读本文,您同意不将本文中的示例复制到生产中。

我已将本文的代码放在我的git服务器上。我故意采取以下步骤以便代码难以在生产环境中使用:

  1. 我在一个名为internal的gitea组织下创建了它。这将使您无法导入包,除非您是从我的gitea服务器上的repo使用它。该gitea服务器上的注册被禁用。有关内部包规则的更多信息,请参见此处
  2. 软件包文档包含一条神奇的注释,它会让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

现在我们就可以改造TryPopdefault分支了:

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)); // 4

上面的代码实现了一个add函数对象,需要两个参数,返回一个参数。很多场景下都会这么使用。但是如果我们只绑定一个参数,让另一个参数做变量,这会变得很困难。

我们可以这样实现add:

1
2
const add = (x) => (y) => x + y;
console.log(add(2)(2)); // 4

它需要我们部分的实现add函数,比如addTwo:

1
2
const addTwo = add(2);
console.log(addTwo(3)); // 5

也可以用在不需要参数的函数中,这样实现了延迟计算:

1
2
const hypotenuse = (x, y) => Math.sqrt(x * x + y * y);
const thunk = () => hypot(3, 4);

你可以传递trunk对象,而不必立即计算它,只有在需要的时候才去计算。

1
dominateWorld(thunk); // thunk is passed as an unevaluated function

现在我们使用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 // action being thunked
o *Option[T] // cache for complete thunk data
}
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
=== RUN TestRecurFib
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
=== RUN TestThunkFib
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
=== RUN TestMemoizedFib
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
=== RUN TestThunkFib
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中使用泛型的有趣研究,但请不要在生产中使用这些示例。