读书笔记:Javascript函数式编程指南(四)

本博客 hjy-xh,转载请申明出处

四.函数式编程——针对复杂应用的设计模式

(读书笔记:JavaScript函数式编程指南)

今日分享

  • 命令式错误处理异常方式的问题
  • 使用容器,以防访问无效数据
  • 用 Functor 的实现来做数据转换

空引用是一个价值数十亿美元的错误。
——Tony Hoare,InfoQ

命令式错误处理的不足

在许多情况下都会发生 JavaScript 错误,特别是在与服务器通信时,或是在试图访问一个为 null 对象的属性时。

因此,开发者在编程时总是需要做好最坏的打算。在命令式编程世界中,异常是通过 try-catch 处理的。

用 try-catch 处理错误

JavaScript 的异常处理机制通常会以大多数现代语言都有的 try-catch 语句来完成:

1
2
3
4
5
try {
// 可能会抛出异常的代码
} catch (e) {
console.log("ERROR" + e.message);
}

以该语句包裹住你认为不太安全的代码,一旦有异常发生,JavaScript 会立即终止程序,并创建导致该问题的指令的函数调用堆栈跟踪。有关错误的具体细节,如消息、行号和文件名,被填充到 Error 类型的对象中,并传递到 catch 块中。

1
2
3
4
5
try {
const student = findStudentById("666");
} catch (e) {
console.log("ERROR" + e.message);
}

使用 try-catch 后的代码将不能组合或连在一起,对于函数式编程来说,这将会严重影响代码设计。

函数式程序不应抛出异常

命令式的 JavaScript 代码结构有很多缺陷,而且也会与函数式的设计有兼容性问题。会抛出异常的函数存在以下问题:

  • 难以与其他函数组合或链接
  • 违反了引用透明性,因为抛出异常会导致函数调用出现另一出口,所以不能确保单一的可预测的返回值
  • 会引起副作用,因为异常会在函数调用之外对堆栈引发不可预料的影响
  • 违反非局域性的原则,因为用于恢复异常的代码与原始的函数调用渐行渐远。当发生错误时,函数离开局部栈与环境
  • 不能只关注函数的返回值,调用者需要负责声明 catch 块中的异常匹配类型来管理特定的异常
  • 当有多个异常条件时会出现嵌套的异常处理块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
try {
try {
throw new Error("oops");
}
catch (ex) {
console.error("inner", ex.message);
throw ex;
}
finally {
console.log("finally");
}
}
catch (ex) {
console.error("outer", ex.message);
}

// Output:
// "inner" "oops"
// "finally"
// "outer" "oops"

那函数式编程真的不需要抛出异常吗?

该作者不这么认为。在实践中,很多因素是在控制范围之外的,而且依赖库也有抛出异常的可能。对于某些边缘情况,使用异常可能颇有效率。异常应该由一个地方抛出,而不应该随处可见。

空值(null)检查问题

另一种跟抛出异常一样烦人的错误是 null 返回值。虽然 null 返回值保证了函数的出口只有一个,但是也并没有好到哪去——给使用函数的用户带来需要 null 检查的负担。比如获取学生地址与国家的 getCountry 函数:

1
2
3
4
5
6
7
8
9
10
11
12
function getUserCountry(student) {
let school = student.getSchool();
if (school !== null) {
let addr = school.getAddress();
if (addr !== null) {
let country = addr.getCountry();
return country;
}
return null;
}
throw new Error("Error extracting country info");
}

这个函数很容易实现,但是需要大量的判空检查。不管是使用 try-catch 还是 null 检查,都是被动的解决方式。

一种更好的解决方案——Functor

函数式以一种完全不同的方法应对软件系统的错误处理。其思想说起来也非常简单,就是创建一个安全的容器,来存放危险代码,比方说 try-catch 就可以看作存放着会抛出异常的函数的保险箱。而保险箱可以看作一种容器。

在函数式编程中,仍然会包裹这些危险代码,但可以不用 try-catch 块。使用函数式数据类型是解决不纯性的主要手段。不过,首先从最简单的类型开始。

包裹不安全的值

将值包裹起来是函数式编程的一个基本设计模式,因为它直接地保证了值不会被任意篡改。这有点像把值保护起来,只能通过 map 操作来访问该容器中的值。实际上数组的 map,而数组也是值的容器。我们将继续扩展更广义的 map 的概念。

其实,可以映射函数到更多类型,而不仅仅是数组。在函数式 JavaScript 中,map 只不过是一个函数,由于引用透明性,只要输入相同,map 永远会返回相同的结果。当然,还可以认为 map 是可以使用 lambda 表达式变换容器内的值的途径。比如,对于数组,就可以通过 map 转换值,返回包含新值的新数组。

下面用 Wrapper 解释一下这个概念

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Wrapper {
constructor(value) { // 存储任意类型值的简单类型
this.value = value;
}
// map :: A -> B
map(f) { // 用一个函数来 map 该类型(就像数组一样)
return f(this.value);
};
toString() {
return 'Wrapper (' + this.value + ')';
}
}
// wrap :: A -> Wrapper(A)
const wrap = (val) => new Wrapper(val); // 能够根据值快速创建 Wrapper 的帮助函数

要访问包裹内的对象,唯一办法是 map 一个 Ramda 的 identity 函数(注意,Wrapper 类型并没有 get 方法)。虽然 JavaScript 允许用户方便地访问这个值,但重要的是,一旦该值进入容器,就不应该能被直接获取或转化(就像一个虚拟的屏障),如图1 所示:

Wrapper 类型使用 map 安全地访问和操作值。在这种情况下,通过映射 identity 函数就能在容器中提取值。

下面是获取值的例子:

1
2
const wrappedValue = wrap('Get Functional');
wrappedValue.map(R.identity); // 'Get Functional' <--- 值的提取

其实还可以映射任何函数到该容器,比如变换该值:

1
wrappedValue.map(R.toUpper); // 'GET FUNCTIONAL' <--- 对内部值应用函数

如此一来,所有对值的操作都必须借助 Wrapper.map “伸入”容器,从而使值得到一定的保护。但是 null 或者 undefined 的情况仍然存在,还是需要在映射的函数中去处理。接下来看看解决的方法:

1
2
const wrappedNull = wrap(null);
wrappedNull.map(doWork); // doWork 被赋予了空值检查的责任

就像这个例子,由于直接调用函数,完全可以交给 Wrapper 类型来做错误处理。换句话说,可以在调用函数之前,检查 null、空字符串或者负数,等等。因此,Wrapper.map 的语义就由具体的 Wrapper 类型来确定。

继续来看看 map 的变种——fmap:

1
2
3
4
// fmap :: (A -> B) -> Wrapper[A] -> Wrapper[B]
Wrapper.prototype.fmap = function (f) {
return wrap(f(this.valve)); // <--- 先将返回值包裹到容器中,再返回给调用者
};

fmap 知道如何在上下文中应用函数值。它会先打开该容器,应用函数到值,最后把返回的值包裹到一个新的同类型容器中。拥有这种函数的类型称为 Functor。

Functor 定义

从本质上讲,Functor 只是一个可以将函数应用到它包裹的值上,并将结果再包裹起来的数据结构。
下面是 fmap 的一般定义:

1
fmap :: (A -> B) -> Wrapper(A) -> Wrapper(B) // <--- Wrapper 可以是任何容器类型

fmap 函数接受一个从 A->B 的函数,以及一个 Wrapper(A) Functor,然后返回包裹着结果的新 Functor —— Wrapper(B)。图 2 显示了用 increment 函数作为 A->B 的映射函数,只是这里的 A 和 B 为同一类型。

图 2 Wrapper 内的值 1,在应用函数 increment 后再次包裹成新的容器。

要注意的是,fmap 在每次调用都会返回一个新的副本,是不可变的。

在开始解决更实际的问题之前,再来看一个简单的例子。试用 Functor 来完成简单的 2 + 3 = 5。首先柯里化 add 函数,这样就得到了 plus3 的函数:

1
2
const plus = R.curry((a, b) => a + b);
const plus3 = plus(3);

现在可以把数字 2 放到 Wrapper 中:

1
const two = wrap(2);

再调用 fmap 把 plus3 映射到容器上:

1
2
const five = two.fmap(plus3); //-> Wrapper(5) <--- 返回一个具有上下文包裹的值
five.map(R.identity); //-> 5

fmap 返回同样类型的结果,可以通过映射 R.identity 来提取它的值。不过需要注意的是,值会一直在容器中,因此可以 fmap 任意次函数来转换值。

1
two.fmap(plus3).fmap(plus10); //-> Wrapper(15)

光看代码可能不够直观,图 3 更清楚地解释了如何 fmapplus3。

图 3 Wrapper 容器中的值是 2。Functor 会将其打开,应用 fmap 的函数,再包裹函数的返回值到新的容器中。
fmap 函数会返回同样的类型,这样就可以链式地继续使用 fmap。

1
2
const two = wrap(2);
const isFive = two.fmap(plus3).fmap(R.equals(5)); //-> Wrapper(true) <--- 返回一个具有上下文包裹的值

这种链式的函数调用是不是非常眼熟?其实很多人一直在使用 Functor 却没有意识到而已。比如 Array的 map 和 filter 方法:

1
2
map :: (A -> B) -> Array(A) -> Array(B)
filter :: (A -> Boolean) -> Array(A) -> Array(A)

map 和 filter 都返回同样类型的 Functor,因此可以不断地链接。来看看另一个 Functor:compose。
正如之前分享提到的,这是从一个函数到另一个函数的映射(也保持类型不变):

1
compose :: (B -> C) -> (A -> B) -> (A -> C)

Functor 有如下一些重要的属性约束:

  • 必须是无副作用的。 若映射 R.identity 函数可以获得上下文中相同的值,即可证明 Functor 是无副作用的:
1
wrap('Get Functional').fmap(R.identity); //-> Wrapper('Get Functional')
  • 必须是可组合的。 这个属性的意思是 fmap 函数的组合,与分别 fmap 函数是一样的。
1
two.fmap(R.compose(plus3, R.add(5))).map(R.identity); //-> 10

Functor 的这些属性并不奇怪。遵守这些规则,可以免于抛出异常、篡改元素或者改变函数的行为。其实际目的只是创建一个上下文或一个抽象,以便可以安全地应用操作到值,而又不改变原始值。这也是 map 可以将一个数组转换到另一个数组,而不改变原数组的原因。而 Functor 就是这个概念的推广。

Functor 本身并不需要知道如何处理 null。例如 Ramda 中的 R.compose,在收到为 null 的函数引用时就会抛出异常。这完全是预期的行为,并不是设计上的缺陷。因为 Functor 映射从一个类型到另一类型的函数。还有一个更为具体化的函数式数据类型——Monad。Monad 可以简化代码中的错误处理,进而更流畅地进行函数组合。但是它跟 Functor 有什么关系呢?其实,Monad 就是 Functor“伸入”的容器。

如果写过 jQuery 代码,那么应该觉得 Monad 很面熟。

Monad 只是给一些资源提供了抽象,例如一个简单的价值,一个 DOM 元素、事件或 AJAX 调用,这样就可以安全地处理其中包含的数据。比如,jQuery 就可以看作 DOM 的 Monad:

1
$('#student-info').fadeIn(3000).text(student.fullname());

这段代码的行为之所以像 Monad,是因为 jQuery 可以将 fadeIn 和 text 行为安全地应用到 DOM 上。如果 student-info 面板不存在,将方法应用到空的 jQuery 对象上只会什么也不发生,而不会抛出任何异常。Monad 旨在安全地传送错误,这样应用才具有较好的容错性。

使用 Monad 函数式地处理错误

Monad 用于函数式地解决传统错误处理的问题。但在深入这个话题之前,先来了解使用 Functor 的局限性。使用 Functor 可以安全地应用函数到其内部的值,并且返回一个不可变的新 Functor。但如果它遍布在代码中,就会有一些让人不那么顺心的地方。下面来看一个通过 id 获取学生地址的例子。对于这个例子,大概需要两个函数——findStudent 和 getAddress,这两个函数都给值包裹上一个安全的上下文:

1
2
3
4
5
6
const findStudent = R.curry(function(db, id) {
return wrap(find(db, id)); // <--- 包裹对象获取逻辑,以避免找不到对象所造成的问题
});
const getAddress = function(student) {
return wrap(student.fmap(R.prop("address"))); // 用 Ramda 的 R.prop()函数来 map 对象以获取其地址, 再将结果包裹起来
}

然后把这两个函数组合在一起:

1
2
3
4
const studentAddress = R.compose(
getAddress,
findStudent(DB('student'))
);

虽然成功地避免了所有的错误处理代码,但是结果却出乎意料。返回的值是被包裹了两层的 address对象:

1
studentAddress('666'); //-> Wrapper(Wrapper(address))

为了提取这个值,需要两次应用 R.identity 函数:

1
studentAddress('666').map(R.identity).map(R.identity);

在自己的代码中见到两层这样的代码还可以勉强接受,如果出现三四层呢?这个时候,Monad 可以成为更好的解决方案。

Monad:从控制流到数据流

Monad 和 Functor 类似,但在处理某些情况时可以带来一些特殊的逻辑。下面就用简单的例子来看看Monad 到底有什么特殊的功能。假如有一个函数 half::Number ->Number(见图 4):

1
2
Wrapper(2).fmap(half); //-> Wrapper(1)
Wrapper(3).fmap(half); //-> Wrapper(1.5)

图 4 Functor 可以将函数应用到包裹的值上。例子中包裹的值会被 2 除。
不过,Functor 只管应用函数到值并将结果包裹起来,并不能加额外的逻辑。如果想要限制 half 只应用到偶数,而输入是一个奇数,该怎么办?或许可以返回 null 或抛出异常,但更好的策略是让该函数能给合法的数字返回正确的结果,并忽略不合法的数字。
现在假设有一个名为 Empty 的类似 Wrapper 的容器:

1
2
3
4
5
6
7
const Empty = function (_) {
; // 无操作。 Empty 不会存储任何值,其代表着“空”或“无”的概念
};
// map :: (A -> B) -> A -> B
Empty.prototype.map = function() { return this; }; // <--- 类似,将函数 map 到 Empty 上会跳过该操作
// empty :: _ -> Empty
const empty = () => new Empty();

为了实现 half 以满足新的需求,可以通过以下方式完成(见图 5):

1
2
3
4
const isEven = (n) => Number.isFinite(n) &amp;&amp; (n % 2 == 0); // <--- 区分奇偶数的工具函数
const half = (val) => isEven(val) ? wrap(val / 2) : empty(); // <--- half 函数只会操作偶数,否则会返回一个空的容器
half(4); //-> Wrapper(2)
half(3); //-> Empty

图 5 函数 half 可以根据输入返回一个包裹好的值或空容器。
Monad 用于创建一个带有一定规则的容器,而 Functor 并不需要了解其容器内的值。Functor 可以有效地保护数据,然而当需要组合函数时,即可以用Monad 来安全并且无副作用地管理数据流。在前面的例子中,对于奇数会返回 Empty 而不是 null。所以此后如果想应用函数,就不必在意可能会出现的异常:

1
2
half(4).fmap(plus3); //-> Wrapper(5)
half(3).fmap(plus3); //-> Empty <--- 容器知道该如何应用函数,即便其值是非法的

除此之外,Monad 还适用于解决其他问题。这里只讨论如何使用 Monad 来解决命令式错误处理的问题,从而使代码更可读、更易于推理。

