函数式编程(Functional programming) 是一种编程范式,我们常见的编程范式有命令式编程(Imperative programming)函数式编程逻辑式编程过程式编程(Procedural programming),常见的面向对象编程(Object-oriented programming)是也是一种命令式编程。

命令式编程是面向计算机硬件的抽象,有变量(对应着存储单元),赋值语句(获取,存储指令),表达式(内存引用和算术运算)和控制语句(跳转指令),一句话,命令式程序就是一个冯诺依曼机的指令序列。

而函数式编程是面向数学的抽象,将计算描述为一种表达式求值,一句话,函数式程序就是一个表达式。

JavaScript 函数式编程

本文为阅读 《JS 函数式编程指南》阮一峰老师的随笔后的所思所感, 将书中核心内容结合自己的理解所撰

函数式编程的本质

函数式编程中的函数这个术语不是指计算机中的函数(实际上是Subroutine),而是指数学中的函数,即自变量的映射。也就是说一个函数的值仅决定于函数参数的值,不依赖其他状态。比如sqrt(x)函数计算x的平方根,只要x不变,不论什么时候调用,调用几次,值都是不变的。

由于变量值是不可变的,对于值的操作并不是修改原来的值,而是修改新产生的值,原来的值保持不便。

函数式编程的特征

  • 函数是第一等公民
  • 强调将计算过程分解成可复用的函数
  • 只有纯的、没有副作用的函数,才是合格的函数 (纯函数)

函数式语言当然还少不了以下特性:

  • 高阶函数(Higher-order function)
  • 偏应用函数(Partially Applied Functions)
  • 柯里化(Currying)
  • 闭包(Closure)

纯函数

纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。

比如 slice 和 splice,这两个函数的作用并无二致——但是注意,它们各自的方式却大不同,但不管怎么说作用还是一样的。我们说 slice 符合纯函数的定义是因为对相同的输入它保证能返回相同的输出。而 splice 却会嚼烂调用它的那个数组,然后再吐出来;这就会产生可观察到的副作用,即这个数组永久地改变了。

数学中的函数是不同数值之间的特殊关系:每一个输入值返回且只返回一个输出值。

纯函数式编程语言中的变量也不是命令式编程语言中的变量,即存储状态的单元,而是代数中的变量,即一个值的名称。变量的值是不可变的(immutable),也就是说不允许像命令式编程语言中那样多次给一个变量赋值。比如说在命令式编程语言我们写“x = x + 1”,这依赖可变状态的事实,拿给程序员看说是对的,但拿给数学家看,却被认为这个等式为假。

纯函数就是数学中的函数,而且是函数式编程的全部。

副作用

副作用是在计算结果的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互。

副作用可能包含,但不限于:

  • 更改文件系统
  • 往数据库插入记录
  • 发送一个 http 请求
  • 可变数据
  • 打印/log
  • 获取用户输入
  • DOM 查询
  • 访问系统状态

范畴

彼此之间存在某种关系的概念、事物、对象等等,都构成"范畴"。随便什么东西,只要能找出它们之间的关系,就能定义一个"范畴"。

数学模型

既然"范畴"是满足某种变形关系的所有对象,就可以总结出它的数学模型。

  • 所有成员是一个集合
  • 变形关系是函数

也就是说,范畴论是集合论更上层的抽象,简单的理解就是"集合 + 函数"。

理论上通过函数,就可以从范畴的一个成员,算出其他所有成员。

范畴与容器

我们可以把"范畴"想象成是一个容器,里面包含两样东西。

  • 值(value)
  • 值的变形关系,也就是函数。

范畴论与函数式编程的关系 范畴论使用函数,表达范畴之间的关系。

伴随着范畴论的发展,就发展出一整套函数的运算方法。这套方法起初只用于数学运算,后来有人将它在计算机上实现了,就变成了今天的"函数式编程"。

本质上,函数式编程只是范畴论的运算方法,跟数理逻辑、微积分、行列式是同一类东西,都是数学方法,只是碰巧它能用来写程序。

所以,为什么函数式编程要求函数必须是纯的,不能有副作用?因为它是一种数学运算,原始目的就是求值,不做其他事情,否则就无法满足函数运算法则了。

总之,在函数式编程中,函数就是一个管道(pipe)。这头进去一个值,那头就会出来一个新的值,没有其他作用。

