【外评】为什么人们对 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 为参数的返回 funcfunc。而且,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

你也许感兴趣的:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注