可以套用函式的 Applicative
February 12, 2022如果有 Just 10
與 Just 5
,若希望對它們相加得到 Just 15
,直接 (Just 10) + (Just 5)
是行不通的,也許可以定義一個 add
函式來解決這個問題:
add :: Num a => Maybe a -> Maybe a -> Maybe a
add (Just a) (Just b) = Just (a + b)
add Nothing _ = Nothing
add _ Nothing = Nothing
現在可以 add (Just 10) (Just 5)
來達到需求,不過,如果需要 (Just 10) * (Just 5)
得到 Just 50
的效果呢?其他像是 list 也有這種需求呢?像是希望能 add ["Justin", "Monica", "Irene"] ["Happy", "Lucky", "Healthy"]
而得到 ["JustinHappy", "JustinLucky", "JustinHealthy", "MonicaHappy", "MonicaLucky", "MonicaHealthy", "IreneHappy", "IreneLucky", "IreneHealthy"]
呢?
也就是說,我們希望將 add (Just 10) (Just 5)
這類的操作通用化!
從 Maybe 開始
先思考一下,+
、-
、*
、/
這類函式,它們的型態是 Num a => a -> a -> a
,也就是接受兩個引數後傳回一個值;然而從另一個度來看,也可以看成是 Num a => a -> (a -> a)
,也就是接受一個引數傳回一個函式,因此可以將 (+10)
、(-5)
這些函式當作引數,傳給另一個函式。
之前的 add
函式接受兩個 Maybe a
,傳回一個 Maybe a
,從另一角度來看,是接受一個 Maybe a
,傳回一個 Maybe a -> Maybe a
的函式,這感覺像是〈可以映射的 Functor〉中 fmap
做的事,如果有個 Just 5
,fmap (+10) (Just 5)
會得到 Just 10
,此時 fmap (+10)
是 f b -> f b
的函式:
ghci> let fPlus10 = fmap (+10)
ghci> :t fPlus10
fPlus10 :: (Functor f, Num b) => f b -> f b
ghci>
如何不直接寫死 10
這個數字呢?fmap
的型態 (a -> b) -> f a -> f b
,第一個參數接受一個 (a -> b)
的函式,那可以傳入 +
、-
、*
、/
這類的函式嗎?可以!+
、-
、*
、/
函式,型態是 a -> a -> a
,然而也可以看成是 a -> (a -> a)
,因此 fmap
可以接受 +
、-
、*
、/
這類函式,例如:
ghci> let fPlus = fmap (+)
ghci> :t fPlus
fPlus :: (Functor f, Num a) => f a -> f (a -> a)
ghci>
fmap (+)
是 f a -> f (a -> a)
型態,如果 fmap (+) (Just 10)
,會得到 Maybe (a -> a)
,也就是 Maybe
包含了 a -> a
的函式,也就是 (+ 10)
函式:
ghci> let Just func = fmap (+) (Just 10)
ghci> :t func
func :: Num a => a -> a
ghci> func 20
30
ghci>
既然如此,fmap
不就可以使用這個 func
,將一個 Maybe
映射為另一個 Maybe
嗎?
ghci> fmap func (Just 20)
Just 30
ghci>
將以上流程合在一起就是:
ghci> let Just func = fmap (+) (Just 10)
ghci> fmap func (Just 20)
Just 30
ghci>
若不想寫死 +
,可以使用變數 operator
:
ghci> let Just operator = Just (+)
ghci> let Just func = fmap operator (Just 10)
ghci> fmap func (Just 20)
Just 30
ghci>
這就出現了一個重複的流程,將 Just
中的函式取出,作為 fmap
第一個引數,而 Just something
就是第二個引數,來將這個流程定義為一個函式,並考慮 Nothing
的情況:
apply :: Maybe (a -> b) -> Maybe a -> Maybe b
apply (Just func) something = fmap func something
apply Nothing _ = Nothing
也可以寫成 Point free 風格:
apply :: Maybe (a -> b) -> Maybe a -> Maybe b
apply (Just func) = fmap func
apply Nothing _ = Nothing
現在有了 apply
函式,將 Just
中的函式取出,直接當作 fmap
的第一個引數,而 Just something
就是第二個引數的這個流程就可以重複使用了,因此上頭從 Just (+)
、Just 10
到 Just 30
的這個過程,就可以寫為:
ghci> Just (+) `apply` (Just 10) `apply` (Just 20)
Just 30
ghci>
透過 apply
,就可以使用 Just (+)
中的 +
函式,來對 Just 10
、Just 5
中的 10
與 20
做套用,得到一個 Just 30
,現在也可以使用 ++
等其他的函式了,例如:
ghci> Just (++) `apply` (Just "123") `apply` (Just "456")
Just "123456"
ghci>
Maybe 的 Applicative 行為
現在,apply
函式對 Maybe
夠通用了,如果將 apply
等行為抽取出來,定義為型態類別,然後將 Maybe
型態參數化呢?這麼一來,就可以是 f (a -> b) -> f a -> f b
,其中 f 是型態參數,可以是 Maybe
等實際型態。
Haskell 的 Control.Applicative
模組定義的 Applicative
就是如此定義:
class (Functor f) => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
Applicative
必須要有 Functor
的行為,這不難理解,畢竟方才就是從 fmap
逐一推導,衍生出 Applicative
的行為,在實作 Applicative
,確實也需要 fmap
的行為。
從另一個角度來看,Functor
就像將一元運算函式,套用至 f a
的 a
,Applicative
則可以將二元運算函式,套用至 f a
、f b
的 a
與 b
,你也可以說,Applicative
就是加強版的 Functor
。
pure
可以將 a
對應至 f a
,對於 Applicative
來說,目的是用來將 a -> b
對應至 f (a -> b)
,也就是將指定的函式置入與 Functor
相同的環境(context),這麼一來你就可以單純地對 Functor
的內含值,套用 a -> b
。
例如,Maybe
就實現了 Applicative
的行為:
instance Applicative Maybe where
pure = Just
(Just f) <*> something = fmap f something
Nothing <*> _ = Nothing
<*>
在實現時,其實以上頭的 apply
是一樣的,因此 Maybe
可以如下套用運算:
ghci> import Control.Applicative
ghci> pure (+) <*> Just 10 <*> Just 20
Just 30
ghci> pure (++) <*> Just "123" <*> Just "456"
Just "123456"
ghci>
因為 (Just f) <*>
等於 fmap f
,pure func <*>
等於 fmap func
:
ghci> pure (+) <*> Just 10 <*> Just 20
Just 30
ghci> (fmap (+)) (Just 10) <*> (Just 20)
Just 30
ghci> (+) `fmap` (Just 10) <*> (Just 20)
Just 30
ghci>
最後一個 fmap
的示範是中序形式,在 Control.Applicative
有個 <$>
函式,它就是中序版本的 fmap
:
(<$>) :: (Functor f) => (a -> b) -> f a -> f b
f <$> x = fmap f x
一開始的需求是想做出像 add (Just 10) (Just 5)
這類的動作,而 (+) <$> Just 10 <*> Just 5
看來就是我們想要的,儘管中間多了 <$>
、<*>
等函式:
ghci> (+) <$> (Just 10) <*> (Just 20)
Just 30
ghci>
Applicative
的行為就是,可以指定函式,讓 Applicative
與 Applicative
可以直接套用函式。
List 的 Applicative 行為
記得上面也希望 add ["Justin", "Monica", "Irene"] ["Happy", "Lucky", "Healthy"]
而得到 ["JustinHappy", "JustinLucky", "JustinHealthy", "MonicaHappy", "MonicaLucky", "MonicaHealthy", "IreneHappy", "IreneLucky", "IreneHealthy"]
嗎?你可以這麼做:
ghci> pure (++) <*> ["Justin", "Monica", "Irene"] <*> ["Happy", "Lucky", "Healthy"]
["JustinHappy","JustinLucky","JustinHealthy","MonicaHappy","MonicaLucky","MonicaHealthy","IreneHappy","IreneLucky","IreneHealthy"]
ghci> (++) <$> ["Justin", "Monica", "Irene"] <*> ["Happy", "Lucky", "Healthy"]
["JustinHappy","JustinLucky","JustinHealthy","MonicaHappy","MonicaLucky","MonicaHealthy","IreneHappy","IreneLucky","IreneHealthy"]
ghci>
這是因為 list 也實現了 Applicative
的行為:
instance Applicative [] where
pure x = [x]
fs <*> xs = [f x | f <- fs, x <- xs]
只不過 pure (++) <*> ["Justin", "Monica", "Irene"] <*> ["Happy", "Lucky", "Healthy"]
到底做了什麼呢?可以先來看看 pure (++) <*> ["Justin", "Monica", "Irene"]
會是什麼結果:
ghci> let fs = pure (++) <*> ["Justin", "Monica", "Irene"]
ghci> :t fs
fs :: [[Char] -> [Char]]
ghci> fs <*> ["Happy", "Lucky", "Healthy"]
["JustinHappy","JustinLucky","JustinHealthy","MonicaHappy","MonicaLucky","MonicaHealthy","IreneHappy","IreneLucky","IreneHealthy"]
ghci> [("Justin" ++), ("Monica" ++), ("Irene" ++)] <*> ["Happy", "Lucky", "Healthy"]
["JustinHappy","JustinLucky","JustinHealthy","MonicaHappy","MonicaLucky","MonicaHealthy","IreneHappy","IreneLucky","IreneHealthy"]
ghci>
pure (++) <*> ["Justin", "Monica", "Irene"]
的結果是個 list,型態是 [[Char] -> [Char]]
,也就是一個裝著函式的 list,也就是 [("Justin" ++), ("Monica" ++), ("Irene" ++)]
,既然如此,如上直接給 <*>
一個 [("Justin" ++), ("Monica" ++), ("Irene" ++)]
也可以,而這感覺有點像 Comprehension 表示呢!
ghci> [f s| f <- [("Justin" ++), ("Monica" ++), ("Irene" ++)], s <- ["Happy", "Lucky", "Healthy"]]
["JustinHappy","JustinLucky","JustinHealthy","MonicaHappy","MonicaLucky","MonicaHealthy","IreneHappy","IreneLucky","IreneHealthy"]
ghci>
別忘了,多參數函式是由單參數函式組成,可以部份套用,因此也可以這麼做:
ghci> (++) <$> ["Justin", "Monica", "Irene"] <*> ["Happy", "Lucky", "Healthy"]
["JustinHappy","JustinLucky","JustinHealthy","MonicaHappy","MonicaLucky","MonicaHealthy","IreneHappy","IreneLucky","IreneHealthy"]
ghci>
這就相當於以下的 Comprehension 表示:
ghci> [name ++ state | name <- ["Justin", "Monica", "Irene"], state <- ["Happy", "Lucky", "Healthy"]]
["JustinHappy","JustinLucky","JustinHealthy","MonicaHappy","MonicaLucky","MonicaHealthy","IreneHappy","IreneLucky","IreneHealthy"]
ghci>
也就是說,list 在實現 Applicative
的行為時,其實就是實現了 list 的 Comprehension 表示式運算,可讀性見人見智,如果你(或共事的其他人)不知道 Applicative
隱藏了什麼,或看不懂 <$>
與 <*>
是啥,Comprehension 表示式也許會比較好懂。
IO 的 Applicative 行為
IO
會是個 Applicative
嗎?想想看,如果有兩個 IO String
,像是從兩個檔案分別讀入了內容,或許你會想將兩個串接在一起,得到一個 IO String
,例如:
readFiles :: FilePath -> FilePath -> IO [Char]
readFiles file1 file2 = do
content1 <- readFile file1
content2 <- readFile file2
return (content1 ++ content2)
若 ++
是可以指定的函式,套用至兩個 IO String
,得到一個 IO String
,看來像是 Applicative
的行為,IO
確實是 Applicative
的實例:
instance Applicative IO where
pure = return
a <*> b = do
f <- a
x <- b
return (f x)
pure
的實現就是 return
,這會將指定的函式置入 IO
,要將 IO
結果使用指定函式處理,就可以使用這種風格,因此方才的 readFiles
函式可以更簡潔地實現為:
import Control.Applicative
readFiles :: FilePath -> FilePath -> IO [Char]
readFiles file1 file2 = (++) <$> readFile file1 <*> readFile file2
活用 Applicative
如果你定義了一個 doubleMe
函式:
doubleMe :: Num a => a -> a
doubleMe n = n * 2
如果 doubleMe 10
就會是 20,這沒問題,如果有個 Just 10
呢?別急著定義新函式,因為 Maybe
是個 Applicative
,就只要 pure doubleMe <*> Just 10
,得到一個 Just 20,如果知道 <$>
的話,那麼寫成 doubleMe <$> Just 10
會更容易閱讀。
多參數函式呢?例如定義一個 addThreeNumber
:
addThreeNumber :: Num a => a -> a -> a -> a
addThreeNumber a b c = a + b + c
addThreeNumber 1 2 3
當然沒問題,如果想要令 addThreeNumber
適用於 Maybe
,可以寫 addThreeNumber <$> Just 1 <*> Just 2 <*> Just 3
。
如果有個 [1, 2, 3]
,實際上它是 1:2:3:[]
,如果有個 [2, 3]
,你可以使用 1:[2, 3]
得到一個 [1, 2, 3]
,如果有個 Just 1
與 Just [2, 3]
呢?你可以這麼做:
ghci> (:) <$> Just 1 <*> Just [2, 3]
Just [1,2,3]
ghci>
如果有個 '*' : "Justin"
,那就會得到一個 "*Justin"
,別老舉 Maybe
為例好了,如果有個 IO Char
,想要與 getLine
傳回的 IO String
直接使用 :
得到一個 IO String
呢?
ghci> text <- (:) <$> return '*' <*> getLine
Justin
ghci> text
"*Justin"
ghci>
基本的函式可以套用在 Applicative
上,那麼一個函式被部份套用後,傳回一個函式自然也可以套用在 Applicative 上囉!
ghci> (+3) 10
13
ghci> (+3) <$> Just 10
Just 13
ghci> filter (>3) [1, 2, 3, 4, 5, 6]
[4,5,6]
ghci> filter (>3) <$> Just [1, 2, 3, 4, 5, 6]
Just [4,5,6]
ghci>
有 Lambda 的情況呢?
ghci> map (\x -> abs x + 10) [1, -2, 3, -4]
[11,12,13,14]
ghci> map (\x -> abs x + 10) <$> Just [1, -2, 3, -4]
Just [11,12,13,14]
ghci>
函式合成自然也就沒問題:
ghci> (show . abs . sum) [1, -2, 3, -4]
"2"
ghci> (show . abs . sum) <$> Just [1, -2, 3, -4]
Just "2"
ghci>
函式合成的意思就是,像 (abs . sum) [1, -2, 3, -4]
,結果與 abs (sum [1, -2, 3, -4])
相同,而 .
不過也只是一個函式,因此 (abs . sum) [1, -2, 3, -4]
也可以寫為 (.) abs sum [1, -2, 3, -4]
,也就是說,(.) abs sum [1, -2, 3, -4]
的結果會與 abs (sum [1, -2, 3, -4])
,進一步應用在 Applicative
就是:
ghci> (.) <$> Just abs <*> Just sum <*> Just [1, -2, 3, -4]
Just 2
ghci> abs <$> (sum <$> Just [1, -2, 3, -4])
Just 2
ghci>
Applicative 定律
實現 Applicative
的行為時,有其應遵守的規範,這可以在 Control.Applicative 的文件中找到:
- Identity:
pure id <*> v = v
- Composition:
pure (.) <*> u <*> v <*> w = u <*> (v <*> w)
- Homomorphism:
pure f <*> pure x = pure (f x)
- Interchange:
u <*> pure y = pure ($ y) <*> u
既然某函式原本能對 Applicative
中的值做套用,該函式就能用來對 Applicative
做套用,那麼從這個角度來思考一個 Applicative
在實作時,應當遵守的 Applicative
定律也就不難理解,就像方才最後的函式合成例子,就是在示範 Maybe Applicative
符合定律中 Composition 的規範。