使用闭包缓存函数的生成

通过闭包, 我们可以缓存函数的生成, 这是函数式编程的核心内容之一, 也是纯函数的应用

先看一个简单的示例:

var memoize = function(f) {
  var cache = {};
  return function() {
    var arg_str = JSON.stringify(arguments);
    cache[arg_str] = cache[arg_str] || f.apply(f, arguments);
    return cache[arg_str];
  };
};
var squareNumber  = memoize(function(x){ return x*x; });
squareNumber(4);
//=> 16
squareNumber(4); // 从缓存中读取输入值为 4 的结果
//=> 16
squareNumber(5);
//=> 25
squareNumber(5); // 从缓存中读取输入值为 5 的结果
//=> 25

甚至可以通过闭包缓存不存的函数, 使其变为纯函数

var pureHttpCall = memoize(function(url, params){
  return function() { return $.getJSON(url, params); }
});

注意, 我们并没有真正发送 http 请求——只是返回了一个函数,当调用它的时候才会发请求。这个函数之所以有资格成为纯函数,是因为它总是会根据相同的输入返回相同的输出:给定了 url 和 params 之后,它就只会返回同一个发送 http 请求的函数。

我们的 memoize 函数工作起来没有任何问题,虽然它缓存的并不是 http 请求所返回的结果,而是生成的函数。重点是我们可以缓存任意一个函数,不管它们看起来多么具有破坏性。

参考: 纯函数的好处: 追求“纯”的理由

高阶函数

高阶函数就是参数为函数为函数的函数。有了高阶函数,就可以将复用的粒度降低到函数级别,相对于面向对象语言,复用的粒度更低。

一个最简单的高阶函数:

function add(x, y, f) {
  return f(x) + f(y);
}

当我们调用 add(-5, 6, Math.abs) 时,参数x,y和f分别接收-5,6和函数Math.abs,根据函数定义,我们应该得出值为 |-5| + |6| = 11

内置高阶函数

map

举例说明,比如我们有一个函数f(x)=x2,要把这个函数作用在一个数组[1, 2, 3, 4, 5, 6, 7, 8, 9]上,就可以用map实现如下:

由于map()方法定义在JavaScript的Array中,我们调用Array的map()方法,传入我们自己的函数,就得到了一个新的Array作为结果:

let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
let results = arr.map(x => x * x); // [1, 4, 9, 16, 25, 36, 49, 64, 81]

reduce

Array的reduce()把一个函数作用在这个Array的[x1, x2, x3...]上,这个函数必须接收两个参数,reduce()把结果继续和序列的下一个元素做累积计算,其效果就是:

[x1, x2, x3, x4].reduce(f) = f(f(f(x1, x2), x3), x4)

比方说对一个Array求和,就可以用reduce实现:

let arr = [1, 3, 5, 7, 9];
arr.reduce((x, y) => x + y); // 25

要把 [1, 3, 5, 7, 9] 变换成整数13579,reduce()也能派上用场:

let arr = [1, 3, 5, 7, 9];
arr.reduce((x, y) => x * 10 + y); // 13579

不使用 parseInt,而使用 reduce 将字符串转化为数字

const str2int = s => s.split('').map(s=>+s).reduce((x,y)=>x*10+y);
str2int('101') // 101

filter

filter也是一个常用的操作,它用于把Array的某些元素过滤掉,然后返回剩下的元素。

和map()类似,Array的filter()也接收一个函数。和map()不同的是,filter()把传入的函数依次作用于每个元素,然后根据返回值是true还是false决定保留还是丢弃该元素。

例如,在一个Array中,删掉偶数,只保留奇数,可以这么写:

let arr = [1, 2, 4, 5, 6, 9, 10, 15];
let r = arr.filter(x => % 2 !== 0); // [1, 5, 9, 15]

把一个Array中的空字符串删掉,可以这么写:

let arr = ['A', '', 'B', null, undefined, 'C', '  '];
let r = arr.filter(s => s && s.trim()); // ['A', 'B', 'C']

可见用filter()这个高阶函数,关键在于正确实现一个“筛选”函数。

filter()接收的回调函数,其实可以有多个参数。通常我们仅使用第一个参数,表示Array的某个元素。回调函数还可以接收另外两个参数,表示元素的位置和数组本身:

