函数式编程中的组合子

函数式编程是一个比较大的话题,里面的知识体系非常的丰富,在这里我并不想讲的特别的详细。为了应对实际中的应用,我们讲一下函数式编程中最为实用的应用方式——组合子。组合子本身是一种高阶函数,他的特点就是将函数进行延迟或者转换,在函数式编程中应用最为广泛。

什么是组合子

组合子在数学中就有,但我们讲的并不是数学中的定义,而是在JavaScript领域中的组合子概念。按照我所理解的JavaScript函数式编程,我将组合子分为辅助组合子函数组合子。后续我们会对这两种组合子进行区别。

组合子(全称:组合子函数)又称之为装饰器函数,用于转换函数或数据,增强函数或数据行为的高阶函数。这里我们提到的高阶函数并不陌生,所谓的高阶函数,就是以函数为参数或者返回值的函数。辅助组合子是最为简单的组合子,它是具有数据流程控制的抽象函数。而函数组合子就很特别了,它必须以函数(称之为原函数契函数)为参数,其大致有如下特点:

  1. 函数组合子本身就是高阶函数
  2. 不改变原函数契函数)的最终意图;
  3. 能增强原函数(契函数)的行为;

图片描述

图片描述

高阶函数的概念我们已经很熟悉了,这里不做多的解释,我们只强调,函数组合子是以原函数为参数,返回新函数高阶函数。知道这一点,我们再来解释后面两个特性,所谓“不改变原函数的最终意图”,即原函数是做什么的,新函数就是做什么的。原函数新函数的所需参数是一致的,返回值也是一样的。这里我们先卖个关子,稍后我们会用实际的案例来讲述一下这个特性。“能增强原函数的行为”,这是函数式编程的核心概念之一,他并不难理解,但需要我们花更多的时间去关注。

那么什么是函数的行为,为什么要增强函数的行为。函数有三个重要的部分:输入、处理、输出。输入就是指的参数,而处理就是函数体中对参数的执行过程,输出就是返回值。在JavaScript语言中,即使函数没有输出,都约定输出的是undefined,以上都是我们非常熟悉的概念。

参数即是“元”(arity)的,分为:一元unary)、二元binary)、三元ternary)、多元polyadic)、可变元variadic)。除了一元函数,其他的参数都或多或少存在着这样的两个问题:

  1. 参数的获取时机;
  2. 参数的获取顺序;

图片描述

以我们最常使用的ajax.get为例子,该函数有4个参数,最常使用的是其中的三元用法:

/**
 * @param {String} url - 请求地址
 * @param {*} data - 请求参数
 * @param {Function} success - 成功回调函数
 */
$.get(url, data, success(response));

对于success回调函数,我们因为知道数据获取的格式以及目标转换格式,因此我们可以很快的构建这个回调。但如果url地址是要从DOM上获取,或者从其他资源文件中读取,那么该函数的执行与否,会更多的依赖url的获取与否,甚至还要考虑异步的问题。

组合子就是要解决这个问题,但这里我们不着急解决刚才提出的例子,因为在讲解组合子之前,我们还要铺垫的说到函数式编程中比较重要的三个概念:柯里化偏函数应用函数组合

柯里化

如果函数的参数足够多,而我又不确定函数参数是否能在同一时间全部获取到,那么在执行这个函数前,总要等待用户的输入全部完成时,才能执行。而在等待之前,任何一处参数的获取时间将会影响后续过程的执行:

var url = getUrl(); // 如果这句耗时过长,将导致后面很难被执行到
var data = getData();
var callback = function (res) {};
$.get(url, data, callback);

如果不是一次性输入完成,为何不返回一个新的函数来等待用户的下一次输入呢?柯里化很轻蔑的说了这么一句,于是它这样做:

var curryGet = url => data => callback => $.get(url, data, callback);
curryGet(url)(data)(callback);

是的,你没看错,这简直像魔法一样,原来JavaScript中的函数还能这么玩。为了照顾很多没有学习ES6+的同学,我们直接用ES5的语法来书写。为了保证一致性,后面都将采用ES5的语法,特别情况下我会用ES6+来重新描述。那么刚才的柯里化代码用ES5写就是:

var currGet = function (url) {
  return function (data) {
    return function (callback) {
      return $.get(url, data, callback);
    }
  }
}

天啊,是不是已经看晕了,是不是有小伙伴迫不及待的想去学习ES6+了呢?但这里原理很简单,只是用到了名为闭包的魔法。原先依靠逗号分隔的参数,现在要一次次的输入,并且输入完最后一个方可执行。在很多同学看来,这样做并不高明,增加了很多function的包裹不说,运行的结果和之前没有区别。是的,但他没有任何意义?

这里我们并不打算详细研究函数编程的性能问题,这是一个很大的话题,在架构中也是有取有舍的。我只能说,总体性能上不一定比原来的差,某些场景下的优化空间还会比传统方式更大。大家只管放心使用即可,后续会对函数式编程优化进行专题讲演的。

柯里化作为一种最简单的组合子之一,他是一种高阶函数(显然这里我们没有用到柯里化组合子),没有改变原有函数的意图,但却延迟了函数的执行。

偏函数应用

如果在编写代码时,我们总能预知datacallback的确定性和及时性,url需要最后代入,那么可以考虑使用偏函数的魔法,还是刚才的例子,我们可以这样的改造:

var partialGet = function (fn, data, callback) {
  return function (url) {
    return fn(url, data, callback);
  }
};
var partialGetByUrl = partialGet($.get, data, callback);
partialGetByUrl(url1);
partialGetByUrl(url2);
// ...

看,现在我们已经实现了函数的重用了,并且我们并没有改造原有的函数,仅仅对原函数进行了改造。它的作用和柯里化非常的相似,但没有柯里化那么贪婪。偏函数应用仅仅是提取原函数中的部分参数,用剩余参数返回一个新函数。不难发现,偏函数柯里化都是延迟了原函数的参数,只是延迟的进度不同而已。

函数组合

仍然接着上面的例子,如果url是从DOM中获取的原始输入,似乎我们为了合理性和安全性,应该对url进行一个预处理过程,大致可以假设有这样的代码:

var preformat = function (url) {};  // 预处理
partialGetByUrl(preformat(url));

这样写似乎一点问题都没有,我们再来增加点难度:

var trim = function (txt) {};  // 去除两边空格
var encode = function (txt) {};  // 编码加密
var preformat = function (url) {};  // 预处理
partialGetByUrl(encode(preformat(trim(url))));

天啊,我相信你已经也看晕了,更愚蠢的是,如果处理字符串的顺序变化了,改动也是很头疼的。面对这样的问题,伟大的组合函数出现了:

var compose = function (f4, f3, f2, f1) {
  return function (txt) {
    return f4(f3(f2(f1(txt))));
  }
};
var getByUrl = compose(
    partialGetByUrl,
  encode,
  preformat,
  trim
);
getByUrl(url1);
getByUrl(url2);

