Go 朝着错误的方向发展

这是 Aliaksandr Valialkin 昨天刚写的一篇文章, 心有戚戚焉,所以特意翻译成中文,个人感觉,自从Rob Pike退休后,Go在大方向迷失了,正如老貘(Go101)所说,目前Go的开发就像完成KPI一样,也许, 大师不会再回来了。

Aliaksandr Valialkin是fasthttp的作者,也是VictoriaMetrics开发者,一位资深的Go程序员。

以下是译文。

以下是对原文的地道中文翻译:

Go编程语言以易于使用而闻名。得益于经过深思熟虑的语法、特性和工具,Go允许编写任意复杂度的易读易维护的程序(参见GitHub上的这个列表)。

有些软件工程师称Go为"无聊"和"过时",因为它缺乏其他编程语言的高级特性,如单子、Option类型、LINQ、借用检查器、零开销抽象、面向方面编程、继承、函数和运算符重载等。虽然这些特性在特定领域可能可以简化编码,但它们除了好处之外还有非零的成本。这些特性通常对锻炼大脑有好处。但是在处理生产代码时,我们不需要额外的精神负担,因为我们已经很忙于解决业务任务了。所有这些特性的主要成本是增加了结果代码的复杂性:

  • 仅仅通过阅读代码就变得更难理解正在发生的事情;
  • 调试此类代码变得更加困难,因为您需要跳过数十个非平凡的抽象才能到达业务逻辑;
  • 由于这些特性施加的限制,为此类代码添加新功能变得更加困难。

这可能会显著减慢甚至阻碍代码开发的进度。这就是Go一开始就没有这些特性的主要原因。

不幸的是,一些这样的特性开始出现在最新的Go版本中:

  • 泛型已在Go1.18中添加。许多软件工程师希望Go有泛型,因为他们认为这将大大提高Go的生产力。Go1.18发布已经两年了,但没有迹象表明生产力有所提高。Go中泛型的整体采用率仍然很低。为什么?因为大多数实际的Go代码根本不需要泛型。另一方面,泛型显著增加了Go语言本身的复杂性。例如,尝试理解泛型添加后Go类型推断的所有细节。它的复杂性看起来已经非常接近于C++的类型推断了:)另一个问题是Go中的泛型缺乏C++模板中存在的基本特性。例如,Go泛型不支持泛型类型的泛型方法。它们也不支持模板特化和模板模板参数,以及许多其他需要充分利用泛型编程的特性。让我们将这些缺失的特性添加到Go中吧!等等,那我们就得到另一个过于复杂的C++克隆了。那么,为什么要一开始就将半生不熟的泛型添加到Go中呢?🤦
  • 根据这个提交,Range over functions 又名迭代器、生成器或协程将在 Go 1.23 中添加。让我们仔细看看这个"特性"。

Go1.23 中的迭代器

如果你不太熟悉Go中的迭代器,请阅读这篇出色的介绍文章。本质上,这是一种语法糖,允许在具有特殊签名的函数上使用for...range循环。这使得可以编写遍历自定义集合和类型的自定义迭代器。听起来像是一个很棒的功能,不是吗?让我们试着弄清楚这一功能解决了哪些实际问题。这在这里有概述:

Go语言没有标准的方式来遍历一系列值。由于缺乏约定,我们最终使用了各种各样的方法。每种实现都是根据当时的上下文做出最合理的决定,但是孤立地做出的决策导致了用户的困惑。

仅在标准库中,我们就有archive/tar.Reader.Next、bufio.Reader.ReadByte、bufio.Scanner.Scan、container/ring.Ring.Do、database/sql.Rows、expvar.Do、flag.Visit、go/token.FileSet.Iterate、path/filepath.Walk、go/token.FileSet.Iterate、runtime.Frames.Next和sync.Map.Range,几乎没有任何一个在迭代的确切细节上达成一致。即使函数签名相同,语义也不总是一致。例如,大多数返回(T, bool)的迭代函数都遵循Go的惯例,即bool表示T是否有效。相反,runtime.Frames.Next返回的bool则表示下一次调用是否会返回有效的内容。

当你想要遍历某些内容时,你首先必须了解你调用的特定代码是如何处理迭代的。这种不统一阻碍了Go追求的在大型代码库中方便移动的目标。人们常常将Go代码看起来都大致相同作为一个优势,但对于包含自定义迭代的代码而言,这显然是不真实的。

再说一次,拥有在Go中遍历各种类型的统一方式听起来是合理的。但是对于作为Go主要优势之一的向后兼容性又如何呢?根据Go的兼容性规则,上面提到的标准库中所有现有的自定义迭代器将永远保留在标准库中。因此,所有新的Go版本在标准库中都将至少提供两种不同的方式来遍历各种类型 —— 旧的方式和新的方式。这增加了Go编程的复杂性,因为:

  • 您需要了解遍历各种类型的两种方式,而不是单一方式。
  • 您需要能够阅读和维护使用旧迭代器的旧代码,以及可能使用旧迭代器、新迭代器或同时使用两种迭代器类型的新代码。
  • 在编写新代码时,您需要选择适当的迭代器类型。