let arr = ['A', 'B', 'C'];
let r = arr.filter((element, index, self) => {
  console.log(element); // 依次打印'A', 'B', 'C'
  console.log(index); // 依次打印0, 1, 2
  console.log(self); // self就是变量arr
  return true;
});

利用filter,可以巧妙地去除Array的重复元素:

let r, arr = ['apple', 'strawberry', 'banana', 'pear', 'apple', 'orange', 'orange', 'strawberry'];
r = arr.filter((element, index, self) => self.indexOf(element) === index) // [ 'apple', 'strawberry', 'banana', 'pear', 'orange' ]

利用 map 和 filter计算素数:

const get_primes = (arr) => {
  const divisors = Array.apply(null, {length: arr.length}).map(Number.call, Number).map(n => n+1); // 构造辅助数组
  return arr.filter((e, i, s) => {
      const remainders = divisors.slice(1, e - 1).map(x => e % x); // 计算所有比它小的数除它后的余数
      return remainders.filter((r, j, self) => r == 0).length == 0 && e != 1; // 没有一个余数为0的,才是素数(还要额外去掉1这个数)
  });
}
let arr = []
for (x = 1; x < 1000; x++) {
  arr.push(x);
}
r = get_primes(arr);
console.log(r.toString());
r = get_primes([983,991,997,10,33,12,7]);
console.log(r.toString());

sort

JavaScript的Array的sort()方法就是用于排序的,但是排序结果可能让你大吃一惊:

// 看上去正常的结果:
['Google', 'Apple', 'Microsoft'].sort(); // ['Apple', 'Google', 'Microsoft'];
// apple排在了最后:
['Google', 'apple', 'Microsoft'].sort(); // ['Google', 'Microsoft", 'apple']
// 无法理解的结果:
[10, 20, 1, 2].sort(); // [1, 10, 2, 20]

第二个排序把apple排在了最后,是因为字符串根据ASCII码进行排序,而小写字母a的ASCII码在大写字母之后。

第三个排序结果是什么鬼?简单的数字排序都能错?

这是因为Array的sort()方法默认把所有元素先转换为String再排序,结果'10'排在了'2'的前面,因为字符'1'比字符'2'的ASCII码小。

而实际上 sort() 也是一个高阶函数,我们完全可以自定义排序规则

let arr = [10, 20, 1, 2, 30, 3];
const asc = arr => arr.concat([]).sort((x, y) => x > y ? 1 : -1)
console.log(asc(arr)); // [ 1, 2, 3, 10, 20, 30 ]
console.log(arr); // [ 10, 20, 1, 2, 30, 3 ]

但是,sort() 虽然是一个高阶函数,但是却不是纯函数,所以为了不破坏原数组,我们需要在变换前进行一次数组拷贝将其变为纯函数

同样地,降序排列如下:

let arr = [10, 20, 1, 2, 30, 3];
const desc = arr => arr.concat([]).sort((x, y) => x === y ? 0 : x > y ? -1 : 1)
console.log(desc(arr)); // [ 30, 20, 10, 3, 2, 1 ]
console.log(arr); // [ 10, 20, 1, 2, 30, 3 ]

默认情况下,对字符串排序,是按照ASCII的大小比较的,现在,我们提出排序应该忽略大小写,按照字母序排序。要实现这个算法,不必对现有代码大加改动,只要我们能定义出忽略大小写的比较算法就可以:

let arr = ['Google', 'apple', 'Microsoft'];
const desc = arr => arr.concat([]).sort((x, y) =>
 (x = x.toUpperCase()) === (y = y.toUpperCase()) ? 0 : x > y ? 1 : -1
)
console.log(desc(arr)); // [ 'apple', 'Google', 'Microsoft' ]
console.log(arr); // [ 'Google', 'apple', 'Microsoft' ]

其他内置高阶函数

对于数组,除了map()、reduce、filter()、sort()这些方法可以传入一个函数外,Array对象还提供了很多非常实用的高阶函数。

  • every() 方法可以判断数组的所有元素是否满足测试条件
  • some() 方法可以判断数组的至少一个元素满足测试条件
  • find() 方法用于查找符合条件的第一个元素,如果找到了,返回这个元素,否则,返回undefined
  • findIndex() 和find()类似,也是查找符合条件的第一个元素,不同之处在于findIndex()会返回这个元素的索引,如果没有找到,返回-1
  • forEach() 和map()类似,它也把每个元素依次作用于传入的函数,但不会返回新的数组。forEach()常用于遍历数组,因此,传入的函数不需要返回值