书写上好看了不少,并且你可以随心所欲的去组合这些函数的,当然这是有一些前提的(纯函数数据不变性),但我不打算在这里讲解这些前提。

这时有很多人很困惑,貌似组合函数对于组合子的特性前两点都是满足的,唯独第三点看似不像。注意了,增强的函数行为不仅仅有延迟,组合串联也是一种增强手段,前一个函数会因为后一个函数而增强。甚至你可以换一种理解方式,如果没有后一个函数的提前处理就会导致前一个函数执行失败,这也是一种增强手段。

辅助组合子

说了那么多,前面说到的都是针对特定问题的高阶函数解决方案,抛开先前说的三个特性,现在回来我们之前组合子的话题,首先讲讲最为简单的辅助组合子,它们本身不处理函数,只是处理数据,因此可以称之为辅助组合子(或者说是投影函数(Projecting Function))。但其实辅助组合子本身并不是不处理函数,而是函数也可以作为特殊的数据,他们虽然小、写法简单,但是意义仍然和组合子一样重大:

// ES5
function nothing () {}
function identity (val) {
  return val;
}
function defaultTo (def) {
  return function (val) {
    return val || def;
  }
}
function always (constant) {
  return function () {
    return constant;
  }
}

// ES6+
const nothing = () => {};
const identity = val => val;
const defaultTo = def => val => val || def;
const always = cons => val => cons;

无为(nothing)

图片描述

nothing函数表面上看好像没有什么意义,但它的名称就和它本身一样,不作任何事情,是空函数的默认值,在ES6+的场景中比较常见,比如稍后将见到的alt组合子,就可以进行默认值传参:

const alt = (f1 = nothing, f2 = nothing) => val => f1(val) || f2(val);

照旧(identity)

图片描述

identity函数是范畴论中非常著名的id函子,同样有关函子的概念不是本文的重点,有兴趣的朋友可以自行找资料学习。id函子有着一个非常重要的特性,也就是输入什么值都将不经处理的返回,即使输入的是函数也是可行的。我们可以利用这个特性在递归中使用,用构建后继传递递归(CPS)版的斐波那契数:

// n >= 1
const fib = (n, cont = identity) => 
    n <= 1 ? 
  cont(n) : 
    fib(n - 2, pre => fib(n - 1, mid => cont(pre + mid)));
注意:这段代码要想解读清楚比较困难,我们只需要知道这个函数的结果是对的即可。

默许(defaultTo)

图片描述

defaultTo函数的出场率是最高的,比如我们处理一些非预期值的时候:

const defaultLikeArray = defaultTo([]);
const array1 = defaultLikeArray('');
const array2 = defaultLikeArray([1, 2, 3]);

array1因为不是预期值,所会返回一个空的数组,防止该参数代入后续函数中后出现问题。defaultTo是一种OR组合子,后续还有更多类似的组合子出现。

图片描述

恒定(always)

图片描述

always函数很多人看不大懂,认为多此一举,直接用const关键字构建一个常量不就可以了吗。这就是函数式编程的特点,一切以函数为中心。该函数通过函数式的方式,构建了某些数据的统一源:

const alwaysUser = always({});
const user1 = alwaysUser();
const user2 = alwaysUser();
user1 === user2; // => true
user1.name = '张三';
user1 === user2; // => true

function alwaysLikeUser () {
  return {};
}
const user3 = alwaysLikeUser();
const user4 = alwaysLikeUser();
user3 == user4; // false

像这样,你就能保证不同位置的数据修改是针对的统一源头,某些场景下还是非常实用的。

函数组合子

柯里化偏函数应用函数组合的例子中我们可以看到函数组合子的身影,但函数组合子本身更具备抽象性,他是这些特定问题的抽象,并且可以复用在绝大部分(只要符合条件)的函数身上。下面,我们就来讲解一下常见的函数组合子

收缩(gather)

/**
 * 函数签名: ((a, b, c, …, n) → x) → [a, b, c, …, n] → x
 * 函数作用: 参数收缩
 * 函数特性: 降维
 * @param fn - 原函数
 * @returns 参数收缩后的新函数
 */

// ES5
function gather (fn) {
  return function (argsArr) {
    return fn.apply(null, argsArr)
  }
}

// ES6+
const gather = fn => argsArr => fn(...argsArr);

// eg: 将`可变元`函数转换为`一元`函数
var log = gather(console.log);
log(['标题', 'log', '日志类容']); // => 标题 log 日志类容
var max = gather(Math.max);
max([1,2,3]); // => 3

图片描述

绝大部分人看到此组合子后,会很敏感的认为,这不就是Function.prototype.apply操作么?是的,gather函数就是封装的Function.prototype.apply,但是它并不关心数据本身,换句说法,它并不关心放进来的是什么函数(函数参数的抽象)。我们经常会在别的函数体中调用某个函数的callapply方法,但很少有人尝试将该方法进行封装。gather函数只关注函数的行为,而不关注函数本身是什么,你传入任意的函数都可以。gather函数的目的是将可变元参数的函数转换为一元的,这是一种降维操作,可以适配用户的输入。被创建的新函数会通过gather的逆运算,将[a, b, c, ...]结构的数据转换为(a, b, c, ...)结构的数据再代入原函数

展开(spread)

/**
 * 函数签名: ([a, b, c, …, n] → x) → (a, b, c, …, n) → x
 * 函数作用: 参数展开
 * 函数特性: 升维
 * @param fn - 原函数
 * @returns 参数展开后的新函数
 */

// ES5
function spread (fn) {
  return function () {
    var argsArr = [].slice.call(arguments);
    return fn(argsArr)
  }
}

// ES6+
const spread = fn => (...argsArr) => fn(argsArr);

// eg: 将`一元`函数转换为`可变元`函数
var promiseAll = spread(Promise.all);
promiseAll(promiseA, promiseB, promiseC);

图片描述

spread函数与gather函数是对称的,通常他们会相互配合的使用,在后续的juxt案例中就可以看到。它的目的可以使得原函数参数进行升维操作,由一元进化为可变元,形如(a, b, c, ...)的参数经过spread的逆运算会转变为[a, b, c, …]形式再代入原函数

值得注意的是,spread(gather)identity是等效的(不是相等==),你可以用几个可变元和多元函数来实验一下。

spread(gather(console.log))(1, 2, 3); // => 1, 2, 3
identity(console.log)(1, 2, 3); // => 1, 2, 3

颠倒(reverse)

/**
 * 函数签名: ((a, b, c, …, n) → x) → (n, …, c, b, a) → x
 * 函数作用: 参数倒序
 * 函数特性: 换序
 * @param fn - 原函数
 * @returns 参数收缩后的新函数
 */

