使用函数式编程来处理异常或错误

前言

程序中出现的很多bug往往是由于数据不经意间变成了null或者undefined造成的,比如说与服务器通信,第三方库抛出异常来表示某些错误等。因此,编写代码时也往往会写大量防御代码来确保所有抛出的错误都能被适当的捕获。最后导致程序越来越复杂,又不能去扩展和重用。

JavaScript的异常处理机制通常是使用try-catch语句实现的:

try {
    ...
} catch {
    ...
}
复制代码

显然,使用try-catch后的代码不能组合或重用,这会严重影响代码的设计。这也与函数式的设计相违背,其中之一的原因就是违反了引用透明性,因为抛出异常会导致函数调用出现另一个出口,不能保证单一的可预测的返回值。

但这不意味着函数式编程不需要抛出异常,这是不可能的。函数式的做法是确保异常应该由一个地方抛出,而不是处处可见。

另一种和抛出异常一样讨人厌的错误就是返回值为null。虽然遵循了函数的返回值只有一个,但也好不了哪去,因为这也需要去写额外的代码去判断是否为null

函数式的解决方案–Functor

说起来与命令式编程差不多,函数式编程应对抛出异常的解决方案就是创建一个安全的容器,来存放可能出抛出异常的代码。try{}也可以看作是存放危险代码的容器。

所以,Functor是一个值的容器,而且它实现了map函数,在遍历每个对象值的时候生成一个新的容器。

将值包裹起来是函数式编程的一个基本设计模式,因为它直接地保证了值不会被人篡改,只能使用特定的方法才能访问容器的值。比如说数组的mapfilterreduce,数组就是值的容器。对于数组来说可以通过map转换值,返回包含新值的一个新的数组。

创建一个Container构造函数:

class Container () {
    contructor (val) {
        this._value = val;
    }
    static of (val) {
        return new Container(val);
    }
    map (fn) {
        return Containe.of(fn(this._value));
    }
}
复制代码

Container只是存储值的容器,而map函数允许调用任何函数使用当前容器的值。要访问容器的val,唯一的办法就是通过map操作去完成,比如map(R.identity)

const contName = Container.of('zhang');
contName.map(R.identity); // Container{ _value: 'zhang'}
复制代码

还能映射任何函数到该容器,比如变换该值:

contName.map(R.toUpper); // 'Container{ _value: 'ZHANG'}
复制代码

map会先打开该容器,应用函数到值,最后把返回的值包裹在一个新的同类型容器中。这也是Functor的含义所在。

Functor and more

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

map::(A -> B) -> Container(A) -> Container(B)
复制代码

map函数接受一个从A->B的函数,以及一个Container(A)Functor,然后返回包裹着返回值的新Container(B)

const plus = R.curry((a, b) => a + b);
const plus1 = plus(1);
复制代码

把另一个参数放到Container中,再调用mapplus1映射到容器中。

const two = Container.of(2); // Container(2)
const three = two.map(plus1); // Container(3)
复制代码

值一直都在容器中,所以可以用map任意次映射函数来转换值。

two.map(plus(4)).map(plus(5)); // Container(11)
复制代码

Functor其实并不是什么新玩意,大家都一直在使用它,只不过没有意识到而已。比如Array的map、filter:

map::(A -> B) -> Array(A) -> Array(B)
filter::(A -> Boolean) -> Array(A) -> Array(A)
复制代码

mapfilter都返回同样类型的Functor,因此可以不断地链接。compose组合函数也是一样,从一个函数到另一个函数的映射。

因为是属于函数式编程,Functor有一些重要的约束:

  • 无副作用
  • 可组合的

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

Functor的不足
使用Functor可以安全地应用函数到内部的值,并返回一个新的Functor。这是一个很好的模式,但如果它遍布在代码中,就会有一些让人不那么顺心的地方。比如说,有两个函数findStudengetName,这两个函数都给值包裹一个上下文Container

const findStudent = R.curry((storage, id) => {
    return Container.of(find(storage, id));
})
const getName = (student) => {
    return Container.of(student.map(R.prop('name')));
}
const studentName = R.compose(getName, findStudent(localStorage('studentList')));
studentName('440***'); // Container(Container('zhang')); ugh!
复制代码

可以看到把这两个函数组合在一起时,返回的值时被包了两层的Container对象。也许两层也还能接受,但出现三层、四层那就头都大了,在JavaScript中,通常我们管这样的代码叫callback hell

Monad

为了解决这种深层嵌套的问题,可以使用MonadMonad也不是一个新的概念,遇到深层嵌套的问题,很自然而然的想到要将其拍平,我们只需要一层Container

所以在Functor中加上join函数.

Container.prototype.join = function () {
    if (this._value instanceof Container) {
        return this._value.join();
    }
    return this;
}
复制代码

所以在studentName中,调用join就能把嵌套的结构给扁平化:

studentName('440***').join(); // Container('zhang')
复制代码

为了避免调用过map过程中出现嵌套结构,所以一般在map之后就要调用一次join。所以将其抽象为chain方法

Container.prototype.chain = function (fn) {
    return this.map(fn).join();
}
复制代码

所以Monad就是一个含有chain方法的Functor

基础知识已经准备好了,下面进入主题:使用函数式编程来处理异常或错误。

创建Maybe来处理异常

函数式编程通常使用MaybeEither处理:

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

Maybe合并判空
Maybe Monad侧重于有效整合null-判断逻辑.

class MayBe {
    constructor(val) {
        this._value = val;
    }
    static of (val) {
        return new MayBe(val);
    }
    isNothing () {
        return (this._value === null || this._value === undefined);
    }
    map (fn) {
        return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value));
    }
}
复制代码

MayBe在应用传入的函数之前会检查nullundefined,这是一种对错误的抽象。

MayBe.of('zhang').map(R.toUpper); // MayBe('ZHANG');
复制代码

调用R.toUpper时,是不需要在意输入值是否为null/undefined,它已经被MayBe抽象出来的。如果值为null,就会得到MayBe(null);代码没有在null或者undefined值下崩溃,因为已经把值放到一个安全的容器中。

MayBe.of('zhang').map(R.toUpper).map(name => 'Mr.' + name); // MayBe('Mr.ZHANG‘)
复制代码

即使第一个map返回null或者undefined,也是没有问题的。第二个map仍然会被调用,它也会返回null。该过程将持续到链条中的最后一个map函数被调用完

MayBe.of('zhang').map(() => undefined).map(name => 'Mr.' + name); // MayBe(null)
复制代码
const fetchStudent = async (id) => {
    const res = await Axios.get(`api/user/${id}`);
    return MayBe.of(res).map(R.props('data')
                        .map(R.props('student')
                        .map((student) => {
                            return {
                                name: student.name,
                                id: student.id
                            }
                        })
}
复制代码

如果其中一个map出错了,或者一开始的res为空。我们只能得到一个MayBe(null)结果,并不知道哪一步出错了,为了了解这一点,需要另一个Monad-Either

创建Either Monad来处理错误

EitherMayBe略有不同。Ether代表的是两个逻辑分离的值a和b,它们永远不会同时出现。

  • Left(a)-包含一个可能的错误或异常
  • Right(b)-包含一个成功的值
class Either {
    static left(a) {
        return Left.of(a);
    }
    static right(b) {
        return Right.of(b);
    }
    static of(val) {
        return !!val ? Either.right(val) : Either.left(val);
    }
}
class Left{
    contructor (val) {
        this._value = val;
    }
    static of (val) {
        return new Left(val);
    }
    join () {
        if (this._value instanceof Left) {
            return this._value.join();
        }
        return this;
    }
    chain () {
        return this.map(fn).join();
    }
    map (fn) {
        return this
    }
}
class Right{
    constructor(val) {
        this._value = val;
    }
    static of (val) {
        return new Right(val)
    }
    map (fn) {
        return Right.of(fn(this._value))
    }
}
复制代码

Either包含了两个classRight、Left。有趣的是Left,它的map不执行给定的函数,而只是返回自身。也就是说在Right上运行的函数,不能在Left上运行。举例:

Right.of('test').map(R.toUpper); // Right('TEST')

Left.of('test').map(R.toUpper); // Left('test')
复制代码

使用Either改写上面的例子,抽象请求api的操作

const fetchFn (id) = {
    return Axios.get(`api/user/${id}`).then(res => {
        return Either.of(res)
    }).catch(err => {
        return Eigher.of(err)
    })
}
const fetchStudent = async (id) => {
    const res = await fetchFn(id);
    return res.map(R.props('data')
                        .map(R.props('student')
                        .map((student) => {
                            return {
                                name: student.name,
                                id: student.id
                            }
                        })
}
复制代码

如果使用错误的id去调用api:

fetchStudent('xxx');
复制代码

将会返回:

Nothing({
    message: 'id not invalid',
    errorCode: 404
})
复制代码

使用Eihter获得了分支失败的确切原因,在错误的情况下fetchStudent返回了Nothing,因此后续的map映射并不会执行,而且Nothing还保存了错误信息。

Monad控制了充满副作用的世界,使得开发者可以在可组合的结构中使用它们。而且只需很少量的代码,就可以将Monad变成可组合的,从而可以享受流畅,富有表现力的错误处理机制。

结尾

如果大家不了解什么是函数式编程,请看我上一篇文章深入浅出JavaScript函数式编程

更多文章请移步楼主github,如果喜欢请点一下star,对作者也是一种鼓励。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享