比如:

[1,2,3].some(a => a < 2) // true
[1,2,3].every(a => a < 2) // false
[1,2,3].find(a => a === 2) // 2
[1,2,3].findIndex(a => a === 2) // 1
[1,2,3].forEach(console.log); // 依次打印每个元素

柯里化 (curry)

curry 的概念很简单:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。

你可以一次性地调用 curry 函数,也可以每次只传一个参数分多次调用。

var add = function(x) {
  return function(y) {
    return x + y;
  };
};
var increment = add(1);
var addTen = add(10);
increment(2);
// 3
addTen(2);
// 12

这里我们定义了一个 add 函数,它接受一个参数并返回一个新的函数。调用 add 之后,返回的函数就通过闭包的方式记住了 add 的第一个参数。一次性地调用它实在是有点繁琐,好在我们可以使用一个特殊的 curry 帮助函数(helper function)使这类函数的定义和调用更加容易。

通过lodash

lodash/ramda 中提供了一个 curry 方法, 用于将函数进行柯里化

var curry = require('lodash').curry;
// =========== match ===========
var match = curry(function(what, str) {
  return str.match(what);
});
match(/\s+/g, "hello world"); // [ ' ' ]
match(/\s+/g)("hello world"); // [ ' ' ]
var hasSpaces = match(/\s+/g); // function(x) { return x.match(/\s+/g) }
hasSpaces("hello world"); // [ ' ' ]
hasSpaces("spaceless"); // null
// =========== replace ===========
var replace = curry(function(what, replacement, str) {
  return str.replace(what, replacement);
});
var noVowels = replace(/[aeiou]/ig); // function(replacement, x) { return x.replace(/[aeiou]/ig, replacement) }
var censored = noVowels("*"); // function(x) { return x.replace(/[aeiou]/ig, "*") }
censored("Chocolate Rain"); // 'Ch*c*l*t* R**n'
// =========== filter ===========
var filter = curry(function(f, ary) {
  return ary.filter(f);
});
filter(hasSpaces, ["tori_spelling", "tori amos"]); // ["tori amos"]
var findSpaces = filter(hasSpaces); // function(xs) { return xs.filter(function(x) { return x.match(/\s+/g) }) }
findSpaces(["tori_spelling", "tori amos"]); // ["tori amos"]
// =========== map ===========
var map = curry(function(f, ary) {
  return ary.map(f);
});

上面的代码中遵循的是一种简单,同时也非常重要的模式。即策略性地把要操作的数据(String, Array)放到最后一个参数里。

这里表明的是一种“预加载”函数的能力,通过传递一到两个参数调用函数,就能得到一个记住了这些参数的新函数。

偏函数

通过bind自身,可以创建偏函数:

function add(a, b){
  return a + b;
}
var addOne = add.bind(this, 1);
addOne(2); // 3

lodash 中提供了一个 partial 方法,也可以创建偏函数:

const _ = require('lodash')
function add(a, b){
  return a + b;
}
var addOne = _.partial(add, 1);
addOne(2); // 3

参考:JS 函数式编程指南 - 第 4 章: 柯里化(curry)

组合 (compose)

如果一个值要经过多个函数,才能变成另外一个值,就可以把所有中间步骤合并成一个函数,这叫做"函数组合"(compose)。

这张图展示了什么是组合:

通过组合:

合成两个函数的简单代码如下:

const compose = function (f, g) {
  return function (x) {
    return f(g(x));
  };
}

组合的使用

以下一个例子, 简答地使用组合说明其作用:

var toUpperCase = function(x) { return x.toUpperCase(); };
var exclaim = function(x) { return x + '!'; };
var shout = compose(exclaim, toUpperCase);
shout("send in the clowns"); // SEND IN THE CLOWNS!

在 compose 的定义中,g 将先于 f 执行,因此就创建了一个从右到左的数据流。这样做的可读性远远高于嵌套一大堆的函数调用,如果不用组合,shout 函数将会是这样的:

var shout = function(x){
  return exclaim(toUpperCase(x));
};