// ES5
function reverse (fn) {
  return function argsReversed () {
    var args = [].reverse.call(arguments);
    return fn.apply(null, args);
  }
}

// ES6+
const reverse = fn => (...argsArr) => fn(...argsArr.reverse());

// eg: 将`多元`函数的参数反转为`多元`函数
var pipe = reverse(compose);
pipe(
  trim,
  format,
  encode,
  request
)(url);

图片描述

reverse组合子也是抽象了函数作为数据,能将任意的函数的参数“反转”,注意,它并不是真的将函数参数反转了,而是生成了一个等待反转参数输入的新函数。

同样, reverse(reverse)idengtity也是等效的,你可以自行尝试

左偏(partial)

/**
 * 函数签名: ((a, b, c, …, n) → x) → [a, b, c, …] → ((d, e, f, …, n) → x)
 * 函数作用: 前置参数提前
 * 函数特性: 降维
 * @param fn - 原函数
 * @returns 前置参数提前后的新函数
 */

// ES5
function partial (fn) {
  var presetArgs = [].slice.call(arguments, 1);
  return function () {
    var laterArgs = [].slice.call(arguments);
    return fn.apply(
      null, 
      presetArgs.concat(laterArgs)
    );
  }
}

// ES6+
const partial = (fn, ...presetArgs) => (...laterArgs) => fn(...presetArgs, ...laterArgs);

// eg: 将`多元`函数转换成比先前更少元的`多元`函数
var getByHandler = partial($.get, url, data);
getByHandler(console.log);
getByHandler(convert);
getByHandler(render);

图片描述

partial函数就是一种偏函数应用(Partial Application),它可以提取函数中的一个或多个参数,但不是全部参数,构造出一个新函数。同样它可以降低原函数(契函数)的维度,使得函数的调用被延迟。之所以称之为““函数应用,是对应于完全函数应用的称呼。那么什么是”完全函数应用“呢?先看如下代码:

// 假设我们抽离出一个map函数
cost map = (arr, transfomer) => [].map.call(arr, transfomer);
// 那么正常的调用过程就是`完全函数应用`
map([1, 2, 3], x => x + 1);
// 提取部分参数构成新函数的过程就是`偏函数应用`
const mapWith = partial(map, [1, 2, 3]);
mapWith(x => x + 1);
// 当然你可以手动提取后面的参数
const withHandler = (fn, handler) => arr => fn(arr, handler);
const mapWithAddOne = withHandler(map, x => x + 1);
mapWithAddOne([1, 2, 3]); // => [2, 3, 4]
mapWithAddOne([-1, 0, 1]); // => [0, 1, 2]

偏函数应用可以延迟函数的执行,把真正关注的数据放在最后,从而实现函数的可复用性。本小节主要介绍的是左偏,与之对应的还有右偏

右偏(partialRight)

/**
 * 函数签名: ((a, b, c, …, n) → x) → [d, e, …, n] → ((a, b, c, …) → x)
 * 函数作用: 前置参数提前
 * 函数特性: 降维
 * @param fn - 原函数
 * @returns 前置参数提前后的新函数
 */

// ES5
function partialRight (fn) {
  var laterArgs = [].slice.call(arguments, 1);
  return function () {
    var presetArgs = [].slice.call(arguments);
    return fn.apply(
      null, 
      presetArgs.concat(laterArgs)
    );
  }
}

// ES6+
const partialRight = (fn, laterArgs) => (...presetArgs) => fn(...presetArgs, ...laterArgs);

// eg: 将`多元`函数转换成比先前更少元的`多元`函数
var getByUrl = partialRight($.getJSON, [data, render]);
getByUrl(url1);
getByUrl(url2);
getByUrl(url3);

图片描述

partialRight函数的思想是和partial一模一样的,甚至我们利用我们已经学会的reversepartial函数转换成partialRight函数,有兴趣的同学可以自行尝试一下,稍后我们也会给出答案。

注意: 学到这里的时候,我们不难发现,从 gatherpartial都是针对参数的维度变化。虽然他们都是可以代入函数为参数,但对函数特性是有要求的,比如 升维的前提必须是数组。再一个,我们已经可以利用他们在不改变原有函数的前提下,组合出各种适合使用需求的函数。

柯里化(curry)

/**
 * 函数签名: (* → a) → (* → a)
 * 函数作用: 逐个参数提前
 * 函数特性: 降维
 * @param {Function} fn 原函数
 * @param {Number} arity 原函数的参数个数, 默认值: 原函数的参数个数
 * @returns 柯里化后的下一个`一元`函数
 */

// ES5
function curry (fn, arity) {
  arity = arity || fn.length;
  return (function nextCurried (prevArgs) {
    return function curried (nextArg) {
      var args = prevArgs.concat(nextArg);
      return args.length >= arity ? fn.apply(null, args) : nextCurried(args);
    }
  })([]);
}

// ES6+
const curry = (fn, arity = fn.length) => {
  return (function nextCurried (prevArgs) {
    return function curried (nextArg) {
      let args = prevArgs.concat(nextArg);
      return args.length >= arity ? fn(...args) : nextCurried(args);
    };
  })([]);
}

// eg: 
var square = curry(reverse(Math.pow))(2);
square(2); // => 4
square(3); // => 9

图片描述

很多人看到这里,觉得curry拆分参数没什么用,反而让原来一个函数变成了多个函数,增加了JS脚本引擎的解析难度(调用栈增加)。函数式编程强调的是一种编程的范式,它是一种代码哲学,也是一种编程艺术,但切不可为了函数式而函数式,这会让原本的开发过程变得繁重。而且,函数式编程确实存在一定的性能上的优化空间(但这不是我们本次讲稿所要叙述的内容),但也不能因为这一点而完全避讳使用它,我们应该根据环境要求去选择更加适当的组合方式。

再次回到curry函数,比如有如下案例:

// 未柯里化,这是很多人都会举的例子
const add = (a, b) => a + b;
add(1, 2); // => 3
// 柯里化后
const addCurry = curry(add);
const addOne = addCurry(1);
[1, 2, 3].map(addOne);

curry在原有函数add的基础上,进行了延拓,我们无需去重新封装一个新的函数来描述addOne的特性,因为addOne的特性本身就是add部分具象化。这便是在鼓励我们,去编写更抽象的函数,然后使用函数式编程使其具象化并且可复用。

curry函数本身的特性,与partial也是极为相似的,只不过它拆分的更加细腻,是逐个拆分。然而,问题也暴露出来了,如果函数是不定元的,那么curry又该如何保证正常使用呢?一起看看curry函数的定义,发现第二个参数默认是取原函数的参数长度。我们知道,JavaScript中的Function可以通过length属性来获取参数的长度,例如:

console.log.length; // => 1
Math.sin.length; // => 1
Math.random.length; // => 0
JSON.parse.length; // => 2

