乐趣区

关于javascript:函数式编程进阶应用函子


图片起源: https://unsplash.com/photos/FqYMtQpE77E

本文作者:赵祥涛

上一章中介绍了 Functor(函子) 的概念,简略来说,就是把一个“value”填装进“Box”中,继而能够应用 map 办法映射变换 Box 中的值:Box(1).map(x => x+1)。本章咱们在持续在 Box 的根底上持续扩大其余更弱小的理念,从纯函数与副作用)的概念及用处作为承前启后的开始,持续坚固 Functor 的概念以及接下来将要介绍的 Applicative Functor 的引子。
函数式编程中纯函数是一个及其重要的概念,甚至能够说是函数组合的根底。你可能曾经听过相似的舆论:“纯函数是援用通明 (Referential Transparency) 的”,“纯函数是无副作用 (Side Effect) 的”,“纯函数没有共享状态(Shared State)”。上面简略介绍下纯函数。

纯函数与副作用

在计算机编程中,如果满足上面这两个条件的束缚,一个函数能够被形容为一个“纯函数”(pure function)

  • 给出雷同的参数,那么函数的返回值肯定雷同。该函数后果值不依赖任何暗藏信息或程序执行解决可能扭转的状态,也不能依赖于任何来自 I/O 的内部输出。
  • 在对函数返回值的计算过程中,不会产生任何语义上可察看的副作用或输入,例如对象的变动或者输入到 I/O 的操作。

对于纯函数的第一条很简略,雷同的输出,总会返回雷同的输入,和中学数学中学习的“函数”齐全相似,传入雷同的参数,返回值肯定雷同,函数自身就是从汇合到汇合的“映射”。
第二条不产生可察看的副作用又是什么意思呢?也就是函数不能够和零碎的其余局部通信。比方:打印日志,读写文件,数据申请,数据存储等等;
从代码编写者的角度来看,如果一段程序运行之后没有可察看到的作用,那他到底运行了没有?或者运行之后有没有实现代码的目标?有可能它只是节约了几个 CPU 周期之后就去睡大觉了!
从 JavaScript 语言的诞生之初就不可避免地须要可能与一直变动的,共享的,有状态的 DOM 相互作用;如果无奈输入输出任何数据,那么数据库有什么用途呢?如果无奈从网络申请信息,咱们的页面又该如何展现?没有“side effect”咱们简直举步维艰,副作用不可避免,上述的任何一个操作,都会产生副作用,违反了援用透明性,咱们仿佛陷入了两难的地步!

世間安得雙全法,不負如來不負卿
如何在 keep pure 的前提下,又能妥善的解决 side effect 呢?

惰性盒子 -LazyBox

要想较现实的解决这个问题,咱们把注意力转回到 JavaScript 的外围 function 上,咱们晓得在 JavaScript 里,函数是“一等公民”,JavaScript 容许开发人员像操作变量一样操作函数,例如将函数赋值给变量、把函数作为参数传递给其余函数、函数作为另一个函数的返回值,等等 …
JavaScript 函数具备 值的行为,也就是说,函数就是一个基于输出的且尚未求值的不可变的值,或者能够认为函数自身就是一个期待计算的惰性的值。那么咱们齐全能够把这个“惰性的值”装入 Box 中,而后提早调用即可,仿照上一章的 Box,能够实现一个 Lazy Box

const LazyBox = g => ({map: f => LazyBox(() => f(g())),
 fold: f => f(g())
})

留神察看,map 函数所做的始终都是在组合函数,函数并没有被理论的调用;而调用 fold 函数才会真正的执行函数调用,看例子:

const finalPrice = str =>
 LazyBox(() => str)
 .map(x => { console.log('str:', str); return x })
 .map(x => x * 2)
 .map(x => x * 0.8)
 .map(x => x - 50) 
const res = finalPrice(100)
console.log(res)  // => {map: [Function: map], fold: [Function: fold] }