让代码从右向左运行,而不是由内而外运行,可以称之为“左倾”。

我们来看一个顺序很重要的例子:

var head = function(x) { return x[0]; };
var reverse = x => x.reduce((acc, x) => [x].concat(acc), []);
var last = compose(head, reverse);
last(['jumpkick', 'roundhouse', 'uppercut']); // 'uppercut'

reverse 反转列表,head 取列表中的第一个元素;所以结果就是得到了一个 last 函数。尽管我们可以定义一个从左向右的版本,但是从右向左执行更加能够反映数学上的含义——是的,组合的概念直接来自于数学课本。

多个函数组合

函数组合并不局限于两个函数的组合, 可以扩展为多个函数组合的形式:

var compose = function(...fns){
  return value => [...fns, value].reverse().reduce((acc,fn) => fn(acc))
}
// 或者
var compose = function(){
  return value => [...arguments, value].reverse().reduce((acc,fn) => fn(acc))
}
// 用真正的 reduce 函数改写一下
var compose = function(...fns){
  return function(value){
      // 这样更容易看出思想,即将 value 作为初始值,然后将其传入最后一个函数,将返回值一直向前传递
      fns.push(value);
      return fns.reverse().reduce((acc,fn)=>{
          return fn(acc);
      })
  }
}

示例:

var toUpperCase = x => x.toUpperCase();
var replace = (reg, rep) => x => x.replace(reg, rep)
var snakeCase = compose(console.log, replace(/\s+/ig, '_'), toUpperCase);
snakeCase('Hello world') // HELLO_WORLD

函数结合律

函数的合成还必须满足结合律。

compose(f, compose(g, h))
// 等同于
compose(compose(f, g), h)
// 等同于
compose(f, g, h)

结合律的一大好处是任何一个函数分组都可以被拆开来,然后再以它们自己的组合方式打包在一起:

var loudLastUpper = compose(exclaim, toUpperCase, head, reverse);
// 或
var last = compose(head, reverse);
var loudLastUpper = compose(exclaim, toUpperCase, last);
// 或
var last = compose(head, reverse);
var angry = compose(exclaim, toUpperCase);
var loudLastUpper = compose(angry, last);
// 更多变种...

pointfree

pointfree 模式指的是,永远不必说出你的数据。意思是,函数无须提及将要操作的数据是什么样的。一等公民的函数、柯里化(curry)以及组合协作起来非常有助于实现这种模式。

// 非 pointfree,因为提到了数据:word
var snakeCase = function (word) {
  return word.toLowerCase().replace(/\s+/ig, '_');
};
// pointfree
var toLowerCase = x => x.toLowerCase();
var replace = (reg, rep) => x => x.replace(reg, rep)
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);
snakeCase('Hello world') // hello_world

这里所做的事情就是通过管道把数据在接受单个参数的函数间传递。利用 curry,我们能够做到让每个函数都先接收数据,然后操作数据,最后再把数据传递到下一个函数那里去。另外注意在 pointfree 版本中,不需要 word 参数就能构造函数;而在非 pointfree 的版本中,必须要有 word 才能进行一切操作。

再来看一个例子:

// 非 pointfree,因为提到了数据:name
var initials = function (name) {
  return name.split(' ').map(compose(toUpperCase, head)).join('. ');
};
// pointfree
const toUpperCase = x => x.toUpperCase()
const head = x => x[0]
const join = s => x => x.join(s)
const split = s => x => x.split(s)
const map = fun => x => x.map(fun)
var initials = compose(console.log, join('. '), map(compose(toUpperCase, head)), split(' '));
initials("hunter stockton thompson"); // 'H. S. T'

错误追踪

在使用组合的使用, 很可能在某个阶段出问题, 为了跟踪每次执行输出的值, 可以使用错误追踪函数进行错误追踪:

var trace = tag =>{
  return x => {
    console.log(tag, x)
    return x
  }
}

比如上面的例子, 可以使用 trace 进行追踪:

var initials = compose(
  console.log,
  trace('join'), join('. '),
  trace('map'), map(compose(toUpperCase, head)),
  trace('split'), split(' ')
);
initials("hunter stockton thompson")
// split [ 'hunter', 'stockton', 'thompson' ]
// map [ 'H', 'S', 'T' ]
// join H. S. T
// H. S. T

