函数式编程进阶:杰克船长的黑珍珠号

banner

本文做者: 赵祥涛

函数式编程(Functional Programming)这一理念不管是在前端领域仍是后端领域,都逐渐热门起来,如今不大量使用函数式编程技术的大型应用程序已经很罕见了,好比前端流行的 React(核心思路数据即视图),Vue3.0 的 Composition API ,Redux ,Lodash 等等前端框架和库,无不充斥着函数式的思惟,实际上函数式编程毫不是最近几年才被创造的编程范式,而是在计算机科学的开始,Alonzo Church 在 20 世纪 30 年代发表的 lambda 演算,能够说是函数式编程的前世此生。javascript

本系列文章适合拥有扎实的 JavaScript 基础和有必定函数式编程经验的人阅读,本文的目的是结合 JavaScript 的语言特性来说解范畴论的一些概念和逻辑在编程中的实际应用。css

黑珍珠号的诅咒

扬帆起航!

首先咱们看一段 双11大促销 的代码,即做为对函数组合等概念的回顾,也做为即将开启的新征程的第一步:html

const finalPrice = number => {
    const doublePrice = number * 2
    const discount = doublePrice * .8
    const price = discount - 50
    return price
}

const result = finalPrice(100)
console.log(result) // => 110

看看上面这段简单的 双11购物狂欢节 的代码,原价 100 的商品,通过商家一顿花式大促销(打折(八折) + 优惠券(50))的操做以后,你们成功拿到剁手价 110好划算,快剁手前端

若是你已经阅读了咱们云音乐前端团队的另一篇函数式编程入门文章,我相信你已经知道如何书写函数式的程序了:即经过管道把数据在一系列纯函数间传递的程序。咱们也知道了,这些程序就是声明式的行为规范。如今再次使用函数组合的思路保持数据管道操做,并消除这么多的中间变量,保持一种 Point-Free 风格:java

const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x)

    const double = x => x * 2
    const discount = x => x * 0.8
    const coupon = x => x - 50

    const finalPrice = compose(coupon, discount, double)

    const result = finalPrice(100)
    console.log(result) // => 110

嗯!终于有了点函数式的味道!这个时候,咱们发现传给函数 finalPrice 的参数 100 像一个工厂的零配件同样在流水线上前后被函数 doublediscountcoupon 所操做。100 像水同样在管道中流通。看到这一幕咱们是否是有点眼熟,Array 的 mapfilter ,不就是彻底相似的概念吗?因此咱们能够用 Array 把咱们输入的参数进行包装:node

const finalPrice = number =>
    [number]
        .map(x => x * 2)
        .map(x => x * 0.8)
        .map(x => x - 50)

const result = finalPrice(100)
console.log(result) // => [110]

如今咱们把 number 放进 Array 这个容器内,而后连续调用了三次 map ,来实现数据的管道流动。仔细观察发现 Array 只是咱们数据的容器,咱们也只是想利用 Array 的 map 方法罢了,其余的方法咱们暂时用不到,那么咱们何不建立一个 Box 容器呢?git

const Box = x => ({
    map: f => Box(f(x)),
    inspect: () => `Box(${x})`
})

const finalPrice = str =>
    Box(str)
        .map(x => x * 2)
        .map(x => x * 0.8)
        .map(x => x - 50)

const result = finalPrice(100)

