Go 朝着错误的方向发展

288次阅读  |  发布于5月以前

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

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

以下是译文。

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

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

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

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

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

Go1.23 中的迭代器

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

“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循环隐藏了实际的函数调用。另外,它还对循环体应用了一些不太明显的转换:

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

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

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

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

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

for k, v := range tree.walk {
  if k == "foo" {
    return v
  }
}

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

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()函数,这可能导致不必要的内存分配和复制:

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 代码复杂性的情况下,进行类似于小的改善代码质量的改进。

参考资料列表:

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8