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 | type Queue[T any] struct { |
上面代码创建了一个名为Queue的类型,它接受一个类型参数T。这个T可以是任何类型,但唯一要求是这个数据是一个Go类型。
您可以使用以下函数为Queue[T]实例创建一个小小的构造函数:
1 | func NewQueue[T any](size int) Queue[T] { |
现在,让我们在Queue struct上创建一些方法,当然也就是压入和弹出操作了。它们可能是这样的:
1 | func (q Queue[T]) Push(val T) { |
这些方法将允许您将数据放在队列的末尾,然后从头部取出数据。你可以这样使用它们:
1 | q := NewQueue[string](5) |
目前来说一切都好,但是有个小问题,当队列为空时,调用Pop时调用者会被阻塞在那里,我们可以使用select - default语句来实现非阻塞版本的TrpPop方法:
1 |
|
但是,不幸的事情发生了,上面的代码无法编译,出错信息如下:
1 | cannot use nil as T value in return statement |
在该代码中,T可以是任何值,包括可能不为nil的值。我们可以利用var语句来解决这个问题,它生成一个新变量,并将其初始化为该类型的零值:
1 | func Zero[T any]() T { |
我们可以像下面一样使用这个返回零值的函数:
1 | log.Printf("%q", Zero[string]()) |
输出的结果可能如下:
1 | 2009/11/10 23:00:00 "" |
现在我们就可以改造TryPop的default分支了:
1 | func (q Queue[T]) TryPop() (T, bool) { |
最后我们写一个单元测试来测试它:
1 | func TestQueue(t *testing.T) { |
Option[T]
使用Go时,人们会有很多原因使用指针值:
- 指针值可能为零,因此可以表示该值可能不存在。
- 指针值只在内存中存储偏移量,因此传递该值会导致只复制指针,而不用复制传递的值。
- 传递给函数的指针值允许您在传递中改变值。否则Go将复制该值,您就可以随心所欲地对其进行更改,但所做的更改不会持续到函数调用之后。你可以认为这是“不可变的”,但它不像在Rust中函数传递那样严格。
Optiion[T]类型可以帮助我们对第一条约束创建一个容器:一个值可能不存在。我们可以这样定义:
1 | type Option[T any] struct { |
你需要为这个容器实现一组方法:
1 | var ErrOptionIsNone = errors.New("gonads: Option[T] has no value") |
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 | func (o Option[T]) IsSome() bool { |
当Option没有保存某个值时,我们可以说Option为空,我们可以使用IsSome来实现IsNone。
最后我们把这些都放在Yank函数中,它类似Rust语言中的Option::unwrap():
1 | func (o Option[T]) Yank() T { |
写个Go单元测试校验它:
1 | func TestOption(t *testing.T) { |
脱离本篇文章,我认为
Option[T], 但是它需要更进一步的工作和泛化(才能更好的应用在生产环境),这应当是Go核心团队去做的事情,而不是第三方自己去实现。
Thunk[T]
在计算机科学中,我们通常程序分成数值(value)和计算(computation)。通常我们会处理其中一个,或者另一个。但是有时候计算也可以被视为值,但这是非常罕见的。更为罕见的是,将部分完成的计算用作一个值。
thunk是一种存储为值的部分计算。为了了解我所说的内容,让我们考虑一下这个JavaScript函数:
1 | const add = (x, y) => x + y; |
上面的代码实现了一个add函数对象,需要两个参数,返回一个参数。很多场景下都会这么使用。但是如果我们只绑定一个参数,让另一个参数做变量,这会变得很困难。
我们可以这样实现add:
1 | const add = (x) => (y) => x + y; |
它需要我们部分的实现add函数,比如addTwo:
1 | const addTwo = add(2); |
也可以用在不需要参数的函数中,这样实现了延迟计算:
1 | const hypotenuse = (x, y) => Math.sqrt(x * x + y * y); |
你可以传递trunk对象,而不必立即计算它,只有在需要的时候才去计算。
1 | dominateWorld(thunk); // thunk is passed as an unevaluated function |
现在我们使用Go来实现这个类型:
1 | type Thunk[T any] struct { |
然后使用Force函数强制Trunk进行计算:
1 | func (t Thunk[T]) Force() T { |
这是可行的,但是我们也可以比JavaScript示例更进一步。我们可以利用Thunk[T]容器来缓存doer函数的结果,这样多次调用它实际上只会返回一次相同的结果。
请记住,这只适用于纯函数,或不修改外部世界的函数。这也不仅仅是全局变量,而是任何可以在任何地方修改任何状态的函数,包括网络和文件系统IO。(如果你不理解纯函数,可以搜索pure function了解更多)
所以Trunk[T]可以实现如下:
1 | type Thunk[T any] struct { |
现在来一个复杂点的例子,我们用它实现斐波那契函数。斐波那契函数正规的例子如下:
1 | func Fib(n int) int { |
我们写一个单元测试看看它的时间花费:
1 | func TestRecurFib(t *testing.T) { |
运行go test,结果如下:
1 | $ go test -run RecurFib |
然而,我们可以使用刚才实现的Trunk[T]实现它,不过更复杂了:
1 | func TestThunkFib(t *testing.T) { |
执行go test,.输出结果:
1 | === RUN TestThunkFib |
奇怪不?为什么更慢了?我们不是缓存了中间计算的结果了么?下面的代码是我们常见的优化,我们的版本和下面不是一样的么?
1 | func TestMemoizedFib(t *testing.T) { |
1 | $ go test -run Memoized |
如果你把我们使用Trunk[T]的斐波那契函数改动如下,也是能快速跑完的:
1 | fib = func(n int) int { |
1 | === RUN TestThunkFib |
要明确的是,这不是Go泛型的错。我几乎可以肯定的是,我糟糕的代码导致了速度大大降低。
网友指出我的代码事实上是错误的,我的
fib实现应该如下:
1 | fib = func(n int) int { |
我很高兴Go语言添加了泛型。这肯定会让很多事情变得更容易,更富有表现力。我担心在学习Go泛型的过程会给人们带来很多麻烦,。泛型应在特定情况下使用,而不是被滥用。
我希望这是一个关于如何在Go中使用泛型的有趣研究,但请不要在生产中使用这些示例。