console.log函数的参数长度虽然和Math.sin函数的长度一样,但是log函数可以再添加可变元的参数的,如果对log使用curry魔法,将导致施了魔法的函数和原函数完全一致。因此,为了解决curry函数本身的缺陷,我们可以手动建立一个新函数,用于指定函数参数的个数:

// 方式一,不使用组合子
const curry2Log = curry(console.log, 2);
const curry3Log = curry(console.log, 3);
curry2Log('title')('message'); // => title message
curry3Log('title')('message')('2018'); // => title message 2018
// 方式二,使用组合子(偏函数)
const curryN = (n, f) => partialRight(curry, [n]);
// 或者(反转函数)
const curryN = reverse(curry);
const curry4Log = curryN(4, console.log);
const curry5Log = curryN(5, console.log);

看,万变不离其宗,是不是非常的神奇,就像我们介绍的一样——像魔法,所以你们是不是对他们越来越感兴趣了呢?

到此,我们已经讲解了有关参数维护变化的组合子,你们发现了没, gatherspreadreversepartialcurry都是基于参数维度的变化。我们已经在文中提过很多次 参数维度,一元函数是一维的,二元函数是二维的……多元函数是多维的,可变元函数是不定维的。这些组合子的特点就是改变维度,让 原函数可以变化成其它可复用的方式。当然,除了这些组合子可以改变维度,还有像 unarybinarynAry等组合子可以强行改变维度,比如 unary是将任意函数变为一元的,如果本身就是二元以上的函数,是会有损失的。即便你很难摸清楚维度切换的法门,也不用担心,函数式编程的精妙在于,当你需要的时候,你就知道怎么去选择了,我们所要做的是掌握和了解更多的组合子。

弃离(tap)

/**
 * 函数签名: (a → *) → a → a
 * 函数作用: 对输入值执行给定函数并立即返回输入值
 * 函数特性: id
 * @param {Function} fn - 原函数
 * @returns 输入值
 */

// ES5
function tap (fn) {
  return function (val) {
    return (fn(val), val);
  }
}

// ES6+
const tap = fn => val => (fn(val), val);

// eg:
var sayX = x => console.log('x is ' + x);
var tapSayX = R.tap(sayX);
tapSayX(100); // 100

tap函数类似一个ididentity,之后我们都简称id)的组合子,函数本身做什么并不关心,我们都没有接受它的返回值。那么它的用处是干嘛呢?函数式编程中有一个非常重要的概念叫纯函数,这个词并不陌生,但很难甄别,先来看看下面哪些函数是“纯”的:

const addOne = x => x + 1;
const log = console.log;
const clickHandler = function (e) {
  e.preventDefault();
  $(this).html($(e.target).html());
};
var count = 1;
function getCount () {
  return count;
}
function append (arr, item) {
  arr.push(item);
  return arr;
}

还有很多的例子就不多举例了,上面的函数,除了addOne,其它的都不算纯,我们来看纯函数应该具备的特性:

  1. 独立性:没有副作用,不会影响外部,也不受外部影响;
  2. 常恒性:官方说法叫引用透明性(Referential Transparency),即在任意时间里,传入相同且确定的参数,返回相同且确定的值;

简单的说,真正的纯函数是“永恒”和“不变”的。再回头看上面的案例,clickHandler使用了this,该函数会因为上下文的变化而作用不同(独立性和常恒性被打破)。getCount引用了外部变量,这也是非常危险的,因为你无法保证变量a永远无法被其他人改变(常恒性被打破)。append传入的参数arr是一个引用类型,因此函数体内部对外部产生的副作用(独立性被打破)。有的人认为console.log是一个纯函数,是的,如果不考虑那么严格的话,它确实是一个比较纯的函数,但它和DOM操作内存操作写库操作(都属于I/O操作)一样,对外部产生了变化,因此它也不是一个纯函数(独立性)。但log操作也很特殊,因为DOM操作内存操作写库操作和它的区别就在于他们三个都存在并发,因此结果是不确定的;是存在异常的,异常会中断函数本身的运行;是不可预测的,虽然我们知道大部分都能正确返回,可不得不承认对结果预测的不稳定性。再看看log,虽然对外部(控制台)有I/O操作,但它既不存在并发,也不会有异常发生,结果是可预测的。因此,log可以认为是一个非严格意义上的纯函数,毕竟查看数据时它是非常有帮助的。

虽然 addOne函数我们认识是一个 的,但如果传入的不是一个 基础类型,而是一个引用类型呢?那就不是的,因为内部的改变是影响了外部。这样的需求是时常发生的,那么又改如何编写函数式所需要的纯函数呢?这就需要我们使用一些函数式的类库了,例如 RamdaImmutableRamda让所有传入的参数对象都会经过 clonedeepClone操作,使之引用关系被断开,从而产生新的对象返回; Immutable可以构造出具备 持久性不变性的数据结构,从而剥离副作用。本讲稿也是推荐各位使用出名的函数式编程库,而不要自己再重复造轮子的去构造这些 组合子,我们只是借用这些例子来讲解他们的特性和实际用途。

说了那么多纯函数,这和tap函数究竟有什么关系,和之后的组合子又有什么关系呢?tap函数就是用来隔离那些不纯的操作(实际使用时应加入clonedeepClone),保留原始数据流通到下一个关口,它可以用于辅助函数式编程进行数据调试。例如:

const getByHandler = partial($.ajax, [url, data]);
getByHandler(pipe(    // 假设获取到的数据是一个对象数组
    sortByField,  // 排序
  map(changeKey('_guid', 'id')),  // 将数据库的字段`_guid`切换为`id`
  tap(
      console.log // 将上一步的操作结果打印,并使数据通过
    // 甚至可以发起一个入库操作,让中间数据持久化
  ),
  renderData    //渲染数据到DOM中
));

经过map的数据到了tap之后,并没有发生变化,就转而到了renderData去进行渲染了,log函数只是简单的将上层数据进行了打印。但如果tap内的函数是一个不纯的怎么办?我们的tap函数还缺少一个重要的辅助函数deepClone,也就是数据进去时只传递副本,这样就能有效避免灾难的发生。包扩我们前后写的代码,都没有一些著名的库写的完备、高性能且安全,我们只是通过这些代码示意来展示函数式编程的魅力。大家只要知道,经过tap函数的数据会直接返回,而tap包裹的函数使用完成后会直接弃用。

交替(alt)

/**
 * 函数签名: (a → x) -> (b → y) → v → x || y
 * 函数作用: 对输入值执行给定两个函数并返回不为空的结果
 * 函数特性: or
 * @param {Function} f1 - 原处理函数
 * @param {Function} f2 - 二次处理函数
 * @returns 不为空的结果值
 */

// ES5
function nothing () {}
function alt (f1, f2) {
  var f1 = f1 || nothing;
  var f2 = f2 || nothing;
  return function (val) {
    return f1(val) || f2(val);
  }
}

