函数式编程是一个比较大的话题,里面的知识体系非常的丰富,在这里我并不想讲的特别的详细。为了应对实际中的应用,我们讲一下函数式编程中最为实用的应用方式——组合子。组合子本身是一种高阶函数,他的特点就是将函数进行延迟或者转换,在函数式编程中应用最为广泛。
什么是组合子
组合子在数学中就有,但我们讲的并不是数学中的定义,而是在JavaScript领域中的组合子概念。按照我所理解的JavaScript函数式编程,我将组合子分为辅助组合子和函数组合子。后续我们会对这两种组合子进行区别。
组合子(全称:组合子函数)又称之为装饰器函数,用于转换函数或数据,增强函数或数据行为的高阶函数。这里我们提到的高阶函数并不陌生,所谓的高阶函数,就是以函数为参数或者返回值的函数。辅助组合子是最为简单的组合子,它是具有数据流程控制的抽象函数。而函数组合子就很特别了,它必须以函数(称之为原函数或契函数)为参数,其大致有如下特点:
- 函数组合子本身就是高阶函数;
- 不改变原函数(契函数)的最终意图;
- 能增强原函数(契函数)的行为;
高阶函数的概念我们已经很熟悉了,这里不做多的解释,我们只强调,函数组合子是以原函数为参数,返回新函数的高阶函数。知道这一点,我们再来解释后面两个特性,所谓“不改变原函数的最终意图”,即原函数是做什么的,新函数就是做什么的。原函数和新函数的所需参数是一致的,返回值也是一样的。这里我们先卖个关子,稍后我们会用实际的案例来讲述一下这个特性。“能增强原函数的行为”,这是函数式编程的核心概念之一,他并不难理解,但需要我们花更多的时间去关注。
那么什么是函数的行为,为什么要增强函数的行为。函数有三个重要的部分:输入、处理、输出。输入就是指的参数,而处理就是函数体中对参数的执行过程,输出就是返回值。在JavaScript
语言中,即使函数没有输出,都约定输出的是undefined
,以上都是我们非常熟悉的概念。
参数即是“元”(arity)的,分为:一元(unary)、二元(binary)、三元(ternary)、多元(polyadic)、可变元(variadic)。除了一元函数,其他的参数都或多或少存在着这样的两个问题:
- 参数的获取时机;
- 参数的获取顺序;
以我们最常使用的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
的包裹不说,运行的结果和之前没有区别。是的,但他没有任何意义?
这里我们并不打算详细研究函数编程的性能问题,这是一个很大的话题,在架构中也是有取有舍的。我只能说,总体性能上不一定比原来的差,某些场景下的优化空间还会比传统方式更大。大家只管放心使用即可,后续会对函数式编程优化进行专题讲演的。
柯里化作为一种最简单的组合子之一,他是一种高阶函数(显然这里我们没有用到柯里化组合子),没有改变原有函数的意图,但却延迟了函数的执行。
偏函数应用
如果在编写代码时,我们总能预知data
和callback
的确定性和及时性,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))。但其实辅助组合子本身并不是不处理函数,而是函数也可以作为特殊的数据
,他们虽然小、写法简单,但是意义仍然和组合子一样重大:
// ES5function 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 >= 1const 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; // => trueuser1.name = '张三';user1 === user2; // => truefunction 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 参数收缩后的新函数 */// ES5function 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
,但是它并不关心数据本身,换句说法,它并不关心放进来的是什么函数(函数参数的抽象)。我们经常会在别的函数体中调用某个函数的call
和apply
方法,但很少有人尝试将该方法进行封装。gather
函数只关注函数的行为,而不关注函数本身是什么,你传入任意的函数都可以。gather
函数的目的是将可变元
参数的函数转换为一元
的,这是一种降维操作,可以适配用户的输入。被创建的新函数会通过gather
的逆运算,将[a, b, c, ...]
结构的数据转换为(a, b, c, ...)
结构的数据再代入原函数。
展开(spread)
/** * 函数签名: ([a, b, c, …, n] → x) → (a, b, c, …, n) → x * 函数作用: 参数展开 * 函数特性: 升维 * @param fn - 原函数 * @returns 参数展开后的新函数 */// ES5function 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, 3identity(console.log)(1, 2, 3); // => 1, 2, 3
颠倒(reverse)
/** * 函数签名: ((a, b, c, …, n) → x) → (n, …, c, b, a) → x * 函数作用: 参数倒序 * 函数特性: 换序 * @param fn - 原函数 * @returns 参数收缩后的新函数 */// ES5function 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 前置参数提前后的新函数 */// ES5function 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 前置参数提前后的新函数 */// ES5function 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
一模一样的,甚至我们利用我们已经学会的reverse
将partial
函数转换成partialRight
函数,有兴趣的同学可以自行尝试一下,稍后我们也会给出答案。
注意: 学到这里的时候,我们不难发现,从gather
到partial
都是针对参数的维度变化。虽然他们都是可以代入函数为参数,但对函数特性是有要求的,比如升维
的前提必须是数组。再一个,我们已经可以利用他们在不改变原有函数的前提下,组合出各种适合使用需求的函数。
柯里化(curry)
/** * 函数签名: (* → a) → (* → a) * 函数作用: 逐个参数提前 * 函数特性: 降维 * @param {Function} fn 原函数 * @param {Number} arity 原函数的参数个数, 默认值: 原函数的参数个数 * @returns 柯里化后的下一个`一元`函数 */// ES5function 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); // => 4square(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; // => 1Math.sin.length; // => 1Math.random.length; // => 0JSON.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 messagecurry3Log('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);
看,万变不离其宗,是不是非常的神奇,就像我们介绍的一样——像魔法,所以你们是不是对他们越来越感兴趣了呢?
到此,我们已经讲解了有关参数维护变化的组合子,你们发现了没,gather
、spread
、reverse
、partial
、curry
都是基于参数维度的变化。我们已经在文中提过很多次 参数维度,一元函数是一维的,二元函数是二维的……多元函数是多维的,可变元函数是不定维的。这些组合子的特点就是改变维度,让 原函数可以变化成其它可复用的方式。当然,除了这些组合子可以改变维度,还有像unary
、binary
、nAry
等组合子可以强行改变维度,比如unary
是将任意函数变为一元的,如果本身就是二元以上的函数,是会有损失的。即便你很难摸清楚维度切换的法门,也不用担心,函数式编程的精妙在于,当你需要的时候,你就知道怎么去选择了,我们所要做的是掌握和了解更多的组合子。
弃离(tap)
/** * 函数签名: (a → *) → a → a * 函数作用: 对输入值执行给定函数并立即返回输入值 * 函数特性: id * @param {Function} fn - 原函数 * @returns 输入值 */// ES5function 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
函数类似一个id
(identity
,之后我们都简称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
,其它的都不算纯,我们来看纯函数应该具备的特性:
- 独立性:没有副作用,不会影响外部,也不受外部影响;
- 常恒性:官方说法叫引用透明性(Referential Transparency),即在任意时间里,传入相同且确定的参数,返回相同且确定的值;
简单的说,真正的纯函数是“永恒”和“不变”的。再回头看上面的案例,clickHandler
使用了this
,该函数会因为上下文的变化而作用不同(独立性和常恒性被打破)。getCount
引用了外部变量,这也是非常危险的,因为你无法保证变量a
永远无法被其他人改变(常恒性被打破)。append
传入的参数arr
是一个引用类型,因此函数体内部对外部产生的副作用(独立性被打破)。有的人认为console.log
是一个纯函数,是的,如果不考虑那么严格的话,它确实是一个比较纯的函数,但它和DOM操作、内存操作、写库操作(都属于I/O操作)一样,对外部产生了变化,因此它也不是一个纯函数(独立性)。但log
操作也很特殊,因为DOM操作、内存操作、写库操作和它的区别就在于他们三个都存在并发,因此结果是不确定的;是存在异常的,异常会中断函数本身的运行;是不可预测的,虽然我们知道大部分都能正确返回,可不得不承认对结果预测的不稳定性。再看看log
,虽然对外部(控制台)有I/O操作,但它既不存在并发,也不会有异常发生,结果是可预测的。因此,log
可以认为是一个非严格意义上的纯函数,毕竟查看数据时它是非常有帮助的。
虽然addOne
函数我们认识是一个 纯的,但如果传入的不是一个 基础类型,而是一个引用类型呢?那就不是的,因为内部的改变是影响了外部。这样的需求是时常发生的,那么又改如何编写函数式所需要的纯函数呢?这就需要我们使用一些函数式的类库了,例如Ramda
和Immutable
,Ramda
让所有传入的参数对象都会经过clone
或deepClone
操作,使之引用关系被断开,从而产生新的对象返回;Immutable
可以构造出具备 持久性和 不变性的数据结构,从而剥离副作用。本讲稿也是推荐各位使用出名的函数式编程库,而不要自己再重复造轮子的去构造这些 组合子,我们只是借用这些例子来讲解他们的特性和实际用途。
说了那么多纯函数,这和tap
函数究竟有什么关系,和之后的组合子又有什么关系呢?tap
函数就是用来隔离那些不纯的操作(实际使用时应加入clone
或deepClone
),保留原始数据流通到下一个关口,它可以用于辅助函数式编程进行数据调试。例如:
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 不为空的结果值 */// ES5function 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
中的ifElse
、unless
、when
、cond
都有类似逻辑功能,但更为丰富一些。
补救(tryCatch)
/** * 函数签名: (a → x) → (b → y) → v → x || y * 函数作用: 对输入值执行`tryer`函数,若无异常则直接返回处理结果,反之返回`catcher`处理后的结果 * 函数特性: or * @param {Function} tryer - 处理函数 * @param {Function} catcher - 补救函数 * @returns 不为异常的结果值 */// ES5function 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 */// ES5function 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函数的返回值 */// ES5function 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
,但为什么说converge
是seq
的先祖呢?学了那么多组合子的知识,我们来尝试用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 经过映射后的新系列 */// ES5function 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
操作都非常的相似,前者是数组分发,后者是单值分发。由此可见,map
和fork
特性(不是函数)是可以相互转换的,感兴趣的同学可以继续往下看。
前面我们提到过
unary
组合子,但是没有给出实现方式以及实际用途,现在我们可以结合map
组合子来使用。首先是unary
的代码形式:/** * 函数签名: (* → b) → (a → b) * 函数作用: 将二元以上的函数转换成一元的(不推荐转零元) * 函数特性: 降维 * @param {Function} fn - 原函数 * @returns 降为一元的新函数 */// ES5function 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函数的返回值 */// ES5function 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]); // => 5pythagoreanTriple([5, 12]); // => 13pythagoreanTriple([7, 24]); // => 25
useWith
函数和converge
函数非常的相似,我们从它们的结构图上可以发现,一个是先map
,一个是先fork
。这两个函数在实际使用中很常见的。
规约(reduce)
/** * 函数签名: ((a, b) → a) → a → [b] → a * 函数作用: 将初始值代入`reducer`的第一参数,输入系列映射为`reducer`的第二参数,并将`reducer`的返回值迭代到下次`reducer`的第一参数中,将最终返回值构成新的系列 * 函数特性: reduce * @param {Function} reducer 规约函数 * @param {*} init 初始数 */// ES5function 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
很像的组合子还有sort
、filter
、flat
等,它们的特别之处就是要等待谓词函数(一种契函数)的嵌入,才能发挥真正的作用。这些函数的特性既不是改变维度,也不是控制逻辑流程(参数分发和结果选择),它们是真正具备数据处理功能的函数。
组合(compose)
/** * 函数签名: ((y → z), (x → y), …, (o → p), ((a, b, …, n) → o)) → ((a, b, …, n) → z) * 函数作用: 将输入值代入最末函数,并将结果代入上一个函数,直到所有函数全部调用完成,返回最终结果 * 函数特性: chain * @param {...Function} fns - 函数列表 * @returns 从下到上依次执行的结果 */// ES5function 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)(或叫断言函数)来实现其功能,这个我们前面已经接触过一次。常见的关键字有of
、by
、is
、when
、do
等等,在实际开发中我们已经见过很多谓语组合子,只是大家都不知道它们的称呼。下面,我们来重新回顾一下。
过滤(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
具备同样特性的组合子有很多,例如:find
、every
、some
等。
分组(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
具备相同特性的组合子有很多,例如:flat
、pair
等。
排序(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
具备相同特性的组合子有很多,例如map
、reduce
等。
其它
这里,我们再次讲到了reduce
,与它相似的,map
也是谓语组合子,它们主要负责组合做什么这类的谓词函数。由此可见,在函数组合子这一节中提到的大部分组合子都是具备谓语特性的,主要目的是达成谓语的"做什么":
const add = (a, b) => a + b;const sum = list => list.reduce(add, 0);sum([1, 2, 3, 4]); // 10const pow = (x, n) => Math.pow(x, n); // x的n次方const squ = list => list.map(pow);squ([2, 2, 2, 2]); // [1, 2, 4, 8]
谓语组合子还有很多很多,其目的就是将某种功能的可开放性交给谓词函数进行扩展。一般在函数库中,见到类似如下字眼并后跟函数参数的,很有可能就是谓语组合子:to
、by
、while
、when
、with
、of
、all/every
、any/some
、none
…… 像is
、eq
、gt
用于判断的谓词函数,有的是用于谓语组合子的,有的是由函数组合子构造出来的。
一些常见函数库中的谓语组合子:
// lodash_.countBy_.dropRightWhile_.differenceWith// ramdaR.indexByR.takeWhileR.mergeWith
组合子变换
回顾一下我们已经掌握的组合子,它们具备的特性如下:
- 变换维度(升维、降维、换序、偏应用、柯里化);
- 数据流程(id、or、fork、map、join);
- 数据处理(reduce、sort、filter...)
我们不仅认识了这些组合子,并且知道他们是通过什么方法获得的,也尝试了用已经学过的组合子来构建起他等效的组合子。现在我们手动构建其它要想或者可能会用到的组合子:
juxt
juxt
将函数列表作用于值列表,在没有封装之前,我们看它是怎么使用的:
// 获取一系列数的范围const getRange = juxt([Math.min, Math.max]);getRange(3, 4, 9, -3); // => [-3, 9]
这个方法使用上和之前的组合子颇有一些相似,究竟是哪些地方相似,只要找出来,谜题自然解开。这个函数的特性是:
- 参数为一系列函数,即函数数组;
- 最终输入为一系列输入;
- 所有的输入都是同时参与这一系列函数的处理;
很显然,第三点就是我们之前学过的fork-join
特性。而且参数和最终输入都非常的相似,有区别的是converge
的最终输入是一元的,且它有一个join
函数。于是我们可以知道,要想改造converge
成为juxt
,需要:
- 降维,将参数进行压缩后再展开;
- 让
join
函数用id
消除即可;
var juxt = fns => spread(converge(spread(identity), fns.map(gather)));
现在,你通过juxt
构建的函数来构建getRange
并且代入数据,结果完全一致,其数据代入的过程是:
-
(3, 4, 9, -3)
经过外部spread
的逆运算变为[3, 4, 9, -3]
; - 每个函数都被
map
内的gather
函数处理过,因此[3, 4, 9, -3]
都会经过内部的gather
的逆运算变为(3, 4, 9, -3)
; -
Math.min
和Math.max
可执行参数为(3, 4, 9, -3)
的运算,得到结果(-3, 9)
; -
(-3, 9)
经过内部spread
的逆运算,变为[-3, 9]
; -
[-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
函数:
-
R.pipe
将各个函数依次从上往下执行的串联(组合)起来; -
R.prop
用于获取上一个输入的对象的authorites
属性; -
R.defaultTo
用于设置一个默认值; -
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
,甚至连最开始的数据都可以不需要知道。我们把数据处理的过程,重新定义成了一种与参数无关的合成(pipe
或compose
)运算,这种将数据进行更加抽象的方式使得函数变得可自由组合,从而提升复用性。但这也要求,我们在编写函数时,参数应该更加偏向抽象的数据形式,而尽可能不要偏向业务。后面的例子,我们也会用到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前端组件开发时,发现的一个问题,首先我们先描述一下环境,让大家对这个有个基本认识:
-
Mendix Client
通过特定api
可获取订阅数据MxObject
; -
MxObject
是一个超级大对象,可通过一系列api
获取对象属性; - 第二条所说的属性就是数据库中的数据;
不过很可惜的是,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' }}
我们发现这段数据存根非常的诡异,它有如下特征:
- 所有的数据字段都存储在
jsonData
中; -
id
信息存在guid
中,其它信息存在attributes
中; -
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
所实现并应用的确实不多,例如:
- A组合子(
apply
); - B组合子(已经讲到过的
compose
方法) - K组合子(
constant
) - Y组合子(
fix
) - C组合子(
flip
) - I组合子(已经讲到过的
identity
方法) - S组合子(
substitution
) - T组合子(
thrush
) - P组合子(
psi
)
这些内容再讲深一点,就可以讲到函子(Functor)的概念了, 这超出了我们需要掌握的范围。有兴趣的朋友可以参阅(),这里有一套已经实现大部分组合子的类库。有机会的话,会给大家讲解比较浅显的函子概念。
认识一些常用的组合子后,我们发现了组合子的妙用,也感受到了函数式所带来的代码美化哲学。但这仅仅是函数式编程中很小的一块,但也是最为实用的。来回顾一下它的特性:
- 组合子是一种高阶函数;
- 组合子不改变原函数(契函数)的原有功能特性;
- 组合子可以通过变换参数、转变流程、控制输出来增强函数;
- 组合子可经由其它组合子等效转换成其它组合子;
而组合子的使用条件也比较苛刻:
- 无论是组合子函数还是契函数都必须是绝对纯的;
- 组合子必须保证数据的不变性和持久性;
感谢以下原创系列文章给本文带来的认知提升和书写灵感:
系列
系列
系列
系列
系列
系列