Lambda Academy

为什么要避免写 for 循环

December 02, 2018

我在写《如何在 JS 代码中消灭 for 循环》的时候,以为我所倡导的应该已经是一个共识,但没想到会有这么大争议,甚至有些编程经验丰富的前辈也反对。所以,我觉得还是有必要再说明一下。我没想说服所有人,只是想尽到把问题说清楚的责任。

欢迎大家讨论。合理的问题我会回复,但只会回复一遍。评论前尽量看下之前的评论。为了方便大家看评论,我会删除无效评论。

Edit: 这篇文章用了早上一个小时,成文比较仓促,问题没说清楚。几个点我再补充一下:我是说尽量在业务逻辑里避免 for 循环,不是不计一切代价从语言层面避免。每一个抽象层有相应的写法策略。for 循环用来写底层是没问题的,就像一些朋友说的封装成一个函数。但是再往高一点的抽象业务层,用 for 循环会带来我说的应用层面的伸缩性和扩展性等问题。

一,性能问题

这是我看到的关注最多的点了。先推荐去 FunFunFunction 看下 MPJ 老师对这个问题怎么说 Fast code is NOT important. 微观层面的极小性能差异,不会成为你整个应用的性能瓶颈。

还见到有朋友说,V8 引擎对 for 循环有优化。拜托,V8 引擎对高阶函数也有优化啊。就我最近知道的例子,V8 引擎的 Array.prototype.sort 方法的底层实现,以前是在排序项少于10个的时候用插入排序,在排序项大于10个的时候用快速排序。就在几个月前,V8 使用了更稳定的 TimSort 算法替代了快排。我不了解 TimSort, 但据说是目前性能最好的排序算法。

如果你给几十上百个元素排序(说实话我也不知道前端排序,需要考虑性能的阈值在哪,不测怎么知道?),一开始就考虑性能不用高阶 sort 函数,而是写个 for 循环实现 TimSort,这不是对开发资源的浪费?

Edit: 我在举这个例子的时候是想说明浏览器对语言指令的优化,也包括高阶函数,当时没想到,原生的 sort 方法也会改变原数据。

我前段在 Twitter 上看到一个小插曲,一个开发者自己写了个方法(时间久了,我忘了是什么方法),发布到 npm 让人去试用。别人问这个方法原生已经有了,你写这个干嘛?他说这个性能好!结果被人怼 “Use the language!”

建议大家关注下 V8 团队,看他们的工程师对 JS 开发者的建议是什么。其实底层引擎和开发社区是一直在良性互动的,引擎团队也想让开发者体验好一点,让自家的产品更有竞争力。比如,React 最近搞出 Time Slicing 技术(Vue 3.0 也会跟进)后,V8 团队就觉得为啥不让浏览器原生支持这个呢?未来的 Chrome 可能会支持 Scheduler API.

Edit: 刚刚才想起来,这个 API 不应该是 V8 引擎去加,而是 Chrome, 写的时候可能脑子短路了…… 到现在也没人指出这个问题,hmmm

性能也不是应用要考虑的唯一因素,如果是的话,那可能大部分应用都要用 WebAssembly 重写了。某些情况下确实需要用到这种极端手段,但大多数时候,可维护性和开发效率,是优先于性能的。

二,指令式 V.S. 声明式

指令式(Imperative)编程就是告诉程序每一步怎么做。比如用 jQuery,告诉某个元素,先左移 10 px,再转个圈,再变个颜色闪几下,然后自己消失吧…… 每一步都要告诉操作对象具体指令是什么。

声明式(Declarative)编程就是告诉程序我想要达到怎样的效果,至于是怎么实现的,其它独立模块已经写好了,组合起来就行了。比如我在这篇文章里用 Rx.js 实现的动画:

const moveDown$ = duration(1500).pipe(
    map(easeInQuint),
    map(distance(700)),
    tap(y => (targetDiv.style.top = y + "px"))
);

这里的意思就是,告诉目标元素,在 1500 ms 内,以 easeInQuint 的曲线加速度,向下移动 700 px. 是不是很好懂?我只是说了我要什么,具体怎么做的,都在相应独立模块里面。而这些独立模块写一遍就行了,甚至能跨项目复用。想象一下指令式代码能这么灵活和易读吗?

而 for 循环就是指令式的。指令式代码难伸缩,难复用,而且全是实施细节(implementation details),易读性极差。有相当一部分人说高阶函数易读性差,for 循环易读性好。我个人观点是这些朋友需要锻炼下程序抽象能力,for 循环几乎能做到零抽象,读抽象层次低的指令式代码,当然好懂,但不意味着好读。举个例子:

// 在产品列表中找到相应产品,提取出价格,再把价格格式化
const formalizeData = compose(formatCurrency, pluckPrice, findProduct);

formalizeData(products)

compose 里面的独立函数实现细节我就不写了。这种写法优势在哪?首先,这种代码几乎不用注释,从右往左读一遍函数名就知道在干嘛。其次,compose 里面的三个独立函数由于是纯函数,可以在其他地方复用。如果用 for 循环一步到位实现 formalizeData 函数,那就没办法复用了。

仅仅为了性能,代码伸缩性和扩展性都不考虑,实在是舍本逐末。

三,改变数据

for 循环由于太底层了,其设计初衷就是执行作用(effects),用来高效执行底层指令。而开发应用时,对于作用是要严格限制的。让你的程序副作用散布在程序各个角落,很容易造成难以发现的 bug。什么是副作用?在一个函数执行计算时,产生了计算目的之外的行为,即是副作用。比如,有一个由数字组成的数组,你想把每一个数字加一个货币符号,然后你用 for 循环把每一个数组元素加了个 $. 这就是副作用,你本来只想要一个格式化价格组成的数组,结果你把原数据改了。如果用 map,就不会改变原数据,而是返回一个新的符合要求的数据。

你也可以说我在 for 循环开始声明一个空对象/数组,然后往这个空对象/数组里 assign/push,这样确实能避免上面说得到的问题。但这种规约靠自觉,而且指令式代码太自由了,难以保证每个人都清楚自己没引起副作用。

副作用最大的问题是无法组合,所以函数式编程才会从数学里面引入抽象层次那么高的 Monad 来解决这个问题。当然这个扯远了,只是想说计算机科学家和数学家们为了驯服副作用费这么大劲,是有原因的。

当然,如果你是框架和底层库的开发者,忽略我所说的。这些高手也不需要看这篇文章了。

补充一下,这篇文章发出来后有个做 C++ 开发的朋友微信告诉我他的想法,这里分享出来。首先声明我不懂 C++ 和底层,仅为参考。

我开发语言写 C++ 写的比较多,用一些计算类语言的时候,特别是大样本其实他们底层设计的不太合理,有些脚本类语言在 for 的时候底层没有考虑过内存析构的问题,导致内存溢出很夸张。反而是近些年的一些开源高级函数能意识到这种问题并作出了修正。。其实底层开发,特别是做通讯传输,拆包等问题上只能用 for,如果是面向业务等高级语言,傻乎乎写 for 就真的是划水外加不思考不学习了。。。


Lambda Academy

Lei Huang

这个博客我主要分享函数式编程和前端开发。我还有一个英文网站