在调用 finalPrice 函数的时候,并没有打印出 'str:100',阐明正如咱们预期的那样,函数并没有真正的被调用,而只是在一直的进行函数组合。在没有调用 fold 函数之前,咱们的代码都是 “pure” 的。

这有点相似于递归,在未满足终止条件之前(没有调用 fold 之前),递归调用会在栈中一直的重叠(组合函数),直到满足终止条件(调用 fold 函数),才开始真正的函数计算。

const app = finalPrice(100)
const res2 = app.fold(x => x)
console.log(res2) // => 110

fold 函数就像关上潘多拉魔盒的双手;通过 LazyBox 咱们把可能会“弄脏双手(产生副作用)”的代码扔给了最初的 fold,这样做又有什么意义呢?

  • 把代码中不纯的局部剥离进去,保障外围局部代码的“pure”个性,比方下面的代码中只有 app.fold(x => x) 是“no pure”的,其余局部都是“pure”
  • 相似于上一章中的谬误集中管理,能够通过 LazyBox 来把副作用集中管理,如果在我的项目中一直的扩充“pure”的局部,咱们甚至能够把不纯的代码推到代码的边缘,保障外围局部的“pure”和“referential transparency”

LazyBox 也和 Rxjs 中的 Observable 有很多相似之处,两者都是惰性的,在 subscribe 之前,Observable 也不会推送数据。
此处请思考下 React 中的 useEffect 以及 Redux 中的 reduceraction 拆散的设计理念。

利用函子

Function in Box

上一小结,介绍了把函数装入 LazyBox 中,放在最初提早执行,以保障最初大多数代码的“pure”个性。
转换下思维,函数能够认为是“惰性的值”,那么咱们把这个稍显非凡的值,装入一般的 Box,又会产生什么呢?还是从小学数学开始吧。

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

inspect 办法的目标是为了应用 Node.js 中的 console.log 隐式的调用它,不便咱们查看数据的类型;而这一办法在浏览器中不可行,能够用 console.log(String(x)) 来代替; Node.js V12 API 有变更,能够采纳 Symbol.for('nodejs.util.inspect.custom') 代替 inspect
当初咱们失去了一个包裹着函数的 Box,可是咱们怎么应用这个函数呢?毕竟 Box(x).map 办法都是接管一个函数!持续回到函数 addOne 上,咱们须要一个数字,传递给 addOne,对吧!所以换句话说就是,咱们怎么传递一个数字进去利用这个 addOne 函数呢,答案非常简单,持续传递一个被包裹的值,而后 map 这个函数 (addOne) 不就能够啦! 看代码:

const Box = x => ({map: f => Box(f(x)),
 apply: o => o.map(x),
 flod: f => f(x),
 inspect: () => `Box(${x})`
})
Box(addOne).apply(Box(2)) // => Box(3)

看看 Box 神奇的新办法,首先被包裹的值是一个 函数 x,而后咱们持续传递另一个 Box(2) 进去,不就能够应用 Box(2) 上的 map 办法调用 addOne 函数了吗!
当初从新扫视一下咱们 Box(addOne)Box(1),那么这个问题实际上能够归结为:把一个 functor 利用到另一个上 functor 上,而这也就是 Applicative Functor (利用函子)最善于的操作了,看一下示意图来形容利用函子的操作流程:

所以依据下面的解说和实例咱们能够得出一个论断:先把一个值 x 装进 Box,而后 map 一个函数 f 和把函数 f 装进 Box,而后 apply 一个曾经曾经装进 Boxx,是齐全等价的!

F(x).map(f) == F(f).ap(F(x))
Box(2).map(addOne) == Box(addOne).apply(Box(2))  // => Box(3)

依据标准,apply 办法前面咱们会简写为ap!
Applicative functor (利用函子) 也是函数式编程中一大堆“弄虚作假”的概念中惟一的比拟“货真价实”的了,想想 Functor(函子)

利用函子与函数柯里化

