mostly:functional 第二十七章:Applicative 的法则

a new born baby rest her head on the earth of mother
everything else is outer space.
新生的宝宝将头靠在妈妈的怀中
其余万物,尽属太空之外

-- Mike Birbiglia, The New One


-- 1209

在终於解开那谜题之後,我听到了更大声的低鸣,我甚至感觉到震动并微微的晕眩了一下。是每次解开问题,建筑具现化後就会伴随着地震的现象吗?才闪过这个念头,那本紫书打开了後门飘走。跟着它离开建筑时,我发现那房间的侧面,有一扇标示着 over, 旁边还悬挂着着棱镜的门。我还注意到,虽然刚刚有那麽大的地震,但悬着的棱镜,一点都没有晃动过的样子……但书实在是远到快看不见了,我只好赶快跟上……

书所停驻的这一栋建筑,上面标识着 <*>,就坐落在我们刚离开的那栋与墙的中间。但相较之下,外观的感觉比之前的楼房,及後面的墙都要新上许多,似乎是相当晚近才盖起来的。而且虽然门上有许多痕迹,但气氛上却不知为何比较冷清一点。

当然那本飘在空中的书还是守在门旁,浮现着与之前类似格式的字:

Applicative 的实作:

class Functor f => Applicative (f :: * -> *) where
 pure :: a -> f a
 (<*>) :: f (a -> b) -> f a -> f b
 liftA2 :: (a -> b -> c) -> f a -> f b -> f c
 (*>) :: f a -> f b -> f b
 (<*) :: f a -> f b -> f a
 {-# MINIMAL pure, ((<*>) | liftA2) #-}

Applicative 的法则:

  1. 单位元素
  2. 结合律
  3. 同态
  4. 交换律



Applicative 的概念似乎是许多其它程序语言的使用者开始卡关的点,探究其原因,应该是惰性函式,以及函式组合的手法在其它语言里使用机率较少,因此用这两个概念继续往下延伸的手法想来也就更加乏人问津了。或者用另一个说法,在其它语言里,倾向用别的方式解决类似的问题。

如果传给 fmap 的函式,需要两个参数怎麽办?

要讨论 applicative,要先从 functor 开始。重新复习一下上一章的 fmap,而这次我们的重点,放在第一个引数:传进去的函式上面:

-- Haskell 语法

fmap (\x -> x + 1) [1, 2] -- => [2, 3]
--   ^^^^^^^^^^^^^
--       就是它

我们这次刻意把那个函式展开来并标记。这个位置一般都会用只接收一个参数的函式,因为之後就会用容器里一个个的元素当引数分别呼叫该函式。

那麽我们的问题是,如果我们在那个地方,传进一个需要两个参数的函式,会发生什麽事?

我们先在其它语言上示范一下:

# Elixir 语法
Enum.map([1, 2], fn x, y -> x + y end)

# 错误
# => ** (BadArityError) #Function<43.97283095/2 in :erl_eval.expr/5> with arity 2 called with 1 argument (1)

这个反应基本上想传达的意思是:

你不尊重函式。函式生气了。

原因你可能已经猜出来了,是因为这些语言里的函式,都是急躁的(比较好听的说法是积极执行的)。

注*:如果你试着在 JavaScript 里的 map 里传入需要两个、或三个参数的函式,会得到相当意外的结果。


把惰性求值的函式考虑进来

然而 Haskell 的函式是惰性求值的,所以在正常呼叫函式时,当我们只喂给双参数函式一个引数时,会拿到一个部份应用的函式,重新复习一下:

-- Haskell 语法
f a b = a + b
g = f 1 -- 这是一个还没有饱和的函式,也就是个 *partial application*

那麽当我们对串列 fmap,但是传进去的是需要二个以上参数的函式时,我们会得到的是装在串列里的部份应用函式

-- Haskell 语法
f a b = a + b

fmap f [1, 2] -- => [ (1+), (2+) ]

重新再看一下上面写法的第一行,那个 f,不就只是把中缀的 +,变成前缀的格式而己吗?所以我们其实可以不要第一行的宣告,而把第四行改成这样写:

-- Haskell 语法
fmap (+) [1, 2] -- => [ (1+), (2+) ]

一样,我们拿到了装在串列里的函式

这要怎麽用?

那麽问题来了,这种装在容器里的函式要怎麽使用

在此我们先作弊一下,先从 Maybe 这个比较简单的情况讲起。我们重新对一个 Maybe functor 用需要两个参数的函式 进行 fmap

-- Haskell 语法
fmap (+) $ Just 1 -- => Just (+1)

我们拿到一个装在 Maybe 容器里的部份应用函式。

接下来我们还要假设一个情况,就是我们想要应用 (apply) 到装在 Maybe 里那个函式的参数,也是被装在 Maybe 容器里的,例如说: (Just 2)

那麽我们需要一个可以接收这两种东西,并进行计算的函式:

-- Haskell
(Just (+1)) ??? (Just 2)

而上方 ??? 位置所在的这个函式,就是 applicative 的 <*>,有人把它叫做 apply,也有人叫它 ap

我们再来比较一下这些二元运算的型别:

-- Haskell
($)   ::   (a -> b) ->   a   -> b -- 函式应用
(<$>) ::   (a -> b) -> f a -> f b -- fmap
(<*>) :: f (a -> b) -> f a -> f b -- ap

注意到差别了吗?在最後一行的部份,连一开始的函式都是放在 f 容器里的。

隐含的 monoid 特性

让我们把 <*> 的型别定义用另一个方式排一下:

-- 把型别里,容器外壳跟里面的内容上下错开一点
(<*>) :: f (a -> b) -> f a -> f b

         f             f      f     --- 外壳
           (a -> b)      a      b   --- 内容

如果我们只看内容那一行的话,看起来就是个正常的函式应用。但注意到外壳的部份,我们可以看到我们把两个 f,合并成一个 f。而这个特性,我们在前不久才做过非常类似的事:monoid 的 mappend。不过仔细看的话,在 Applicative 的型别定义里并没有 Monoid (只有少数几个有),因此有些时候,我们得要手动模拟出这个行为。

所以我们想要的是类似这样的东西:

mappend ::  f             f      f
$       ::    (a -> b)      a      b

(<*>) ::    f (a -> b) -> f a -> f b

Maybe 上的 Applicative

我们要来看一下 Maybe 上的 applicative 会怎麽运作了:

-- Haskell 语法
(+) <$> (Just 1) <*> (Just 2) -- => Just 3
(+) <$> (Just 1) <*> Nothing  -- => Nothing
(+) <$> Nothing  <*> (Just 2) -- => Nothing

只要後面任何一步出现 Nothing,其结果就会是 Nothing。而在容器内都有值的情况下,就会用里面的值去应用函式,接着再把结果装回合并再一起的函式里。

这个行为是怎麽实作出来的呢?

-- Haskell 语法

instance Applicative Maybe where
  Nothing  <*> _         = Nothing
  (Just f) <*> something = fmap f something

我们在前面装在容器里的函式是 Nothing 时,回传 Nothing。除此之外,将容器里的函式 f 用 functor 的特性,fmap 到後面的 Maybe 容器里去。

最基本的包装函式: pure

而另外一个需要实作的函式 pure,则是怎麽把任意元素装进这个容器里的方法,用另一种说法,这是一个接收,回传把这个值包在容器里的函式。因此在 Maybe 的例子里,就是 Just,所以完整的实作会长这样:

-- Haskell 语法

instance Applicative Maybe where
  pure = Just               -- 多了这行
  Nothing  <*> _         = Nothing
  (Just f) <*> something = fmap f something

法则

单位元素

当我们用 pure ,把 id 这个函式装到容器里之後,再与任何容器进行 <*>,後面的容器保持不变:

-- Haskell 语法
pure id <*> v -- => v

-- 示范
pure id <*> [1, 2, 3]     -- => [1, 2, 3]
pure id <*> Just "test"   -- => Just "test"
pure id <*> Left "wrong!" -- => Left "wrong!"

注意到用 pure 包装函式之後,这个容器的型别其实还没确定下来,等到要进行 <*> 的时候,就可以推测出要用哪一种容器来装函式了。

结合律

首先我们有两个装着函式的容器,例如 Just fJust g,还有一个装着值的容器比如说 Just x

那麽

先把两个容器里的函式结合,再去应用最後容器里的值

先用中间容器里的函式来应用後面的容器值,再把结果应用到前面容器里的函式

结果会是相同的

但是为了要先结合容器里的函式,所以语法会… 有点奇怪:

pure (.) <*> f <*> g -- 把函式结合这件事也放到容器里,再去 ap 两个容器里的函式

-- 所以
pure (.) <*> f <*> g <*> x
=
f <*> (g <*> x)

-- 示范
pure (.) <*> Just (+10) <*> Just (*3) <*> Just 2 -- => Just 16

Just (+10) <*> (Just <*3> <*> Just 2)            -- => Just 16

同态

感觉有点类似封闭律,但重点在於容器里的函式应用,能保持外壳是相同的这件事。定义上是这麽写的:

pure f <*> pure x = pure (f x)

-- 示范
pure (*2) <*> pure 3
=
pure ((*2) 3)

也就是两个装在容器里的东西 ap 之後,其结果等同於先进行函式应用,再把结果装到容器里

交换律

数学上的加法交换律是这样的:
https://chart.googleapis.com/chart?cht=tx&chl=%201%20%2B%202%20%3D%202%20%2B%201

跟据这条定律,那麽两个容器前後对调,再进行 <*> 结果不变。

但是… 那不是函式应用吗?可以把引数写在前面,函式写在後面吗?

可以的,我们有 $

--- Haskell

pure ($ 2) <*> Just (+10)  -- => Just 12



你知道吗?只要能忽视那些不满的声音(摀耳朵),我觉得串列 [] 根本就是这个城市的万用钥匙。 (丢

[to be continue]


<<:  [Day 27]用Django架构建置专属的LINEBOT吧 - 用LINE进行影片画面边缘侦测处理

>>:  Elastic Stack第二十七重

[Day 30] 总结 Conclusion

好快就过完三十天了,这系列文章也要结束了,还记得第一天的时候,希望可以涵盖一些主题,对应这三十天以来...

Day20 用python写UI-聊聊Combobox

Combobox就是组合框,是tkinter.ttk的空件,所以要先import才可以用。他跟ope...

图的基本介绍 - DAY 19

前言 「图」就是前面所有的大集合体,并从中衍生很多的内容,内容有点超乎想像的多,容小的慢慢写~~ 有...

【Day 26】CSS Animation - CSS 动画资源蒐集与使用教学

by CSS Animations- Guide to Cubic Bezier Curves 关...

夜间模式真的对眼睛比较好吗? 详细整理(上)

重点摘要: 可靠的研究中,没有白底黑底对眼健康的直接支持或反对 蓝光已知影响睡眠周期 对眼睛好要: ...