可以映射的 Functor

February 11, 2022

在〈流程抽象〉探討過函數式設計中一些簡單的流程抽象,有些型態具有類似的流程抽象,這類抽象會被進一步抽取出來,定義為型態類別(Typeclass),而型態成為型態類別的實例。

流程抽象經由抽取再抽取,會構成一些看似高深莫測的型態類別,不少初學者認為這些行為難以理解,某些程度上,這是因為沒有先去觀察過具體實作,直接從抽象去理解,才會造成這種誤會。

例如,來看看 Functor

class Functor f where 
    fmap :: (a -> b) -> f a -> f b

喔!如果你第一次接觸 Functor,一定看不懂這啥鬼!還是先來看看 Maybe!你應該還記得它吧!忘了或沒看過的話,先回頭看看〈Maybe 有無、Either 對錯〉。

從 Maybe a 到 Maybe b

函式呼叫結果若會有或沒有值時,可以傳回 Maybe,如果你拿到了一個 Maybe 型態的值,基本上要判斷是 Just somethingNothing 後採取進一步動作,例如:

customerName :: Maybe Int -> Maybe String
customerName (Just orderId)  = Just (customer orderId)
customerName Nothing         = Nothing

上面的 customName 函式可以給個 Maybe Int,其中 Int 是訂單 id,而 customer 會查找對應訂單的客戶名稱;或許你也會想要取得客戶的住址資訊:

customerAddress :: Maybe Int -> Maybe String
customerAddress (Just orderId)  = Just (address orderId)
customerAddress Nothing         = Nothing

顯然地,兩者具有類似的流程,只是當中使用了 customer 或是 address,既然如此,不如定義一個通用的 fmap 函式,讓使用者可以指定 a -> b 型態的函式:

fmap' :: (a -> b) -> Maybe a -> Maybe b
fmap' mapper (Just x) = Just (mapper x) 
fmap' mapper Nothing  = Nothing

那麼,customerNamecustomerAddress 就可以分別改寫如下:

customerName :: Maybe Int -> Maybe String
customerName maybeOrderId = fmap' customer maybeOrderId

customerAddress :: Maybe String -> Maybe String
customerAddress maybeOrderId = fmap' city maybeOrderId

進一步地,可以寫為 Point free 風格:

customerName :: Maybe Int -> Maybe String
customerName = fmap' customer

customerAddress :: Maybe Int -> Maybe String
customerAddress = fmap' city

從 [a] 到 [b]

方才實現的 fmap',可以指定 a -> b 型態的函式,這句話聽起來很熟悉?map 函式不也是如此?還記得 map 的型態嗎?

ghci> :t map
map :: (a -> b) -> [a] -> [b]
ghci>

方才實現的 fmap',型態是 (a -> b) -> Maybe a -> Maybe b,而 map 的型態是 (a -> b) -> [a] -> [b],其實就相當於 (a -> b) -> List a -> List b,兩個函式的型態只差別在一個是 Maybe、一個是 List,而兩個函式的實作一個是針對 Maybe,一個是針對 List

在〈型態類別定義、實作與衍生〉談過,可以將共同行為定義為型態類別,然後由各型態實現行為,既然如此,就來用型態類別定義一個 Functor',其中 MaybeList 的部份未定,那就放個型態參數 f

class Functor' f where 
    fmap :: (a -> b) -> f a -> f b

嗯?這不就像是這篇文件一開始的 Functor 定義嗎?而且,因為已經有 map 函式,要 list 實現 Functor' 的行為,只要令 fmapmap 就可以了:

instance Functor' [] where 
    fmap = map

Haskell 本身就定義了 Functor,list 就是 Functor 的實例,因此可以使用 fmap

ghci> :t fmap
fmap :: Functor f => (a -> b) -> f a -> f b
ghci> fmap show [1, 2, 3] 
["1","2","3"]
ghci>

也就是說,你可以指定 a -> b 函式,以便將 f a 對應至 f b,就上例而言,f 就是 List 罷了。

Functor 的行為

