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 的法則:
- 單位元素
- 結合律
- 同態
- 交換律
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
容器裡的。
讓我們把 <*>
的型別定義用另一個方式排一下:
-- 把型別裡,容器外殼跟裡面的內容上下錯開一點
(<*>) :: 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 會怎麼運作了:
-- 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 f
與 Just 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
之後,其結果等同於先進行函式應用,再把結果裝到容器裡。
數學上的加法交換律是這樣的:
跟據這條定律,那麼兩個容器前後對調,再進行 <*>
結果不變。
但是… 那不是函式應用嗎?可以把引數寫在前面,函式寫在後面嗎?
可以的,我們有 $
。
--- Haskell
pure ($ 2) <*> Just (+10) -- => Just 12
你知道嗎?只要能忽視那些不滿的聲音(摀耳朵),我覺得串列 []
根本就是這個城市的萬用鑰匙。 (丟
[to be continue]