其他原则

ramda 中的map是满足组合律的:

// map 的组合律
var law = compose(map(f), map(g)) == map(compose(f, g));

参考:

函子 (Functor)

函数不仅可以用于同一个范畴之中值的转换,还可以用于将一个范畴转成另一个范畴。这就涉及到了函子(Functor)。

函子是函数式编程里面最重要的数据类型,也是基本的运算单位和功能单位。

它首先是一种范畴,也就是说,是一个容器,包含了值和变形关系。比较特殊的是,它的变形关系可以依次作用于每一个值,将当前容器变形成另一个容器。

上图中,左侧的圆圈就是一个函子,表示人名的范畴。外部传入函数f,会转成右边表示早餐的范畴。

下面是一张更一般的图。

上图中,函数f完成值的转换(a到b),将它传入函子,就可以实现范畴的转换(Fa到Fb)。

容器

我们创建一个容器(container),这个容器必须能够装载任意类型的值。

函数式编程一般约定,函子有一个of方法,用来生成新的容器,我们将使用 Container.of 作为构造器(constructor),这样就省去了糟糕的面向对象的 new 的写法。

class Container {
  constructor(val) {
    this.val = val;
  }
  static of(x) {
    return new Container(x);
  };
  map(f) {
    return Container.of(f(this.val));
  }
}
// 使用容器
Container.of("hotdogs"); // Container { val: 'hotdogs' }

一旦容器里有了值,不管这个值是什么,我们就需要一种方法来让别的函数能够操作它,就是我们上面创建的 map 方法

Container.of(2).map(two => two + 2 ); // Container { val: 4 }
Container.of(2).map(two => two + 2 ).map(num => num * 2 ); // Container { val: 8 }

之后 Container 通常写为 Functor

class Functor {
  constructor(val) {
    this.val = val;
  }
  static of(x) {
    return new Functor(x);
  };
  map(f) {
    return Functor.of(f(this.val));
  }
}

空值检测:Maybe 函子

Maybe 看起来跟 Container 非常类似,但是有一点不同:Maybe 会先检查自己的值是否为空,然后才调用传进来的函数。这样我们在使用 map 的时候就能避免恼人的空值了

const _ = require('ramda')
class Maybe {
  constructor(val) {
    this.val = val;
  }
  static of(x) {
    return new Maybe(x);
  };
  isNothing() {
    return (this.val === null || this.val === undefined);
  }
  map(f) {
    return this.isNothing() ? Maybe.of(null) : Maybe.of(f(this.val));
  }
}
Maybe.of("Malkovich Malkovich").map(_.match(/a/ig));
//=> Maybe(['a', 'a'])
Maybe.of(null).map(_.match(/a/ig));
//=> Maybe(null)
Maybe.of({name: "Boris"}).map(_.prop("age")).map(_.add(10));
//=> Maybe(null)
Maybe.of({name: "Dinah", age: 14}).map(_.prop("age")).map(_.add(10));
//=> Maybe(24)

注意看,当传给 map 的值是 null 时,代码并没有报出错误。这是因为每一次 Maybe 要调用函数的时候,都会先检查它自己的值是否为空。

释放容器里的值

“如果一个程序运行之后没有可观察到的作用,那它到底运行了没有?”。或者,运行之后达到自身的目的了没有?有可能它只是浪费了几个 CPU 周期然后就去睡觉了...

应用程序所做的工作就是获取、更改和保存数据直到不再需要它们,对数据做这些操作的函数有可能被 map 调用,这样的话数据就可以不用离开它温暖舒适的容器。

不过,对容器里的值来说,还是有个逃生口可以出去。

Maybe.prototype.getVal = function() {
  return this.val
}
Maybe.of(1).map(val => val + 2).map(val => val * 2).getVal() // 6

Monad 函子

我们先前创建的容器类型上的 of 方法,不只是用来避免使用 new 关键字的,而是用来把值放到默认最小化上下文(default minimal context)中的。of 没有真正地取代构造器,它是一个我们称之为 pointed 的重要接口的一部分。

pointed functor 是实现了 of 方法的 functor。

函子是一个容器,可以包含任何值。函子之中再包含一个函子,也是完全合法的。但是,这样就会出现多层嵌套的函子。

