当前位置: 首页 > news >正文

为什么人们对即将推出的 Go 1.23 迭代器感到愤怒

在这里插入图片描述

原文: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) - 1return func(onBreak bool) (idx int, elem E, ok bool) {if onBreak || !(i >= 0) {// 这里放清理代码,如果有的话return}idx, elem, ok = i, s[i], truei--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 routinesselect 语句和许多其他结构。然而,我认为这有点过于神奇了,因为它向用户暴露了太多的魔法,同时对普通 Go 程序员来说显得过于复杂。

另一个让人感到“困惑”的方面是,它是一个返回 funcfunc,后者以 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, trueit.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_breakfmt.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 团队(也不想在)。

相关文章:

  • 开关阀(1):定位器与电磁阀的区别
  • HarmonyOS【ArkUI组件--TextInput】
  • AWS无服务器 应用程序开发—第八章 计算服务(AWS Lambda)
  • 一文理清OCR的前世今生
  • 【千帆AppBuilder】你有一封邮件待查收|未来的我,你好吗?欢迎体验AI应用《未来信使》
  • 查看服务器端口,如何查看服务器端口是多少并修改
  • 深度解析“科技信贷”:构建科技支行的五维模型
  • 【AI-5】Pointnet
  • 【BFS算法】广度搜索·由起点开始逐层向周围扩散求得最短路径(算法框架+题目)
  • Boost 网络库
  • 【植物大战僵尸杂交版】最新版2.1下载链接
  • 线程池的艺术:深度解析Java多线程并发性能的优化之道
  • 【AIGC】MetaGPT原理以及应用
  • 创建activity中的context
  • 什么是SLA
  • avalon2.2的VM生成过程
  • Java 网络编程(2):UDP 的使用
  • java2019面试题北京
  • opencv python Meanshift 和 Camshift
  • React-Native - 收藏集 - 掘金
  • Redis的resp协议
  • ViewService——一种保证客户端与服务端同步的方法
  • 人脸识别最新开发经验demo
  • 入职第二天:使用koa搭建node server是种怎样的体验
  • 使用 Node.js 的 nodemailer 模块发送邮件(支持 QQ、163 等、支持附件)
  • 通过几道题目学习二叉搜索树
  • ionic入门之数据绑定显示-1
  • shell使用lftp连接ftp和sftp,并可以指定私钥
  • Spark2.4.0源码分析之WorldCount 默认shuffling并行度为200(九) ...
  • 如何用纯 CSS 创作一个菱形 loader 动画
  • 曾刷新两项世界纪录,腾讯优图人脸检测算法 DSFD 正式开源 ...
  • ​Java基础复习笔记 第16章:网络编程
  • # C++之functional库用法整理
  • #微信小程序:微信小程序常见的配置传旨
  • (173)FPGA约束:单周期时序分析或默认时序分析
  • (AtCoder Beginner Contest 340) -- F - S = 1 -- 题解
  • (java)关于Thread的挂起和恢复
  • (java版)排序算法----【冒泡,选择,插入,希尔,快速排序,归并排序,基数排序】超详细~~
  • (pojstep1.3.1)1017(构造法模拟)
  • (板子)A* astar算法,AcWing第k短路+八数码 带注释
  • (超简单)使用vuepress搭建自己的博客并部署到github pages上
  • (附源码)springboot 智能停车场系统 毕业设计065415
  • (附源码)基于SpringBoot和Vue的厨到家服务平台的设计与实现 毕业设计 063133
  • (简单有案例)前端实现主题切换、动态换肤的两种简单方式
  • (四)模仿学习-完成后台管理页面查询
  • (学习日记)2024.01.09
  • (转)shell中括号的特殊用法 linux if多条件判断
  • (转载)在C#用WM_COPYDATA消息来实现两个进程之间传递数据
  • .“空心村”成因分析及解决对策122344
  • .NET 4.0中的泛型协变和反变
  • .NET Standard 的管理策略
  • .NET/C# 阻止屏幕关闭,阻止系统进入睡眠状态
  • .NET/C#⾯试题汇总系列:集合、异常、泛型、LINQ、委托、EF!(完整版)
  • .net经典笔试题
  • .NET与java的MVC模式(2):struts2核心工作流程与原理