Go1.23 中迭代器的其他问题

以下是对原文的地道中文翻译:

在Go 1.23之前,for...range循环只能应用于内置类型:整数(从Go1.22开始)、字符串、切片、映射和通道。这些循环的语义很清晰,易于理解(遍历通道的循环语义更加复杂,但如果你处理并发编程,那你应该很容易理解)。

从Go 1.23开始,for...range循环可以应用于具有特殊签名的函数(又称拉取和推送函数)。这使得单凭阅读代码就无法理解给定的看似无辜的for...range循环到底会在底层做什么。它可以做任何事情,就像任何函数调用一样。不同之处在于,Go中的函数调用一直都是显式的,比如f(args),而for...range循环隐藏了实际的函数调用。另外,它还对循环体应用了一些不太明显的转换:

  • 隐式地将循环体包裹在一个匿名函数中,并隐式地将这个函数传递给推送迭代器函数。
  • 隐式地调用匿名的拉取函数,并将返回的结果传递给循环体。
  • 隐式地returncontinuebreakgotodefer语句转换为另一个不太明显的语句,存在于传递给推送迭代器函数的匿名函数中。

另外,在一般情况下,在循环迭代之后使用迭代器函数返回的参数是不安全的,因为迭代器函数可能会在下一次循环迭代时重用它们。

Go 曾因易于阅读和理解的显式代码执行路径而闻名。这一特性在 Go1.23 中不可逆转地被破坏了:(我们用什么来交换?另一种遍历类型的方式,它具有一些隐式的语义,而且在某些情况下行为与广告描述的不同。当遍历可能在迭代过程中返回错误的类型时(例如database/sql.Rows、path/filepath.Walk 或任何其他在迭代过程中进行 IO 操作的类型),这种新方式就无法按预期工作,因为你需要手动检查迭代错误,无论是在循环内部还是在循环之后,这与使用旧方法的做法是一样的。

即使你使用不会返回错误的迭代器,生成的 for ... range 循环也看起来比使用显式回调的旧方法更加不清晰。哪种代码更容易理解和调试?

1
2
3
tree.walk(func(k, v string) {
println(k, v)
})
1
2
3
for k, v := range tree.walk {
println(k, v)
}

请记住,后一个循环会被隐式地转换为前一个带有显式回调调用的代码。现在让我们从循环中返回一些东西:

1
2
3
4
5
for k, v := range tree.walk {
if k == "foo" {
return v
}
}

它被隐式转换为难以跟踪的代码,类似于以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
var vOuter string
needOuterReturn := false
tree.walk(func(k, v string) bool {
if k == "foo" {
needOuterReturn = true
vOuter = v
return false
}
})
if needOuterReturn {
return vOuter
}

看起来很容易调试:)

如果tree.walk通过从字节切片进行不安全转换将v传递给回调函数,那么这段代码可能会崩溃,因为v的内容在下一次循环迭代时可能会发生变化。因此,隐式生成的防弹代码必须使用strings.Clone()函数,这可能导致不必要的内存分配和复制:

1
2
3
4
5
6
7
8
9
10
11
12
var vOuter string
needOuterReturn := false
tree.walk(func(k, v string) bool {
if k == "foo" {
needOuterReturn = true
vOuter = strings.Clone(v)
return false
}
})
if needOuterReturn {
return vOuter
}

range over func这一特性对函数签名施加了限制。这些限制不适用于所有需要遍历集合元素的场景。这迫使软件工程师在使用for...range循环时进行丑陋的hack,以及编写理想情况下适合给定任务的显式代码之间做出艰难选择。

结论

令人遗憾的是,Go开始朝着增加复杂性和隐式代码执行的方向发展。也许我们需要停止添加增加Go复杂性的新功能,而是专注于Go的核心特性 - 简单性、高效性和性能。例如,最近Rust开始在对性能要求苛刻的领域取代Go的份额。我相信如果Go核心团队专注于优化热循环,比如循环展开和SIMD使用,这种趋势是可以扭转的。这不应该太过影响编译和链接速度,因为只有少量编译后的Go代码需要优化。没有必要试图优化所有简单代码的变体 - 这些代码即使优化了热循环也仍然会很慢。只需针对那些由注重代码性能的软件工程师故意编写的特定模式进行优化就足够了。
Go比Rust容易使用得多。为什么要在性能竞赛中输给Rust呢?
Go可以获得的另一个有用特性的例子是,在不增加语言本身和使用这些特性的Go代码复杂性的情况下,进行类似于小的改善代码质量的改进。

我是谁?

我是一名专门编写简单、面向性能的Go代码的软件工程师,如VictoriaMetrics、quicktemplate、fastjson、fasthttp、fastcache、easyproto等。多亏了Go,我一直试图遵循KISS(Keep It Simple,Stupid)设计原则。