FP12:Either:Left or Right

August 02, 2018 by Sylvenas

Left or Right ?

Left or Right

说出来可能会让你震惊,try/catch 并不十分“纯”。当一个错误抛出的时候,我们没有收到返回值,反而是得到了一个警告!抛错的函数吐出一大堆的 0 和 1 作为盾和矛来攻击我们,简直就像是在反击输入值的入侵而进行的一场电子大作战。有了Either这个新朋友,我们就能以一种比向输入值宣战好得多的方式来处理错误,那就是返回一条非常礼貌的消息作为回应。我们来看一下:

class Either {
    constructor(value) {      // Either构造函数,接受一个异常或者合法的值
        this.$value = value;
    }
    static left(a) {
        return new Left(a)
    }
    static right(a) {
        return new Right(a)
    }
    static of(a) {
        return Either.right(a)
    }
    static fromNullable(val) {       // 若值非法则返回Left,否则返回Right
        return val != null ? Either.right(val) : Either.left(val)
    }
    get value() {
        return this.$value
    }
}

class Left extends Either {
    map() {     // Left不做任何操作
        return this
    }
    chain(fn) {   
        return this
    }
    filter(fn) {
        return this
    }
    getOrElse(other) {  // 尝试提取Right中的值,如果不存在则返回默认值
        return other
    }
    orElse(fn) {       // 将给定的函数应用于Left值,Right不做任何操作
        return fn(this.$value)
    }
    getOrElseThrow(a) {
        throw new Error(a)
    }
    fold(f, g) {
        return f(this.$value)
    }
    get value() {
        throw new TypeError('Can’t extract the value of a Left(a).')
    }
}

class Right extends Either {
    map(fn) {
        return Either.of(fn(this.$value))
    }
    getOrElse(other) {
        return this.$value
    }
    orElse() {
        return this
    }
    chain(fn) {
        return fn(this.$value)
    }
    getOrElseThrow() {
        return this.$value
    }
    filter(fn) {
        return Either.fromNullable(fn(this.$value) ? this.$value : null)
    }
    fold(f, g) {
        return g(this.$value)
    }
}

Maybe略有不同,Either代表的是两个逻辑分离的Left和Right,他们永远不会同时出现:

  • Left(a) --包含一个可能的错误消息或抛出的一场对象
  • Right(b) --包含一个成功的值

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

来看看它们是怎么运行的:

Either.Right("rain").map(function(str){ return "b"+str; });
// Right("brain")

Either.Left("rain").map(function(str){ return "b"+str; });
// Left("rain")

Either.Right({host: 'localhost', port: 80}).map(_.prop('host'));
// Right('localhost')

Either.Left("rolls eyes...").map(_.prop("host"));
// Left('rolls eyes...')

Left 就像是青春期少年那样无视我们要 map 它的请求。Right 的作用就像是一个 Container(也就是 Identity)。这里强大的地方在于,Left 有能力在它内部嵌入一个错误消息。

假设有一个可能会失败的函数,就拿根据生日计算年龄来说好了。的确,我们可以用 Maybe(null) 来表示失败并把程序引向另一个分支,但是这并没有告诉我们太多信息。很有可能我们想知道失败的原因是什么。用 Either 写一个这样的程序看看:

const moment = require('moment');

// getAge :: Date -> User -> Either(String, Number)
const getAge = curry((now, user) => {
  const birthDate = moment(user.birthDate, 'YYYY-MM-DD');

  return birthDate.isValid()
    ? Either.of(now.diff(birthDate, 'years'))
    : left('Birth date could not be parsed');
});

getAge(moment(), { birthDate: '2005-12-12' });
// Right(9)

getAge(moment(), { birthDate: 'July 4, 2001' });
// Left('Birth date could not be parsed')

这么一来,就像Maybe(null),当返回一个Left的时候就直接让程序短路。跟Maybe(null)不同的是,现在我们对程序为何脱离原先轨道至少有了一点头绪。有一件事要注意,这里返回的是Either(String, Number),意味着我们这个 Either左边的值是String,右边(也就是正确的值)的值是Number

如果 birthdate 合法,这个程序就会把它神秘的命运打印在屏幕上让我们见证;如果不合法,我们就会收到一个有着清清楚楚的错误消息的 Left,尽管这个消息是稳稳当当地待在它的容器里的。这种行为就像,虽然我们在抛错,但是是以一种平静温和的方式抛错,而不是像一个小孩子那样,有什么不对劲就闹脾气大喊大叫。