Maybe.of(
  Maybe.of(
    Maybe.of({name: 'Mulburry', number: 8402})
  )
)

上面这个函子,一共有三个Maybe嵌套。如果要取出内部的值,就要连续取三次this.val。这当然很不方便,因此就出现了 Monad 函子。

Monad 还被喻为洋葱,Monad 函子的作用是,总是返回一个单层的函子。它有一个flatMap方法,与map方法作用相同,唯一的区别是如果生成了一个嵌套函子,它会取出后者内部的值,保证返回的永远是一个单层的容器,不会出现嵌套的情况。

class Monad {
  constructor(val) {
    this.val = val;
  }
  static of(x) {
    return new Monad(x);
  };
  map(f) {
    return Monad.of(f(this.val));
  }
  isNothing() {
    return (this.val === null || this.val === undefined);
  }
  join() {
    return this.isNothing() ? Monad.of(null) : this.val;
  }
  flatMap(f) {
    return this.map(f).join();
  }
}

上面代码中,如果函数f返回的是一个函子,那么this.map(f)就会生成一个嵌套的函子。

join

join方法保证了flatMap方法总是返回一个单层的函子。这意味着嵌套的函子会被铺平(flatten)。

let a = Monad.of(Monad.of(Monad.of('Hello')))
console.log(a); // Monad { val: Monad { val: Monad { val: 'Hello' } } }
console.log(a.join()); // Monad { val: Monad { val: 'Hello' } }
console.log(a.join().join()); // Monad { val: 'Hello' }
console.log(a.join().join().join()); // Hello

一个 functor,只要它定义个了一个 join 方法和一个 of 方法,并遵守一些定律,那么它就是一个 monad。

组合

有了 join,就可以结合 compose,每遇到一层 Monad 嵌套,就使用一个 join 剥开一层皮:

var join = function(mnd){ return mnd.join(); }
var firstAddressStreet = _.compose(
  join, _.map(_.prop('name')),
  join, _.map(_.prop('street')),
  join, _.map(_.head),
  join, _.map(_.prop('addresses'))
);
firstAddressStreet(
  Monad.of({
    addresses: Monad.of([
      Monad.of({
        street: Monad.of({
          name: 'Mulburry',
          number: 8402
        }),
        postcode: "WC2N"
      })
    ])
  })
);
// Mulburry

flatMap(chain)

flatMap 这里仅仅是把 map/join 套餐打包到一个单独的函数中。chain 叫做 >>=(读作 bind)或者 flatMap;都是同一个概念的不同名称。

var chain = _.curry(function(f, m){
  return m.map(f).join(); // 或者 _.compose(_.join, _.map(f))(m)
});
var firstAddressStreet = _.compose(
  chain(_.prop('name')),
  chain(_.prop('street')),
  chain(_.head),
  chain(_.prop('addresses'))
);
firstAddressStreet(
  Monad.of({
    addresses: Monad.of([
      Monad.of({
        street: Monad.of({
            name: 'Mulburry',
            number: 8402
          }),
          postcode: "WC2N"
        }
      )
    ])
  })
);
// Mulburry

或者使用 flatMap:

let mnd = Monad.of({
  addresses: Monad.of([
    Monad.of({
      street: Monad.of({
        name: 'Mulburry',
        number: 8402
      }),
      postcode: "WC2N"
    })
  ])
})
let firstAddressStreet = mnd.flatMap(_.prop('addresses')).flatMap(_.head).flatMap(_.prop('street')).flatMap(_.prop('name'))
console.log(firstAddressStreet); // Mulburry

Either 函子

class Either {
  constructor(left, right) {
    this.left = left;
    this.right = right;
  }
  map(f) {
    return this.right ?
      Either.of(this.left, f(this.right)) :
      Either.of(f(this.left), this.right);
  }
  static of (left, right) {
    return new Either(left, right);
  };
}
var addOne = function (x) {
  return x + 1;
};
Either.of(5, 6).map(addOne);
// Either(5, 7);
Either.of(1, null).map(addOne);
// Either(2, null);

上面代码中,如果右值有值,就使用右值,否则使用左值。通过这种方式,Either 函子表达了条件运算。

提供默认值

Either 函子的常见用途是提供默认值。下面是一个例子。

