Go的发展,似乎正在走上“邪路”?
众所周知,Go 语言向来以易于使用而著称。得益于其精心设计而成的语法、功能和工具,开发者可以通过 Go 轻松编写出易于阅读和维护、且复杂程度各异的应用程序。
但也有一些软件工程师抱怨 Go 语言既“无聊”又“陈旧”,理由是其缺乏其他编程语言所具备的不少高级功能,例如 monad、option 类型、LINQ、借用检查器、零成本抽象、面向方面编程、继承、函数与算子重载等。虽然这些功能有助于简化特定领域内的代码编写体验,但在好处之外,它们也对应着自己的实现成本。
具体来讲,此类功能往往会增加开发者的认知负担,而在处理生产代码时,我们最不需要的就是额外认知负担了——毕竟大家已经被业务需求搞得焦头烂额,实在不想因为这些功能令代码编写的复杂性进一步提高:
这些功能导致人们很难通过直接阅读代码来理解业务逻辑;
此类代码的调试变得更加困难,因为往往需要跨越几十个奇怪的抽象才能触及业务逻辑;
这些功能各有适用限制,因此向此类代码中添加新功能也变得更加困难。
这可能会大大减慢,甚至中止代码的开发进度。也正因为如此,GO 语言才决定在起步之初不引入这些功能。
遗憾的是,其中部分功能近来开始出现在 Go 新版本当中:
泛型已经出现在 Go 1.18 版本当中。许多软件工程师都希望能在 Go 中用上泛型,并认为这将显著提高他们在 Go 开发环境下的工作效率。但自 Go 1.18 发布以来已经过去了两年,生产率却并没有提高的迹象。Go 中泛型的总体采用率也仍然很低。为什么?因为大多数 Go 代码实际上都不需要泛型。另一方面,泛型却显著增加了 Go 语言本身的复杂性。例如,我们已经很难在引入泛型之后,正确理解 Go 类型推断的所有细节。其复杂性已经非常接近 C++ 类型推断。另一个问题在于,Go 中的泛型还不像 C++ 模板那样具备全套必要功能。例如,Go 泛型在其类型中不支持泛型方法,也不支持模板特化及模板模板参数(即模板中再套模板)等充分利用泛型编程所需要的许多其他功能。而如果把这么多缺失的功能再塞进 GO 当中,那我们得到的就是又一个过于复杂的 C++ 克隆。所以,当初何必费力气把泛型引入 Go 语言呢?🤦
根据一项提交,Go 1.23 版本中将新增 range over functions,即函数迭代器、生成器或者协程。感兴趣的朋友可以研究研究这项“功能”( https://github.com/golang/go/commit/5a181c504263b6cc2879d0a4fa19b2c993c59704)。
Go 1.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 版本都将至少提供两种不同的方法来对标准库内的各类型进行迭代——一种旧方法,一种新方法。
而这无疑又会增加 Go 编程的复杂性,因为:
在对各种类型进行迭代时,我们需要掌握两种方法,而非单一方法。
我们需要学会阅读并维护使用旧迭代器的原有代码;以及可能继续使用旧迭代器、转而使用新迭代器,或者同时使用这两种迭代器的新代码。
在编写新代码时,我们需要审慎选择适当的迭代器类型。
Go 1.23 中的迭代器问题还不止于此
在 Go 1.23 版本之前,for ... range 循环只能应用于内置类型,即整数(自 Go 1.22 版本起)、字符串、切片、映射和通道。这些循环的语义清晰易懂(虽然通道上的循环语义较为复杂,但对于有能力处理并发编程的开发者来说,理解起来也不算困难)。
自从 Go 1.23 版本开始,for ... range 循环可以应用于具有特殊签名的函数(即 pull 和 push 函数)。如此一来,开发者就无法单纯通过阅读代码来理解特定 for ... range 循环到底会在后台执行怎样的操作。跟其他函数调用一样,它可能对应任何行为。唯一的区别就是,Go 中的函数调用始终显式进行,例如 f(args),只是 for ... range 循环会隐藏掉实际函数调用。此外,它还会悄悄对循环体施加转换:
它会隐式将循环体打包进匿名函数之内,并隐式将此函数传递给 push 迭代器函数。
它会隐式调用匿名 pull 函数,并将返回的结果传递给循环体。
它会隐式将 return、continue、break、goto 和 defer 语句转换为另一个非显式语句,再将其作为匿名函数传递给 push 迭代器函数。
除此之外,在一般情况下,在循环迭代之后使用迭代器函数返回的参数是不安全的,因为迭代器数据可以在下一次循环迭代中重新使用这些参数。
Go 向来以代码内容易于阅读和理解,且代码执行路径清晰明确而闻名。但从以上情况来看,这样的优势在 Go 1.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 functions”功能还会对函数签名加以限制。当需要对集合项进行迭代时,这些限制并不适合一切可能的情况。这就迫使软件工程师在面对特定任务时,只能在 for…range 循环的丑陋修补跟编写显式代码之间做出两难选择。
在我看来,Go 开始朝着增加复杂性和隐式代码执行“邪路”的发展势头实在令人遗憾。也许我们应当停止向 Go 中添加会进一步增加其复杂性的功能,转而专注于最基本的 Go 特性——简单性、高生产力和高性能。举例来说,最近 Rust 就开始在性能关键领域夺取 Go 语言的市场份额。我相信只要核心 Go 团队专注于热循环优化,例如循环展开和使用 SIMD,这种趋势完全是可以逆转的。由于只需要对 Go 代码中的一小部分进行优化编译,所以不会对编译和链接速度产生太大影响。另外也没必要费力优化 dumb 代码的所有变体——因为对这些代码来说,即使优化了热循环速度也快不了多少。所以只需优化特定模式就足够了,毕竟这些模式本身就是由关心代码性能的软件工程师所有意为之。
Go 要比 Rust 更易用,怎么会在性能竞赛当中反被压了一头?
另一个值得参考的示例,就是 Go 完全能够在实现上述功能的同时,保证不增加语言本身及使用这些功能的代码的复杂性。大家可以了解这项建议(https://github.com/golang/go/issues/9367),看看何谓合理改善开发体验的正道。
Aliaksandr Valialkin , VictoriaMetrics 的联合创始人兼首席技术官 (CTO)。他是一位经验丰富的软件工程师,在可观察性、时间序列数据库和高性能系统方面拥有多年经验。Valialkin 是 Prometheus 的早期贡献者,并于 2018 年创立了 VictoriaMetrics。Valialkin 积极参与开源社区,并经常在会议和活动上发言。
原文链接:
https://itnext.io/go-evolves-in-the-wrong-direction-7dfda8a1a620
声明:本文为 InfoQ 翻译,未经许可禁止转载。
德国再次拥抱Linux:数万系统从windows迁出,能否避开二十年前的“坑”?
哈佛退学本科生开发史上最快芯片;居然之家汪林朋:AI时代名校毕业生不如厨师司机,北大的到我那就八千元;英伟达高层频频套现|Q资讯
InfoQ 将于 10 月 18-19 日在上海举办 QCon 全球软件开发大会 ,覆盖前后端 / 算法工程师、技术管理者、创业者、投资人等泛开发者群体,内容涵盖当下热点(AI Agent、AI Infra、RAG 等)和传统经典(架构、稳定性、云原生等),侧重实操性和可借鉴性。现在大会已开始正式报名,可以享受 8 折优惠,单张门票立省 960 元(原价 4800 元),详情可联系票务经理 17310043226 咨询。
微信扫码关注该文公众号作者