为什么程序员都讨厌 reduce?
Anecdotally, programmers dislike "reduce"
在我的日常开发中,map 和 filter 几乎从未在代码审查中引发争议,但 reduce 却是个例外。每次提交包含 reduce 的代码,我总会收到“难以阅读”的反馈。尽管我在 Clojure 开发时并未遇到这种情况,但在 JavaScript、Python 和 Swift 等语言中,reduce 似乎总显得格格不入。我推测这可能源于其可读性较差、开发者熟悉度不足,或是性能与优雅度上的劣势。虽然我个人偏爱 reduce,但为了团队协作的顺畅,我通常会将其替换为其他写法。这似乎是一个有趣的社交现象,不知你是否也有同感?
简而言之:根据我的经验,人们喜欢 map 和 filter,但不喜欢 reduce。
HN 评论区
263- rspeele
Map 和 filter 通常只有一个参数,如果有两个,第二个几乎总是基于 0 的索引。即便微软把它们叫作 Select 和 Where,它们在大多数语言里看起来也一模一样。
Reduce 则有一个累加器和一个双参数函数,而且各语言之间并不一致:有的是 reduce(initial_acc, callback(acc, elem)),有的是 reduce(callback(acc, elem), initial_acc),有的是 reduce(callback(elem, acc), initial_acc),等等。
很难记。此外,有些语言里的 reduce 版本甚至根本不接收初始累加器,这简直是个等着你去踩的陷阱,一旦遇到空集合就完蛋。还有还有,在那些不支持匿名类型、或者难以轻松修改匿名类型记录的语言中,累加器很容易变得很别扭。而这样的语言占了大多数!
- chubot
关于性能更差这一点,我相当确定我就在现场,见证了 reduce 被“放逐”出 Python 3 的那一刻——从 Python 2 的内置 reduce() 降级为 functools.reduce()。
故事是这样的:2006 或 2007 年某时,Guido van Rossum 正在调试为什么 Google 内部代码审查工具(是他写的)里的一个网页渲染需要 30 多秒。
这基本上算是一个“生产事故”,因为成千上万的 Google 工程师都依赖这个工具。这类请求可能占用了线程并耗尽了线程池。
最终追踪到了一行用 reduce() 编写的换行算法。我不确定是不是他写的——可能是通过某个依赖引入的。正如大家所知,reduce() 本质上就是:
s1 + s2
s1 + s2 + s3
s1 + s2 + s3 + s4
...
当 s_i 是字符串时,这就是 O(n^2) 的复杂度。我记得如果你查看一个 5000 行以上的 diff,或者一个 5000 行以上的文件,就会触发这个问题。(像 Github 这样的新程序也受此影响)
我相信,当时的 Python 中 += 已经被优化以避免这种情况(就像几乎所有 JS 虚拟机一样)。或者你可以使用向列表 append() 然后 join() 的习惯用法。
但 reduce() 基本上强制使用了这种低效的实现,而且我确信在 Python 3 中依然如此。
---
所以基本上,Guido 花了很多时间调试一个与 reduce() 相关的性能问题,并决定将其移除,以帮助用户避免“陷阱”。当时我是他的同事,所以我记得这件事,但我不是 i […]
- ggorlen
Reduce 很好地诠释了“最小权力原则”[1]:它强大、灵活、通用且处于底层,技术上可以实现求和、map、filter、find/includes、some/any/every 等任何组合。但如果用它去重新实现那些已有更高级形式可用的模式,那就是误用。
在确实需要 reduce 的情况下(例如 JS 没有 sum 函数),应保持简单。如果 lodash 的 _.sum() 不可用,`arr.reduce((acc, el) => el + acc, 0)` 是可以接受的。
在确实需要 reduce 的情况下,是因为像 map/filter 这样的高级操作不够灵活,那么应将归约操作分解为更简单的步骤,使用多次 map/filter,或者写一个传统的 for..of 循环。
这一原则也解释了为什么增强型/range/of 循环优于基于计数器的 `for` 循环,而基于计数器的循环又优于 `while`。从技术上讲,所有循环都可以用 `while` 处理,但很少需要,因为增强型循环用更清晰的语法处理了常见情况。Reduce/while/基于计数器的 `for` 循环都是反模式,当存在更高级、权力更小的抽象时。
- snackbroken
Map 和 Filter 很好,因为它们让你能孤立地针对单个元素进行局部推理。Reduce(Fold)则强迫你针对中间结果进行全局推理。Reduce 还强迫你构想出相关类型的“零”值,这通常不难,但确实构成了一些额外的认知负担。
- el_oni
我在工作中只用过 reduce 六次左右,每次都会让人挑挑眉。
但对于合并一堆 spark dataframes,我觉得
df = reduce(DataFrame.union, list_of_dfs)
比
df, *rest = list_of_dfs
for other in rest:
df = df.union(other)
要优雅得多。
人们就是有点反应过度,尤其是现在你还得从 functools 导入它。
- catapart
对我来说,问题在于名字[0]。map 输出一个由另一个数组映射而来的数组。filter 输出一个输入数组的过滤结果。这两者永远为真。而 reduce 则不同,它可能输出输入数组的归约结果(大部分时间确实如此),但既然它不一定输出归约结果,那么实际发生的事情就根本不是“归约”。在像 js/ts 这样的语言中,你甚至不需要返回与输入数组元素类型相同的东西。你完全可以“归约”一个整数数组得到一个取消令牌(cancellation token),或者一个状态对象,或者任何东西。
我意识到这不是最高效的工作方式,但我喜欢我的代码读起来像指令。reduce 能做的,for 循环都能做到,而且 for 循环(当然加上一个累加器)比 reduce 更“可读”。如果我读到 map,我就知道在发生什么。如果我读到 filter,我也知道。如果我读到 reduce,我就得去搞清楚在发生什么,哪怕我很有把握知道是什么。如果我能指望 reduce 总是返回输入数组的一个元素,我会更多地使用它。但既然它可以返回任何东西,我更喜欢 for 循环的简单性。
[0] 我没有关于“更好”名字的建议,因为整个操作很难用一个词概括?“dispatch”说得通,作为一个函数在数组的每个元素上分发函数,但它掩盖了从返回值中累积的概念。“transform”是准确的,但几乎无法描 […]
- hibikir
你在 Clojure 里不会听到这种抱怨,就像在 Scala 或 Haskell 里也不会一样,因为一旦你期望用户懂一点范畴论,并且可能还会用类型思维思考,这一切就都很容易了。即使是 fold,尽管签名更复杂,也还算容易。但对于那些不习惯函数式编程的人来说,传递 [A][A,A =>A] 就很糟糕,而 [A][A,B => A] 更糟。这往往糟糕到让人去构建一个比较器。
每种行业语言都在不断增加更多函数式特性:许多新版 Java 都在添加一堆 Scala 特性,但语法更差。但我们根本不在函数式编程上培训人们,所以等到他们形成直觉时,传递函数对他们来说毫无意义,不可变性是陌生的,纯函数的概念对他们来说似乎无关紧要。因此,他们接触不到那些让 reduce 看起来简单的构建模块。我们总是教他们递归,但其他的呢?为时已晚,太少太迟。
我可以告诉你很多简化签名的方法,比如强制传递一个幺半群(monoid)之类的东西,但虽然签名会更容易,那些只习惯命令式面向对象的人并不会觉得更轻松,因为他们可能学过两年微积分,却从未闻过抽象代数的气味。你不仅可以从编程训练营毕业,甚至可以从许多计算机科学学位毕业,而对此一无所知。因此 […]
- japgolly
我假设作者说的是 `fold`,即 `[A] -> B -> ((B,A) -> B) -> B`,而不是我通常认为的 reduce,即 `[A] -> ((A,A) -> A) -> A`。
`fold` 很棒,超级有用。它是将集合转换为单个值最简单、最方便的方法。把我归到相反的那一类吧。