引用

纯函数

纯函数的定义是,对于相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用,也不依赖外部环境的状态

函数的柯里化

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { curry } from "lodash";

//首先柯里化两个纯函数
var match = curry((reg, str) => str.match(reg));
var filter = curry((f, arr) => arr.filter(f));

//判断字符串里有没有空格
var haveSpace = match(/\s+/g);

haveSpace("ffffffff");
//=>null

haveSpace("a b");
//=>[" "]

filter(haveSpace, ["abcdefg", "Hello World"]);
//=>["Hello world"]

函数组合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//两个函数的组合
var compose = function (f, g) {
return function (x) {
return f(g(x));
};
};

//或者
var compose = (f, g) => (x) => f(g(x));

var add1 = (x) => x + 1;
var mul5 = (x) => x * 5;

compose(mul5, add1)(2);

Point Free

细心的话你可能会注意到,之前的代码中我们总是喜欢把一些对象自带的方法转化成纯函数:

1
2
3
var map = (f, arr) => arr.map(f);

var toUpperCase = (word) => word.toUpperCase();

Point Free这种模式现在还暂且没有中文的翻译,有兴趣的话可以看看这里的英文解释:

Tacit_programming
阮一峰 Pointfree 编程风格指南

比如:

1
2
//这不Point free
var f = (str) => str.toUpperCase().split(" ");
1
2
3
4
5
6
var toUpperCase = (word) => word.toUpperCase();
var split = (x) => (str) => str.split(x);

var f = compose(split(" "), toUpperCase);

f("abcd efgh");

这种风格能够帮助我们减少不必要的命名,让代码保持简洁和通用。当然,为了在一些函数中写出 Point Free 的风格,在代码的其它地方必然是不那么 Point Free 的,这个地方需要自己取舍。

例一

比如,读取对象的 role 属性,不要直接写成 obj.role,而是要把这个操作封装成函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var prop = (p, obj) => obj[p];
var propRole = R.curry(prop)("role");

var isWorker = (s) => s === "worker";
var getWorkers = R.filter(R.pipe(propRole, isWorker));

var data = [
{ name: "张三", role: "worker" },
{ name: "李四", role: "worker" },
{ name: "王五", role: "manager" },
];
getWorkers(data);
// [
// {"name": "张三", "role": "worker"},
// {"name": "李四", "role": "worker"}
// ]

上面代码中,data是传入的值,getWorkers是处理这个值的函数。定义getWorkers的时候,完全没有提到data,这就是 Pointfree

简单说,Pointfree 就是运算过程抽象化,处理一个值,但是不提到这个值。这样做有很多好处,它能够让代码更清晰和简练,更符合语义,更容易复用,测试也变得轻而易举。

例二

1
var str = "Lorem ipsum dolor sit amet consectetur adipiscing elit";

上面是一个字符串,请问其中最长的单词有多少个字符?

先定义一些基本运算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 以空格分割单词
var splitBySpace = (s) => s.split(" ");

// 每个单词的长度
var getLength = (w) => w.length;

// 词的数组转换成长度的数组
var getLengthArr = (arr) => R.map(getLength, arr);

// 返回较大的数字
var getBiggerNumber = (a, b) => (a > b ? a : b);

// 返回最大的一个数字
var findBiggestNumber = (arr) => R.reduce(getBiggerNumber, 0, arr);

然后,把基本运算合成为一个函数

1
2
3
4
5
6
7
8

var getLongestWordLength = R.pipe(
splitBySpace,
getLengthArr,
findBiggestNumber
);

getLongestWordLength(str) // 11

可以看到,整个运算由三个步骤构成,每个步骤都有语义化的名称,非常的清晰。这就是 Pointfree 风格的优势。

Ramda 提供了很多现成的方法,可以直接使用这些方法,省得自己定义一些常用函数

1
2
3
4
5
6
// 上面代码的另一种写法
var getLongestWordLength = R.pipe(
R.split(" "),
R.map(R.length),
R.reduce(R.max, 0)
);

声明式与命令式代码

命令式代码的意思就是,我们通过编写一条又一条指令去让计算机执行一些动作,这其中一般都会涉及到很多繁杂的细节。