// ES6+
const nothing = () => {};
const alt = (f1 = nothing, f2 = nothing) => val => f1(val) || f2(val);

// eg:
var getFromDB = () => {};
var getFromCache = () => {};
var getData = alt(getFromDB, getFromCache);
getData(query);

图片描述

alt函数是最简单的一种OR组合子,它描述的就是程序语言中的if-else,只不过它并不是用特定的表达式去判断,而是用||符号。OR组合子的变种有很多,像Ramda.js中的ifElseunlesswhencond都有类似逻辑功能,但更为丰富一些。

补救(tryCatch)

/**
 * 函数签名: (a → x) → (b → y) → v → x || y
 * 函数作用: 对输入值执行`tryer`函数,若无异常则直接返回处理结果,反之返回`catcher`处理后的结果
 * 函数特性: or
 * @param {Function} tryer - 处理函数
 * @param {Function} catcher - 补救函数
 * @returns 不为异常的结果值
 */

//  ES5
function tryCatch (tryer, catcher) {
  return function (val) {
    try {
      return tryer(val);
    }catch (e) {
      return catcher(val);
    }
  }
}

// ES6+
const tryCatch = (tryer, catcher) => val => {
  try {
    return tryer(val);
  }catch (e) {
    return catcher(val);
  }
};

// eg:
var toJson = tryCatch(JSON.parse, defaultTo({}));
var toArray = tryCatch(JSON.parse, defaultTo([]));
toJson('{"a": 1}'); // => {a: 1}
toArray(''); // []

tryCatch函数也是一种OR组合子,它和所有的类OR组合子一样,目的就是实现非此即彼

同时(seq)

/**
 * 函数签名: (a → x, b → y, …,) → val → undefined
 * 函数作用: 对输入值执行给定的所有函数
 * 函数特性: fork
 */

// ES5
function seq () {
  var fns = [].slice.call(arguments);
  return function (val) {
    for (var i = 0; i < fns.length; i++) {
      fns(val);
    }
  }
}

// ES6+
const seq = (...fns) => val => fns.forEach(fn => fn(val));

// eg: ajax请求成功后,做三件事
// 1. render 将请求到的数据渲染到页面上;
// 2. cache 将数据缓存到前端数据库中;
// 3. log 写一段日志,打印请求到的数据,方便控制台观测;
var ajaxSuccessHandler = seq(render, cache, log);

图片描述

seq组合子是一种分流操作,它的实际用途正如案例中所描述的,可以同时做一些事情。虽然代码是同步的版本,但也很容易用学到的知识去创建异步版本的。seq并不关注这些分流函数的结果,所以可以同步去做一些操作,尤其是I/O操作。注意,它的特性是fork,后面的组合子中,将会基于fork进行扩展。

图片描述

聚集(converge)

/**
 * 函数签名: ((x1, x2, …) → z) → [((a, b, …) → x1), ((a, b, …) → x2), …] → (a → b → … → z)
 * 函数作用: 将输入值fork到各个forker函数中运行,并将结果集聚集到join函数中运行,返回最终结果
 * 函数特性: fork-join
 * @param {Function} join 聚集函数
 * @param {...Function} forkers 分捡函数列表
 * @returns join函数的返回值
 */

// ES5
function converge (join, forkers) {
  return function (val) {
    var args = [];
    for (var i = 0; i < forkers.length; i++) {
      args[i] = forkers[i](val);
    }
    join.apply(null, args);
  }
}

// ES6+
const converge = (join, forkers) => val => join(...forkers.map(forker => forker(val)));

// eg: 数组求平均数
var len = arr => arr.length;
var sum = arr => arr.reduce((init, item) => init + item, 0);
var div = (sum, len) => sum / len; 
var avg = converge(div, [sum, len]);
avg([1,2,3,4,5]); // => 3

图片描述

图片描述

converge函数其实是seq的祖先,你看他们的特性都是fork,但为什么说convergeseq的先祖呢?学了那么多组合子的知识,我们来尝试用converge来重写seq吧:

// 不用组合子的写法
const seq = (...fns) => converge(nothing, fns);
// 使用组合子的写法
const seq = spread(curry(converge)(nothing));
const seq = spread(partial(converge, [nothing]));

重写的思路也很简单,首先使用完全函数应用,然后找参数的特点,不使用组合子(即完全函数应用)的时候,我们发现seq形参converge的第二实参是对应的,但coverge的第二实参是一维的,因此需要用spread升维。然后converge的第一实参前置出来即可,所以我们可以使用curry或者partial。看,组合子再一次发挥了极其重要的作用。

映射(map)

/**
 * 函数签名: (a → b) → [a] → [b]
 * 函数作用: 将系列输入值映射到`transfomer`函数中运行,并将结果整理成新的系列
 * 函数特性: map
 * @param {Function} transfomer - 转换器
 * @returns 经过映射后的新系列
 */

// ES5
function map (transfomer) {
  return function (arr) {
    var result = [];
    for (var i = 0; i < arr.length; i++) {
      result.push(transfomer(arr[i]));
    }
    return result;
  }
}

// ES6+
const map = transfomer => arr => arr.map(transfomer);

图片描述

map居然是组合子,很多人一脸茫然。是的,你没听错,在ES5上增加的这些数组函数,都是组合子,只不过它是数组实例的方法。但推的更广一点,但凡具备Iterator特性的对象,都可以具备map方法。而在范畴论中,Functor也是可以具备map方法的,这不在我们本章的讨论范围呢,我们假定拥有map特性的都是集合。以上代码中,我们用自己的方式抽离出了map函数,它的特点是,将集合中的每一项提取并映射到目标函数transfomer中,并将结果重新整理成集合。map操作和fork操作都非常的相似,前者是数组分发,后者是单值分发。由此可见,mapfork特性(不是函数)是可以相互转换的,感兴趣的同学可以继续往下看。

图片描述

前面我们提到过unary组合子,但是没有给出实现方式以及实际用途,现在我们可以结合map组合子来使用。首先是unary的代码形式:

/**
 * 函数签名: (* → b) → (a → b)
 * 函数作用: 将二元以上的函数转换成一元的(不推荐转零元)
 * 函数特性: 降维
 * @param {Function} fn - 原函数
 * @returns 降为一元的新函数
 */

// ES5
function unary (fn) {
  return function (value) {
    return fn(value);
  }
}

// ES6+
const unary = fn => value => fn(value);

然后我们来看如下的案例:

// 假设数据从某个文件中获取,转换出来之后是一个字符串数组
const datasFromFile = ['1', '2', '3', '4'];
// 对字符串数组进行转换,转变为数字数组
datasFromFile.map(parseInt); // => [1, NaN, NaN, NaN]

