Ramda.js学习总结:

0 前言

在这段时间学习函数式编程的过程中,我主要用到的是 Ramda.js
你可能会问,UnderscoreLodash 已经这么流行了,为什么还要学习好像有些雷同的 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

有时我们需要以 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

柯里化的意思是将一个多元函数,转换成一个依次调用的单元函数
对函数进行柯里化。柯里化函数与其他语言中的柯里化函数相比,有两个非常好的特性:

  1. 参数不需要一次只传入一个。如果 f 是三元函数,gR.curry(f) ,则下列写法是等价的:

    • g(1)(2)(3)
    • g(1)(2, 3)
    • g(1, 2)(3)
    • g(1, 2, 3)
  2. 占位符值 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 参数。

这种编程方式的好处是什么?

无需考虑参数命名:能减轻不少思维负担,毕竟参数命名也是个很费事的过程。
关注点集中:你无需考虑数据,只需要把所有的注意力集中在转换关系上。
代码精简:可以省去通过中间变量不断的去传递数据的过程。
可读性强:一眼就可以看出来数据的整个的转换关系。
参考资料:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342