原文:gingerBill - 2024.06.17
TL;DR 它让 Go 变得太“函数式”,而不再是不折不扣的命令式语言。
最近,我在 Twitter 上看到一篇帖子,展示了 Go 1.23(2024 年 8 月)即将推出的 Go 迭代器设计。据我所知,很多人似乎都不喜欢这种设计。作为一名语言设计者,我想谈谈自己的看法。
有关该提案的 合并 PR 可在此处找到:https://github.com/golang/go/issues/61897
其中有对设计的深入解释,说明了为什么要选择某些方法,因此我建议熟悉 Go 的人阅读一下。
以下是我从原始推文中看到的示例:
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]) {
// 清理代码的位置
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` 相当于显式 `break`
})
这意味着 Go 的迭代器更接近于某些语言中的 “for each” 方法(例如 JavaScript 中的 .forEach()
),并向其传递一个回调函数。有趣的是,这种方法在 Go 1.23 之前的版本中已经可以实现,但没有语法糖可以在 for range
语句中使用。
我将尝试总结 Go 1.23 迭代器的基本原理,但它们似乎希望尽量减少几个因素:
- 让迭代器看起来像其他语言中的生成器(因此使用
yield
) - 尽量减少共享过多的栈帧
- 允许使用
defer
进行清理 - 减少在控制流之外存储数据
正如 Russ Cox (rsc) 在原始提案中解释的那样:
关于 push 和 pull 迭代器类型的说明:在绝大多数情况下,push 迭代器更方便实现和使用,因为可以围绕 yield 调用进行设置和拆卸,而不必将它们实现为独立的操作,然后再暴露给调用者。直接使用(包括 range 循环)push 迭代器需要放弃在控制流中存储任何数据,因此个别客户端可能偶尔需要 pull 迭代器。任何此类代码都可以轻松调用 Pull 并 defer 停止。
Russ Cox 在他的文章 Storing Data in Control Flow 中更详细地阐述了他喜欢这种设计方法的原因。
更复杂的示例
注意: 不用担心它实际上做了什么,我只是想展示一个需要 defer
进行清理的示例。
原始 PR 中的一个示例展示了一个需要清理的更复杂的方法,其中值是直接 pull 出来的:
// Pairs 返回一个迭代器,该迭代器依次迭代 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 语言时,我希望用户能够设计自己的“迭代器”,但它们要非常简单;事实上,它们只是普通的过程(procedure)。我不想为此在语言中添加特殊结构——这会使语言变得过于复杂,而这正是我想在 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) {
// 这里放清理代码,如果有的话
return
}
idx, elem, ok = i, s[i], true
i--
return
}
}
这个伪提案的操作方式是这样的:
for it := Backward(s);; {
_, el, ok := it(false)
if !ok {
break // 不需要调用 it(true),因为调用了 `false`
}
fmt.Print(el, " ")
}
这与我在 Odin 中的做法相似,但 Odin 不支持栈帧范围捕获闭包,只支持非范围捕获的过程字面量。由于 Go 是有 GC 的,所以我认为没有必要这样使用它们。主要区别在于,Odin 并不试图将这些概念统一到一个构造中。
我知道有些人会认为这种方法更加复杂。它与 Cox 在控制流中存储数据的偏好相反,而是将数据存储在控制流之外。但这通常是我对迭代器的期望,而不是 Go 将要做的。这就是问题所在:它消除了在控制流中存储数据的优雅性——正如 Cox 解释的那样,push/pull 的区别。
注: 我是一个非常命令式编程的程序员,喜欢了解事情的实际执行过程,而不是尝试让代码“看起来优雅”。因此,我上面写的方法基本上是关于执行方面的思考。
附注:typeclass/interface 的路线在 Go 中行不通,因为这不是一个正交的设计概念,实际上会比必要的更令人困惑——这就是我最初没有提出它的原因。不同的语言对哪些方法有效有不同的要求。
Go 的显见理念
Go 1.23 所采用的方法似乎与其显见理念背道而驰——即让 Google 的普通(坦率地说是平庸)程序员使用 Go,因为他们不想(也不能)使用像 C++ 这样“复杂”的语言。
引用 Rob Pike 的话:
关键点在于我们的程序员是 Googlers,他们不是研究人员。他们通常相当年轻,刚从学校毕业,可能学过 Java,也许学过 C 或 C++,可能学过 Python。他们无法理解一种复杂的语言,但我们希望他们能编写出好的软件。因此,我们提供给他们的语言必须易于理解和采用。
我知道很多人对这一评论感到不快,但通过了解你为谁设计语言,这才是 出色的 语言设计。这不是侮辱,而是实事求是的陈述,因为 Go 最初是为在谷歌和类似行业工作的人设计的。你可能是一个比普通 Googler “更好、更能干”的程序员,但这并不重要。人们喜欢 Go 是有原因的:它简单、坚持自己的设计理念,而且大多数人可以很快上手。
然而,这种迭代器设计确实不符合 Go 的特性,尤其是对于像 Russ Cox(假设他是最初的提议者)这样的 Go 团队提议者来说。它让 Go 变得更加复杂,甚至更“神奇”。我理解迭代器系统的工作原理,因为我是一个语言设计和编译器实现者。但它也可能存在性能问题,因为需要使用闭包和回调。
也许这种设计的理由是,普通 Go 程序员 并不需要 实现 迭代器,而只是 使用 它们。而且大多数人需要的迭代器已经在 Go 的标准库中,或者由第三方库提供。因此,责任在于库的 编写者,而不是库的 使用者。
这就是为什么很多人似乎对这种设计感到“愤怒”。在很多人看来,它违背了 Go 最初的“宗旨”,看起来像一个非常复杂的“混乱”。我理解它看起来像生成器那样使用 yield
和内联代码的“美感”,但我不认为这符合许多人对 Go 的理解。Go 确实隐藏了很多神奇的运作方式,特别是垃圾收集、go routines
、select
语句和许多其他结构。然而,我认为这有点过于神奇了,因为它向用户暴露了太多的魔法,同时对普通 Go 程序员来说显得过于复杂。
另一个让人感到“困惑”的方面是,它是一个返回 func
的 func
,后者以 func
作为参数。而 for range
的主体被转换为 func
,所有的 break
(以及其他逃逸控制流)都被转换为 return false
。这只是三层深的过程(procedures),再次让人感觉像是函数式语言而不是命令式语言。
注意: 我并不是建议他们用我的方法替换迭代器设计,而是认为通用的迭代器方法可能一开始就不适合 Go。至少对我而言,Go 是一种不妥协的命令式语言,具有一流的类似 CSP 的结构。它并不试图成为一种类似函数式的语言。迭代器处于一个奇怪的位置,它们存在于命令式语言中,但作为一种概念,它们非常“函数式”。迭代器在函数式语言中可以非常 优雅,但在许多不妥协的命令式语言中,它们总是显得有些“奇怪”,因为 它们被统一为一个独立的结构,而不是将其部分分离出来(初始化+迭代器+销毁)。
题外话:Odin 的方法
正如我之前提到的,在 Odin 中,迭代器只是一个过程调用,多个返回值中的最后一个值只是一个布尔值,表示是否继续。由于 Odin 不支持闭包,相比于 Go 的 Backward
迭代器,Odin 中的等效迭代器需要编写更多的代码。
注意: 在有人说“这看起来更复杂”之前,请继续阅读本文。大多数 Odin 迭代器都不是这样的,而且我从不建议编写这样的迭代器,因为对于代码的读者和编写者来说,简单的 for 循环都是更可取的。
// 状态的显式结构体
Backward_Iterator :: struct($E: typeid) {
slice: []E,
idx: int,
}
// 迭代器的显式构造
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`
fmt.print(el, " ")
}
// 输出:c b a
由于需要编写更多的代码,这看起来确实比 Go 方法复杂得多。然而,它实际上更容易理解、掌握,并且执行速度更快。迭代器不会调用 for 循环的主体,而是主体调用迭代器。我知道 Cox 喜欢 在控制流中存储数据 的能力,我也同意这很好,但它不太适合 Odin,特别是 Odin 缺乏闭包(因为 Odin 是手动内存管理的语言)。
一个“迭代器”只是以下代码的语法糖:
for {
el, _, ok := backward_iterate(&it)
if !ok {
break
}
fmt.print(el, " ")
}
// 使用 `or_break`
for {
el, _ := backward_iterate(&it) or_break
fmt.print(el, " ")
}
Odin 的方法只是去除了魔法,让事情变得非常清楚。“构造”和“销毁”必须通过显式的过程手动处理。而迭代只是一个简单的过程,每次循环调用一次。这三个结构被分别处理,而不是像 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) {...}
// 用户代码
for record, idx, err in csv.iterator_next(&reader) {
...
}
题外话:C++ 迭代器
我尽量避免在这篇文章中对 C++ 的“迭代器”进行大篇幅的抱怨。C++ 的迭代器远不止是简单的迭代器,而至少 Go 的方法仍然是简单的迭代器。我完全理解为什么 C++ 的“迭代器”会那样设计,但 99.99% 的时间我只需要一个简单的迭代器,而不是具有所有代数性质的可以在更“通用”的地方使用的东西。
对于那些不太了解 C++ 的人来说,迭代器是一个自定义的 struct
/class
,它需要重载 operators
来使其表现得像一个“指针”。历史上,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++ 的“迭代器”至少需要你定义五种不同的操作。
以下三个运算符重载:
operator==
或operator!=
operator++
operator*
还有两个返回迭代器值的独立过程或绑定方法:
begin
end
如果让我设计 C++ 的简单迭代器,我只会在 struct
/class
中添加一个名为 iterator_next
的简单方法。就是这样。是的,这确实意味着其他代数性质会丧失,但老实说,我在处理任何问题时都不需要这些特性。当我处理这些类型的问题时,要么使用连续数组,要么手动实现算法,因为我要保证这种数据结构的性能。然而,我之所以创建了自己的语言(Odin),是因为我完全不同意 C++ 的整个哲学,并且我想远离那种混乱。
C++ 的“迭代器”比 Go 的迭代器复杂得多,但在本地操作上更“直接”。至少在 Go 中,你不需要构造一个包含五个不同属性的类型。
总结
我觉得 Go 的迭代器在设计原则上是合理的,但是看起来违背了大多数人对 Go 的看法。我知道 Go 随着时间的推移“不得不”变得更加复杂,特别是引入了泛型(我认为这些泛型设计得很好,只有一些语法上的小问题),但是引入这种类型的迭代器感觉 是错误的。
简而言之,这种设计感觉与许多人认为的 Go 的哲学相违背,并且这种方法非常具有函数式编程的风格,而不是命令式编程的风格。
正因为这些原因,我认为人们不喜欢迭代器的设计,尽管我完全理解这些设计选择。对很多人来说,它“感觉”不行 Go 最初的样子。
也许我和其他人的担忧被夸大了,大多数人实际上不会去实现它们,只是使用它们,而且它们的复杂性主要体现在实现上。
倒数第二个有争议的观点:也许 Go 需要更多地“把关”,告诉那些“函数式编程爱好者”走开,不要再要求这些特性,因为它们会让 Go 成为一种更复杂的语言。
最后一个有争议的观点:如果是我,我根本不会允许自定义迭代器进入 Go,但我不在 Go 团队(也不想在)。