在这个例子中,我们根据 birthdate 的合法性来控制代码的逻辑分支,同时又让代码进行从右到左的直线运动,而不用爬过各种条件语句的大括号。通常,我们不会把 console.log 放到 zoltar 函数里,而是在调用 zoltar 的时候才 map 它,不过本例中,让你看看 Right 分支如何与 Left 不同也是很有帮助的。我们在 Right 分支的类型签名中使用 _ 表示一个应该忽略的值(在有些浏览器中,你必须要 console.log.bind(console) 才能把 console.log 当作一等公民使用)。

我想借此机会指出一件你可能没注意到的事:这个例子中,尽管 fortune 使用了 Either,它对每一个 functor 到底要干什么却是毫不知情的。前面例子中的 finishTransaction 也是一样。通俗点来讲,一个函数在调用的时候,如果被 map 包裹了,那么它就会从一个非 functor 函数转换为一个 functor 函数。我们把这个过程叫做 lift。一般情况下,普通函数更适合操作普通的数据类型而不是容器类型,在必要的时候再通过 lift 变为合适的容器去操作容器类型。这样做的好处是能得到更简单、重用性更高的函数,它们能够随需求而变,兼容任意 functor。

Either 并不仅仅只对合法性检查这种一般性的错误作用非凡,对一些更严重的、能够中断程序执行的错误比如文件丢失或者 socket 连接断开等,Either 同样效果显著。你可以试试把前面例子中的 Maybe 替换为 Either,看怎么得到更好的反馈。

仅仅是把 Either 当作一个错误消息的容器使用,这样的介绍有失偏颇,它的能耐远不止于此。比如,它表示了逻辑或(也就是 ||)。再比如,它体现了范畴学里 coproduct 的概念,当然本书不会涉及这方面的知识,但值得你去深入了解,因为这个概念有很多特性值得利用。还比如,它是标准的 sum type(或者叫不交并集,disjoint union of sets),因为它含有的所有可能的值的总数就是它包含的那两种类型的总数

try-catch

Either还可以用来包装try-catch,来让我们的程序更加的适合函数组合(普通的try/catch会导致程序出现另一个出口,无法进行多个分支的组合):

const tryCatch = f => {
    try {
        return Right(f())
    } catch (e) {
        return Left(e)
    }
}

Either Use Cases

const openSite = (current_user) => {
    if (current_user) {
        return renderpage(current_user)
    } else {
        return showLogin()
    }
}

const openSite1 = (current_user) => {
    fromNullable(current_user)
        .fold(showLogin, renderpage)
}
const getPrefs = user => {
    if (user.premium) {
        return loadPrefs(user.preferences)
    } else {
        return defaultPrefs
    }
}

const getPrefs1 = user =>
    (user.premium ? Right(user) : Left('not premium'))
        .map(u => u.preferences)
        .fold(() => defaultPrefs, prefs => loadPrefs(prefs))
const streetName = user => {
    const address = user.address
    if (address) {
        const street = address.street
        if (street) {
            return street.name
        }
    }
    return 'no street'
}

const streetName1 = user =>
    fromNullable(user.address)
        .chain(a => fromNullable(a.street))
        .chain(s => fromNullable(s.name))
        .fold(e => 'no street', n => n)
const concatUniq = (x, ys) => {
    const found = ys.filter(y => y === x)[0]
    return found ? ys : ys.concat(x)
}

const concatUniq1 = (x, ys) =>
    fromNullable(ys.filter(y => y === x)[0])
        .fold(() => ys.concat(x), y => ys)
const wrapExamples = example => {
    if (example.previewPath) {
        try {
            example.preview = fs.readFileSync(example.previewPath)
        } catch (e) { }
    }
    return example
}

const readFile = x => tryCatch(() => fs.readFileSync(x))

const wrapExample = example =>
    fromNullable(example.previewPath)
        .chain(readFile)
        .fold(() => example,
            preview => Object.assign({}, { preview }, example))
const parseDbUrl = cfg => {
    try {
        const c = JSON.parse(cfg)
        if (c.url) {
            return c.url.match(/postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/(.+)/)
        }
    } catch (e) {
        return null
    }
}

const parseDbUrl = cfg =>
    tryCatch(() => JSON.parse(cfg))
        .chain(c => fromNullable(c.url))
        .fold(e => null,
            u => u.match(/postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/(.+)/))