而声明式就要优雅很多了,我们通过写表达式的方式来声明我们想干什么,而不是通过一步一步的指示。

1
2
3
4
5
6
7
8
//命令式
var CEOs = [];
for (var i = 0; i < companies.length; i++) {
CEOs.push(companies[i].CEO);
}

//声明式
var CEOs = companies.map((c) => c.CEO);

容器、Functor

容器为函数式编程里普通的变量、对象、函数提供了一层极其强大的外衣,赋予了它们一些很惊艳的特性,就好像 Tony Stark 的钢铁外衣,Dva 的机甲,明日香的 2 号机一样。

1
2
3
4
5
6
7
8
9
10
11
var Container = function (x) {
this.__value = x;
};
Container.of = (x) => new Container(x);

//试试看
Container.of(1);
//=> Container(1)

Container.of("abcd");
//=> Container('abcd')

我们调用 Container.of 把东西装进容器里之后,由于这一层外壳的阻挡,普通的函数就对他们不再起作用了,所以我们需要加一个接口来让外部的函数也能作用到容器里面的值:

1
2
3
Container.prototype.map = function (f) {
return Container.of(f(this.__value));
};

我们可以这样使用它:

1
2
3
Container.of(3)
.map((x) => x + 1) //=> Container(4)
.map((x) => "Result is " + x); //=> Container('Result is 4')

没错!我们仅花了 7 行代码就实现了很炫的 『链式调用』 ,这也是我们的第一个 Functor

Functor(函子)是实现了 map 并遵守一些特定规则的容器类型。

也就是说,如果我们要将普通函数应用到一个被容器包裹的值,那么我们首先需要定义一个叫 Functor 的数据类型,在这个数据类型中需要定义如何使用 map 来应用这个普通函数。

把东西装进一个容器,只留出一个接口 map 给容器外的函数,这么做有什么好处呢?

本质上,Functor 是一个对于函数调用的抽象,我们赋予容器自己去调用函数的能力。当 map 一个函数时,我们让容器自己来运行这个函数,这样容器就可以自由地选择何时何地如何操作这个函数,以致于拥有惰性求值、错误处理、异步调用等等非常牛掰的特性。

Maybe

举个例子,我们现在为 map 函数添加一个检查空值的特性,这个新的容器我们称之为 Maybe(原型来自于 Haskell):

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
var Maybe = function (x) {
this.__value = x;
};

Maybe.of = function (x) {
return new Maybe(x);
};

Maybe.prototype.map = function (f) {
return this.isNothing() ? Maybe.of(null) : Maybe.of(f(this.__value));
};

Maybe.prototype.isNothing = function () {
return this.__value === null || this.__value === undefined;
};

//试试看
import _ from "lodash";
var add = _.curry(_.add);

Maybe.of({ name: "Stark" }).map(_.prop("age")).map(add(10));
//=> Maybe(null)

Maybe.of({ name: "Stark", age: 21 }).map(_.prop("age")).map(add(10));
//=> Maybe(31)

有了柯里化这个强大的工具,我们可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
import _ from "lodash";
var compose = _.flowRight;
var add = _.curry(_.add);

// 创造一个柯里化的 map
var map = _.curry((f, functor) => functor.map(f));

var doEverything = map(compose(add(10), _.property("age")));

var functor = Maybe.of({ name: "Stark", age: 21 });
doEverything(functor);
//=> Maybe(31)

错误处理、Either

如果你对 Promise 熟悉的话应该还记得,Promise 是可以调用 catch 来集中处理错误的:

1
2
3
4
doSomething()
.then(async1)
.then(async2)
.catch((e) => console.log(e));

对于函数式编程我们也可以做同样的操作,如果运行正确,那么就返回正确的结果;如果错误,就返回一个用于描述错误的结果。这个概念在 Haskell 中称之为 Either 类,LeftRight 是它的两个子类。我们用 JS 来实现一下:

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;
};
var Right = function (x) {
this.__value = x;
};

// 这里也是一样的=。=
Left.of = function (x) {
return new Left(x);
};
Right.of = function (x) {
return new Right(x);
};

// 这里不同!!!
Left.prototype.map = function (f) {
return this;
};
Right.prototype.map = function (f) {
return Right.of(f(this.__value));
};

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

1
2
3
4
5
6
7
8
var getAge = (user) => (user.age ? Right.of(user.age) : Left.of("ERROR!"));

//试试
getAge({ name: "stark", age: "21" }).map((age) => "Age is " + age);
//=> Right('Age is 21')

getAge({ name: "stark" }).map((age) => "Age is " + age);
//=> Left('ERROR!')

是的,Left 可以让调用链中任意一环的错误立刻返回到调用链的尾部,这给我们错误处理带来了很大的方便,再也不用一层又一层的 try/catch

LeftRightEither 类的两个子类,事实上 Either 并不只是用来做错误处理的,它表示了逻辑或,范畴学里的 Coproduct。但这些超出了我们的讨论范围。

惰性求值 IO, URL

下面我们的程序要走出象牙塔,去接触外面“肮脏”的世界了,在这个世界里,很多事情都是有副作用的或者依赖于外部环境的,比如下面这样:

1
2
3
4
5
6
7
8
9
10
function readLocalStorage() {
return window.localStorage;
}

// 为了让它“纯”起来,我们可以把它包裹在一个函数内部,延迟执行它
function readLocalStorage() {
return function () {
return window.localStorage;
};
}

额……好吧……好像确实没什么卵用……我们只是(像大多数拖延症晚期患者那样)把讨厌做的事情暂时搁置了而已。为了能彻底解决这些讨厌的事情,我们需要一个叫 IO 的新的 Functor

IO

1
2
3
4
5
6
7
8
9
10
11
12
import _ from "lodash";
var compose = _.flowRight;

var IO = function (f) {
this.__value = f;
};

IO.of = (x) => new IO((_) => x);

IO.prototype.map = function (f) {
return new IO(compose(f, this.__value));
};

IO 跟前面那几个 Functor 不同的地方在于,它的 __value 是一个函数。它把不纯的操作(比如 IO、网络请求、DOM)包裹到一个函数内,从而延迟这个操作的执行。所以我们认为,IO 包含的是被包裹的操作的返回值。

1
2
3
4
5
6
var io_document = new IO((_) => window.document);

io_document.map(function (doc) {
return doc.title;
});
//=> IO(document.title)

注意我们这里虽然感觉上返回了一个实际的值 IO(document.title),但事实上只是一个对象:{ __value: [Function] },它并没有执行,而是简单地把我们想要的操作存了起来,只有当我们在真的需要这个值得时候,IO 才会真的开始求值,这个特性我们称之为『惰性求值』。

URL

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import _ from "lodash";

// 先来几个基础函数:
// 字符串
var split = _.curry((char, str) => str.split(char));
// 数组
var first = (arr) => arr[0];
var last = (arr) => arr[arr.length - 1];
var filter = _.curry((f, arr) => arr.filter(f));
//注意这里的 x 既可以是数组,也可以是 functor
var map = _.curry((f, x) => x.map(f));
// 判断
var eq = _.curry((x, y) => x == y);
// 结合
var compose = _.flowRight;

var toPairs = compose(map(split("=")), split("&"));
// toPairs('a=1&b=2')
//=> [['a', '1'], ['b', '2']]

var params = compose(toPairs, last, split("?"));
// params('http://xxx.com?a=1&b=2')
//=> [['a', '1'], ['b', '2']]

// 这里会有些难懂=。= 慢慢看
// 1.首先,getParam是一个接受IO(url),返回一个新的接受 key 的函数;
// 2.我们先对 url 调用 params 函数,得到类似[['a', '1'], ['b', '2']]
// 这样的数组;
// 3.然后调用 filter(compose(eq(key), first)),这是一个过滤器,过滤的
// 条件是 compose(eq(key), first) 为真,它的意思就是只留下首项为 key
// 的数组;
// 4.最后调用 Maybe.of,把它包装起来。
// 5.这一系列的调用是针对 IO 的,所以我们用 map 把这些调用封装起来。
var getParam = (url) => (key) =>
map(compose(Maybe.of, filter(compose(eq(key), first)), params))(url);

// 创建充满了洪荒之力的 IO!!!
var url = new IO((_) => window.location.href);
// 最终的调用函数!!!
var findParam = getParam(url);

