我正在参加「掘金·启航计划」
如果你使用过或者学习过函数式编程,那么你大概率知道 Ramda 这个库,这是一个很优秀的函数式编程库。 关于函数式编程在这里就不做过多介绍了,本文主要是对 Ramda 源码中部分 API 实现的一个简要分析,其实我本人并没有使用过这个库,只是在接触函数式编程的过程中了解到的,大概了解了一下基本用法之后,发现确实有点意思的,尤其是这段代码
const sum = (a, b, c) => a + b + c
const curried = R.curry(sum)
const res = curried(1, 2, 3)
const res2 = curried(1)(2)(3)
const res3 = curried(1, 2)(3)
const res4 = curried(1)(2, 3)
// 以上写法都是等价的,也就是说他们的结果都是一样的
// 还可以多次调用
const f = curried(1)
const g = f(2)
const res5 = g(3)
// 甚至还可以传入占位符
const res6 = curried(_, 1)(2)(3)
// 以上写法都可以得到同样的结果
相信看完这段代码之后,你会和我一样对它背后的实现原理产生极大的好奇心。
本文作为 Ramda 源码解析系列的第一篇,我会先挑其中的几个 API 来看,之后可能会继续更新。
占位符 R.__, 可以对任何位置的参数进行占位。
// 假设 g 代表柯里化的三元函数,_ 代表 R.__,则下面几种写法是等价的:
g(1, 2, 3)
g(_, 2, 3)(1)
g(_, _, 3)(1)(2)
这里先提一嘴,源码中的很多 API 都用到了这个。
add
add 方法是文档中第一个 API,也是比较简单的,就先从这个入手。先来看下这个函数的用法
R.add(2, 3); //=> 5
R.add(7)(10); //=> 17
const f = R.add(1)
f(2) //=> 3
const f = R.add(R.__, 3);
f(2) //=> 5
显而易见,这就是个用来做两数相加的方法,两个参数可以一次性传入,也可以分开传入,来看下源码
// source/add.js
var add = _curry2(function add(a, b) {
return Number(a) + Number(b);
});
调用了一个 _curry2
函数,又传入了一个两数相加的函数进去,接着再看 _curry2
函数里面干了什么
// source/internal/_curry2.js
export default function _curry2(fn) {
return function f2(a, b) {
// 判断传入参数的个数
switch (arguments.length) {
// 如果没有传入参数,就原样返回这个函数
case 0:
return f2;
case 1:
// 如果只传入一个参数,判断这个参数是不是占位符
return _isPlaceholder(a)
? f2
: _curry1(function(_b) { return fn(a, _b); });
default:
// 如果两个参数都传入的话,首先判断这俩参数是否都是占位符
return _isPlaceholder(a) && _isPlaceholder(b)
? f2
// 或者其中之一是
: _isPlaceholder(a)
? _curry1(function(_a) { return fn(_a, b); })
: _isPlaceholder(b)
? _curry1(function(_b) { return fn(a, _b); })
: fn(a, b);
}
};
}
这个方法里直接返回了一个函数 f2
,调用 R.add
方法实际返回的就是这个函数,这个函数判断了传入参数的个数,对不同情况做了不同处理
- 如果没有传入参数,就原样返回这个函数
- 如果只传入了一个参数,这里先表示为
a
,判断a
是否是占位符,_isPlaceholder
这个函数就是用来判断是否是占位符,如果是占位符的话,相当于还是没传,还是原样返回这个函数,如果不是占位符的话,调用了_curry1
方法,给他传入了一个接收一个参数_b
的函数,这个函数中返回的是最开始那个两数相加方法add
的执行,并且把已经传进来的参数a
和之后要传进来的参数_b
传了进去, 再来看下_curry1
方法中干了什么
export default function _curry1(fn) {
return function f1(a) {
if (arguments.length === 0 || _isPlaceholder(a)) {
return f1;
} else {
return fn.apply(this, arguments);
}
};
}
接收一个函数作为参数,实际会传进来的其实就是上面的这个 function(_b) { return fn(a, _b); }
,然后也是直接返回了一个函数 f1
,f1
接收一个参数,这个参数就对应上面那个参数 _b
,然后方法中判断是否传入了参数或者传入的参数是否是占位符,是的话,就还是原样返回这个函数;不是的话,就说明现在也拿到了另一个参数,就可以直接执行 function(_b) { return fn(a, _b); }
这个方法,把另一个参数也传进去,然后这个方法里面返回的 fn
的执行,fn
其实就是最开始的那个两数相加的函数 add
,此时他已经拿到了所需的两个参数,就可以计算出最后的结果了
- 如果两个参数都传入的话,首先判断这两个参数是否都是占位符,都是的话,就还是原样返回
f2
函数,如果只有其中之一是的话,就还是 step2 的逻辑,最后如果两个参数都是实际所需的参数,就直接调用两数相加的方法add
,并返回其执行结果,就是最终的值了。
curryN
还是先看下用法
// 计算传入所有参数的和
const sumArgs = (...args) => R.sum(args);
const curriedAddFourNumbers = R.curryN(4, sumArgs);
const f = curriedAddFourNumbers(1, 2);
const g = f(3);
g(4); //=> 10
对函数进行柯里化,并限制柯里化函数的元数。柯里化函数有两个很好的特性:
- 参数不需要一次只传入一个。假设
g
由R.curryN(3, f)
生成,则下列写法是等价的:
g(1)(2)(3)
g(1)(2, 3)
g(1, 2)(3)
g(1, 2, 3)
- 占位符值
R.__
可用于标记暂未传入参数的位置,允许部分应用于任何参数组合,而无需关心它们的位置和顺序。 假设g
定义如前所示,_
代表R.__
,则下列写法是等价的:
g(1, 2, 3)
g(_, 2, 3)(1)
g(_, _, 3)(1)(2)
g(_, _, 3)(1, 2)
以上是官网的说明,这里我说一下我的理解:
这个方法接收两个参数,第一个是需要参数的个数,第二个是一个函数,这里我们传入了 4
和 sumArgs
函数,也就是说,我之后传入四个参数之后,就会返回这四个参数的结果了,这四个参数可以一次性传入,也可以一次只传一个,传四次,也可以在其中传入占位符,下面还是来看源码中是如何实现的
// source/curryN.js
var curryN = _curry2(function curryN(length, fn) {
if (length === 1) {
return _curry1(fn);
}
return _arity(length, _curryN(length, [], fn));
});
可以看到这里又调用了 _curry2
方法,对 curryN
这个方法也进行了柯里化,也就是说curryN
方法的两个参数也是可以分开传入的,像这样
const sumArgs = (...args) => R.sum(args);
// 先传入要限制参数的个数
const curriedAddFourNumbers = R.curryN(4);
// 再传入要进行柯里化的函数
const curriedAddFourNumbers2 = curriedAddFourNumbers(sumArgs)
// 分批传入参数
const f = curriedAddFourNumbers2(1, 2);
const g = f(3);
g(4); //=> 10
// 以上等价于
const sumArgs = (...args) => R.sum(args);
//直接传入需要的两个参数
const curriedAddFourNumbers = R.curryN(4, sumArgs);
const f = curriedAddFourNumbers(1, 2);
const g = f(3);
g(4); //=> 10
然后再来看一下调用 _curry2
方法传入的这个参数
function curryN(length, fn) {
if (length === 1) {
return _curry1(fn);
}
return _arity(length, _curryN(length, [], fn));
}
首先判断了如果需要的参数个数传入的值是 1,返回 _curry1 的执行结果,_curry1
上面也说过了,会返回一个只接受一个参数的函数;如果需要参数个数大于 1 的话,会调用这个 _arity
方法,传入了需要参数的个数以及调用这个 _curryN
方法的结果,先来看 _arity
的代码
// source/internal/_arity.js
export default function _arity(n, fn) {
/* eslint-disable no-unused-vars */
switch (n) {
case 0: return function() { return fn.apply(this, arguments); };
case 1: return function(a0) { return fn.apply(this, arguments); };
case 2: return function(a0, a1) { return fn.apply(this, arguments); };
case 3: return function(a0, a1, a2) { return fn.apply(this, arguments); };
case 4: return function(a0, a1, a2, a3) { return fn.apply(this, arguments); };
case 5: return function(a0, a1, a2, a3, a4) { return fn.apply(this, arguments); };
case 6: return function(a0, a1, a2, a3, a4, a5) { return fn.apply(this, arguments); };
case 7: return function(a0, a1, a2, a3, a4, a5, a6) { return fn.apply(this, arguments); };
case 8: return function(a0, a1, a2, a3, a4, a5, a6, a7) { return fn.apply(this, arguments); };
case 9: return function(a0, a1, a2, a3, a4, a5, a6, a7, a8) { return fn.apply(this, arguments); };
case 10: return function(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9) { return fn.apply(this, arguments); };
default: throw new Error('First argument to _arity must be a non-negative integer no greater than ten');
}
}
其实就是返回一个接收 n
个参数的函数,n
的范围是 0 - 10,先大概知道这些,稍后再讨论他,接着再看这个 _curryN
函数,其实可以忽略掉_curry2
和_arity
,理解成是直接调用的_curryN
,
// source/internal/_curryN.js
export default function _curryN(length, received, fn) {
return function() {
// 存放调用过程中传入的参数和之前已经传入的参数
var combined = [];
// 当前在处理参数的索引
var argsIdx = 0;
// 需要参数的个数
var left = length;
// 当前在处理已传入参数的索引
var combinedIdx = 0;
// 遍历已传入参数和当前调用时传入的参数
while (combinedIdx < received.length || argsIdx < arguments.length) {
var result;
if (combinedIdx < received.length &&
(!_isPlaceholder(received[combinedIdx]) ||
argsIdx >= arguments.length)) {
// 取之前已传入的参数
result = received[combinedIdx];
} else {
// 取当前传入的参数
result = arguments[argsIdx];
argsIdx += 1;
}
// 都放在这个数组中
combined[combinedIdx] = result;
if (!_isPlaceholder(result)) {
left -= 1;
}
combinedIdx += 1;
}
return left <= 0
// 如果传入参数够了就返回执行结果
? fn.apply(this, combined)
// 如果还不够就返回函数 接着接收其他的参数
: _arity(left, _curryN(length, combined, fn));
};
}
这个方法接收三个参数
length
:需要参数的个数received
: 已经接收到的参数fn
:最开始要执行的那个函数
方法内部直接返回了一个匿名函数,当我们调用 R.curryN
返回的其实就是这个匿名函数了。如果直接看里面的代码逻辑可能不太好理解,所以我们这里可以代入下参数,然后再看他的逻辑是怎么流转的
const sumArgs = (...args) => R.sum(args);
const curriedAddFourNumbers = R.curryN(4, sumArgs);
const f = curriedAddFourNumbers(1, 2);
const g = f(3, 4); //=> 10
还是以上面这个代码来看,需要四个参数,然后我们把这四个参数分两次传入
- 第一次传入两个参数,走过
while
循环,combined
这个变量会存放当前传入的两个参数[1, 2]
,然后又调用了_arity
,也就是说会返回一个接收两个参数的函数,这里会把已经接收到的的[1, 2]
传进去 - 第二次传入剩下的两个参数,注意此时的
received
参数是有值的,就是之前已经传入的那两个参数,然后走while
循环,会先把上次已经传入的参数[1, 2]
拿出来,也就是received
中的值,然后会再把当前传入的参数[3, 4]
也都拿出来,都放到combined
中,循环完事之后,会判断已经接收到了所有的参数,所以就直接调用了fn
方法,在这里就是刚开始传入的sumArgs
方法,并且把接收的参数都传进去,然后就可以直接返回结果了。
curry
看下用法
const addFourNumbers = (a, b, c, d) => a + b + c + d;
const curriedAddFourNumbers = R.curry(addFourNumbers);
const f = curriedAddFourNumbers(1, 2);
const g = f(3);
g(4); //=> 10
依旧是对函数进行柯里化,和上面的 curryN
不同的是,这里不传入需要参数的个数了,而是由传入函数的形参的个数决定的,比如说这个例子,传入 addFourNumbers
函数需要四个参数,所以后面在传入四个参数之后,就返回了结果。还是继续看源码
var curry = _curry1(function curry(fn) {
return curryN(fn.length, fn);
});
最外层调用了 _curry1
方法,然后里面返回了 curryN
方法的调用,curryN
方法就是上面那个 curryN
方法,传入的 fn.length
, 也就是传入函数所需要的参数个数,为啥这样写,就显而易见了嘛,接着 curryN
方法里面的逻辑就不再说了。
_arity
上面还留了一个坑啊,就是这个 _arity
方法,这个方法里面那一堆相似的代码
case 0: return function() { return fn.apply(this, arguments); };
case 1: return function(a0) { return fn.apply(this, arguments); };
case 2: return function(a0, a1) { return fn.apply(this, arguments); };
//.....
可以看到里面那些形参 a0
、a1
... 在函数体内并没有用到,然后函数体内只是返回了传入的 fn
的调用,除此之外就没有其他逻辑,看起来这堆代码就是毫无用处的,而且我尝试把这个方法去掉,发现对函数的运行结果并没有影响,然后我去 Github 上找到 Ramda,去 issuse 下面搜了一下,发现有人和我提出了同样的疑问,看了下关于这个问题的讨论,
大概意思就是说,如果不用 _arity
包一层,返回的函数的 length
的值就会是错误的,
// 这里说一下,函数有一个 length 属性,他的值是函数需要的参数的个数,
function foo(a, b) {
return a + b
}
foo.length // 2
说回源码中的实现
var curryN = _curry2(function curryN(length, fn) {
if (length === 1) {
return _curry1(fn);
}
// return _arity(length, _curryN(length, [], fn));
return _curryN(length, [], fn)
});
如果我们直接把 _curryN(length, [], fn)
返回,其实它里面返回的是一个没有形参的匿名函数,所以返回函数的 length
的值就会一直是 0
,如果用 _arity
包一层,根据 _arity
的实现,返回函数的 length
属性的值就是正确的,是我们期望传入的参数个数。虽然但是,我还是觉得这个没有什么实质性的意义,js 本来也不关心实参和形参的个数是否对的上,而我们在平常的开发中,也几乎不会用到函数的这个 length
属性。
OK,本文就先说这三个方法的源码的实现,之后可能会继续更新其他方法,欢迎大佬们的建议和意见。