[译] Ranging Near and Far

82 阅读6分钟

原文信息


声明

代码 100% 还原,翻译了部分注释。

直译感觉不妥的地方和几处换行按自己理解加工过,欢迎提出建议意见。


假如一个函数,有三个参数,其中第一和第三参数是可选的:

function f(a, b, c) { /* ... */ }

调用就传俩参数,你估计函数会怎么跑?

f(x, y);
  --> {a == x, b == y, c == undefined, ...} // or
  --> {a == undefined, b == x, c == y, ...} // or
  --> // something weirder depending on the actual values of x, y?

很难啃腚吧?


我正试着写一篇文章,关于 Ramda 背后哲学,重点关注几个重要的特性,包括简洁,Mike 让我注意到另一个库的一个函数,它一点也不简单。Ramda 也有个类似的函数,比较函数 API 没准儿能帮我理解两个库做的不同权衡。文章写不下去,正好换换脑子。

Underscore 文档 里是这么描述 range 1 的:

.range([start], stop, [step]) 

此函数用于创建 灵活有序 的整数列表,供 eachmap 循环。

start 如果省略,默认为 0;step 默认为 1。

返回从 start 到(不含)stop 的整数列表,以 step 递增(或递减)。

注意,stop 小于 start 会返回空数组 —— 要是想倒着排,还得传负数 step

示例

_.range(10);         //=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
_.range(1, 11);      //=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
_.range(0, 30, 5);   //=> [0, 5, 10, 15, 20, 25]
_.range(0, -10, -1); //=> [0, -1, -2, -3, -4, -5, -6, -7, -8, -9]
_.range(0);          //=> []

Ramda 里 对等的函数 是这个:

R.range :: Number -> Number -> [Number]

返回一个从 from 到(不含)to 的数字列表。

示例

range(1, 5);   //=> [1, 2, 3, 4]
range(50, 53); //=> [50, 51, 52]

灵活度

Underscore 的函数明显更灵活。列表能正向也能反向,step 的值都能传不同的。

Ramda 出的晚,为何没有 Underscore 那种灵活度?请看三个充分理由:

  • 没看过。既然 range 函数是刚需,我就得写一个。高端的 API,往往只需要 ____ 的写法2,包容还是排他呢?思来想去,排他性的更好,能和 for 循环合作愉快。Python 的 range() 都知道吧,看 文档 就确定,我要写成排他性的。Underscore 我压根儿没看。

  • 用不着。YAGNI3 原则很强大。[20, 18, 16, 14, 12, 10, 8] 这种需求的列表,我反正没遇到过。就因为万一哪天有人用得上,去提升灵活度,等于踏上不归路。你想想,for 循环写成 for var i in [1 .. 10] {/* ... */} 会简单多少?你又会因为没用上当前丑陋的 for 循环语法提供的灵活而叹息几次?我一年一两次吧。不过无所谓,罕见情况总能应对。

  • 太复杂。文章最开始的问题,当然和 Underscore 的函数有关。因为函数够复杂才能应对罕见情况,导致每次用之前基本上都得先看文档。Ramda 一切从简,一个函数只做一件事。

简洁 vs 费解

R.range() 的用法,聪明的你一想便知。边缘情况(传非整数将如何?to 小于 from 又将如何?)别去想。传一个初始值和一个比结束值大 1 的值就行:R.range(1, 4); //=> [1, 2, 3]

再看 Underscore,也是这样调用 _.range(1, 4); //=> [1, 2, 3]。它还能创建反向列表。那要是想创建反向列表,是不是得 _.range(4, 1) 这样?不行,因为结果含第一参数,不含第二参数。想从 3 开始,_.range(3, 2) 又会直接停在结束值之前。它得对称,得刚好停在,正向列表例子传的第一参数 1 那儿。那这样 _.range(3, 0) 能行吧?

也不行。

看定义吧,那句提示最重要:

注意,stop 小于 start 会返回空数组 —— 要是想倒着排,还得传负数 step

所以,创建递增列表,可以不传 step,但是递减的得传,即便以 1 递减。

现在再看文档里关键的一句。怪哉,给人家放到第三句:

返回从 start 到(不含)stop 的整数列表,以 step 递增(或递减)。

屏幕前的你,能知道「不含」stop,是因为读过 Ramda 文档,或者看过文中示例,亦或诸葛再世。尽管 API 本身没问题,是文档的问题,可二者关系密切。简单的函数,其文档往往也简洁明了,复杂的则冗长费解。

依我看,Underscore 最复杂的就是参数,群魔乱舞,一会儿 可选,一会儿 必传,过会儿又 可选。Ramda 不搞 可选参数,稳如泰山的参数排前面,变化多端的放最后,通过简单的柯里化,用旧函数构建新函数会很容易。可以从 step 入手,代入 1 构建 range 写出 steppedRange 函数。或者想做成递减的,简单实现一下,文档再一改,齐活儿。很容易就更新成了 R.range(3, 0); //=> [3, 2, 1]

可是真没必要。就像 Rich Hickey 讲的,保持简单更重要。

如果真的需要呢?

看到这里,你一定也会觉得 Ramda API 比 Underscore 的简单。你可能想问,我要是真的需要 Underscore 里某些特性呢?实用函数库不是应该考虑到尽可能多的场景吗?

我的回答是,一个好的 API 应该让常见的场景的变得容易,不常见的场景变得可能。

Ramda 不提供 Underscore 的倒着排 _.range(3, 0, -1); //=> [3, 2, 1],它是围绕着组合小函数的概念构建的。两个函数的简单组合就能完成:

R.reverse(R.range(1, 4)); //=> [3, 2, 1]

能接受吧。

如果需要跨着排呢?Underscore 又出招 _.range(10, 20, 2); //=> [10, 12, 14, 16, 18],Ramda 如何接招?

看招:

var evens = R.reject(R.modulo(R.__, 2));
evens(R.range(10, 20)); //=> [10, 12, 14, 16, 18]

如果想更通用,想随便传 startstopstep 呢?

这样就行了:

R.reject(R.pipe(R.add(R.modulo(-start, step)), R.modulo(R.__, step)))(R.range(start, stop));

我这个,只是 Underscore API 的大体实现,完全实现就别了,我懒得写。(既然有了处理正数 step,剩下就的留给读者作为练习。)一个系统如果需要这种灵活度应当明确地指出,并且专函数专办。谁也不愿意搞乱代码库。但并不是说让你的每个函数都成 Ramda 里的那样。Ramda 是为了让你自己动手容易,但自己的 API 过于倾向简单可就错了。

Footnotes

  1. 本文开始书写时,Underscore 版本为 1.13.6,文档中 range() 的描述和默认行为与原文发布时的已经不同。

  2. 高端的食材往往只需要最朴素的烹饪方式。——《舌尖上的中国》

  3. You Aren't Gonna Need It —— 等需要了再去实现。