// 上面的代码都是很干净的纯函数,下面我们来对它求值,求值的过程是非纯的。
// 假设现在的 url 是 http://xxx.com?a=1&b=2
// 调用 __value() 来运行它!
findParam("a").__value();
//=> Maybe(['a', '1'])

Monad

如果你对 Promise 这种规范有了解的话,应该记得 Promise 里一个很惊艳的特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
doSomething()
.then((result) => {
// 你可以return一个Promise链!
return fetch("url").then((result) => parseBody(result));
})
.then((result) => {
// 这里的result是上面那个Promise的终值
});

doSomething()
.then((result) => {
// 也可以直接return一个具体的值!
return 123;
})
.then((result) => {
// result === 123
});

对于 Promise 的一个回调函数来说,它既可以直接返回一个值,也可以返回一个新的 Promise,但对于他们后续的回调函数来说,这二者都是等价的,这就很巧妙地解决了 nodejs 里被诟病已久的嵌套地狱。

事实上,Promise 就是一种 Monad,是的,可能你天天要写一大堆 Promise,可直到现在才知道天天用的这个东西竟然是个听起来很高大上的函数式概念。

下面我们来实际实现一个 Monad,如果你不想看的话,只要记住 Promise 就是一种 Monad 这句话然后直接跳过这一章就好了。

我们来写一个函数 cat,这个函数的作用和 Linux 命令行下的 cat 一样,读取一个文件,然后打出这个文件的内容,这里 IO 的实现请参考上一篇文章:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import fs from "fs";
import _ from "lodash";

var map = _.curry((f, x) => x.map(f));
var compose = _.flowRight;

var readFile = function (filename) {
return new IO((_) => fs.readFileSync(filename, "utf-8"));
};

var print = function (x) {
return new IO((_) => {
console.log(x);
return x;
});
};

var cat = compose(map(print), readFile);

cat("file");
//=> IO(IO("file的内容"))

由于这里涉及到两个 IO:读取文件和打印,所以最后结果就是我们得到了两层 IO,想要运行它,只能调用:

cat("file").__value().__value();

很尴尬对吧,如果我们涉及到 100 个 IO 操作,那么难道要连续写 100 个 __value() 吗?

当然不能这样不优雅,我们来实现一个 join 方法,它的作用就是剥开一层 Functor,把里面的东西暴露给我们:

1
2
3
4
5
6
7
8
9
10
var join = (x) => x.join();
IO.prototype.join = function () {
return this.__value ? IO.of(null) : this.__value();
};

// 试试看
var foo = IO.of(IO.of("123"));

foo.join();
//=> IO('123')

有了 join 方法之后,就稍微优雅那么一点儿了:

1
2
3
var cat = compose(join, map(print), readFile);
cat("file").__value();
//=> 读取文件并打印到控制台

join 方法可以把 Functor 拍平(flatten),我们一般把具有这种能力的 Functor 称之为 Monad

这里只是非常简单地移除了一层 Functor 的包装,但作为优雅的程序员,我们不可能总是在 map 之后手动调用 join 来剥离多余的包装,否则代码会长得像这样:

var doSomething = compose(join, map(f), join, map(g), join, map(h));

所以我们需要一个叫 chain 的方法来实现我们期望的链式调用,它会在调用 map 之后自动调用 join 来去除多余的包装,这也是 Monad 的一大特性:

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
var chain = _.curry((f, functor) => functor.chain(f));
IO.prototype.chain = function (f) {
return this.map(f).join();
};

// 现在可以这样调用了
var doSomething = compose(chain(f), chain(g), chain(h));

// 当然,也可以这样
someMonad.chain(f).chain(g).chain(h);

// 写成这样是不是很熟悉呢?
readFile("file")
.chain(
(x) =>
new IO((_) => {
console.log(x);
return x;
})
)
.chain(
(x) =>
new IO((_) => {
// 对x做一些事情,然后返回
})
);

你可能看出来了,chain 不就类似 Promise 中的 then 吗?是的,它们行为上确实是一致的(then 会稍微多一些逻辑,它会记录嵌套的层数以及区别 Promise 和普通返回值),Promise 也确实是一种函数式的思想。