图片起源: 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 中的reducer
,action
拆散的设计理念。
利用函子
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
一个曾经曾经装进 Box
的 x
,是齐全等价的!
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
属性的起因在于须要记录表单校验的错误信息,这个很好了解,而新增的 isLeft
,isRight
属性就更简略了,用来辨别 Left/Right
分支。
咱们认真看一下新增的 ap
办法,先看 Right
分支的 ap: o => o.isLeft ? o : o.map(x)
,毫无疑问 ap
办法接管另一个 functor
,如果另一个 functor
是 Left
的实例,则不须要 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
,所以咱们也能够认为 Applicative
是 Functor
的拓展,比 Functor
更弱小。
那么弱小在何处呢?Functor
只能映射一个接管单个参数的函数 (e.g., x => y
),如果咱们想把接管多个参数的函数(e.g., x => y => z
) 利用到多个值上,则是 Applicative
的舞台了,想想 checkUserInfo
的例子。
毫无疑问,Applicative Funtor 能够
apply
屡次(当然包含一次),那么如果函数只有一个参数的状况下,则能够认为map
和apply
是等效的,换句话说: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!