let currentUser = {
  address: null
}
Either.of('昆明', currentUser.address).map(console.log); // 昆明
currentUser.address = '北京'
Either.of('昆明', currentUser.address).map(console.log); // 北京

上面代码中,如果用户没有提供地址,Either 函子就会使用左值的默认地址。

错误处理

Either 函子的另一个用途是代替 try...catch,使用左值表示错误。

function parseJSON(json, err) {
  try {
    return Either.of(null, JSON.parse(json));
  } catch (e) {
    return Either.of(err, null);
  }
}
console.log(parseJSON('{"a": 1}', '解析失败')); // Either { left: null, right: { a: 1 } }
console.log(parseJSON({"a": 1}, '解析失败')); // Either { left: '解析失败', right: null }

上面代码中,左值为空,就表示没有出错,否则左值会包含一个错误对象e。一般来说,所有可能出错的运算,都可以返回一个 Either 函子。

Applicative 函子

有的时候,我们需要让两个容器产生联系,比如我们想这样:

add(Container.of(2), Container.of(3)); // NaN

这样是行不通的,因为 2 和 3 都藏在瓶子里。

ap 就是这样一种函数,能够把一个 functor 的函数值应用到另一个 functor 的值上。

applicative函子 是实现了 ap 方法的 pointed functor

class Ap {
  constructor(val) {
    this.val = val;
  }
  static of(x) {
    return new Ap(x);
  };
  map(f) {
    return Ap.of(f(this.val));
  }
  ap(F) {
    return Ap.of(this.val(F.val));
  }
}
function add(x) {
  return function (y) {
    return x + y;
  };
}
Ap.of(add).ap(Maybe.of(2)).ap(Maybe.of(3)); // Ap(5)
// or
Ap.of(add(2)).ap(Maybe.of(3));

Ap函子有个特性:

Ap.of(x).map(f) == Ap.of(f).ap(Ap.of(x))

比如:

Ap.of(x => x + 2).ap(Functor.of(2)) // Ap(4)
Ap.of(2).map(x => x + 2) // Ap(4)

并行执行

假设我们要创建一个旅游网站,既需要获取游客目的地的列表,还需要获取地方事件的列表。这两个请求就是相互独立的 api 调用。

var renderPage = _.curry(function(destinations, events) { /* render page */  });
Task.of(renderPage).ap(Http.get('/destinations')).ap(Http.get('/events'))

两个请求将会同时立即执行,当两者的响应都返回之后,renderPage 就会被调用。这与 monad 版本的那种必须等待前一个任务完成才能继续执行后面的操作完全不同。本来我们就无需根据目的地来获取事件,因此也就不需要依赖顺序执行。

需要强调,因为我们是使用局部调用的函数来达成上述结果的,所以必须要保证 renderpage 是 curry 函数,否则它就不会一直等到两个 Task 都完成。

lift

我们可以以一种 pointfree 的方式调用 applicative functor。因为 map 等价于 of/ap,那么我们就可以定义无数个能够 ap 通用函数。

var liftA1 = _.curry(function(f, functor1) {
  return functor1.map(f);
});
var liftA2 = _.curry(function(f, functor1, functor2) {
  return functor1.map(f).ap(functor2);
});
var liftA3 = _.curry(function(f, functor1, functor2, functor3) {
  return functor1.map(f).ap(functor2).ap(functor3);
});
// liftA4, etc
liftA1(add(3), Ap.of(2)) // Ap(5)
liftA2(add, Ap.of(6), Ap.of(2)) // Ap(8)

衍生

由于 of/ap 等价于 map,那么我们就可以利用这点来定义 map:

// 从 of/ap 衍生出的 map
X.prototype.map = function(f) {
  return this.constructor.of(f).ap(this);
}

monad 可以说是处在食物链的顶端,因此如果已经有了一个 chain 函数,那么就可以免费得到 functor 和 applicative:

// 从 chain 衍生出的 map
X.prototype.map = function(f) {
  var m = this;
  return m.chain(function(a) {
    return m.constructor.of(f(a));
  });
}
// 从 chain/map 衍生出的 ap
X.prototype.ap = function(other) {
  return this.chain(function(f) {
    return other.map(f);
  });
};

参考资料

MIT Licensed | Copyright © 2018-present 滇ICP备16006294号

Design by Quanzaiyu | Power by VuePress