为什么会出现[1, NaN, NaN, NaN]这样的结果?如果你对parseInt函数了解,应该知道它有两个参数string(被解析的字符串)和radix(解析基数)。第二个参数告诉程序string会以什么样的进制数进行解析,这个函数我们不多赘述了,只要知道默认值是0就能按照十进制进行解析。出现这个问题的原因是因为Array.prototype.map组合子中的transfomer默认带有三个参数:item(项)、index(项索引)、array(数组实例)。因此调用时,索引被添加上去导致后面的字符串无法按照该索引所确立的进制数进行转换,但使用了unary就可以:

datasFromFile.map(unary(parentInt)); // => [1, 2, 3, 4]

分捡(useWith)

/**
 * 函数签名: ((x1, x2, …) → z) → [(a → x1), (b → x2), …] → (a → b → … → z)
 * 函数作用: 将系列输入值映射到各个transfomer函数中运行,并将结果集聚集到join函数中运行,返回最终结果
 * 函数特性: map-join
 * @param {Function} join - 聚集函数
 * @param {Function[]} transfomers - 转换器
 * @returns join函数的返回值
 */

// ES5
function useWith (join, transfomers) {
  return function (vals) {
    var args = [];
    for (var i = 0; i < transfomers.length; i++) {
      args[i] = transfomers[i](vals[i]);
    }
    join.apply(null, args);
  }
}

// ES6+
const useWith = (join, transfomers) => vals => join(...transfomers.map((transfomer, i) => transfomer(vals[i])));

// eg:
var square = val => Math.pow(val, 2);
var sumSqrt = (a, b) => Math.sqrt(a + b);
var pythagoreanTriple = useWith(sumSqrt, [square, square]);
pythagoreanTriple([3, 4]); // => 5
pythagoreanTriple([5, 12]); // => 13
pythagoreanTriple([7, 24]); // => 25

图片描述

useWith函数和converge函数非常的相似,我们从它们的结构图上可以发现,一个是先map,一个是先fork。这两个函数在实际使用中很常见的。

规约(reduce)

/**
 * 函数签名: ((a, b) → a) → a → [b] → a
 * 函数作用: 将初始值代入`reducer`的第一参数,输入系列映射为`reducer`的第二参数,并将`reducer`的返回值迭代到下次`reducer`的第一参数中,将最终返回值构成新的系列
 * 函数特性: reduce
 * @param {Function} reducer 规约函数
 * @param {*} init 初始数
 */

// ES5
function reduce (reducer, init) {
  return function (arr) {
    var result = init;
    for (var i = 0; i < arr.length; i++) {
      result = reducer(result, arr[i]);
    }
    return result;
  }
}

// ES6+
const reduce = (reducer, init) => arr => arr.reduce(reducer, init);

图片描述

reduce是集合中一个比较特殊的函数,功能特性为“折叠”,能够将一个列表折叠成一个单一输出。用来做统计是非常不错的。它常和其它函数联系使用,不仅能实现功能,还能让代码的语意化变得有艺术感,这里不多做赘述。和reduce很像的组合子还有sortfilterflat等,它们的特别之处就是要等待谓词函数(一种契函数)的嵌入,才能发挥真正的作用。这些函数的特性既不是改变维度,也不是控制逻辑流程(参数分发和结果选择),它们是真正具备数据处理功能的函数。

组合(compose)

/**
 * 函数签名: ((y → z), (x → y), …, (o → p), ((a, b, …, n) → o)) → ((a, b, …, n) → z)
 * 函数作用: 将输入值代入最末函数,并将结果代入上一个函数,直到所有函数全部调用完成,返回最终结果
 * 函数特性: chain
 * @param {...Function} fns - 函数列表
 * @returns 从下到上依次执行的结果
 */

// ES5
function compose() {
  var fns = [].slice.call(arguments);
  var len = fns.length;
  return function (val) {
    var result = val;
    for (var i = len - 1; i >= 0; i--) {
      result = fns[i](result);
    }
    return result;
  }
}

// ES6+
const compose = (...fns) => val => fns.reverse().reduce((result, fn) => fn(result), val);

现在,我们抽离出更加通用的组合函数compose,可以将任意个函数组合在一起。但注意,除了最后一个函数可以是可变元的,其它的函数都应该是一元的,它的执行顺序是从后到前,如果不适应这样的方式,也可以reverse一下参数,构成命令行中常见的管道方式pipe函数:

const pipe = reverse(compose);

谓语组合子

谓词,用来 描述判定客体性质、特征或客体之间关系的词项。

谓词函数,用于表达是什么(is)做什么(do)怎么样(how)等的函数。

谓语组合子是一种最常见的函数组合子,它需要组合谓词函数(predicate)(或叫断言函数)来实现其功能,这个我们前面已经接触过一次。常见的关键字有ofbyiswhendo等等,在实际开发中我们已经见过很多谓语组合子,只是大家都不知道它们的称呼。下面,我们来重新回顾一下。

过滤(filter)

例如,有限数字列表或哈希中,过滤出偶数。此时是偶数isEven就是谓词函数

// 构建`是什么`的`谓词函数`
const isEven = n => n % 2 === 0;
// 将`isEven`嵌入到`filter`组合子中
const filter = fn => list => list.filter(fn);
const getEvens = filter(isEven);
getEvens([1, 2, 3, 4]); // => [2, 4]

谓词函数返回boolean类型,嵌入filter后发生效用。与filter具备同样特性的组合子有很多,例如:findeverysome等。

分组(group)

例如,将一个对象数组按照对象的name字段进行分组。此时name字段byName就是谓词函数

// 构建`怎么样`的`谓词函数`
const byName = obj => obj.name;
// 将`byName`嵌入到`group`组合子中
const group = fn => list => list.reduce((groups, item) => {
  const name = JSON.stringify(fn(item));
  groups[name] = groups[name] || [];
  groups[name].push(item);
  return groups;
}, {});
const groupByName = group(byName);
groupByName([
  {name: 'A', tag: 'a'},
  {name: 'B', tag: 'b'},
  {name: 'A', tag: 'α'},
  {name: 'B', tag: 'β'}
]);

谓词函数返回某个属性,嵌入group后发生效用。与group具备相同特性的组合子有很多,例如:flatpair等。

排序(sort)

sort组合子需要嵌入一个comparator函数,是一个比较函数,用于描述两个参数之间做比较(即做什么)的过程。我们先来看看最简单的例子:

const diff = (a, b) => a - b;
const sort = fn => list => list.sort(fn);
const asc = sort(diff);
asc([4, 2, 7, 5]); // => [2, 4, 5, 7]

谓词函数返回一个单值,嵌入sort后发生效用。与sort具备相同特性的组合子有很多,例如mapreduce等。

其它

这里,我们再次讲到了reduce,与它相似的,map也是谓语组合子,它们主要负责组合做什么这类的谓词函数。由此可见,在函数组合子这一节中提到的大部分组合子都是具备谓语特性的,主要目的是达成谓语的"做什么":