在持续学习函数柯里化之前,先温习一下中学数学中的高斯消元法:设函数 f(x,y) = x + y,在 y = 1 的时候,函数能够批改为 f(x) = x + 1。基本思路就是把二元变成一元,同理咱们能够把三元函数降元为二元,甚至把多元函数降元为一元函数。
那么咱们能够在肯定水平上认为函数求值的过程,就是就是函数消元的过程,当所有的元都被消完之后,那么就能够求的函数值。
数学中的高斯消元法和函数式编程中的“柯里化”是有点相似的,所谓函数柯里化就是把一个接管多个参数的函数,转换为一次接管一个参数,直到收到全副参数之后,进行函数调用(计算函数值),看例子:

const add = (x, y) => x + y
const curriedAdd = x => y => x + y

好了,简略了解了函数柯里化的概念之后,持续往前走一步,思考一下,如果当初有两个「被包裹的值」,怎么把一个函数利用下来呢?举个例子:

const add = x => y => x + y
add(Box(1))(Box(2))

下面的计划显著是走不通的,咱们没方法间接把 Box(1)Box(2) 相加,他们都在盒子里;
可是咱们的需要不就是把 Box(1)Box(2)add 三者相互利用一下,想要失去最初的后果 Box(3)
从第一章开始,咱们的函数运算都是在 Box 的“爱护”下进行的,当初无妨也把 add 函数包装进 Box 中,不就失去了一个利用函子 Box(add),而后持续“apply”其余的函子了吗?

Box(add).ap(Box(1))  // => Box(y => 1 + y) (失去另一个利用函子)
Box(add).ap(Box(1)).ap(Box(2))  // => Box(3) (失去最终的后果)

下面的例子,因为每次 apply 一个 functor,相当于把函数降元一次,咱们能够得出一个论断,一个柯里化的函数,有几个参数,咱们就能够 apply 几次

每次 apply 之后都会返回包裹新函数的利用函子,换句话说就是:利用多个数据到多个函数,这和多重循环十分相似。

利用函子的利用案例

表单校验是咱们日常开发中常见的一个需要,举个具体的例子,如果咱们有一个用户注册的表单,咱们须要校验用户名,明码两个字段,常见的代码如下:

const checkUserInfo = user => {const { name, pw, phone} = user
 const errInfo = []
 if (/^[0-9].+$/.test(name)) {errInfo.push('用户名不能以数字结尾')
 }
 if (pw.length <= 6) {errInfo.push('明码长度必须大于 6 位')
 }
 if (errInfo.length) {return errInfo}
 return true
}
const userInfo = {
 name: '1Melo',
 pw: '123456'
}
const checkRes = checkUserInfo(userInfo)
console.log(checkRes)  // => ['用户名不能以数字结尾', '明码长度必须大于 6 位']

这个代码天然没有问题,然而,如果咱们要持续增加须要校验的字段 (e.g., 电话号码,邮箱),checkUserInfo 函数毫无疑问会越来越宏大,并且如果咱们要批改某一个字段的校验规定的话,整个 checkUserInfo 函数可能会受到影响,咱们须要减少的单元测试工作要更多了。
回忆一下第一章中介绍的 Either(Left or Rigth) Right 指代失常的分支,Left 指代出现异常的分支,他们两者绝不会同时呈现,当初咱们略微换个了解形式:Right 指代校验通过的分支,Left 指代校验不通过的分支。
此时咱们持续在第一章 Either 的根底上扩大其余的属性和办法,用来做表单校验的工具:

const Right = x => ({
 x,
 map: f => Right(f(x)),
 ap: o => o.isLeft ? o : o.map(x),
 fold: (f, g) => g(x),
 isLeft: false,
 isRight: true,
 inspect: () => `Right(${x})`
})
const Left = x => ({
 x,
 map: f => Left(x),
 ap: o => o.isLeft ? Left(x.concat(o.x)) : Left(x),
 fold: (f, g) => f(x),
 isLeft: true,
 isRight: false,
 inspect: () => `Left(${x})`
})

