函数式编程

JavaScript 作为一种典型的多范式编程语言,这两年随着React的火热,函数式编程的概念也开始流行起来,RxJS、cycleJS、lodashJS、underscoreJS、ramda等多种开源库都使用了函数式的特性。记录一下相关的概念和知识。

纯函数

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var xs = [1,2,3,4,5];
// 纯的
xs.slice(0,3);
//=> [1,2,3]
xs.slice(0,3);
//=> [1,2,3]
xs.slice(0,3);
//=> [1,2,3]
// 不纯的
xs.splice(0,3);
//=> [1,2,3]
xs.splice(0,3);
//=> [4,5]
xs.splice(0,3);
//=> []

追求“纯”的理由

1.可缓存性
纯函数总能够根据输入来做缓存,实现缓存的一种典型技术就是memoize技术:

1
2
3
4
5
6
7
8
9
10
11
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); // 16 从缓存中读取输入值为4的结果

2.可移植性/自文档化
纯函数完全是自给自足的,依赖很明确,因此更易于观察和理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 不纯的
var signUp = function(attrs) {
var user = saveUser(attrs);
welcomeUser(user);
};
var saveUser = function(attrs) {
var user = Db.save(attrs);
...
};
var welcomeUser = function(user) {
Email(user, ...);
...
};
// 纯的
var signUp = function(Db, Email, attrs) {
return function() {
var user = saveUser(Db, attrs);
welcomeUser(Email, user);
};
};
var saveUser = function(Db, attrs) {
...
};
var welcomeUser = function(Email, user) {
...
};

3.可测试性
纯函数让测试更加容易。我们不需要伪造一个“真实的”支付网关,或者每一次测试之前都要配置、之后都要断言状态(assert the state)。只需简单地给函数一个输入,然后断言输出就好了。
4.合理性
如果一段代码可以替换成它执行所得的结果,而且是在不改变整个程序行为的前提下替换的,那么我们就说这段代码是引用透明的。
由于纯函数总是能够根据相同的输入返回相同的输出,所以它们就能够保证总是返回同一个结果,这也就保证了引用透明性。
5.并行代码
最后一点,也是决定性的一点:我们可以并行运行任意纯函数。因为纯函数根本不需要访问共享的内存,而且根据其定义,纯函数也不会因副作用而进入竞争态。
并行代码在服务端 js 环境以及使用了 web worker 的浏览器那里是非常容易实现的,因为它们使用了线程。不过出于对非纯函数复杂度的考虑,当前主流观点还是避免使用这种并行。

函数柯里化

传递给函数的一部分参数来调用它,让它返回一个函数去处理剩下的参数。

1
2
3
4
5
6
7
8
9
10
var add = function (x) {
return function (y) {
return x + y;
}
};
var increment = add(1);
var addTen = add(10);
increment(2); // 3
addTen(2); // 12

事实上柯里化是一种“预加载”函数的方法,通过传递较少的参数,得到一个已经记住了这些参数的新函数,某种意义上讲,这是一种对参数的“缓存”,是一种非常高效的编写函数的方法。
柯里化的实现

1
onst curry = ( f, arr = []) => (...args) => ( a => a.length === f.length ? f(...a) : curry(f, a))([...arr, ...args]);

函数组合

纯函数以及把它柯里化写出的洋葱代码h(g(f(x))),为了解决函数嵌套问题,我们需要用到“函数组合”。

1
2
3
4
5
const compose = (f, g) => (x => f(g(x)));
let first = arr => arr[0];
let reverse = arr => arr.reverse();
let last = compose(first, reverse);
last([1,2,3,4,5]);

compose

1
2
3
4
5
compose(f, compose(g, h))
// 等同于
compose(compose(f, g), h)
// 等同于
compose(f, g, h)

函数就像数据的管道(pipe)。那么,函数合成就是将这些管道连了起来,让数据一口气从多个管道中穿过。

Point Free

函数无须提及将要操作的数据是什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 非 pointfree,因为提到了数据:word
var snakeCase = function (word) {
return word.toLowerCase().replace(/\s+/ig, '_');
};
// pointfree
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);
// 非 pointfree,因为提到了数据:name
var initials = function (name) {
return name.split(' ').map(compose(toUpperCase, head)).join('. ');
};
// pointfree
var initials = compose(join('. '), map(compose(toUpperCase, head)), split(' '));
initials("hunter stockton thompson");
// 'H. S. T'

声明式与命令式代码

命令式代码的意思就是,我们通过编写一条又一条指令去让计算机执行一些动作,这其中一般都会涉及到很多繁杂的细节。而声明式就要优雅很多了,我们可以通过写表达式的方式来声明我们想要干嘛,而不是一步一步的指示。

1
2
3
4
5
6
7
8
// 命令式
var makes = [];
for (i = 0; i < cars.length; i++) {
makes.push(cars[i].make);
}
// 声明式
var makes = cars.map(function(car){ return car.make; });

惰性求值、惰性函数