const add = (a, b) => a + b;
const sum = list => list.reduce(add, 0);
sum([1, 2, 3, 4]); // 10

const pow = (x, n) => Math.pow(x, n); // x的n次方
const squ = list => list.map(pow);
squ([2, 2, 2, 2]); // [1, 2, 4, 8]

谓语组合子还有很多很多,其目的就是将某种功能的可开放性交给谓词函数进行扩展。一般在函数库中,见到类似如下字眼并后跟函数参数的,很有可能就是谓语组合子tobywhilewhenwithofall/everyany/somenone…… 像iseqgt用于判断的谓词函数,有的是用于谓语组合子的,有的是由函数组合子构造出来的。

一些常见函数库中的谓语组合子

// lodash
_.countBy
_.dropRightWhile
_.differenceWith
// ramda
R.indexBy
R.takeWhile
R.mergeWith

组合子变换

回顾一下我们已经掌握的组合子,它们具备的特性如下:

  1. 变换维度(升维、降维、换序、偏应用、柯里化);
  2. 数据流程(id、or、fork、map、join);
  3. 数据处理(reduce、sort、filter...)

我们不仅认识了这些组合子,并且知道他们是通过什么方法获得的,也尝试了用已经学过的组合子来构建起他等效的组合子。现在我们手动构建其它要想或者可能会用到的组合子:

juxt

juxt将函数列表作用于值列表,在没有封装之前,我们看它是怎么使用的:

// 获取一系列数的范围
const getRange = juxt([Math.min, Math.max]);
getRange(3, 4, 9, -3); // => [-3, 9]

这个方法使用上和之前的组合子颇有一些相似,究竟是哪些地方相似,只要找出来,谜题自然解开。这个函数的特性是:

  1. 参数为一系列函数,即函数数组;
  2. 最终输入为一系列输入;
  3. 所有的输入都是同时参与这一系列函数的处理;

很显然,第三点就是我们之前学过的fork-join特性。而且参数和最终输入都非常的相似,有区别的是converge的最终输入是一元的,且它有一个join函数。于是我们可以知道,要想改造converge成为juxt,需要:

  1. 降维,将参数进行压缩后再展开;
  2. join函数用id消除即可;
var juxt = fns => spread(converge(spread(identity), fns.map(gather)));

现在,你通过juxt构建的函数来构建getRange并且代入数据,结果完全一致,其数据代入的过程是:

  1. (3, 4, 9, -3)经过外部spread的逆运算变为[3, 4, 9, -3]
  2. 每个函数都被map内的gather函数处理过,因此[3, 4, 9, -3]都会经过内部的gather的逆运算变为(3, 4, 9, -3)
  3. Math.minMath.max可执行参数为(3, 4, 9, -3)的运算,得到结果(-3, 9)
  4. (-3, 9)经过内部spread的逆运算,变为[-3, 9]
  5. [-3, 9]代入到identity函数,返回原值[-3, 9]

现在是不是觉得特别的神奇,有兴趣的朋友可以尝试进行其它组合。

实战案例

数据判断

我们有这样的对象信息从sessionStorage中获取(该代码摘至芒果项目):

{
  authorites: ['xxxx', 'xxxx', 'ROLE_ADMIN', 'xxxx', 'xxxx']
}

这个对象描述了一个用户信息的缓存,其中authorites反应了用户所具备的权限,如果权限字段中带有ROLE_ADMIN,则我们认为他就是一个系统管理员。如果不使用函数式编程,会是这样的:

// 不使用函数式(ES6+)
function isAdmin (userInfo) {
  var authorites = [];
  if (userInfo.hasOwnProperty('authorites')) {
    authorites = userInfo.authorites || [];
  }
  return authorites.some(item => item === 'ROLE_ADMIN');
}

然后我们看看函数式编程会怎么的改写,这里我们将用到比较出名的函数式函数库Ramda

// 使用Ramda(ES6+)
const isAdmin = R.pipe(            // 1. 从上到下串联(组合)函数
    R.prop('authorites'),            // 2. 获取`数据`的`authorites`信息
  R.defaultTo([]),                    // 3. 数据处理,如果为`undefined`、`null`或`NaN`则返回`[]`
  R.contains('ROLE_ADMIN')    // 4. 判断是否包含`ROLE_ADMIN`信息
);

这段代码明显就比没有使用函数式的代码要剪短不少,这不足为奇,你甚至还能发现userInfo这个参数没有了。这段代码应该从上往下读,因为我们使用了能组合函数的pipe函数:

  1. R.pipe将各个函数依次从上往下执行的串联(组合)起来;
  2. R.prop用于获取上一个输入的对象的authorites属性;
  3. R.defaultTo用于设置一个默认值;
  4. R.contains用于判断数组中是否含有ROLE_ADMIN这一项;

R.pipe将各个函数串联(组合)起来,并返回一个新的函数,这个新函数的输入就是userInfo,它不是不存在,而是被Pointfree化,中文翻译为“无参风格”。这个代码如果完整的写则表示为:

const isAdmin = userInfo => R.pipe(...)(userInfo);

函数式中有一个重要特性就是:如果f = x => g(x),那么f === g。这就是Pointfree风格。它不是完全无参,只是弱化了数据本身的形式,而注重过程(方法)的实现。数据进去之后会获取一个authorites信息a,然而处理该信息的默认值b,最后判断是否包含预定信息c,并将结果c返回。由于isAdmin = R.pipe(f1, f2, f3),通过f1/f2/f3就能计算出isAdmin,那么整个过程就根本不需要知道a/b/c,甚至连最开始的数据都可以不需要知道。我们把数据处理的过程,重新定义成了一种与参数无关的合成(pipecompose)运算,这种将数据进行更加抽象的方式使得函数变得可自由组合,从而提升复用性。但这也要求,我们在编写函数时,参数应该更加偏向抽象的数据形式,而尽可能不要偏向业务。后面的例子,我们也会用到Pointfree风格,并讲到使用无参风格所需要的一些条件。

数据转换

现在,我们尝试做如下两种数据之间的转换:

var list = [{id: 1, name: 'a'}, {id: 2, name: 'b'}, /* ... */];
var obj = {
  "1": {id: 1, name: 'a'},
  "2": {id: 2, name: 'b'},
  /* ... */
};

这是一个非常常见的列表转哈希需求,目的是为了给列表数据做缓存,现在我们不用函数式来实现:

// 不用函数式(ES6+)
const list2object = function (list) {
  const result = {};
  list.forEach(item => {
    result[item.id] = item;
  });
  return result;
};

如果我们查阅Ramda文档,很容易将该函数进行改写,但我们先不这么做,我们来看看这样的函数有什么 问题?不难发现,取id这一操作应该是可以配置的。我们只需要加入谓词函数即可:

