为什么人们对即将推出的 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) - 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 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, 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 团队(也不想在)。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/723166.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

35 Debian如何配置Postfix+Dovecot

作者:网络傅老师 特别提示:未经作者允许,不得转载任何内容。违者必究! Debian如何配置Postfix+Dovecot 《傅老师Debian知识库系列之35》——原创 ==前言== 傅老师Debian知识库特点: 1、拆解Debian实用技能; 2、所有操作在VMware虚拟机实测完成; 3、致力于最终形成Deb…

网安人必备!开源网络安全工具TOP 10(附下载地址)

工欲善其事,必先利其器。对于广大的网络安全从业者,以及未来想要从事网络安全的人来说,选择并善用合适的网络安全工具,能有效提升工作效率。 开源网络安全工具之所以能够在众多安全解决方案中脱颖而出,不仅是因为它们…

1.22 LeetCode总结(基本算法)_位运算

进制的概念 进制即进位计数制,是利用固定的数字符号和统一的规则的带进位的计数方法。 任何一种进位计数制都有一个基数,基数为 X 的进位计数制称为 X 进制,表示每一个数位上的数运算时都是逢 X 进一。 504. 七进制数 手法1:当…

小林图解系统-二.硬件结构 2.4CPU缓存一致性

CPU Cache的数据写入 CPU和内存的访问性能越差越大,于是在CPU内部嵌入CPU Cache(高速缓存)。 CPU Cache由Cache Line组成,Cache Line由头标志Tag数据块Data Block组成。 如果数据写入Cache,内存和Cache相对应的数据将不同,需要…

排序(3)【归并排序】【计数排序】【排序算法度及其稳定性分析】

一.归并排序 归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有…

CCAA质量管理【学习笔记】​​ 备考知识点笔记(六)质量改进系统方法与工具

第七节 质量改进系统方法与工具 1 质 量 改 进 方 法 概 述 可以说几乎每种质量管理领域的方法与工具都可以用于质量改进,但是一个组织在改进的整体推进中,往往不是采用单一的方法,会涉及多种改进的工具和手段,并依据一定的模式…

虹科免拆诊断案例 | 2022款问界M5增程式混合动力车充电口盖指示灯不工作

故障现象 一辆2022款问界M5增程式混合动力车,搭载1.5T发动机和发电机作为增程器,累计行驶里程约为3.6万km。该车因尾部受到碰撞进厂维修,维修后进行慢充,发现充电口盖指示灯不点亮(图1),但仪表…

开放式运动耳机哪个品牌音质好用又实惠?五款2024高口碑产品精选力荐!

​开放式运动耳机在如今社会中已经迅速成为大家购买耳机的新趋势,深受喜欢听歌和热爱运动的人群欢迎。当大家谈到佩戴的稳固性时,开放式耳机都会收到一致好评。对于热爱运动的人士而言,高品质的开放式运动耳机无疑是理想之选。特别是在近年来…

要颜值有颜值,有性价比有性价比,华硕天选键、鼠组合分享

作为ROG产品的忠实粉丝,用过不少ROG 相关的产品,近期华硕天选TX98和天选MINI 鼠标的发布,独特配色令我眼前一亮。 华硕天选TX98键盘,作为新品,从看上的第一眼就觉得这款键盘是非常值得推荐。 它完美地诠释了潮玩新次元…

联想618收官:以69亿销售额勇夺15冠王

随着6月19日零点钟声的响起,今年的618年中大促正式落下帷幕。在这个备受瞩目的购物狂欢节里,联想凭借出色的产品表现和市场策略,再次展现了其强大的品牌实力和市场号召力。 数据显示,在今年618活动期间,联想全网销售额…

关于微信小程序(必看)

前言 为规范开发者的用户个人信息处理行为,保障用户的合法权益,自2023年9月15日起,对于涉及处理用户个人信息的小程序开发者,微信要求,仅当开发者主动向平台同步用户已阅读并同意了小程序的隐私保护指引等信息处理规则…

elasticsearch的安装和配置

单节点安装与部署 我们通过docker进行安装 1.docker的安装 如果以及安装了docker就可以跳过这个步骤。 首先更新yum: yum update安装docker: yum install docker查看docker的版本: docker -v此时我们的docker就安装成功了。 2.创建网络 我们还需要部署kiban…

计算机专业毕设-springboot论坛系统

1 项目介绍 基于SSM的论坛网站:后端 SpringBoot、Mybatis,前端thymeleaf,具体功能如下: 基本功能:登录注册、修改个人信息、修改密码、修改头像查看帖子列表:按热度排序、按更新时间排序、查看周榜月榜查…

SSMP整合案例

黑马程序员Spring Boot2 文章目录 1、创建项目1.1 新建项目1.2 整合 MyBatis Plus 2、创建表以及对应的实体类2.1 创建表2.2 创建实体类2.2.1 引入lombok,简化实体类开发2.2.2 开发实体类 3、数据层开发3.1 手动导入两个坐标3.2 配置数据源与MyBatisPlus对应的配置3…

创建第一个Springboot项目(环境准备、环境存在的问题、启动时存在的问题、启动的方式)

一、环境准备 专业版创建springboot,直接有一个选项可以选择 社区版,需要下载一个spring的插件 不要直接点 install 因为这个插件是付费的,直接点安装只有30天使用期限 在里面找免费版本的下载 然后安装 安装完成后,这个插件名会变…

记一次生产事故,来来回回搞了一个月

🍅我是小宋,关注我,带你轻松过面试 读源码。提升简历亮点(14个demo) 🌏号:tutou123com。拉你进专属群。 记一次生产事故,来来回回搞了一个月 背景 我们的主要业务是台湾省的一个小…

免费分享:2014-2021年OSM中国POI数据(附下载方法)

OpenStreetMap(OSM)是一个全球性的开源协作地图项目,允许任何人编辑和分享地理信息,旨在创建自由、准确且可广泛使用的世界地图。POI是“Point of Interest”的缩写,意为“兴趣点”。 OSM POI矢量数据是OpenStreetMap项…

通过MATLAB实现PID控制器,积分分离控制器以及滑模控制器

目录 1.课题概述 2.系统仿真结果 3.核心程序与模型 4.系统原理简介 5.完整工程文件 1.课题概述 通过MATLAB实现PID控制器,积分分离控制器以及滑模控制器。通过对比三个算法可知,采用滑模控制算法,其具有最快的收敛性能,较强的鲁棒性&…

安装idea后配置的全局配置

1、打开IDEA应用:Customize→All settings...,如果启动IDEA后,默认打开的是之前的项目,可以关闭当前项目:File→Close Project,就退到全局配置界面了。 2、打开全局配置界面:Editor→File Encod…

zustand 状态管理库的使用 结合TS

zustand 是一个用于React应用的简单、快速且零依赖的状态管理库。它使用简单的钩子(hooks)API来创建全局状态,使得在组件之间共享状态变得容易。 React学习Day10 基本用法 安装:首先,你需要安装zustand库。 npm insta…