可以套用函式的 Applicative

February 12, 2022

如果有 Just 10Just 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 5fmap (+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 10Just 30 的這個過程,就可以寫為:

ghci> Just (+) `apply` (Just 10) `apply` (Just 20)
Just 30
ghci> 

透過 apply,就可以使用 Just (+) 中的 + 函式,來對 Just 10Just 5 中的 1020 做套用,得到一個 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 aaApplicative 則可以將二元運算函式,套用至 f af bab,你也可以說,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 fpure 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 的行為就是,可以指定函式,讓 ApplicativeApplicative 可以直接套用函式。

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 1Just [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 的規範。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter