0 前言
在这段时间学习函数式编程的过程中,我主要用到的是 Ramda.js。
你可能会问,Underscore 和 Lodash 已经这么流行了,为什么还要学习好像有些雷同的 Ramda 呢?
//ramda
const double = x => x * 2;
R.map(double, [1, 2, 3]);
//=> [2, 4, 6]
R.map(double, {x: 1, y: 2, z: 3});
//=> {x: 2, y: 4, z: 6}
//lodash
function square(n) {
return n * n;
}
_.map([4, 8], square);
// => [16, 64]
_.map({ 'a': 4, 'b': 8 }, square);
// => [16, 64] (iteration order is not guaranteed)
//underscore
_.map([1, 2, 3], function(num){ return num * 3; });
// => [3, 6, 9]
_.map({one: 1, two: 2, three: 3}, function(num, key){ return num * 3; });
// => [3, 6, 9]
_.map([[1, 2], [3, 4]], _.first);
// => [1, 3]
其实Ramda有较大的不同是,
其一:Lodash/underscore 的函数库,因为它的很多函数把需要处理的参数放在了首位( 例如 map
)这里推荐使用 Ramda,它应该是目前最符合函数式编程的工具库,它需要操作的数据参数都是放在最后的。
其二:柯里化:Ramda 几乎所有的函数都是自动柯里化的。也即可以使用函数必需参数的子集来调用函数,这会返回一个接受剩余参数的新函数。当所有参数都传入后,原始函数才被调用。
由于这两个特点,使得 Ramda 成为 JavaScript 函数式编程最理想的工具库。今天,接下来是我总结的Ramda的几种常见的使用场景,展示怎样用 Ramda 写出既简洁易读,又方便扩展复用的代码
1 使用集合迭代函数代替循环
不必写显式for循环,而是用 forEach 函数代替循环。示例如下:
// Replace this:
for (const value of myArray) {
console.log(value+5)
}
// with:
const printXPlusFive = x => console.log(x + 5);
R.forEach(printXPlusFive, [1, 2, 3]); //=> [1, 2, 3]
// logs 6
// logs 7
// logs 8
forEach 接受一个函数和一个数组,然后将函数作用于数组的每个元素。虽然 forEach 是这些函数中最简单的,但在函数式编程中它可能是最少用到的一个。forEach 没有返回值,所以只能用在有副作用的函数调用中。
其实最常用的函数是 map。类似于 forEach,map 也是将函数作用于数组的每个元素。但与 forEach 不同的是,map 将函数的每个返回值组成一个新数组,并将其返回。示例如下:
R.map(x => x * 2, [1, 2, 3])
//=> [2, 4, 6]
//这里使用了匿名函数,但我们也可以在这里使用具名函数:
const double = x => x * 2
R.map(double, [1, 2, 3])
R.map(double, {x: 1, y: 2, z: 3}); //=> {x: 2, y: 4, z: 6}
/reject
接下来,我们来看看 filter 和 reject。就像名字所示,filter 会根据函数的返回值从数组中选择元素,例如:
const isEven = n => n % 2 === 0;
R.filter(isEven, [1, 2, 3, 4]); //=> [2, 4]
R.filter(isEven, {a: 1, b: 2, c: 3, d: 4}); //=> {b: 2, d: 4}
filter 将函数(本例中为 isEven)作用于数组中的每个元素。每当函数返回 "true" 时,相应的元素将包含到结果中;反之当断言函数返回为 "falsy" 值时,相应的元素将从结果数组中排除(过滤掉)。
reject 是 filter 的补操作。它保留使断言函数返回 "falsy" 的元素,排除使断言函数返回 "truthy" 的元素。
const isOdd = (n) => n % 2 === 1;
R.reject(isOdd, [1, 2, 3, 4]); //=> [2, 4]
R.reject(isOdd, {a: 1, b: 2, c: 3, d: 4}); //=> {b: 2, d: 4}
find 将函数作用于数组中的每个元素,并返回第一个使函数返回真值的元素。
R.find(isEven, [1, 2, 3, 4]) //=> 2
const xs = [{a: 1}, {a: 2}, {a: 3}];
R.find(R.propEq('a', 2))(xs); //=> {a: 2}
R.find(R.propEq('a', 4))(xs); //=> undefined
reduce 比之前遇到的其他函数要复杂一些。
reduce 接受一个二元函数(reducing function)、一个初始值和待处理的数组。
归约函数的第一个参数称为 "accumulator" (累加值),第二个参数取自数组中的元素;返回值为一个新的 "accumulator"。
先来看一个示例,然后看看会发生什么。
const subtract= (accum, value) => accum - value
R.reduce(subtract, 0, [1, 2, 3, 4]) // => ((((0 - 1) - 2) - 3) - 4) = -10
// - -10
// / \ / \
// - 4 -6 4
// / \ / \
// - 3 ==> -3 3
// / \ / \
// - 2 -1 2
// / \ / \
// 0 1 0 1
reduce 首先将初始值 5 和 数组中的首个元素 1 传入归约函数 subtract,subtract 返回一个新的累加值:0- 1 = -1。
reduce 再次调用subtract,这次使用新的累加值 -1 和 数组中的下一个元素 2 作为参数subtract返回 -3。
reduce 再次使用 -3和 数组中的下个元素 3 来调用 subtract,输出 -6。
reduce 最后一次调用subtract,使用 -6 和 数组中的最后一个元素 4 ,输出 -10。
reduce 将最终累加值 -10作为结果返回。
为了深入的了解这些API,我思考如果用原生的js怎么实现这些函数呢?(以下只考虑了是数组类型的情况,其实假如数据类型是一般对象,思路是类似的)
const map =(fn1,xs)=>{
let ys=[];
for(x of xs){
ys.push(fn1(x))
}
return ys;
}
const fn1 = x=>x*2;
res1=map(fn1,[1,2,3,4]);
console.log(res1);
//=>[2, 4, 6, 8]
const find =(fn2,xs)=>{
let item;
for(x of xs){
if(fn2(x)) {
return item = x;
}
}
return item;
}
const fn2=x=> x===2;
res2 = find(fn2,[1,2,3,4])
console.log(res2)
//=>2
const filter=(fn3,xs)=>{
let ys=[];
for(x of xs){
if(fn3(x)){
ys.push(x);
}
}
return ys;
}
const fn3=x=>x%2 === 0;
res3 = filter(fn3,[1,2,3,4])
console.log(res3)
//=>[2, 4]
reduce要比较复杂一点
reduce接受三个参数,执行函数,初始值,执行队列(可以不止为一个数组),返回一个针对这些参数的reduce处理,这里只写数组部分(_arrayReduce),源码中还包含了关于迭代器的_iterableReduce 等等,而且ramda.js对执行函数也有一层对象封装,扩展了函数的功能
var reduce = (fn, acc, list) => (fn = _xwrap(fn), _arrayReduce(fn, acc, list))
在写_arrayReduce之前,先来看一下函数的对象封装_xwrap
var _xwrap = (function(){
function XWrap(fn) {
this.f = fn;
}
XWrap.prototype['@@transducer/init'] = function() {
throw new Error('init not implemented on XWrap');
};
XWrap.prototype['@@transducer/result'] = function(acc) {
return acc;
};
XWrap.prototype['@@transducer/step'] = function(acc, x) {
return this.f(acc, x);
};
return function _xwrap(fn) { return new XWrap(fn); };
})()
其实就是对函数执行状态做了一个分类管理
@@transducer/step 这种状态认为是一种过程状态
@@transducer/result 这种状态被认为是一种结果状态
这种状态管理通过对象也是合情合理的
最后再来完成_arrayReduce,就很简单了,这个函数只是专心一件事情,就是写reduce的过程规则。
var _arrayReduce = (xf, acc, list) => {
var idx = 0
var len = list.length
while (idx < len) {
acc = xf['@@transducer/step'](acc, list[idx]);
idx += 1;
}
return xf['@@transducer/result'](acc);
}
至此,ramda.js简化版的reduce就完成了。
三 函数组合
Ramda 为简单的函数组合提供了一些函数。我们来看看。
在之前,我们使用 find 来查找列表中的首个偶数。
const isEven = x => x % 2 === 0
find(isEven, [1, 2, 3, 4]) //=> 2
如果想找首个奇数呢?我们可以随手写一个 isOdd 函数并使用它。但我们知道任何非偶整数都是奇数,所以可以重用 isEven 函数。
Ramda 提供了一个更高阶的函数:complement,给它传入一个函数,返回一个新的函数:当原函数返回 "false" 时,新函数返回 true;原函数返回 "true" 时,新函数返回 false,即新函数是原函数的补函数。
const isEven = x => x % 2 === 0
find(complement(isEven), [1, 2, 3, 4]) // --> 1
更进一步,可以给 complement 过的函数起个名字,这样新函数便可以复用:
const isEven = x => x % 2 === 0
const isOdd = complement(isEven)
find(isOdd, [1, 2, 3, 4]) // --> 1
注意,complement 以函数的方式实现了逻辑非操作(!, not)的功能。
假设我们正在开发一个投票系统,给定一个人,我们希望能够确定其是否有资格投票。根据现有知识,一个人必须年满 18 岁并且是本国公民,才有资格投票。成为公民的条件:在本国出生,或者后来加入该国国籍。
const wasBornInCountry = person => person.birthCountry === OUR_COUNTRY
const wasNaturalized = person => Boolean(person.naturalizationDate)
const isOver18 = person => person.age >= 18
const isCitizen = person => wasBornInCountry(person) || wasNaturalized(person)
const isEligibleToVote = person => isOver18(person) && isCitizen(person)
上面代码实现了我们的需求,但 Ramda 提供了一些方便的函数,以帮助我们精简代码。
both 接受两个函数,返回一个新函数:当两个传入函数都返回 truthy 值时,新函数返回 true,否则返回 false,either 接受两个函数,返回一个新函数:当两个传入函数任意一个返回 truthy 值时,新函数返回 true,否则返回 false
我们可以使用这两个函数来简化 isCitizen 和 isEligibleToVote。
const isCitizen =R. either(wasBornInCountry, wasNaturalized)
const isEligibleToVote = R.both(isOver18, isCitizen)
注意,both 以函数的方式实现了逻辑与(&&)的功能,either 实现了逻辑或(||)的功能。Ramda 还提供了 allPass 和 anyPass,接受由任意多个函数组成的数组作为参数。如名称所示,allPass 类似于 both,而 anyPass 类似于 either,
ifElse 接受三个函数,返回一个新函数:当第一个传入函数都返回 truthy 值时,调用第二个传入函数,返回一个新函数:当第一个传入函数都返回 false值时,调用第三个函数,返回一个新函数。
const incCount = R.ifElse(
R.has('count'),
R.over(R.lensProp('count'), R.inc),
R.assoc('count', 1)
);
incCount({}); //=> { count: 1 }
incCount({ count: 1 }); //=> { count: 2 }
类似的API还有## when/ unless/## cond
-
pipe(管道)
有时我们需要以 pipeline 的方式将多个函数依次作用于某些数据。例如,接受两个数字,将它们相乘,加 1 ,然后平方。我们可以这样写:
const multiply = (a, b) => a * b
const addOne = x => x + 1
const square = x => x * x
const operate = (x, y) => {
const product = multiply(x, y)
const incremented = addOne(product)
const squared = square(incremented)
return squared
}
operate(3, 4) // => ((3 * 4) + 1)^2 => (12 + 1)^2 => 13^2 => 169
注意,每次操作是对上次操作的结果进行处理。
Ramda 提供了 pipe 函数:接受一系列函数,并返回一个新函数。
新函数的元数与第一个传入函数的元数相同(元数:接受参数的个数),然后顺次通过 "管道" 中的函数对输入参数进行处理。它将第一个函数作用于参数,返回结果作为下一个函数的入参,依次进行下去。"管道" 中最后一个函数的结果作为 pipe 调用的最终结果。
注意,除首个函数外,其余的函数都是一元函数。
了解这些后,我们可以使用 pipe 来简化我们的 operate 函数:
const operate = R.pipe(
multiply,
addOne,
square
)
当调用 operate(3, 4) 时,pipe 将 3 和 4 传给 multiply 函数,输出 12,然后将 12 传给 addOne,返回 13,然后将 13 传给 square,返回 169,并将 169 作为最终 operate 的最终结果返回。
compose 的工作方式跟 pipe 基本相同,除了其调用函数的顺序是从右到左,而不是从左到右。下面使用 compose 来重写 operate:
const operate =R. compose(
square,
addOne,
multiply
)
这与上面的 pipe 几乎一样,除了函数的顺序是相反的。实际上,Ramda 中的 compose 函数的内部是用 pipe 实现的。
其中compose 的工作方式:compose(f, g)(value) 等价于 f(g(value))。
注意,与 pipe 类似,compose 中的函数除最后一个外,其余都是一元函数。
四 函数组合与柯里化
在函数式编程中有两种操作是必不可少的那无疑就是柯里化(Currying)和函数组合(Compose),柯里化其实就是流水线上的加工站,函数组合就是我们的流水线,它由多个加工站组成。我们可以 在JS 中利用函数式编程的思想去组装一套高效的流水线。
加工站——柯里化 curry
柯里化的意思是将一个多元函数,转换成一个依次调用的单元函数。
对函数进行柯里化。柯里化函数与其他语言中的柯里化函数相比,有两个非常好的特性:
-
参数不需要一次只传入一个。如果
f
是三元函数,g
是R.curry(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)
g(_, 2)(1)(3)
g(_, 2)(1, 3)
-
g(_, 2)(_, 3)(1)
我们尝试写一个curry
版本的add
函数
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
为什么这个单元函数很重要?因为函数的返回值,有且只有一个 如果我们想顺利的组装流水线,那我就必须保证我每个加工站的输出刚好能流向下个工作站的输入。因此,在流水线上的加工站必须都是单元函数。这就能很好理解为什么柯里化配合函数组合有奇效了,因为柯里化处理的结果刚好就是单输入的。
柯里化 curry VS 偏函数应用partial
偏函数应用简单来说就是:一个函数,接受一个多参数的函数且传入部分参数后,返回一个需要更少参数的新函数。
柯里化一般和偏函数应用相伴出现,但这两者是不同的概念:
// 柯里化
f(a,b,c) → f(a)(b)(c)
// 部分函数调用
f(a,b,c) → f(a)(b,c) / f(a,b)(c)
柯里化的实现
虽然从理论上说柯里化应该返回的是一系列的单参函数,但在实际的使用过程中为了像偏函数应用那样方便的调用,所以这里柯里化后的函数也能接受多个参数。很明显第一反应是需要使用递归,这样才能返回一系列的函数。而递归的结束条件就是接受了原函数数量的参数,所以重点就是参数的传递~
//ES5
function curry1(fn, arr) {
// debugger;
arr = arr || []
return function () {
// debugger;
var arg = [].slice.call(arguments);
var args = arr.concat(arg)
return args.length >= fn.length ? fn.apply(null, args) : curry1(fn, args)
}
}
const fn1 = (a, b, c, d) => {
return a + b + c + d;
}
const curryFn1 = curry1(fn1);
console.log(curryFn1(1, 2, 3, 4));
//=> 10
console.log(curryFn1(1, 2, 3)(4));
//=> 10
console.log(curryFn1(1, 2)(3)(4));
//=> 10
console.log(curryFn1(1)(2)(3)(4));
//=> 10
// es6
const curry2 = (fn, arr = []) =>
(...arg) =>
(args =>
args.length >= fn.length ? fn(...args) : curry2(fn, args)
)([...arr, ...arg])
const curryFn2 = curry2(fn1);
console.log(curryFn2(1, 2, 3, 4));
//=> 10
console.log(curryFn2(1, 2, 3)(4));
//=> 10
console.log(curryFn2(1, 2)(3)(4));
//=> 10
console.log(curryFn2(1)(2)(3)(4));
//=> 10
搭建一条流水线
现在看看能否将我们的 filter 和 map 调用进行组合,比如我们想找一本某个年代的书。正常平时代码如下所示:
const publishedInYear = R.curry((year, book) => book.year === year)
const titlesForYear = (books, year) => {
const selected = R.filter(publishedInYear(year), books)
return R.map(book => book.title, selected)
}
我们可以利用 pipe 和 compose对上面代码进行改造,但我们还需要另一部分信息,以便能够使用上面所学的知识。缺少的信息是:几乎所有的 Ramda 函数都是默认柯里化的,包括 filter 和 map。所以 filter(publishedInYear(year)) 是完全合法的,它会返回一个新函数,该函数等待我们传递 books 给它,map(book => book.title) 也是如此。
所以我们可以如下所示改造代码:
const publishedInYear = R.curry((year, book) => book.year === year)
const titlesForYear = (books, year) =>
pipe(
R.filter(publishedInYear(year)),
R.map(book => book.title)
)(books)
我们来更进一步,将 titlesForYear 的参数顺序也调换一下,这样更符合 Ramda 中待处理数据放在最后的约定,也可以将该函数进行柯里化。
const publishedInYear =R. curry((year, book) => book.year === year)
const titlesForYear =R. curry((year, books) =>
pipe(
R. filter(publishedInYear(year)),
R.map(book => book.title)
)(books)
)
五 Pointfree 编程
Pointfree是一种编程风格,它其实就是强调在整个函数编写过程中不出现参数(point),而只是通过函数的组合生成新的函数,实际数据只需要在最后使用函数的时候再传入即可。Pointfree 的本质就是使用一些通用的函数,组合出各种复杂运算。上层运算不要直接操作数据,而是通过底层函数去处理。
例如:
const titlesForYear =R. curry((year, books) =>
pipe(
R. filter(publishedInYear(year)),
R.map(book => book.title)
)(books)
)
注意,books 出现了两次:一次作为参数列表的最后一个参数(最后一个数据!);一次出现在函数最后,当我们将其传入 pipeline 的时候。函数转成 "pointfree" 风格,我们来将会看到这个样子:
const titlesForYear = year =>
pipe(
R.filter(publishedInYear(year)),
R.map(book => book.title)
)
让 'books' 消失了。这就是 Pointfree 风格。注意,这两个版本所做的事情完全一样。我们仍然返回一个接受年龄的函数,但并未显示的指定 books
参数。
这种编程方式的好处是什么?
无需考虑参数命名:能减轻不少思维负担,毕竟参数命名也是个很费事的过程。
关注点集中:你无需考虑数据,只需要把所有的注意力集中在转换关系上。
代码精简:可以省去通过中间变量不断的去传递数据的过程。
可读性强:一眼就可以看出来数据的整个的转换关系。
参考资料: