5.针对复杂应用的设计模式

命令式处理异常方式的问题
使用容器,以防访问无效数据
用Functor的实现来做数据转换
利于组合的Monad数据类型
使用Monadic类型来巩固错误处理策略
Monadic类型的组合与交错

命令式错误处理的不足

函数式编程其实可以把错误处理得比任何其他开发风格更为优雅,软件中的许多问题都是由于数据不经意地变成了nullundefined、出现了异常、失去网络连接等情况造成的。所以需要大量得错误捕获代码,在每一个使用引用得地方判断nullundefined,是的代码得逻辑越来月复杂

try catch

我们尝试使用try,catch来捕获错误

1
2
3
4
5
try {
var student = findStudent('444-44-4444');
} catch (e) {
console.log('ERROR' + e.message);
}

但这样会与函数式的设计有兼容性问题。

  • 难以与其他函数组合或链接。
  • 违反了引用透明性,因为抛出异常会导致函数调用出现另一出口,所以不能确保单一的可预测的返回值。
  • 会引起副作用,因为异常会在函数调用之外对堆栈引发不可预料的影响。
  • 违反非局域性的原则,因为用于恢复异常的代码与原始的函数调用渐行渐远。当发生错误时,函数离开局部栈与环境。即 try 中的逻辑会在遇到错误时停止执行,转移到catch处理错误逻辑
  • 不能只关注函数的返回值,调用者需要负责声明catch块中的异常匹配类型来管理特定的异常。
  • 当有多个异常条件时会出现嵌套的异常处理块

其中一个常见的场景是JavaScript中因在null对象上调用函数所产生的TypeError。

空值(null)检查问题

本来可以简单地创建一个lens来获取该属性,若是null即返回undefined,但它并不会打印任何错误信息。

这使代码需要大量的判空检查代码。不管是使用try-catch还是null检查,都是被动的解决方式。

Functor 一种更好得解决方案

思想说起来也非常简单,创建一个安全的容器,来存放危险代码

包裹不安全的值

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

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

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

取值得方法

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

映射任何函数到该容器,比如记录日志或是变换该值:

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

现在所有得对值操作都与要通过map方法先伸入到容器中取值,可以说得到了一定得保护,但现在还没有对null,和undefined得处理

当初始化一个空值得时候

1
2
const wrappedNull = wrap(null);
wrappedNull.map(doWork); // 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.val)); // 先将返回值包裹到容器中,再返回给调用者
};

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

Functor定义

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

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

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

一个简单得例子

通过柯里化初始化一个求和函数

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

把数字2放到wrap中

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)

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

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

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

使用Monad函数式地处理错误

Functor本身并不需要知道如何处理null。例如Ramda中的R.compose,在收到为null的函数引用时就会抛出异常。

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

const studentAddress = R.compose(
getAddress,
findStudent(DB('student'))
);

//返回的值是被包裹了两层的address对象

studentAddress('444-44-4444').map(R.identity).map(R.identity)
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
27
28
29
30
31
const R = ramda;
const Wrap = function (value) {
this._value = value
}
Wrap.of = (value) => new Wrap(value)
Wrap.prototype.fmap = function (f) {
return Wrap.of(f(this._value))
}
Wrap.prototype.map = function (f) {
return f(this._value)
}
// console.log(wrap)
// console.log(wrap.map(R.identity))

const Empty = function () {}
Empty.of = () => new Empty();
Empty.prototype.fmap = function () {
return this
}
Empty.prototype.map = function () {
return this
}
const empty = Empty.of()

const add = R.curry((a, b) => a + b);
const plus10 = add(10);
const isEven = (num) => Number.isFinite(num) && num % 2 === 0;
const half = (num) => isEven(num) ? Wrap.of(num / 2) : empty;

console.log(half(10).fmap(plus10).map(R.identity)) //15
console.log(half(9).fmap(plus10).map(R.identity)) // Empty

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

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

  • 类型构造函数——创建Monadic类型(类似于Wrapper的构造函数)。
  • unit函数——可将特定类型的值放入Monadic结构中(类似于wrap和前面看到的empty函数)。对于Monad的实现来说,该函数也被称为of函数。
  • bind函数——可以链式操作(这就是Functor的fmap,也被称为flatmap)
  • join函数——将两层Monadic结构合并成一层。这会对嵌套返回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
class Wrapper {
//类型构造器
constructor(value) {
this._value = value;
}
static of (a) {
//unit 函数
return new Wrapper(a);
}
map(f) {
//bind 函数( Functor)
return Wrapper.of(f(this.value));
}
join() {
//压平嵌套的Wrapper
if (!(this.value instanceof Wrapper)) {
return this;
}
return this.value.join();
}
toString() {
//返回一个当前结构的文本描述
return `Wrapper (${this.value})`;
}
}

join函数用于逐层扁平化嵌套结构,就像剥洋葱一样。这可以用来消除之前用functor时发现的问题

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

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

使用Maybe Monad和Either Monad来处理异常

除了用来包装有效值,Monadic的结构也可用于建模null或undefined。函数式编程通常使用Maybe和Either来做下列事情。

  • 隔离不纯。
  • 合并判空逻辑。
  • 避免异常。
  • 支持函数组合。
  • 中心化逻辑,用于提供默认值。
用Maybe合并判空

Maybe Monad侧重于有效整合null -判断逻辑。Maybe是一个包含两个具体字类型的空类型(标记类型)。

  • Just(value)——表示值的容器。
  • Nothing()——表示要么没有值或者没有失败的附加信息。当然,还可以应用函数到Nothing上。
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
const R = ramda;
class Maybe { //容器类型(父类)
static just(a) {
return new Just(a);
}
static nothing() {
return new Nothing();
}
static fromNullable(a) {
return a != null ? Maybe.just(a) :
Maybe.nothing(); //由一个可为空的类型创建 Maybe(即构造函数)。如果值为空,则 建一个 Nothing; 否则, 将值存储在 Just 子类型中来表示其存在性
}
static of (a) {
return Maybe.just(a);
}
get isNothing() {
return false;
}
get isJust() {
return false;
}
}
class Just extends Maybe { //Just 子类型用于处理存在的值
constructor(value) {
super();
this._value = value;
}
get value() {
return this._value;
}
map(f) {
return Just.of(f(this.value)); //将映射函数应用于 Just,变换其中的值,并存储回容器中
}
getOrElse() {
return this.value; //Monad 提供默认的一元操
}
filter(f) {
Maybe.fromNullable(f(this.value) ? this.value : null);
}
get isJust() {
return true;
}
toString() { //返回该结构的文本描述
return `Maybe.Just(${this.value})`;
}
}
class Nothing extends Maybe { //Nothing子类型用于为无值的情况提供保护
map(f) {
return this;
}
get value() {
throw new TypeError(
'Can"t extract the valueof a Nothing.'); //任何试图从 Nothing 类型中取值的操作会引发表征错误使用 Monad的异常(后文会予以介绍)
}
getOrElse(other) {
return other; //忽略值,返回 other
}
filter() {
return this.value; //如果存在的值满足所给的断言,则返回包含值的 Just,否则,返回 Nothing
}
get isNothing() {
return true;
}
toString() {
return 'Maybe.Nothing'; //返回结构的文本描述
}
}

Maybe显式地抽象对“可空”值(null和undefined)的操作,可让开发者关注更重要的事情。如上述代码所示,Maybe是Just和Nothing的抽象,Just和Nothing各自包含自己的Monadic的实现。正如前面提到的,对于Monadic操作的实现最终取决于具体类型给予的语义。例如,map的行为具体取决于该类型是 Nothing 还是Just

1
2
3
4
5
6
7
8
9
10
11
12
13
const find = (db, id) => (id ? {
name: '小红',
address: '北京'
} : undefined)
const findStudent = R.curry((db, id) => Maybe.fromNullable(find(db, id)))
const findStudentByDB = findStudent('DB');
// 如果有意义得值传入会生成just
console.log(findStudentByDB('id').map(R.prop('address')).value)

// 如果没有意义得值会生成nothing,如果使用value取值会报错
console.log(findStudentByDB(undefined).map(R.prop('address')).getOrElse(123))
//赋值时需要注意
document.querySelector('#student-firstname').value = username.getOrElse('Enter first name');

提升函数

很明显,Maybe擅长于集中管理的无效数据的检查,但它没有(双关Nothing)提供关于什么地方出了错的信息。我们需要一个更积极的,可以知道失败原因的解决方案。解决这个问题,要最好的工具是Either monad。

使用Either

Either跟Maybe略有不同。Either代表的是两个逻辑分离的值a和b,它们永远不会同时出现。这种类型包括以下两种情况。

Left(a)——包含一个可能的错误消息或抛出的异常对象。
Right(b)——包含一个成功的值。

Either通常操作右值,这意味着在容器上映射函数总是在Right(b)子类型上执行。它类似于Maybe的Just分支。

Either的常见用法是为失败的结果提供更多的信息。在不可恢复的情况下,左侧可以包含一个合适的异常对象

与MayBe 类似,合法值也是主右的

IO Monad
打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2015-2025 SunZhiqi

此时无声胜有声!

支付宝
微信