以下两个概念非常重要。

  • Monad:为 Monadic 操作提供抽象接口。
  • Monadic 类型: 该接口的具体实现。

Monadic 类型类似于上面提到的的 Wrapper 对象。不过每个 Monad 都有不同的用途,可以定义不同的语义便于确定其行为(例如 map 或 fmap)。使用这些类型可以进行链式或嵌套操作,但都应遵循下列接口定义。

  • 类型构造函数: 创建 Monadic 类型(类似于 Wrapper 的构造函数)。
  • unit 函数: 可将特定类型的值放入 Monadic 结构中(类似于 wrap 和前面看到的 empty 函数)。对于 Monad 的实现来说,该函数也被称为 of 函数。
  • bind 函数: 可以链式操作,后文将使用更简短的 map。
  • join 函数: 将两层 Monadic 结构合并成一层。这会对嵌套返回 Monad 的函数特别有用。
    将这一个接口应用到 Wrapper 类型,就可以重构成以下这种形式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Wrapper {
constructor(value) {
this.value = value;
}
// unit 函数
static of(a) {
return new Wrapper(a);
}
// bind 函数( Functor)
map(f) {
return Wrapper.of(f(this.value));
}
// 压平嵌套的 Wrapper
join() {
if(!(this.value instanceof Wrapper)) {
return this;
}
return this.value.join();
}
// 返回一个当前结构的文本描述
toString() {
return `Wrapper (${this.value})`;
}
}

Wrapper 使用 Functor 的 map 将数据提升到容器中,这样就可以无任何副作用。通常还可以用_.identity 函数来检查其内容:

1
2
3
Wrapper.of('Hello Monads!')
.map(R.toUpper)
.map(R.identity); //-> Wrapper('HELLO MONADS!')

map 操作被视为一种中立的 functor,因为它无非只是映射函数到对象,然后关闭它。之后,Monad 给map 加入特殊的功能。join 函数用于逐层扁平化嵌套结构,就像剥洋葱一样。这可以用来消除之前用functor 时发现的问题,如下 所示。
扁平化 Monadic 结构

1
2
3
4
5
6
7
8
9
10
// findObject :: DB -> String -> Wrapper
const findObject = R.curry(function(db, id) {
return Wrapper.of(find(db, id));
});
// getAddress :: Student -> Wrapper
const getAddress = function(student) {
return Wrapper.of(student.map(R.prop('address')));
}
const studentAddress = R.compose(getAddress, findObject(DB('student')));
studentAddress('444-44-4444').join().get(); // Address

该代码返回一组嵌套的 wrapper,其中 join 操作用于将这种嵌套结构压平成单一的层:

1
2
Wrapper.of(Wrapper.of(Wrapper.of('Get Functional'))).join();
//-> Wrapper('Get Functional')

图 6 为 join 操作的示意图,递归扁平化嵌套结构的 Monad,像剥洋葱一样:

Monad 通常有更多的操作,这里提及的最小接口只是其整个 API 的子集。一个 Monad 本身只是抽象,没有任何实际意义。只有实际的实现类型才有丰富的功能。大多数函数式编程的代码只用一些常用的类型就可以消除大量的样板代码,同时还能完成同样的工作。

Monad 实例丰富,例如:Maybe、Either 和 IO,大家有兴趣可以自行查看。

总结

  • 面向对象抛异常的机制让函数变得不纯,把大部分的责任都推到了调用者的尝试——try-catch逻辑上
  • 把值包裹到容器中的模式是为了构建无副作用的代码,把可能不纯的变化包裹成引用透明的过程
  • 使用Functor将函数应用到容器中的值,这是无副作用地、不可变地访问和修改操作
  • Monad是函数式中用来降低应用复杂度的设计模式,通过这种模式可以将函数编排成安全的数据流程
  • 交错的组合函数和Monadic类型是非常有弹性而且强大的,如Maybe、Either和IO

阮一峰:图解 Monad
阮一峰:函数式编程入门教程