Haskell/應用函子
在介紹重要的 Functor 和 Monad 型別類時,我們略過了第三個型別類:Applicative,即應用函子的類。與單子一樣,應用函子是帶有額外定律和操作的函子;事實上,Applicative 是 Functor 和 Monad 之間的中間類。Applicative 是一個廣泛使用的類,擁有豐富的應用。它使同名的應用風格成為可能,這是一種構建函子計算的便捷方式,並且還提供了表達多種重要模式的方法。
我們將從快速回顧函子類章節開始。Functor 的特徵是 fmap 函式
class Functor f where
fmap :: (a -> b) -> f a -> f b
如果一個型別是 Functor 的例項,您可以使用 fmap 將函式應用於其中的值。描述 fmap 的另一種方式是說它提升函式以作用於函子值。為了確保 fmap 正常工作,Functor 的任何例項都必須符合以下兩條定律
fmap id = id -- 1st functor law
fmap (g . f) = fmap g . fmap f -- 2nd functor law
例如,Maybe 具有 Functor 例項,因此我們可以輕鬆地修改其內部的值...
Prelude> fmap negate (Just 2)
Just (-2)
...當然,只要它存在。
Prelude> fmap negate Nothing
Nothing
fmap 有一箇中綴同義詞,(<$>)。它通常有助於可讀性,並且還表明如何將 fmap 看作一種不同型別的函式應用。
Prelude> negate <$> Just 2
Just (-2)
| 練習 |
|---|
|
為以下型別定義
|
fmap 雖然有用,但如果我們想將一個有兩個引數的函式應用於函子值,它就無能為力了。例如,我們如何將 Just 2 和 Just 3 相加?蠻力方法是將值從 Maybe 包裝器中提取出來。但是,這意味著必須對 Nothing 進行繁瑣的檢查。更糟糕的是:在不同的 Functor 中,提取值甚至可能不是一種選擇(想想 IO)。
我們可以使用 fmap 將 (+) 部分應用於第一個引數
Prelude> :t (+) <$> Just 2
(+) <$> Just 2 :: Num a => Maybe (a -> a)
但現在我們被卡住了:我們有一個函式和一個值都封裝在 Maybe 中,並且無法將一個應用於另一個。我們能最接近期望的 Just 5 的方法可能是這個
Prelude> (<$> Just 3) <$> ((+) <$> Just 2)
Just (Just 5)
(注意 (+) <$> Just 2 是 Just (2+))。
我們想要的是一個型別類似於 f (a -> b) -> f a -> f b 的運算子,以便在函子的上下文中應用函式。如果該運算子被稱為 (<*>),我們就可以寫
(+) <$> Just 2 <*> Just 3
瞧,這有效!
Prelude> (+) <$> Just 2 <*> Just 3
Just 5
(<*>) 的型別是
Prelude> :t (<*>)
(<*>) :: Applicative f => f (a -> b) -> f a -> f b
(<*>) 是 Applicative 的方法之一,Applicative 是應用函子的型別類 - 支援在其上下文中應用函式的函子。諸如 (+) <$> Just 2 <*> Just 3 之類的表示式被稱為以應用風格編寫,這在我們使用函子工作時最接近常規函式應用。如果你暫時假裝 (<$>)、(<*>) 和 Just 不存在,我們的示例看起來就像 (+) 2 3。
Applicative 的定義是
class (Functor f) => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
除了 (<*>) 之外,該類還具有第二個方法 pure,它將任意值帶入函子。例如,讓我們看一下 Maybe 例項
instance Applicative Maybe where
pure = Just
(Just f) <*> (Just x) = Just (f x)
_ <*> _ = Nothing
它沒有做任何令人驚訝的事情:pure 使用 Just 包裝值;(<*>) 將函式應用於值(如果兩者都存在),否則會產生 Nothing。
注意
由於缺乏更好的簡寫,在下文中,我們將使用術語態射來指代位於 (<*>) 左側的值,它們符合型別 Applicative f => f (a -> b);也就是說,插入應用函子的類函式的東西。“態射”是一個來自範疇論的術語,它有更廣泛的含義,但我們現在不必關心它。
就像 Functor 一樣,Applicative 也有一組合理的例項應該遵循的定律。它們是
pure id <*> v = v -- Identity
pure f <*> pure x = pure (f x) -- Homomorphism
u <*> pure y = pure ($ y) <*> u -- Interchange
pure (.) <*> u <*> v <*> w = u <*> (v <*> w) -- Composition
這些定律有點拗口。如果你將 pure 視為以預設的、無特徵的方式將值注入函子的方法,這樣結果儘可能接近普通值,那麼它們就更容易理解了。因此
- 恆等定律指出,應用
pure id態射不會做任何事情,就像使用普通的id函式一樣。 - 同態定律指出,將“純”函式應用於“純”值與以正常方式將函式應用於值,然後對結果使用
pure相同。從某種意義上說,這意味著pure保留了函式應用。 - 交換定律指出,將態射應用於“純”值
pure y與將pure ($ y)應用於態射相同。這沒什麼奇怪的 - 正如我們在高階函式章節中所見,($ y)是一個函式,它將y作為引數提供給另一個函式。 - 組合律指出,
pure (.)對態射的組合方式類似於(.)對函式的組合方式:將組合態射pure (.) <*> u <*> v應用於w,所得結果與先將v應用於w,再將u應用於結果相同。[1]
關於 fmap 和 (<*>) 之間的關係,還有一個額外定律。
fmap f x = pure f <*> x -- fmap
使用 (<*>) 應用一個 “純” 函式,等同於使用 fmap。這個定律是其他定律的推論,所以在編寫 Applicative 的例項時,無需費心證明它。
| 練習 |
|---|
|
似曾相識
[edit | edit source]pure 是否讓你想起了什麼?
pure :: Applicative f => a -> f a
它與……
return :: Monad m => a -> m a
… 的唯一區別是類約束。pure 和 return 具有相同的目的,即把值帶入函子。這種不可思議的相似之處並沒有就此停止。在關於 State 的章節中,我們提到了一個名為 ap 的函式……
ap :: (Monad m) => m (a -> b) -> m a -> m b
… 它可以用來讓處理具有多個引數的函式在單子程式碼中變得不那麼痛苦。
allTypes :: GeneratorState (Int, Float, Char, Integer, Double, Bool, Int)
allTypes = liftM (,,,,,,) getRandom
`ap` getRandom
`ap` getRandom
`ap` getRandom
`ap` getRandom
`ap` getRandom
`ap` getRandom
ap 看起來很像 (<*>)。
當然,這些都不是巧合。Monad 繼承自 Applicative …
Prelude> :info Monad
class Applicative m => Monad (m :: * -> *) where
--etc.
… 因為 return 和 (>>=) 足以實現 pure 和 (<*>)。[2]
pure = return
(<*>) = ap
ap u v = do
f <- u
x <- v
return (f x)
許多其他單子函式都有更通用的 Applicative 版本。以下是其中幾個。
| 單子 | Applicative | 模組 (在哪裡可以找到 Applicative 版本) |
|---|---|---|
(>>) |
(*>) |
Prelude (GHC 7.10+);Control.Applicative |
liftM2 |
liftA2 |
Control.Applicative |
mapM |
traverse |
Prelude (GHC 7.10+);Data.Traversable |
sequence |
sequenceA |
Data.Traversable |
forM_ |
for_ |
Data.Foldable |
| 練習 |
|---|
|
ZipList
[edit | edit source]列表也是 Applicative 函子。專門針對列表,(<*>) 的型別變為……
[a -> b] -> [a] -> [b]
… 因此 (<*>) 將一個函式列表應用於另一個列表。但究竟是如何實現的呢?
列表的標準 Applicative 例項(源自 Monad 例項)將每個函式應用於每個元素,就像一個爆炸版的 map。
Prelude> [(2*),(5*),(9*)] <*> [1,4,7]
[2,8,14,5,20,35,9,36,63]
有趣的是,還有另一種合理的函式列表應用方法。與其使用每個函式和值的組合,我們可以將每個函式與另一個列表中對應位置的值進行匹配。Prelude 中可以用於此目的的函式是 zipWith。
Prelude> :t zipWith
zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
Prelude> zipWith ($) [(2*),(5*),(9*)] [1,4,7]
[2,20,63]
當對於同一個型別存在兩種有用的可能例項時,可以透過建立 newtype 來實現其中之一來解決困境。在本例中,我們有 ZipList,它位於 Control.Applicative 中。
newtype ZipList a = ZipList { getZipList :: [a] }
我們已經瞭解了 <*> 對於 zip 列表應該是什麼樣子;只需要新增 newtype 包裝器。
instance Applicative ZipList where
(ZipList fs) <*> (ZipList xs) = ZipList (zipWith ($) fs xs)
pure x = undefined -- TODO
至於 pure,使用 pure x = ZipList [x] 很誘人,遵循標準列表例項。然而,我們無法這樣做,因為它違反了 Applicative 定律。根據恆等律
pure id <*> v = v
替換 (<*>) 和建議的 pure,我們得到
ZipList [id] <*> ZipList xs = ZipList xs
ZipList (zipWith ($) [id] xs) = ZipList xs
現在,假設 xs 是無限列表 [1..]
ZipList (zipWith ($) [id] [1..]) = ZipList [1..]
ZipList [1] = ZipList [1..]
[1] = [1..] -- Obviously false!
問題在於 zipWith 生成的列表長度等於作為引數傳遞的最短列表的長度,因此 (ZipList [id] <*>) 將在第一個元素之後截斷其他 zip 列表中的所有元素。確保 zipWith ($) fs 永遠不會刪除元素的唯一方法是讓 fs 成為無限列表。正確的 pure 來自此結論
instance Applicative ZipList where
(ZipList fs) <*> (ZipList xs) = ZipList (zipWith ($) fs xs)
pure x = ZipList (repeat x)
ZipList Applicative 例項為 Data.List 中的所有 zipN 和 zipWithN 函式提供了一種替代方案,可以擴充套件到任意數量的引數。
>>> import Control.Applicative
>>> ZipList [(2*),(5*),(9*)] <*> ZipList [1,4,7]
ZipList {getZipList = [2,20,63]}
>>> (,,) <$> ZipList [1,4,9] <*> ZipList [2,8,1] <*> ZipList [0,0,9]
ZipList {getZipList = [(1,2,0),(4,8,0),(9,1,9)]}
>>> liftA3 (,,) (ZipList [1,4,9]) (ZipList [2,8,1]) (ZipList [0,0,9])
ZipList {getZipList = [(1,2,0),(4,8,0),(9,1,9)]}
效果的排序
[edit | edit source]正如我們所見,列表的標準 Applicative 例項將一個列表中的每個函式應用於另一個列表中的每個元素。然而,這並不能明確地指定 (<*>)。為了理解這一點,嘗試在不檢視上面的示例或下面的答案的情況下,猜一下 [(2*),(3*)]<*>[4,5] 的結果。
Prelude> [(2*),(3*)] <*> [4,5]
--- ...
[8,10,12,15]
除非你非常專注,或者已經分析過 (<*>) 的實現,否則你猜對的可能性大約是 50%。另一種可能性是 [8,12,10,15]。區別在於,對於第一個(也是正確的)答案,結果是透過取第一個列表的骨架,並將每個元素替換為與第二個列表中的元素的所有可能組合來獲得的,而對於另一個可能性,起點是第二個列表。
更一般地說,這兩個實現之間的區別在於 效果的排序。在這裡,效果指的是函子上下文,而不是函子中的值(一些示例:列表的骨架、IO 中執行的現實世界中的動作、Maybe 中值的存在)。對於列表存在兩個合法 (<*>) 實現,它們僅在效果排序方面有所不同,這表明 [] 是一個非交換 Applicative 函子。相反,交換 Applicative 函子在效果排序方面不會留下任何歧義。更正式地說,交換 Applicative 函子滿足以下條件
liftA2 f u v = liftA2 (flip f) v u -- Commutativity
或者,等效地,
f <$> u <*> v = flip f <$> v <*> u
順便說一句,如果你在 Haskell 中聽說過 交換單子,那麼涉及的概念是相同的,只是專門針對 Monad。
交換性(或缺乏交換性)也會影響其他從 (<*>) 派生的函式。(*>) 就是一個明顯的例子
(*>) :: Applicative f => f a -> f b -> f b
(*>) 組合效果,同時只保留第二個引數的值。對於單子,它等效於 (>>)。以下是用 Maybe(它是交換的)演示它。
Prelude> Just 2 *> Just 3
Just 3
Prelude> Just 3 *> Just 2
Just 2
Prelude> Just 2 *> Nothing
Nothing
Prelude> Nothing *> Just 2
Nothing
交換引數不會影響效果(即包裝值的存亡)。但是,對於 IO,交換引數會重新排序效果
Prelude> (print "foo" *> pure 2) *> (print "bar" *> pure 3)
"foo"
"bar"
3
Prelude> (print "bar" *> pure 3) *> (print "foo" *> pure 2)
"bar"
"foo"
2
在 Haskell 中,慣例是始終使用從左到右的排序來實現 (<*>) 和其他 Applicative 運算子。儘管這個慣例有助於減少混淆,但它也意味著外表有時會具有誤導性。例如,(<*) 函式不是 flip (*>),因為它像 (*>) 一樣從左到右排序效果
Prelude> (print "foo" *> pure 2) <* (print "bar" *> pure 3)
"foo"
"bar"
2
出於同樣的原因,來自 Control.Applicative 的 (<**>) :: Applicative f => f a -> f (a -> b) -> f b 也不是 flip (<*>)。這意味著它提供了一種反轉排序的方法
>>> [(2*),(3*)] <*> [4,5]
[8,10,12,15]
>>> [4,5] <**> [(2*),(3*)]
[8,12,10,15]
另一種選擇是來自 transformers 的 Control.Applicative.Backwards 模組,它提供了一個 newtype 用於翻轉效果的順序
newtype Backwards f a = Backwards { forwards :: f a }
>>> Backwards [(2*),(3*)] <*> Backwards [4,5]
Backwards [8,12,10,15]
| 練習 |
|---|
|
Functor,Applicative,Monad。三個密切相關的函子型別類;Haskell 中最重要的三個類。儘管我們已經看到了許多Functor和Monad的使用示例,以及一些Applicative的示例,但我們還沒有將它們進行正面比較。如果我們暫時忽略pure/return,這三個類的特徵方法是
fmap :: Functor f => (a -> b) -> f a -> f b
(<*>) :: Applicative f => f (a -> b) -> f a -> f b
(>>=) :: Monad m => m a -> (a -> m b) -> m b
雖然這些型別看起來不同,但我們可以透過一些美觀的調整來改變這種局面。讓我們用它的中綴同義詞(<$>)替換fmap;用它的反轉版本(=<<)替換(>>=);並整理一下簽名
(<$>) :: Functor t => (a -> b) -> (t a -> t b)
(<*>) :: Applicative t => t (a -> b) -> (t a -> t b)
(=<<) :: Monad t => (a -> t b) -> (t a -> t b)
突然之間,相似之處令人注目。fmap,(<*>)和(=<<)都是將函式對映到Functor上[3]。它們之間的區別在於每種情況下對映的內容
fmap將任意函式對映到函子上。(<*>)將t (a -> b)態射對映到(應用)函子上。(=<<)將a -> t b函式對映到(單子)函子上。
Functor,Applicative和Monad在日常使用中的差異源於這三個對映函式的型別允許你做什麼。當你從fmap移動到(<*>),然後移動到(>>=)時,你會獲得更多力量、多功能性和控制,但代價是結果的保證。我們現在將沿著這個尺度滑動。在這樣做的過程中,我們將使用對比的術語值和上下文來分別指代函子中的普通值和它們周圍的一切。
fmap的型別以及與之相關的兩個定律確保無論給它什麼函式,都無法用它來改變上下文。在(a -> b) -> t a -> t b中,(a -> b)函式與t a函子值的t上下文無關,因此應用它不會影響上下文。因此,如果你對某個列表xs執行fmap f xs,列表的元素個數永遠不會改變。
Prelude> fmap (2*) [2,5,6]
[4,10,12]
這可以被視為一項安全保證,也可以被視為不幸的限制,這取決於你的意圖。無論如何,(<*>)顯然能夠改變上下文
Prelude> [(2*),(3*)] <*> [2,5,6]
[4,10,12,6,15,18]
t (a -> b)態射本身帶有上下文,該上下文與t a函子值的上下文相結合。然而,(<*>)受到更微妙的限制。雖然t (a -> b)態射帶有上下文,但其中有普通(a -> b),它們仍然無法修改上下文。這意味著(<*>)對上下文執行的更改完全由其引數的上下文決定,而值對結果上下文沒有影響。
Prelude> (print "foo" *> pure (2*)) <*> (print "bar" *> pure 3)
"foo"
"bar"
6
Prelude> (print "foo" *> pure 2) *> (print "bar" *> pure 3)
"foo"
"bar"
3
Prelude> (print "foo" *> pure undefined) *> (print "bar" *> pure 3)
"foo"
"bar"
3
因此,使用列表(<*>),你知道結果列表的長度將是原始列表長度的乘積,使用IO (<*>),你知道只要評估終止,所有真實世界的效果都會發生,等等。
然而,使用Monad,我們參與的是一場非常不同的遊戲。(>>=)採用a -> t b函式,因此它能夠從值中建立上下文。這意味著很大的靈活性
Prelude> [1,2,5] >>= \x -> replicate x x
[1,2,2,5,5,5,5,5]
Prelude> [0,0,0] >>= \x -> replicate x x
[]
Prelude> return 3 >>= \x -> print $ if x < 10 then "Too small" else "OK"
"Too small"
Prelude> return 42 >>= \x -> print $ if x < 10 then "Too small" else "OK"
"OK"
然而,利用額外的靈活性可能意味著對某些方面有更少的保證,例如你的函式是否能夠意外地刪除病態輸入的資料結構的部分,或者你的應用程式中的控制流是否保持清晰。在某些情況下,也可能存在效能影響,因為單子程式碼可能導致複雜的資料依賴關係,這可能會阻止有用的重構和最佳化。總而言之,最好只使用完成手頭任務所需的權力。如果你確實需要Monad的額外功能,那就繼續使用它;然而,通常值得檢查Applicative或Functor是否足夠。
| 練習 |
|---|
|
接下來的幾個練習涉及以下樹資料結構
|
回到理解單子,我們看到了如何使用(>=>)或join而不是(>>=)來指定Monad類。以類似的方式,Applicative也有一個替代表示法,可以透過以下型別類實現
class Functor f => Monoidal f where
unit :: f ()
(*&*) :: f a -> f b -> f (a,b)
在名稱“單子”背後有深刻的理論原因[4]。無論如何,我們可以非正式地說它看起來非常像一個么半群:unit提供一個預設的函子值,其上下文不包含任何有趣的內容,(*&*)透過將值配對並將效果組合來組合函子值。Monoidal公式更清楚地展示了Applicative如何操作函子上下文。當然,unit和(*&*)可以用來定義pure和(<*>),反之亦然。
Applicative定律等效於以下定律集,這些定律是用Monoidal表示的
fmap snd $ unit *&* v = v -- Left identity
fmap fst $ u *&* unit = u -- Right identity
fmap asl $ u *&* (v *&* w) = (u *&* v) *&* w -- Associativity
-- asl (x, (y, z)) = ((x, y), z)
($)左側的函式只是將等效型別(例如b和((), b))相互轉換的樣板。如果你忽略它們,這些定律比通常的Applicative公式中的定律不那麼不透明。順便說一句,就像Applicative一樣,還有一個額外的定律,它保證在 Haskell 中成立
fmap (g *** h) (u *&* v) = fmap g u *&* fmap h v -- Naturality
-- g *** h = \(x, y) -> (g x, h y)
| 練習 |
|---|
|
注意
- ↑
(.)和普通函式的對應屬性是(u . v) w = u (v w)。 - ↑ 如果
Monad例項遵循單子定律,那麼生成的pure和(<*>)將自動遵循應用定律。 - ↑ 這不僅僅是型別簽名彼此相似的問題:這種相似性具有理論基礎。連線的一個方面是,所有三個型別類都具有恆等式和組合定律絕非偶然。
- ↑ 有關更多詳細資訊,請關注Typeclasseopedia 中相應部分和Edward Z. Yang 的部落格文章的線索,該文章啟發了它。