对我来说,使得JavaScript如此有趣的一个原因是它函数式编程方面的特性。从一开始函数就是JavaScript世界中的一等公民。这使得通过多种方式的组合编写优雅,富有表现力的代码成为可能。
然而,仅仅是拥有一些函数式编程的能力并不代表你的代码就是函数式的。Ramda.js是一个很流行的库(GitHub上有超过4K的star),我们可以利用它来帮助我们学习使用JavaScript进行函数式编程。
入门
为了能充分利用Ramda.js,让我们通过一个Node.js项目来了解它的好处。我们可以很容易的使用Node包管理器(npm)安装它:
npm install ramda
通常我们会把这个库的功能引入到命名空间R
下。这样所有对Ramda方法的调用都有一个R.
的前缀。
var R = require('ramda');
当然我们也可以在前端代码里使用Ramda.js。在浏览器中我们只需要引入该库某个拷贝的正确路径。就像下面的HTML片段一样简单:
<script src="ramda.min.js"></script>
Ramda.js没有使用任何DOM或Node.js的专属特性。它只是一个建立在JavaScript语言(ECMAScript 5标准)现有功能和算法之上的的扩展。
准备好了吗?让我们看看它具体的功能吧!
概念
函数式编程中最重要的概念是纯粹函数。一个纯函数是幂等的并且不会改变任何状态。在数学领域这对诸如sin(x)
这样的函数来说是很自然的并且它们也不依赖任何外部状态。
除了纯粹函数以外,我们还想使用只有一个参数的函数。它们是最原始的形式。没有参数的函数通常意味着会改变外部状态,因此也就谈不上纯粹了。但是在像JavaScript这样的语言里我们通常会遇到接收多个参数的函数。
柯里化
拥有高阶函数(例如,函数可以接收一个函数作为参数并返回一个新函数)的能力以及闭包(缓存本地变量)联合起来给了我们一个很好的方式:柯里化。柯里化是一个把多个参数的函数转变为只接受一个参数并返回另一个只接收一个参数的函数的过程。这个过程持续多次直到收集到所有所需参数。
比如我们想用Ramda.js的辅助方法is
实现一个单参数函数用于检测其参数是否是string
。以下的代码可以实现功能。
function isString (test) {
return R.is(String, test);
}
var result = isString('foo'); //=> true
同样的功能使用柯里化实现就会简单很多。由于R.is
是Ramda.js的一部分,当我们传入较少的参数时,它就会自动返回一个柯里化过的函数:
var isString = R.is(String);
var result = isString('foo'); //=> true
这样的代码更富表现力。由于我们使用R.is
时只传入了一个参数,因此它返回了一个函数。在第二次调用时(这里要注意,原始的函数需要传入2个参数)我们得到了最终的结果。
但是如果一开始我们没有使用Ramda.js提供的辅助方法呢?假设在我们的代码中有以下函数:
var quadratic = (a, b, c, x) => x * x * a + x * b + c;
quadratic(1, 0, 0, 2); //=> 4
quadratic(1, 0, 0)(2); //=> TypeError: quadratic(..) is not a function
这是一个完全二阶多项式。它有4个可以是任何值的参数。但是通常我们会固定a
,b
,c
的值而只改变x
。让我们看看如果利用Ramda.js进行这种转变:
var quadratic = R.curry((a, b, c, x) => x * x * a + x * b + c);
quadratic(1, 0, 0, 2); //=> 4
quadratic(1, 0, 0)(2); //=> 4
再者,我们可以通过改变参数来实现特定子集的别名。比如,我们可以这样实现方程式x - 1
:
var xOffset = quadratic(0, 1, -1);
xOffset(0); //=> -1
xOffset(1); //=> 0
当函数没有明确的参数数量时,我们需要通过curryN
方法明确的指明参数数量。
柯里化是Ramda.js的核心,但如果除此之外没有更多功能的话这个库就显得不那么有意思了。在函数式编程中另一个重要概念就是不可变性。
不可变的结构
最简单的防止函数改变程序状态的方法就是只操作不能被修改的数据结构。对于简单对象来说我们需要只读访问器,如此一来像下边的行为是不被允许的了。
var position = {
x: 5,
y: 9
};
position.x = 10; // works!
除了把属性声明为只读以外,我们还可以把他们改成getter函数:
var position = (function (x, y) {
return {
getX: () => { return x; },
getY: () => { return y; }
};
})(5, 9);
position.getX() = 10; // does not work!
现在看起来好一些了,但是这个对象还是可能被修改。某人可以直接覆盖getX
函数的声明:
position.getX = function () {
return 10;
};
实现不可变性最好的途径是使用Object.freeze
。配合const
关键字我们可以创建一个不能被修改的不可变变量。
const position = Object.freeze({ x: 5, y: 9 });
另一个例子涉及到列表。向一个不可变列表添加一个元素需要你为原始列表创建一份拷贝并将新元素添加到拷贝的末尾。当然我们也可以在原始对象上使用不可变性的知识来进行优化。这样我们就可以用一个简单的引用替换掉拷贝。本质上,它会变成某种形式的链表。需要我们注意的是标准的JavaScript数组是可变的,因此需要通过拷贝保证正确性。
像append()
这样的方法可以操作JavaScript数组并返回这种拷贝后的数组。这个操作是幂等的;如果使用相同的参数调用函数多次,我们将得到相同的结果:
R.append('tests', ['write', 'more']); //=> ['write', 'more', 'tests']
R.append('tests', ['write', 'more']); //=> ['write', 'more', 'tests']
R.append('tests', ['write', 'more']); //=> ['write', 'more', 'tests']
(译注:这里作者举例并不恰当,因为例子中的每次调用都是在一个全新的数组字面量上执行的。并不能证明R.append
的幂等性)
还有一个remove
方法用于删除数组中的指定元素。它像下面这样工作:
R.remove('write', 'tests', ['write', 'more', 'tests']); //=> ['more']
由于该方法的参数个数是不固定的,但我们要对它进行柯里化时我们需要使用前面提到的curryN
函数。此外,Ramda.js还提供了一套通用的辅助方法。
工具方法
所有辅助方法最重要的一点就是参数被以方便进行柯里化的方式排序。一个参数变化的越频繁,那么它在参数列表中的位置就越靠后。
sum() 和 range()
常见的操作如sum和range当然可以在Ramda.js中找到:
R.sum(R.range(1, 5)); //=> 10
针对range()
方法,我们可以利用柯里化创建一个包装器:
var from10ToExclusive = R.range(10);
from10ToExclusive(15); //=> [10, 11, 12, 13, 14]
如果我们想把它包装成一个固定了最大值的函数呢?Ramda.js为我们提供了一个表示为R.__
的特殊参数:
var to14FromInclusive = R.range(R.__, 15);
to14FromInclusive(10); //=> [10, 11, 12, 13, 14]
map()
此外,Ramda.js还尝试为JavaScript内置方法,例如Array.prototype.map
,提供更好的替代方案。这些替代方案使用了不同的参数顺序并且自带柯里化功能。
对于map方法,它看起来像下面这样:
R.map(x => 2 * x, [1, 2, 3]); //=> [2, 4, 6]
prop()
另一个有用的工具是prop方法,它用来获取某个特定属性的值。如果指定属性不存在,它就返回undefined
。当这个属性的值真的是undefined
的时候,这可能会造成混淆。但在实践中我们并不会区分这两种情况。
R.prop('x', { x: 100 }); //=> 100
R.prop('x', { y: 50 }); //=> undefined
zipWith()
如果前面介绍的几个方法还不能说服你Ramda.js真的有用,那么接下来的例子对你来说可能会更有趣。这次我们不会在看某个具体的例子而是会看几个随意选择的情景。
假设我们要合并两个列表。这个功能使用zip
方法实现很简单。然而,该方法的返回值(一个新数组,其中每个元素又是一个包含两个待合并数组相同位置值的数组)有可能并不是你想要的。这时就该zipWith方法登场了。它可以使用任意函数将来自两个列表的值映射为一个。
var letters = ["A", "B", "C", "D", "E"];
var numbers = [1, 2, 3];
var zipper = R.zipWith((x, y) => x + y);
zipper(letters, numbers); // ["A1", "B2", "C3"]
类似的,我们可以创建一个计算向量点积的函数:
var dot = R.pipe(R.zipWith((x, y) => x * y), R.sum);
dot([1, 2, 3], [1, 2, 3]) // 14
我们先是使用乘法将两个数组合并(得到[1, 4, 9]
)然后将结果交给sum函数。
不管怎么说,操作可枚举的数据类型是个很大的话题。不出乎意料的,Ramda.js带来了很多有用的工具。我们已经介绍过的R.map
可以在每个元素上执行同一方法。类似的,还有一些工具用于减少元素的数量。无论是使用最常见的filter
方法(返回一个新数组)还是使用reduce
函数获取一个单一的值。
chain()
Ramda.js自带了一些操作数组的有用方法。例如,使用chain可以很容易的合并数组。假设我们有一个primeFactorization
函数接受一个数字作为参数并输出该数字的质因子。我们可以像下面这样将该函数作用于一组数字并把结果合并在一起:
R.chain(primeFactorization, [4, 7, 21]); //=> [2, 2, 7, 3, 7]
一个实际的例子
到目前为止一切顺利。现在最大的问题是:通过在日常工作中使用Ramda.js引入的概念,我们能得到什么好处呢?假设我们有如下(看起来已经不错了)的代码片段:
fetchFromServer()
.then(JSON.parse)
.then(function (data){ return data.posts })
.then(function (posts){
return posts.map(function (post){ return post.title })
});
如何利用Ramda.js使这段代码变得更加可读呢?事实上,第一行其实已经很好了。第二行就已经变得杂乱了。我们要做的不过是提取传入参数的posts
属性。
最后,我们有一个凌乱的第三行。这里我们想要循环所有post。同样的,这里的目的只是想抽取一个特定的属性。下面的解决方案怎么样?
fetchFromServer()
.then(JSON.parse)
.then(R.prop('posts'))
.then(R.map(R.prop('title')));
得益于Ramda.js提供的函数式编程能力,这可能是最具可读性的方案了。然而,要知道ECMAScript 6引入的‘胖箭头’语法也提供了一种简单,易读的方案:
fetchFromServer()
.then(JSON.parse)
.then(json => json.posts)
.then(posts => posts.map(p => p.title));
在不需要任何Ramda.js知识的情况下,这几乎拥有同样的可读性。此外,我们还减少了抽象 —— 这对性能和可维护性是有益的。
Lenses
最后,我们也应该讨论一下有用的对象辅助方法。这里我们要提到的是lens方法。
lens是一个特殊的对象,它可以和一个数组或对象一起作为参数传递给Ramda.js函数。它允许这些函数读取或操作对应对象或数组上的特定属性或索引的值。
假设我们有一个带有两个键x
和y
的对象 —— 就像在本文开始提到的不可变性中的例子那样。我们可以创建一个lenses来‘聚焦’到我们感兴趣的属性上,而不是使用getter和setter创建一个新的包裹对象。
要创建一个访问x
属性的lens,我们可以这么写:
var x = R.lens(R.prop('x'), R.assoc('x'));
prop
是一个标准的getter方法(这一点已经介绍过了), assoc是一个setter方法(语法包括3个值: key, value, object)。
现在就可以使用Ramda.js中的函数访问该lens定义的熟悉了。
var xCoordinate = R.view(x, position);
var newPosition = R.set(x, 7, position);
要注意的是这里的操作并不会修改position
对象(不管我们是否forze过它)。
要知道的是set只是over的一种特殊形式。over和set
很类似只不过第二个参数接受一个函数。这个函数会被用来改变属性的值。例如,下面的函数将会把x坐标乘3:
var newPosition = R.over(x, R.multiply(3), position);
Ramda.js, lodash, 还是其他什么东西?
一个合理的问题是为什么要选择Ramda.js?为什么不是lodash或是其他的什么东西?当然有人可能会争辩说Ramda.js更新,因此一定更好。但这并不是事实。事实是Ramda.js的设计充分考虑了函数式编程的原则 —— 在参数位置和选择方面使用了新的方式。
举例来说,Ramda.js中的列表迭代器默认只会传入循环元素,而不包括列表。另一方面,其他库(比如lodash)的标准做法是给回掉函数传入元素和索引。这看起来是个微不足道的问题,但它阻碍了你使用像parseInt()
这样的内置函数(接受一个可选的第二个参数)。然而,Ramda.js却可以完美的工作。
最后,选择哪个库需要由具体的需求或是团队的经验/能力来决定,但也有一些不错的应该重视Ramda.js的理由
测试地址:
http://ramdajs.com/repl/?v=0.23.0