绝对比与原 Either,新增了 x 属性和 ap 办法,其余的属性齐全相似,就不做解释了;新增 x 属性的起因在于须要记录表单校验的错误信息,这个很好了解,而新增的 isLeftisRight 属性就更简略了,用来辨别 Left/Right 分支。
咱们认真看一下新增的 ap 办法,先看 Right 分支的 ap: o => o.isLeft ? o : o.map(x),毫无疑问 ap 办法接管另一个 functor,如果另一个 functorLeft 的实例,则不须要 Right 解决间接返回,如果是 Right,则和平时 applicative functor 一样,对 o 作为主体进行 map
Left 分支上的 ap: o => o.Left ? Left(x.concat(o.x)) : Left(x),如果是 Left 的实例,则进行一个“叠加”,实际上就是为了累加错误信息,而如果不是 Left 的实例则间接返回本来曾经记录的错误信息。
做好了后期的筹备工作,咱们就能够大刀阔斧的依照函数式的思维 (函数组合) 来拆分一下 checkUserInfo 函数:

const checkName = name => {return /^[0-9].+$/.test(name) ? Left('用户名不能以数字结尾') : Right(true)
}
const checkPW = pw => {return pw.length <= 6 ? Left('明码长度必须大于 6 位') : Right(true)
}

下面把两个字段校验从一个函数中拆分成了两个函数,更重要的是齐全解耦;返回值要么是校验不通过的 Left,要么是校验通过的 Right,所以咱们能够了解为当初有了两个 Either,只有咱们再领有一个 被包裹进 Either 盒子并且柯里化两次的函数 不就能够让他们相互 apply 了吗?

const R = require('ramda')
const success = () => true
function checkUserInfo(user) {const { name, pw, phone} = user
 // 2 是因为咱们须要 `ap` 2 次。const returnSuccess = R.curryN(2, success);
 return Right(returnSuccess)
 .ap(checkName(name))
 .ap(checkPW(pw))
}
const checkRes = checkUserInfo({name: '1Melo', pw: '123456'})
console.log(checkRes) // => Left(用户名不能以数字结尾明码长度必须大于 6 位)
const checkRes2 = checkUserInfo({name: 'Melo', pw: '1234567'})
console.log(checkRes2) // => Right(true)

当初 checkUserInfo 函数的返回值是一个 Either(Left or Righr) 函子,具体前面就能够持续应用 fold 函数,展现校验不通过弹窗或者进行下一步的表单提交了。

对于校验参数应用 Validation 函子更适合,这里为了聚焦解说 Applicative Functor 理念这条主干线,就不再持续引入新概念了。

PointFree 格调

下面举例说明的 checkUserInfo 函数,须要 ap 两次,感觉有点繁琐(想想如果咱们须要校验更多的字段呢?),咱们能够形象出一个 point-free 格调的函数来实现上述操作:

const apply2 = (T, g, funtor1, functor2) => T(g).ap(funtor1).ap(functor2)
function checkUserInfo(user) {const { name, pw, phone} = user
 const returnSuccess = R.curryN(2, success);
 return apply2(Right, returnSuccess, checkName(name), checkPW(pw))
}

apply2 函数的参数特地多,尤其是须要传递 T 这个不确定的容器,用来把一般函数 g 装进盒子里。

把一个“值”(任意非法类型,当然包含函数),装进容器中 (Box or Context) 中有一个对立的办法叫 of,而这个过程被称为 lift,意为晋升:即把一个值晋升到一个上下文中。
再回头看看后面介绍的:Box(addOne).ap(Box(2))Box(2).map(addOne) 从后果 (Box(3)) 上来看是一样。也就说执行 map 操作 (map(addOne))等同于先执行 of (Box(addOne)),而后执行 ap (ap(Box(2))),用公式表白就是:

F(f).ap(F(x)) == F(x).map(f)

套用公式,咱们能够批改简化 apply2 函数体中的 T(g).ap(funtor1)funtor1.map(g),看上面的比照:

const apply2 = (T, g, funtor1, functor2) => T(g).ap(funtor1).ap(functor2)
const liftA2 = (g, funtor1, functor2) => funtor1.map(g).ap(functor2)

看到了下面的关键点了吗?下面的 liftA2 函数中不再耦合于“T”这个特定类型的盒子,这样更加的通用灵便。
依照下面的实践,能够改写 checkUserInfo 函数为:

function checkUserInfo(user) {const { name, pw, phone} = user
 const returnSuccess = R.curryN(2, success);
 return liftA2(returnSuccess, checkName(name), checkPW(pw))
}

当初再假如一下咱们新增了须要校验的第三个字段“手机号码”,那齐全能够扩大 liftA2 函数为 liftA3,liftA4 等等:

const liftA3 = (g, funtor1, functor2, functor3) => funtor1.map(g).ap(functor2).ap(functor3)
const liftA4 = (g, funtor1, functor2, functor3, functor4) => funtor1.map(g).ap(functor2).ap(functor3).ap(functor4)

刚开始可能会感觉 liftA2-3-4 看起来又丑又没必要;这种写法的意义在于:固定参数数量,个别会在函数式的 lib 中提供,不必本人手动去写这些代码。

Applicative Functor 和 Functor 的区别和分割

依据 F(f).ap(F(x)) == F(x).map(f),咱们能够得出一个论断,如果一个盒子 (Box),实现了 ap 办法,那么咱们肯定能够利用 ap 办法推导出一个 map 办法,如果领有了 map 办法,那它就是一个 Functor,所以咱们也能够认为 ApplicativeFunctor 的拓展,比 Functor 更弱小。
那么弱小在何处呢?Functor 只能映射一个接管单个参数的函数 (e.g., x => y),如果咱们想把接管多个参数的函数(e.g., x => y => z) 利用到多个值上,则是 Applicative 的舞台了,想想 checkUserInfo 的例子。

毫无疑问,Applicative Funtor 能够 apply 屡次(当然包含一次),那么如果函数只有一个参数的状况下,则能够认为 mapapply 是等效的,换句话说:map 相当于 apply 一次。
下面是理论利用中的比照,从形象的数学层面来比照:

  • Functor: 利用一个函数到包裹的值:Box(1).map(x => x+1).
  • Applicative: 利用一个包裹的函数到包裹的值:Box(x => x+1).ap(Box(1))

总结与打算

咱们从纯函数与副作用的概念动手介绍了 LazyBox (惰性求值)的概念,从而引入了把函数这个“非凡的值”装进 Box 中,以及怎么 apply 这个“盒子中的函数”,而后介绍了函数柯里化与利用函子的关系(被装进盒子里的函数必须是柯里化的函数);而后应用应用扩大后的 Either 来做表单校验,解耦合函数,最初介绍了应用 point-free 格调来编写链式调用。

打算

到目前为止,咱们所探讨的问题都是同步的问题,然而在 Javascript 的世界中 90% 的代码都是异步,能够说异步才是 JavaScript 世界的支流,谁能更优雅的解决异步的问题,谁就是 JavaScript 中的大明星,从 callback,到 Promise,再到 async await,那么在函数式编程中异步又该如何解决呢,下一章咱们将会介绍一个重量级的概念 Monad 以及 异步函数的组合
参考资料与援用文章:

  • Functor, Applicative, and Why
  • Applicative and list
  • Functors, Applicatives, And Monads In Pictures
  • Applicative Functors and data validation
  • validation: A data-type like Either but with an accumulating Applicative
  • Understanding Functor and Monad With a Bag of Peanuts
  • How to deal with dirty side effects in your pure functional javascript
  • Functional Programming In JavaScript — With Practical Examples
  • 《JavaScript 函数式编程》

本文公布自 网易云音乐大前端团队,文章未经受权禁止任何模式的转载。咱们长年招收前端、iOS、Android,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!

退出移动版