console.log(result) // => Box(110)
这里使用函数 Box 而不是 ES6 的 Class 来生产对象的缘由是,尽可能避免了“糟糕”的 new 和 this 关键字(摘自《 You Don't Know JS 上册》), new 让人误觉得是建立了 Class 的实例,但其实根本不存在所谓的 实例化,只是简单的 属性委托机制(对象组合的一种),而 this 则引入了执行上下文和词法做用域的问题,而我只是想建立一个简单的对象而已!

inspect 方法的目的是为了使用 Node.js 中的 console.log 隐式的调用它,方便咱们查看数据的类型;而这一方法在浏览器中不可行,能够用 console.log(String(x)) 来代替; Node.js V12 API 有变动,能够采用 Symbol.for('nodejs.util.inspect.custom') 替代 inspect 程序员

这里使用连续 dot、dot、dot 的链式调用而不是使用 compose 组合的缘由是为了更方便的理解,compose 更为函数式。github

被封印的黑珍珠号

杰克船长的黑珍珠号

Box 中这个 map 跟数组那个著名的 map 同样,除了前者操做的是 Box(x) 然后者是 [x] 。它们的使用方式也几乎一致,把一个值丢进 Box ,而后不停的 map,map,map...:编程

Box(2).map(x => x + 2).map(x => x * 3);
// => Box(12)

Box('hello').map(s => s.toUpperCase());
// => Box('HELLO')

这是讲解函数式编程的第一个容器,咱们将它称之为 Box,而数据就像杰克船长瓶子中的黑珍珠号同样,咱们只能经过 map 方法去操做其中的值,而 Box 像是一种虚拟的屏障,也能够说在必定程度上保护 Box 中的值不被随意的获取和操做。

为何要使用这样的思路?由于咱们可以在不离开 Box 的状况下操做容器里面的值。Box 里的值传递给 map 函数以后,就能够任咱们操做;操做结束后,为了防止意外再把它放回它所属的 Box。这样作的结果是,咱们能连续地调用 map,运行任何咱们想运行的函数。甚至还能够改变值的类型,就像上面最后一个例子中那样。

map 是可使用 lambda 表达式变换容器内的值的有效且安全的途径。

等等,若是咱们能一直调用 map.map.map ,那咱们是否是能够称这种类型为 Mappable Type ? 这样理解彻底没有问题!

map 知道如何在上下文中映射函数值。它首先会打开该容器,而后把值经过函数映射为另一个值,最后把结果值再次包裹到一个新的同类型的容器中。而这种变换容器内的值的方法(map)称为 Functor(函子)

函子图解

Functor(函子)范畴论里的概念。范畴论又是什么??? 我不懂!!!

不慌!后面咱们会再继续简单的讨论一下范畴论与 Functor 的概念和理论,让咱们暂时忘记这个奇怪的名字,先跳过这个概念。

仍是继续称之为咱们都能理解的 Box 吧!

黑珍珠号的救赎

相似于 Box(2).map(x => x + 2) 咱们已经能够把任何类型的值,包装到 Box 中,而后不断的 map,map,map...。

另外一个问题,咱们怎么取出来咱们的值呢?我想要的结果是 4 而不是 Box(4)!

若是黑珍珠号不能从瓶子中释放出来又有什么用处呢?接下来让杰克斯派洛船长抢过黑胡子的宝剑,释放出来黑珍珠号!

是时候为咱们的这个最为原始的 Box 添加别的方法了。

const Box = x => ({
    map: f => Box(f(x)),
    fold: f => f(x),
    inspect: () => `Box(${x})`
})

Box(2)
    .map(x => x + 2)
    .fold(x => x)  // => 4

嗯,看出来 foldmap 的区别了吗?

map 是把函数执行的结果从新包装到 Box 中后然返回一个新的 Box 类型,而 fold 则是直接把函数执行的结果 return 出来,就结束了!

Box 的实际应用

Try-Catch

在许多状况下都会发生 JavaScript 错误,特别是在与服务器通信时,或者是在试图访问一个 null 对象的属性时。咱们老是要预先作好最坏的打算,而这种大部分都是经过 try-catch 来实现的。

举例来讲:

const getUser = id =>
    [{ id: 1, name: 'Loren' }, { id: 2, name: 'Zora' }]
        .filter(x => x.id === id)[0]

const name = getUser(1).name
console.log(name) // => 'Loren'

const name2 = getUser(4).name
console.log(name2) // => 'TypeError: Cannot read property 'name' of undefined'

那么如今代码报错了,使用 try-catch 能够必定程度上解决这个问题:

try {
    const result = getUser(4).name
    console.log(result)
} catch (e) {
    console.log('error', e.message) // => 'TypeError: Cannot read property 'name' of undefined'
}

一旦发生了错误,JavaScript 会当即终止执行,并建立致使该问题的函数的调用堆栈跟踪,并保存到 Error 对象中,catch 就像是咱们代码的避风港湾同样。可是 try-catch 能妥善的解决咱们的问题吗?try-catch 存在如下缺点:

  • 违反了引用透明原则,由于抛出异常会致使函数调用出现另外一个出口,因此不能确保单一的可预测的返回值。
  • 会引发反作用,由于异常会在函数调用以外对堆栈引起不可预料的影响。
  • 违反局域性的原则,由于用于恢复异常的代码和原始的函数调用渐行渐远,当发生错误的时候,函数会离开局部栈和环境。
  • 不能只关心函数的返回值,调用者须要负责声明 catch 块中的异常匹配类型来管理特定的异常;难以与其余函数组合或连接,总不能让管道中的下一个函数处理上一个函数抛出的错误吧。
  • 当有多个异常条件的时候会出现嵌套的异常处理块
异常应该由一个地方抛出,而不是随处可见。

上面的描述和代码能够看出,try-catch 是彻底被动的解决方式,也很是的不“函数式”,如果能轻松的处理错误甚至包容错误,该有多好?下面不妨让咱们使用Box理念,来优化这些问题

向左? 向右?

仔细分析 try-catch 代码块的逻辑,发现咱们的代码出口要么在 try 中,要么在 catch 中(函数总不能有两个返回值吧)。按照咱们代码设计的指望,咱们是但愿代码从 try 分支走完的,catch 是咱们的一个兜底方案,那么咱们能够类比 try 为 Right 指代正常的分支,catch 为 Left 指代出现异常的分支,他们二者毫不会同时出现!那么咱们扩展一下咱们的 Box ,分别为 LeftRight ,看代码:

const Left = x => ({
    map: f => Left(x),
    fold: (f, g) => f(x),
    inspect: () => `Left(${x})`
})

const Right = x => ({
    map: f => Right(f(x)),
    fold: (f, g) => g(x),
    inspect: () => `Right(${x})`
})

const resultLeft = Left(4).map(x => x + 1).map(x => x / 2)
console.log(resultLeft)  // => Left(4)

const resultRight = Right(4).map(x => x + 1).map(x => x / 2)
console.log(resultRight)  // => Right(2.5)

LeftRight 的区别在于 Left 会自动跳过 map 方法传递的函数,而 Right 则相似于最基本的 Box,会执行函数并把返回值从新包装到 Right 容器里面。LeftRight 彻底相似于 Promise 中的 RejectResolve,一个 Promise 的结果要么是 Reject 要么是 Resolve,而拥有 Right 和 Left 分支的结构体,咱们能够称之为 Either ,要么向左,要么向右,很好理解,对吧!上面的代码说明了 Left 和 Right 的基本用法,如今把咱们的 LeftRight 应用到 getUser 函数上吧!

const getUser = id => {
    const user = [{ id: 1, name: 'Loren' }, { id: 2, name: 'Zora' }]
        .filter(x => x.id === id)[0]
    return user ? Right(user) : Left(null)
}

const result = getUser(4)
    .map(x => x.name)
    .fold(() => 'not found', x => x)

console.log(result) // => not found

不可相信!咱们如今居然能线性的处理错误,而且甚至可以给出一个 not found 的提醒了(经过给 fold 提供),可是再仔细思考一下,是否是咱们原始的 getUser 函数,有可能会返回 undefined 或者一个正常的值,是否是能够直接包装一下这个函数的返回值呢?

const fromNullable = x =>
    x != null ? Right(x) : Left(null)

const getUser = id =>
    fromNullable([{ id: 1, name: 'Loren' }, { id: 2, name: 'Zora' }]
            .filter(x => x.id === id)[0])

const result = getUser(4)
    .map(x => x.name)
    .fold(() => 'not found', c => c.toUpperCase())

console.log(result) // => not found

如今咱们已经成功处理了可能出现 null 或者 undefined 的状况,那么 try-catch 呢?是否也能够被 Either 包装一下呢?

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

const jsonFormat = str => JSON.parse(str)

const app = (str) =>
    tryCatch(() => jsonFormat(str))
        .map(x => x.path)
        .fold(() => 'default path', x => x)

const result = app('{"path":"some path..."}')
console.log(result) // => 'some path...'

const result2 = app('the way to death')
console.log(result2) // => 'default path'

如今咱们的 try-catch 即便报错了,也不会打断咱们的函数组合了,而且错误获得了合理的控制,不会随意的 throw 出来一个 Error 对象了。

此处建议打开网易云音乐听一首 《向左向右》!放松一下,顺带回味一下咱们的 Right 与 Left。

什么是 Functor? 怎么使用 Functor? 为何使用 Functor?

什么是 Functor?

上面咱们定义了一个简单的 Box,其实也就是拥有 mapfold 方法的类型。让咱们把脚步放慢一点,再仔细观察和思考一下咱们的 mapBox(a) -> Box(b) ,本质上就是经过一个函数 a -> b 把一个 Box(a) 映射为 Box(b)。这和中学代数中的函数知识何其相似,不妨再回顾一下代数课本中函数的定义:

假设 A 和 B 是两个集合,若按照某种对应法则,使得 A 的任一元素在 B 中都有惟一的元素和它对应,则称这种对应为从集合 A 到集合 B 的函数。

上面的集合 A 和集合 B,拿到咱们的程序世界,彻底能够类比与 String 、Boolean、Number 和更抽象的 Object,一般咱们能够把数据类型视做全部可能值的一个集合( Set )。像 Boolean 就能够看做是 [true,false] 的集合,Number 是全部实数的集合,全部的集合,以集合为对象,集合之间的映射做为箭头,则构成了一个范畴:

范畴

看图:a,b,c 分别表示三个范畴,如今咱们作个类比:a 为字符串的集合(String),b 为实数的集合(Number),c 为 Boolean 的集合;那么咱们彻底能够实现映射函数 gstr => str.length,而函数 fnumber => number >=0 ? true : false,那么咱们就能够经过函数 g 完成从字符串范畴到实数范畴的映射,而后经过函数 f 从实数范畴映射到 Boolean 范畴。

如今从新回顾一下以前跳过的那个晦涩的名字: Functor (函子)就是范畴到范畴之间映射的那个箭头!而这个箭头通常经过 map 方法配合一个变换函数(i.e. str => str.length )来实现,这样理解起来就很容易了,对吧(才怪

若是咱们有了函数 g 和函数 f,那么咱们必定能够推导出函数 h = f·g ,也就是 const h = compose(f,g),而这就是上图下半部分 a -> c的变换过程,这不就是中学的数学结合律吗? 咱们可都是学太高数的,谁不会啊

等等,a,b,c 上面的那个 id 箭头又是什么鬼?本身映射到本身?不错!
对于任何 Functor,经过函数 const id = x => x 能够实现 fx.map(id) == id(fx),而这被称为 Identity,也就是数学中的同一概

这也是为何咱们必定要引入范畴论,引入 Functor 的概念,而不仅是简单的把他们称为 mappale 或者其余什么东西,由于这样咱们就能够在保持名称不变的基础上更加理解伴随着数学原理而来的 Functor 的其余的定理(CompositionIdentity),不要由于这个晦涩的名称而让咱们驻步不前。

上面的介绍仅仅是方便前端渣渣们( 和Haskell大神相比),在必定程度上理解范畴。并非十分的严谨( 很是不严谨好不),范畴中的对象能够不是集合,箭头也能够不是映射...停停!!打住!再说下去我就能够转行作代数老师了(ahhhh.jpg)。

怎么使用 Functor?

如今再次让咱们回到代码的世界,毫无疑问 Functor 这个概念太常见了。其实绝大多数的开发人员一直在使用 Functor 却没有意识到而已。好比:

  • Array 的 mapfilter
  • jQuery 的 cssstyle
  • Promise 的 thencatch 方法(Promise 也是一种 Functor? Yes!)。
  • Rxjs Observable 的 mapfilter (异步函数的组合?Relax!)。

都是返回一样类型的 Functor,所以能够不断的链式调用,其实这些都是 Box 理念的延伸:

[1, 2, 3].map(x => x + 1).filter(x => x > 2)

$("#mybtn").css("width","100px").css("height","100px").css("background","red");

Promise.resolve(1).then(x => x + 1).then(x => x.toString())

Rx.Observable.fromEvent($input, 'keyup')
    .map(e => e.target.value)
    .filter(text => text.length > 0)
    .debounceTime(100)

为何使用 Functor?

把值装进一个容器(好比 Box,Right,Left 等),而后只能用 map 来操做它,这么作的理有究竟是什么呢?若是咱们换种方式来思考,答案就很明显了:让容器本身去运用函数能给咱们带来什么好处呢?答案是:
抽象,对于函数运用的抽象。

纵观整个函数式编程的核心就在于把一个个的小函数组合成更高级的函数。
举个函数组合的例子:若是想给任何 Functor 应用一个统一的 map ,该如何处理?答案是 Partial Application

const partial =
    (fn, ...presetArgs) =>
        (...laterArgs) =>
            fn(...presetArgs, ...laterArgs);

const double = n => n * 2
const map = (fn, F) => F.map(fn)
const mapDouble = partial(map, double)

const res = mapDouble(Box(1)).fold(x => x)
console.log(res)  // => 2

关键在于 mapDouble 函数返回的结果是一个等待接收第二个参数 F (Box(1)) 的函数; 一旦收到第二个参数,则会直接执行 F.map(fn) ,至关于 Box(1).map(double) ,该表达式返回的结果为 Box(2) ,因此后面能够继续 .fold等等链式操做。

总结与计划

总结

上面经过双十一购物狂欢节的例子,介绍了函数式编程的几个基本概念(pure function,compose)等,并逐渐引入了功能强大的 Box 理念,也就是最基本的 Functor。后面经过无时不刻可能出现的 null ,介绍了 Either 能够用来作 null 的包容器。再经过 try-catch 的例子,了解了比较 pure 的处理错误的方式, Either 固然不只仅是这两种用法,后面会继续介绍其余高级的用法。最后总结了什么是 Functor,怎么使用 Functor,以及使用 Functor 的优点何在。

计划

Functor 是咱们介绍的范畴论中的最最最基本的一个概念,不过咱们目前解决的都是最简单的问题(更优秀的组合(map),更健壮的代码(fromNullAble),更纯的错误处理(TryCatch)),可是嵌套的 try-catch 呢?异步函数怎么组合呢?后面会继续经过 双11购物狂欢节的案例 来介绍范畴论中的其余概念和实际用法示例(实际目的:继续揭露奸商的套路,顺便转行作代数老师,摆脱 34岁 淘汰的潜规则; 狗头.jpg)。

参考资料与引用文章:

本文发布自 网易云音乐前端团队,文章未经受权禁止任何形式的转载。咱们一直在招人,若是你刚好准备换工做,又刚好喜欢云音乐,那就 加入咱们
相关文章
相关标签/搜索