// 使用Ramda(ES6+)
const list2objectBy = name => R.compose(
  R.indexBy,        // 2. 根据谓词函数进行索引转换(转换器)
    R.prop(name)    // 1. 实现的谓词函数,按照属性名进行转换(转换规则)
);
const list2objectById = list2objectBy('id');
const list2objectByName = list2objectBy('name');

这样,list2objectById可以将id提取成list2objectByName可以将name提取成list2objectBy成为了创造函数的函数,我们非常巧妙且灵活的运用了函数式的灵活性。

Mendix案例——MxObject对象数据提取

刚才的案例,都是比较小比较弱的案例,现在来让我们看更大的案例。这是出现在Mendix前端组件开发时,发现的一个问题,首先我们先描述一下环境,让大家对这个有个基本认识:

  1. Mendix Client 通过特定api可获取订阅数据MxObject;
  2. MxObject是一个超级大对象,可通过一系列api获取对象属性;
  3. 第二条所说的属性就是数据库中的数据;

不过很可惜的是,MxObject只能通过get方法获取某一个属性,不能直接获得整个对象的JSON值。如果我们希望通过console.log来打印一个MxObject对象,那就很繁琐了,要一个个属性去转,如果订阅获取到的数据是列表MxObjects,会更麻烦。好在该对象的JSON存根中有这样的一段信息,形如:

{
  jsonData: {
    attributes: {
      customAttr: {value: '@value'}
    },
    guid: '@guid'
  }
}

一个完整的表达是这样的:

{
  jsonData: {
    attributes: {
      name: {value: 'bill'}
      age: {value: 45},
        address: {value: 'usa'}
        /* ... */
    },
      guid: '9876543210'
  }
}

我们发现这段数据存根非常的诡异,它有如下特征:

  1. 所有的数据字段都存储在jsonData中;
  2. id信息存在guid中,其它信息存在attributes中;
  3. id信息的值是直接存储的,其它信息的值是存储在value键值对中的;

现在,我们需要 将它转换成这样的格式:

{ 
  id: '9876543210',
    name: 'bill',
  age: 45,
  address: 'usa',
  /* ... */
}

也可以简化成如下的形式:

{
  guid: '@guid',
  customAttr: '@value'
}

如果不使用函数式,相信各位都会很轻松的写出来,但我们讲的是函数式,而且我使用的是Ramda库,所以我是这样去处理的:

// 由于id和其它属性存储方式不一样,因此我们要分开处理

// 1. 先处理id,处理的思路就是`对象提取`
const getJSONFromMxObjectWithGuid = R.pipe(
  // 获取MxObject.jsonData属性
  R.prop('jsonData'),
  // 筛选出guid键值对
  R.pick(['guid'])
);
// 2. 再处理非id字段,处理的思路就是`遍历键值对`,再进行时`属性提取`
const getJsonFromMxObjectWidthoutGuid = R.pipe(
    // 获取MxObject.jsonData.attributes属性
  R.path(['jsonData', 'attributes']),
  // 遍历键值对,提取value属性构成新键值对
  // {customAttr: {value: 'value'}} => {customAttr: 'value'}
  R.map(R.prop('value'))
);
// 3. 合并数据
const getJsonFromMxObject = R.converge(
  R.merge, 
  [getJsonFromMxObjectWidthoutGuid, getJSONFromMxObjectWithGuid]
);

现在问题来了,我们是期望将guid提取出来,变成id的,但是上面的代码中,我们没有对提取的键值进行转换,我们需要修改源代码吗?在函数式的帮助下,我们的答案是不需要,由于Ramda并没有更换对象键名的方法,所以我们要自己手动创建一个:

/**
 * 重命名Object的键名
 * @curried 已柯里化
 * @param {String} oldKey -  旧键名
 * @param {String} newKey -  新键名
 */
const renameKey = R.curry((oldKey, newKey) => R.converge(
  // 3. 合并对象(合并1.*和2的操作)
  R.merge, [
    // 2. 删除旧键
    R.omit([oldKey]), 
    R.compose(
      // 1.2 创建新键值对对象
      R.objOf(newKey), 
      // 1.1 获取旧键值
      R.prop(oldKey)
    )
  ]
));

然后我们新增一个方法,并修改最终的函数:

const getJSONFromMxObjectWithId = R.pipe(
  getJSONFromMxObjectWithGuid,
  renameKey('guid', 'id')
);
// 3. 合并数据
const getJsonFromMxObject = R.converge(
  R.merge, 
  [getJSONFromMxObjectWithId, getJSONFromMxObjectWithGuid]
);

总结

经过一系列的长文,稍显“粗略”的介绍了一下函数式编程中组合子的构成、特点和使用方式。之所以说是“粗略”的介绍,是因为有关组合子的内容还有更多更深的,在数学和计算机领域真实存在,但被JavaScript所实现并应用的确实不多,例如:

  1. A组合子apply);
  2. B组合子(已经讲到过的compose方法)
  3. K组合子constant
  4. Y组合子fix
  5. C组合子flip
  6. I组合子(已经讲到过的identity方法)
  7. S组合子substitution
  8. T组合子thrush
  9. P组合子psi

这些内容再讲深一点,就可以讲到函子(Functor)的概念了, 这超出了我们需要掌握的范围。有兴趣的朋友可以参阅fantasy-landFantasy-Land-Specification 中文翻译),这里有一套已经实现大部分组合子的类库combinators-js。有机会的话,会给大家讲解比较浅显的函子概念。

认识一些常用的组合子后,我们发现了组合子的妙用,也感受到了函数式所带来的代码美化哲学。但这仅仅是函数式编程中很小的一块,但也是最为实用的。来回顾一下它的特性:

  1. 组合子是一种高阶函数;
  2. 组合子不改变原函数契函数)的原有功能特性;
  3. 组合子可以通过变换参数转变流程控制输出增强函数
  4. 组合子可经由其它组合子等效转换成其它组合子

而组合子的使用条件也比较苛刻:

  1. 无论是组合子函数还是契函数都必须是绝对的;
  2. 组合子必须保证数据的不变性持久性

感谢以下原创系列文章给本文带来的认知提升和书写灵感:

准备充分了嘛就想学函数式编程 系列

跌宕起伏的函数式编程 系列

JS 函数式编程指南 系列

JavaScript 轻量级函数式编程 系列

Thinking in Ramda 系列

Ramda 杂谈 系列

函数式编程中的“函数们”

代数 JavaScript 规范

Transducers Explained: Part 1 中文

Transducers Explained: Pipelines 中文

JavaScript 中的 Currying(柯里化) 和 Partial Application(偏函数应用)

一步一步教你 JavaScript 函数式编程(第一部分)

一步一步教你 JavaScript 函数式编程(第二部分)

一步一步教你 JavaScript 函数式编程(第三部分)

JavaScript 函数式编程术语大全

函数式编程入门教程

JavaScript中的函数式编程(英文原文)

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值