iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 28
1
Software Development

mostly:functional 從零開始的異世界程式觀 --- 函數式程式設計的試煉系列 第 30

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]


上一篇
mostly:functional 第二十六章的試煉:Functor 的證明
下一篇
mostly:functional 第二十八章:Applicative 的實體
系列文
mostly:functional 從零開始的異世界程式觀 --- 函數式程式設計的試煉35

尚未有邦友留言

立即登入留言