1
2
3
4
5
6
7
8
9
10
11
12
13
function eventBinderGenerator () {
if (window.addEventListener) {
return function (element, type, handler) {
element.addEventListener(type, handler, false);
}
} else {
return function (element, type, handler) {
element.attachEvent('on' + type, handler.bind(element, window.event));
}
}
}
var addEvent = eventBinderGenerator();

高阶函数

函数当参数,把传入的函数做一个封装,然后返回这个封装函数,达到更高程度的抽象。

1
2
3
4
5
6
7
8
9
10
11
12
var getSingle = function (fn) {
var ret;
return function () {
return ret || (ret = fn.apply(this, arguments));
};
};
var getScript = getSingle(function () {
return document.createElement('script');
});
var script1 = getScript();
var script2 = getScript();
alert(script1 === script2); // 输出true

这是个高阶函数的例子,既把函数当做参数传递,又让函数执行后返回了另外一个函数。

容器

1
2
3
4
5
6
var Container = function (x) {
this.__value = x;
}
Container.of = function (x) {
return new Container(x);
}

使用Container.of作为构造器,就不用到处去写糟糕的new关键字了。

1
Container.of(3) // Container(3)

functor

functor 是实现了map函数并遵守一些特定规则的容器类型。

1
2
3
4
5
6
7
Container.prototype.map = function (f) {
return Container.of(f(this.__value))
}
Container.of(2).map(function (two) {
return two + 2;
})
// Container(4)

上面代码中,Container是个函子,它的map方法接受函数f作为参数,然后返回一个新的函子,里面包含的值是被f处理过的(f(this.__value))

Maybe 函子

函子接受各种函数,处理容器内部的值。这里就有一个问题,容器内部的值可能是一个空值(比如null),而外部函数未必有处理空值的机制,如果传入空值,很可能就会出错。

1
2
3
4
5
6
7
8
9
10
11
12
var Maybe = function (x) {
this.___value = x;
}
Maybe.of = function (x) {
return new Maybe(x);
}
Maybe.prototype.isNothing = function () {
return (this.__value === null || this.___value === undefined);
}
Maybe.prototype.map = function (f) {
return this.isNothing ? Maybe.of(null) || Maybe.of(f(this.__value));
}

Either 函子

条件运算if…else是最常见的运算之一,函数式编程里面,使用either函子表达。Either函子内部有两个值:左值(Left)和右值(right)。右值是征程情况下使用的值,左值是右值不存在时使用的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Either extends Functor {
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);
}
}
Either.of = function (left, right) {
rerurn new Either(left, right)
}
var addOne = function (x) {
return x + 1;
}
Either.of(5, 6).map(addOne);
// Either.of(5, 7)
Either.of(1, null).map(addOne);
// Either.of(2, null)

Left和Right唯一的区别在于map方法的实现,Right.map的行为和我们之前提到的map函数一样。但是Left.map就很不同了,它不会对容器做任何事情,只是很简单地把这个容器拿进来又扔出去,这个特性就意味着,left可以用来传递一个错误信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var Left = function(x) {
this.__value = x;
}
Left.of = function(x) {
return new Left(x);
}
Left.prototype.map = function(f) {
return this;
}
var Right = function(x) {
this.__value = x;
}
Right.of = function(x) {
return new Right(x);
}
Right.prototype.map = function(f) {
return Right.of(f(this.__value));
}

IO 函子

1
2
3
4
5
6
7
8
9
10
11
12
13
var IO = function(f) {
this.__value = f;
}
IO.of = function(x) {
return new IO(function() {
return x;
});
}
IO.prototype.map = function(f) {
return new IO(_.compose(f, this.__value));
}

IO 跟之前的 functor 不同的地方在于,它的 __value 总是一个函数。不过我们不把它当作一个函数——实现的细节我们最好先不管。IO 把非纯执行动作捕获到包裹函数里,目的是延迟执行这个非纯动作。就这一点而言,我们认为 IO 包含的是被包裹的执行动作的返回值,而不是包裹函数本身。这在 of 函数里很明显:IO(function(){ return x }) 仅仅是为了延迟执行,其实我们得到的是 IO(x)。

Monad 函子

Monad就是一种设计模式,表示将一个运算过程,通过函数拆解成互相连接的多个步骤,你只要提供下一步运算所需的函数,整个运算就会自动进行下去。Monad让我们避开了嵌套地狱,可以轻松地进行深度嵌套的函数式编程,比如IO和其他异步任务。


1
2
3
4
5
6
7
8
class Monad extends Functor {
join () {
return this.val;
}
flatMap (f) {
return this.map(f).join();
}
}

Monad 函子的作用是,总是返回一个单层的函子,他有一个flatMap方法,与map方法作用相同,唯一的区别就是如果生成了一个嵌套函子,他会取出后者内部的值,保证返回的永远是一个单层的容器,不会出现嵌套的问题。
如果函数f返回的是一个函子,那么this.map(f)就会生成一个嵌套的函子。所以,join方法保证了flatMap方法总是返回一个单层的函子。这意味着嵌套的函子会被铺平。