【外评】为什么人们对 Go 1.23 的迭代器设计感到愤怒?
注:本文基于推特上的一篇文章,但完全是改写的: https://x.com/TheGingerBill/status/1802645945642799423
TL;DR 它让 Go 感觉过于 “函数式”,而不是一种不折不扣的命令式语言。
我最近在 Twitter 上看到一篇帖子,展示了 Go 1.23(2024 年 8 月)中即将推出的 Go 迭代器设计。据我所知,很多人似乎都不喜欢这种设计。作为一名语言设计者,我想谈谈自己的看法。
有关该提案的合并 PR 可在此处找到:https://github.com/golang/go/issues/61897
其中有对设计的深入解释,解释了为什么要选择某些方法,因此我建议熟悉 Go 的人阅读一下。
以下是我从原始 Tweet 中找到的示例:
func Backward[E any](s []E) func(func(int, E) bool) {
return func(yield func(int, E) bool) {
for i := len(s)-1; i >= 0; i-- {
if !yield(i, s[i]) {
// Where clean-up code goes
return
}
}
}
}
s := []string{"a", "b", "c"}
for _, el in range Backward(s) {
fmt.Print(el, " ")
}
// c b a
这个示例的功能足够清晰,但对于一般/大多数用例来说,它的整个设计对我来说有点疯狂。
据我所知,代码似乎会被转换成类似下面的内容:
Backward(s)(func(_ int, el string) bool {
fmt.Print(el, " ")
return true // `return false` would be the equivalent of an explicit `break`
})
这意味着 Go 的迭代器更接近于某些语言中的 “for each “方法(例如 JavaScript 中的 .forEach()
),并向其传递回调。有趣的是,这种方法在 Go <1.23 中已经可以实现,但它不具备在 for range
语句中使用这种方法的语法糖。
我将尝试总结 Go 1.23 迭代器的基本原理,但它们似乎希望尽量减少几个因素:
- 让迭代器看起来/行动起来像其他语言中的生成器(因此有了
yield
) - 尽量减少共享过多栈帧的需要
- 允许使用
defer
进行 clean-up - 减少存储在控制流之外的数据
正如 Russ Cox (rsc) 在原始提案中解释的那样
关于推迭代器与拉迭代器类型的注意事项:在绝大多数情况下,推迭代器的实现和使用都更方便,因为设置和拆分可以在 yield 调用周围完成,而不必将它们作为单独的操作来实现,然后再暴露给调用者。直接使用推迭代器(包括使用范围循环)需要放弃在控制流中存储任何数据,因此个别客户有时可能需要使用拉迭代器。任何此类代码都可以简单地调用 Pull 并延迟停止。
Russ Cox 在他的文章《在控制流中存储数据》中更详细地阐述了他喜欢这种设计方法的原因。
更复杂的示例
注意:不要担心这个例子的实际作用,我只是想举例说明,在使用类似 defer
的功能时需要进行哪些 clean-up 工作。
原始报告中的一个示例显示了一种更复杂的方法,需要对直接提取的值进行 clean-up:
// Pairs returns an iterator over successive pairs of values from seq.
func Pairs[V any](seq iter.Seq[V]) iter.Seq2[V, V] {
return func(yield func(V, V) bool) bool {
next, stop := iter.Pull(it)
defer stop()
v1, ok1 := next()
v2, ok2 := next()
for ok1 || ok2 {
if !yield(v1, v2) {
return false
}
}
return true
}
}
另一种伪提案(状态机)
注:我并不是建议 Go 这样做。
在设计 Odin 时,我希望用户能设计自己的 “迭代器”,但这些迭代器必须非常简单;事实上,它们只是普通的过程。我不想为此在语言中添加一个特殊的结构–这会使语言过于复杂,而这正是我想在 Odin 中尽量减少的。
我可以为 Go 的迭代器提出一个如下的伪方案:
func Backward[E any](s []E) func() (int, E, bool) {
i := len(s)-1
return func(onBreak bool) (idx int, elem E, ok bool) {
if onBreak || !(i >= 0) {
// Where clean-up code goes, if there is any
return
}
idx, elem, ok = i, s[i], true
i--
return
}
}
这个伪提案的操作过程是这样的:
for it := Backward(s);; {
_, el, ok := it(false)
if !ok {
break // it(true) does not need to be called because the `false` was called
}
fmt.Print(el, " ")
}
这与我在 Odin 中的做法类似,但 Odin 不支持栈帧范围捕获闭包,只支持非范围捕获过程字面。因为 Go 是垃圾回收的,所以我认为没有必要这样使用它们。主要区别在于,Odin 并不试图将这些想法统一到一个构造中。
我知道有些人会认为这种方法复杂得多。它的做法与考克斯喜欢的在控制流中存储数据的做法相反,而是在控制流之外存储数据。但这通常是我想要的迭代器,而不是 Go 要做的。这就是问题所在:它消除了在控制流中存储数据的优雅性–Cox 所解释的推/拉区别。
注:我是一个命令式程序员,我喜欢了解事情的实际执行过程,而不是试图让代码看起来 “优雅”。因此,我上面写的方法从根本上说是关于执行的思考。
注:类型分类/接口路线在 Go 中行不通,因为这不是一个正交的设计概念,实际上会比必要的更令人困惑,这就是我最初没有提出的原因。不同的语言有不同的要求。
Go 的明显理念
Go 1.23 所采用的方法似乎与 Go 的明显理念背道而驰,即让 Google 的普通(坦率地说是平庸)程序员使用 Go,因为他们不想(也不能)使用像 C++ 这样 “复杂 “的语言。
引用 Rob Pike 的一句话
这里的关键是,我们的程序员都是谷歌员工,他们不是研究人员。他们通常相当年轻,刚从学校毕业,可能学过 Java,可能学过 C 或 C++,可能学过 Python。他们无法理解一门高明的语言,但我们希望用他们来构建优秀的软件。因此,我们提供给他们的语言必须易于理解和采用。
我知道很多人对这一评论感到不快,但通过了解你为谁设计语言,这才是出色的语言设计。这不是侮辱,而是实事求是的陈述,因为 Go 最初是为在谷歌和类似行业工作的人设计的。你可能是一个比普通 Googler “更好、更能干 “的程序员,但这并不重要。人们喜欢 Go 是有原因的:它简单、有主见,大多数人都能很快掌握。
然而,这种迭代器设计确实不符合 Go 的特性,尤其是对于像 Russ Cox(假设他是最初的提议者)这样的 Go 团队提议者来说。它让围棋变得更加复杂,甚至更加 “神奇”。我理解迭代器系统是如何工作的,因为我是一名语言设计和编译器实现者。由于需要闭包和回调,它也可能不是一种性能良好的方法。
也许设计迭代器的理由是,普通 Go 程序员并不打算实现迭代器,而只是使用迭代器。而且人们所需要的大多数迭代器都已经可以在 Go 的标准库或第三方软件包中找到。因此,责任在于软件包编写者,而非软件包用户。
这就是为什么我觉得很多人似乎都对这一设计感到 “愤怒”。在很多人看来,它违背了 Go 最初的 “本意”,而且看起来非常复杂 “混乱”。我理解它的 “美 “之处在于,它看起来就像一个采用屈服和内联代码方法的生成器,但我认为这并不一定符合许多人对 Go 的理解。Go 确实隐藏了许多幕后的神奇工作原理,尤其是垃圾回收、goroutines、select 语句和许多其他构造。然而,我认为这有点过于神奇,因为它向用户暴露了太多的神奇之处,而对于普通的 Go 程序员来说又显得过于复杂。
另一个让人感到 “困惑 “的地方是,它是一个以 func
为参数的返回 func
的 func
。而且,for
范围的主体被转换为一个 func
,所有中断(和其他转义控制流)都被转换为返回 false
。这只是三层深的过程,再次让人感觉像是一种函数式语言,而不是命令式语言。
注意:我并不是建议他们用我的建议来取代迭代器设计,而是说通用化的迭代器方法可能从一开始就不是 Go 的好东西。至少对我来说,Go 是一种不折不扣的命令式语言,具有一流的 CSP 类构造。它并不想成为一种类似函数式的语言。迭代器在命令式语言中确实存在,但作为一个概念,它非常 “函数化”。在函数式语言中,迭代器可以非常优雅,但在许多不折不扣的命令式语言中,迭代器总给人一种莫名其妙的 “怪异 “感觉,因为它们被统一为一个单独的构造,而不是将其中的各个部分分离出来(初始化+迭代器+销毁)。
题外话:Odin 的方法
正如我之前提到的,在 Odin 中,迭代器只是一个过程调用,多次返回的最后一个值只是一个布尔值,表示是否继续。由于 Odin 不支持闭包,因此在 Odin 中与 Go Backward 迭代器相当的迭代器需要输入更多的代码。
注意:在有人说 “这看起来更复杂 “之前,请继续阅读本文。大多数 Odin 迭代器都不是这样的,而且我从不建议编写这样的迭代器,因为对于代码的读者和编写者来说,琐碎的 for 循环都是更可取的。
// Explicit struct for the state
Backward_Iterator :: struct($E: typeid) {
slice: []E,
idx: int,
}
// Explicit construction for the iterator
backward_make :: proc(s: []$E) -> Backward_Iterator(E) {
return {slice = s, idx = len(s)-1}
}
backward_iterate :: proc(it: ^Backward_Iterator($E)) -> (elem: E, idx: int, ok: bool) {
if it.idx >= 0 {
elem, idx, ok = it.slice[it.idx], it.idx, true
it.idx -= 1
}
return
}
s := []string{"a", "b", "c"}
it := backward_make(s)
for el, _ in backward_iterate(&it) { // `for el in` could have been written too
fmt.print(el, " ")
}
// c b a
由于需要编写更多的代码,这看起来确实比 Go 方法复杂得多。但实际上,它更容易理解和掌握,执行起来也更快。迭代器不会调用 for 循环的主体,而是主体调用迭代器。我知道 Cox 很喜欢在控制流中存储数据的功能,我也同意这很好,但它并不适合 Odin,尤其是缺乏闭包(因为 Odin 是一种手动内存管理语言)。
迭代器 “只是以下内容的语法糖:
for {
el, _, ok := backward_iterate(&it)
if !ok {
break
}
fmt.print(el, " ")
}
// With `or_break`
for {
el, _ := backward_iterate(&it) or_break
fmt.print(el, " ")
}
Odin 的方法只是去掉了魔法,让事情变得非常清楚。”构建 “和 “销毁 “必须通过明确的过程手动处理。而迭代只是一个简单的过程,称为 each loop。所有这三个构造都是单独处理的,而不是像 Go 1.23 中那样合并成一个令人困惑的构造。
Odin 并没有隐藏魔法,而 Go 的方法实际上非常神奇。Odin 会让你手动处理 “类闭包 “值以及 “迭代器 “本身的构建和销毁。
Odin 的方法还允许你拥有任意多个返回值!Odin 的 core:encoding/csv
软件包就是一个很好的例子,在该软件包中,Reader
可以被当作迭代器来处理:
// core:encoding/csv
iterator_next :: proc(r: ^Reader) -> (record: []string, idx: int, err: Error, more: bool) {...}
// User code
for record, idx, err in csv.iterator_next(&reader) {
...
}
题外话:C++ 迭代器
在这篇文章中,我尽量不对 C++ 的 “迭代器 “大放厥词。C++ 的迭代器远不止迭代器这么简单,而 Go 的方法至少还只是一个迭代器。我完全理解 C++”迭代器 “的作用,但 99.99% 的情况下,我只想要一个单纯的迭代器;而不是一个具有所有代数特性、可以在更 “通用 “的地方使用的迭代器。
对于不太了解 C++ 的人来说,迭代器是一个自定义的 struct
/class
,它需要重载操作符才能像 “指针 “一样运行。历史上,C++ 的 “迭代器 “是这样的
{
auto && __range = range-expression ;
auto __begin = begin-expr ;
auto __end = end-expr ;
for ( ; __begin != __end; ++__begin) {
range-declaration = *__begin;
loop-statement
}
}
在 C++11 的 ranged-for 循环语法(和 auto)出现之前,这些语法会被包裹在一个 “宏 “中。
最大的问题是,C++ 的 “迭代器 “至少需要定义 5 种不同的操作。
以下三个操作符重载:
operator==
或operator!=
operator++
operator*
还有两个独立的过程或绑定方法,它们都会返回一个迭代器值:
begin
end
如果我只设计 C++ 的迭代器,我就会在结构/类中添加一个简单的方法,叫做 iterator_next 或其他什么。仅此而已。是的,这确实意味着其他代数特性的丢失,但老实说,我在处理任何问题时都不需要这些特性。我在处理这类问题时,要么使用连续数组,要么手动实现算法,因为我要保证这种数据结构的性能。不过,我自创语言(Odin)是有原因的,因为我完全不同意整个 C++ 的理念,我想摆脱这种疯狂。
C++ 的 “迭代器 “比 Go 的迭代器复杂得多,但在本地操作上却更 “直接”。至少在围棋中,你不需要构造一个有 5 种不同属性的类型,而只需要在 “迭代器 “中加入 “本地操作”。
总结
我觉得 Go 的迭代器在设计原则上是合理的,但似乎与大多数人对 Go 的理解背道而驰。我知道 Go 这些年来 “不得不 “变得越来越复杂,尤其是引入了泛型(我认为泛型设计得很好,只有语法上的一些小问题),但引入这种迭代器感觉是不对的。
简而言之,我认为这有悖于许多人所相信的 Go 理念,而且 Go 是一种功能性很强的做事方式,而非命令式。
基于这些原因,我认为这就是人们不喜欢迭代器的原因,即使我完全理解他们的设计选择。对很多人来说,它 “感觉 “不像围棋的原版。
也许我(和其他人)的担忧被夸大了,大多数人都不会真正实现它们,而只是使用它们,而它们的实现又是如此复杂。
倒数第二个有争议的观点:也许 Go 更需要 “把关”,让 “函数式兄弟 “们走开,不要再要求这些功能,因为它们会让 Go 成为一种更复杂的语言。
最后一个有争议的观点:如果是我,我不会允许在 Go 中使用自定义迭代器,但我不是 Go 团队的人(我也不想成为 Go 团队的人)。
本文文字及图片出自 Why People are Angry over Go 1.23 Iterators
你也许感兴趣的:
- Go语言有个“好爹”反而被程序员讨厌?
- 【译文】Go语言性能从 1.0 版到 1.22 版
- Go 语言程序员的进化
- 【译文】面试时,有人问我喜欢Go语言什么?
- 4 秒处理 10 亿行数据! Go 语言的 9 大代码方案,一个比一个快
- 【译文】Go语言设计:我们做对了什么,做错了什么
- 最好的 Go 框架就是不用框架?
- 吵翻了!到底该选 Rust 还是 Go,成 2023 年最大技术分歧
- “Go 语言的优点、缺点和平淡无奇之处”的十年
- 为什么不用Go开发操作系统?
你对本文的反应是: