第 10 章: Applicative Functor
第 10 章:Applicative Functor
应用 applicative functor
考虑到其函数式的出身,applicative functor 这个名称堪称简单明了。函数式程序员最为人诟病的一点就是,总喜欢搞一些稀奇古怪的命名,比如 mappend
或者 liftA4
。诚然,此类名称出现在数学实验室是再自然不过的,但是放在其他任何语境下,这些概念就都像是扮作达斯维达去汽车餐馆搞怪的人。(译者注:此处需要做些解释,1. 汽车餐馆(drive-thru)指的是那种不需要顾客下车就能提供服务的地方,比如麦当劳、星巴克等就会有这种 drive-thru;2. 达斯维达(Darth Vader)是《星球大战》系列主要反派角色,在美国大众文化中的有着广泛的影响力,其造型是很多人致敬模仿的对象;3. 由于 2 的缘故,美国一些星战迷会扮作 Darth Vader 去 drive-thru 点单,YouTube 上有不少这种搞怪视频;4. 作者使用这个“典故”是为了说明函数式里很多概念的名称有些“故弄玄虚”,而 applicative functor 是少数比较“正常”的。)
无论如何,applicative 这个名字应该能够向我们表明一些事实,告诉我们作为一个接口,它能为我们带来什么:那就是让不同 functor 可以相互应用(apply)的能力。
然而,你可能会问了,为何一个正常的、理性的人,比如你自己,会做这种“让不同 functor 相互应用”的事?而且,“相互应用”到底是什么意思?
要回答这些问题,我们可以从下面这个场景讲起,可能你已经碰到过这种场景了。假设有两个同类型的 functor,我们想把这两者作为一个函数的两个参数传递过去来调用这个函数。简单的例子比如让两个 Container
的值相加:
这时候我们创建了一个 Container
,它内部的值是一个局部调用的(partially applied)的函数。确切点讲就是,我们想让 Container(add(2))
中的 add(2)
应用到 Container(3)
中的 3
上来完成调用。也就是说,我们想把一个 functor 应用到另一个上。
巧的是,完成这种任务的工具已经存在了,即 chain
函数。我们可以先 chain
然后再 map
那个局部调用的 add(2)
,就像这样:
只不过,这种方式有一个问题,那就是 monad 的顺序执行问题:所有的代码都只会在前一个 monad 执行完毕之后才执行。想想看,我们的这两个值足够强健且相互独立,如果仅仅为了满足 monad 的顺序要求而延迟 Container(3)
的创建,我觉得是非常没有必要的。
事实上,当遇到这种问题的时候,要是能够无需借助这些不必要的函数和变量,以一种简明扼要的方式把一个 functor 的值应用到另一个上去就好了。
瓶中之船
ap
就是这样一种函数,能够把一个 functor 的函数值应用到另一个 functor 的值上。把这句话快速地说上 5 遍。
这样就大功告成了,而且代码干净整洁。可以看到,Container(3)
从嵌套的 monad 函数的牢笼中释放了出来。需要再次强调的是,本例中的 add
是被 map
所局部调用(partially apply)的,所以 add
必须是一个 curry 函数。
可以这样定义一个 ap
函数:
记住,this.__value
是一个函数,将会接收另一个 functor 作为参数,所以我们只需 map
它。由此我们可以得出 applicative functor 的定义:
applicative functor 是实现了
ap
方法的 pointed functor
注意 pointed
这个前提,这是非常重要的一个前提,下面的例子会说明这一点。
讲到这里,我已经感受到你的疑虑了(也或者是困惑和恐惧);心态开放点嘛,ap
还是很有用的。在深入理解这个概念之前,我们先来探索一个特性。
这行代码翻译成人类语言就是,map 一个 f
等价于 ap
一个值为 f
的 functor。或者更好的译法是,你既可以把 x
放到容器里然后调用 map(f)
,也可以同时让 f
和 x
发生 lift(参看第 8 章),然后对他们调用 ap
。这让我们能够以一种从左到右的方式编写代码:
细心的读者可能发现了,上述代码中隐约有普通函数调用的影子。没关系,我们稍后会学习 ap
的 pointfree 版本;暂时先把这当作此类代码的推荐写法。通过使用 of
,每一个值都被输送到了各个容器里的奇幻之地,就像是在另一个平行世界里,每个程序都可以是异步的或者是 null 或者随便什么值,而且不管是什么,ap
都能在这个平行世界里针对这些值应用各种各样的函数。这就像是在一个瓶子中造船。
你注意到没?上例中我们使用了 Task
,这是 applicative functor 主要的用武之地。现在我们来看一个更深入的例子。
协调与激励
假设我们要创建一个旅游网站,既需要获取游客目的地的列表,还需要获取地方事件的列表。这两个请求就是相互独立的 api 调用。
两个请求将会同时立即执行,当两者的响应都返回之后,renderPage
就会被调用。这与 monad 版本的那种必须等待前一个任务完成才能继续执行后面的操作完全不同。本来我们就无需根据目的地来获取事件,因此也就不需要依赖顺序执行。
再次强调,因为我们是使用局部调用的函数来达成上述结果的,所以必须要保证 renderpage
是 curry 函数,否则它就不会一直等到两个 Task
都完成。而且如果你碰巧自己做过类似的事,那你一定会感激 applicative functor
这个异常简洁的接口的。这就是那种能够让我们离“奇点”(singularity)更近一步的优美代码。
再来看另外一个例子。
signIn
是一个接收 3 个参数的 curry 函数,因此我们需要调用 ap
3 次。在每一次的 ap
调用中,signIn
就收到一个参数然后运行,直到所有的参数都传进来,它也就执行完毕了。我们可以继续扩展这种模式,处理任意多的参数。另外,左边两个参数在使用 getVal
调用后自然而然地成为了一个 IO
,但是最右边的那个却需要手动 lift
,然后变成一个 IO
,这是因为 ap
需要调用者及其参数都属于同一类型。
lift
(译者注:此处原标题是“Bro, do you even lift?”,是一流行语,发源于健身圈,指质疑别人的健身方式和效果并显示优越感,后扩散至其他领域。再注:作者书中用了不少此类俚语或俗语,有时并非在使用俚语的本意,就像这句,完全就是为了好玩。另,关于 lift 的概念可参看第 8 章。)
我们来试试以一种 pointfree 的方式调用 applicative functor。因为 map
等价于 of/ap
,那么我们就可以定义无数个能够 ap
通用函数。
liftA2
是个奇怪的名字,听起来像是破败工厂里挑剔的货运电梯,或者伪豪华汽车公司的个性车牌。不过你要是真正理解了,那么它的含义也就不证自明了:让那些小代码块发生 lift,成为 applicative functor 中的一员。
刚开始我也觉得这种 2-3-4 的写法没什么意义,看起来又丑又没有必要,毕竟我们可以在 JavaScript 中检查函数的参数数量然后再动态地构造这样的函数。不过,局部调用(partially apply)liftA(N)
本身,有时也能发挥它的用处,这样的话,参数数量就固定了。
来看看实际用例:
createUser
接收两个参数,因此我们使用的是 liftA2
。上述两个语句是等价的,但是使用了 liftA2
的版本没有提到 Either
,这就使得它更加通用灵活,因为不必与特定的数据类型耦合在一起。
我们试试以这种方式重写前一个例子:
操作符
在 haskell、scala、PureScript 以及 swift 等语言中,开发者可以创建自定义的中缀操作符(infix operators),所以你能看到到这样的语法:
<$>
就是 map
(亦即 fmap
),<*>
不过就是 ap
。这样的语法使得开发者可以以一种更自然的风格来书写函数式应用,而且也能减少一些括号。
免费开瓶器
我们尚未对衍生函数(derived function)着墨过多。不过看到本书介绍的所有这些接口都互相依赖并遵守一些定律,那么我们就可以根据一些强接口来定义一些弱接口了。
比如,我们知道一个 applicative 首先是一个 functor,所以如果已经有一个 applicative 实例的话,毫无疑问可以依此定义一个 functor。
这种完美的计算上的大和谐(computational harmony)之所以存在,是因为我们在跟一个数学“框架”打交道。哪怕是莫扎特在小时候就下载了 ableton(译者注:一款专业的音乐制作软件),他的钢琴也不可能弹得更好。
前面提到过,of/ap
等价于 map
,那么我们就可以利用这点来定义 map
:
monad 可以说是处在食物链的顶端,因此如果已经有了一个 chain
函数,那么就可以免费得到 functor 和 applicative:
定义一个 monad,就既能得到 applicative 也能得到 functor。这一点非常强大,相当于这些“开瓶器”全都是免费的!我们甚至可以审查一个数据类型,然后自动化这个过程。
应该要指出来的一点是,ap
的魅力有一部分就来自于并行的能力,所以通过 chain
来定义它就失去了这种优化。即便如此,开发者在设计出最佳实现的过程中就能有一个立即可用的接口,也是很好的。
为啥不直接使用 monad?因为最好用合适的力量来解决合适的问题,一分不多,一分不少。这样就能通过排除可能的功能性来做到最小化认知负荷。因为这个原因,相比 monad,我们更倾向于使用 applicative。
向下的嵌套结构使得 monad 拥有串行计算、变量赋值和暂缓后续执行等独特的能力。不过见识到 applicative 的实际用例之后,你就不必再考虑上面这些问题了。
下面,来看看理论知识。
定律
就像我们探索过的其他数学结构一样,我们在日常编码中也依赖 applicative functor 一些有用的特性。首先,你应该知道 applicative functor 是“组合关闭”(closed under composition)的,意味着 ap
永远不会改变容器类型(另一个胜过 monad 的原因)。这并不是说我们无法拥有多种不同的作用——我们还是可以把不同的类型压栈的,只不过我们知道它们将会在整个应用的过程中保持不变。
下面的例子可以说明这一点:
你看,不必担心不同的类型会混合在一起。
该去看看我们最喜欢的范畴学定律了:同一律(identity)。
同一律(identity)
是的,对一个 functor 应用 id
函数不会改变 v
里的值。比如:
Identity.of(id)
的“无用性”让我不禁莞尔。这里有意思的一点是,就像我们之前证明了的,of/ap
等价于 map
,因此这个同一律遵循的是 functor 的同一律:map(id) == id
。
使用这些定律的优美之处在于,就像一个富有激情的幼儿园健身教练让所有的小朋友都能愉快地一块玩耍一样,它们能够强迫所有的接口都能完美结合。
同态(homomorphism)
同态就是一个能够保持结构的映射(structure preserving map)。实际上,functor 就是一个在不同范畴间的同态,因为 functor 在经过映射之后保持了原始范畴的结构。
事实上,我们不过是把普通的函数和值放进了一个容器,然后在里面进行各种计算。所以,不管是把所有的计算都放在容器里(等式左边),还是先在外面进行计算然后再放到容器里(等式右边),其结果都是一样的。
一个简单例子:
互换(interchange)
互换(interchange)表明的是选择让函数在 ap
的左边还是右边发生 lift 是无关紧要的。
这里有个例子:
组合(composition)
最后是组合。组合不过是在检查标准的函数组合是否适用于容器内部的函数调用。
总结
处理多个 functor 作为参数的情况,是 applicative functor 一个非常好的应用场景。借助 applicative functor,我们能够在 functor 的世界里调用函数。尽管已经可以通过 monad 达到这个目的,但在不需要 monad 的特定功能的时候,我们还是更倾向于使用 applicative functor。
至此我们已经基本介绍完容器的 api 了,我们学会了如何对函数调用 map
、chain
和 ap
。下一章,我们将学习如何更好地处理多个 functor,以及如何以一种原则性的方式拆解它们。
Chapter 11: Transformation Again, Naturally
练习
Last updated