重新來看 Haskell 的 Functor 定義,這邊的 f 是型態參數,f 不是 function 的縮寫,而是代表 Functor 的縮寫:

class Functor f where 
    fmap :: (a -> b) -> f a -> f b

具有行為 Functor 的型態,要實現 fmap 行為,例如,Maybe 若要自行實現 Functor 行為,可以如下:

instance Functor Maybe where 
    fmap :: (a -> b) -> Maybe a -> Maybe b
    fmap mapper (Just x) = Just (mapper x) 
    fmap mapper Nothing  = Nothing

實際上 Maybe 早就是 Haskell 內建 Functor 的實例,因此也可以使用 fmap

ghci> fmap show (Just 10)
Just "10"
ghci>

也就是說,Functor 的實例必須具有的行為是,能接受一個 a -> b 的函式,以便將 f a 對應至 f b,就以上的例子而言,fMaybe

如果將 ListMaybe 看成是值的容器,那麼具有 Functor 行為的型態,其 fmap 實現,可以將容器與值進行映射,成為另一同型態容器與值的組合,像是 Maybe a -> Maybe b,或者是 [a] -> [b]

從 IO a 到 IO b

在 Haskell 中,你可能寫過以下類似的程式:

main = do
    input <- getLine
    let result = (show . (*2) . read) input
    putStrLn result

getLine 的傳回型態是 IO String,因此要將結果綁定到一個名稱,以便傳給其他函式,在這邊其他函式是指 read(*2)show 這些函式,這些函式最後傳回了一個 Int

IO 也是 Functor 的實例喔!在實作行為時的定義是這樣的:

instance Functor IO where 
    fmap f action = do 
        result <- action 
        return (f result) 

也就是說,IO 可以適用 fmap,你可以指定 a -> b 函式,以便從 IO a 對應至 IO b,因此你可以這麼寫:

main = do
    result <- fmap (show . (*2) . read) getLine
    putStrLn result

show . (*2) . read 組成了 String -> Int 的函式,因此 fmap (show . (*2) . read) getLine 的結果,就是將 IO String 對應至 IO Int

因此,如果想將 IO something 的結果綁定到一個名稱,以便傳給其他函式,可以考慮使用 fmap,讓程式看來簡潔一些。

函式提昇

方才談到,fmap 函式的型態是什麼呢?

ghci> :t fmap
fmap :: Functor f => (a -> b) -> f a -> f b
ghci>

如果你部份套用 fmap 的話呢?

ghci> let fmapPlus10 = fmap (+ 10)
ghci> :t fmapPlus10
fmapPlus10 :: (Functor f, Num b) => f b -> f b
ghci>

喔!這可有趣了,你得到了一個 f b -> f b 的函式,f 必須是 Functor,像是 MaybeList

ghci> fmapPlus10 (Just 10)
Just 20
ghci> fmapPlus10 [10, 20, 30]
[20,30,40]
ghci>

來看看另一個例子:

ghci> let fmapShow = fmap show
ghci> :t fmapShow             
fmapShow :: (Functor f, Show a) => f a -> f String
ghci> fmapShow (Just 100)     
Just "100"
ghci> fmapShow [100, 200, 300]
["100","200","300"]
ghci>

fmap 部份套用 a -> b 函式,就像是給 fmap 一個 a -> b 函式,傳回一個 f a -> f b 的函式,就像是將函式 a -> b 提昇(lift)為 f a -> f b

Functor 定律

Functor 在實作 fmap 時有必須遵合的定律,別被定律這兩個字嚇到,其實就是實作規範,這在 Data.Functor 的文件中也有定義:

fmap id  ==  id
fmap (f . g)  ==  fmap f . fmap g

以上兩條分別對應的陳述就是:

  1. fmap 該做的事,就只是按照指定的函式做對應,不做多餘的事,因此,對 fmap 指定 id 恒等函式,傳回的函式在進行 Functor 的對應,其結果應該與對 Functor 執行 id 相同。
  2. fmap 指定的函式,若為數個函式合成,那對 Functor 的執行結果,應與數個函式分別進行 fmap 相同。

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