翻譯自 https://gist.github.com/cscal...
為什麼程式設計師應該關心 Monoids?因為 Monoids 是一種在程式設計中反覆出現的常見模式。當模式出現時,我們可以將它們抽象化並利用我們過去所做的工作。這使我們能夠在經過驗證的穩定程式碼之上快速開發解決方案。
將"可交換性"新增到 Monoid(Commutative Monoid),你就有了可以並行執行的東西。隨著摩爾定律的終結,平行計算是我們提高處理速度的唯一希望。
以下是我在學習 Monoids 後學到的。它未必完整,但希望能夠對於向人們介紹 Monoids 有所幫助。
Monoid 譜系
Monoid 來自數學,從屬於代數結構的譜系。因此,從頭開始並逐步展開到 Monoids 會有所幫助。 實際上,我們進一步可以推到"群"(Groups).
Magma(元群)
Magma 是一個集合以及一個必須閉合的二元運算:
∀ a, b ∈ M : a • b ∈ M
如果將二元運算應用於集合的任意 2 個元素時,它會生成集合的另一個成員,則該二元運算是封閉的。 (這裡 ·
表示二元運算)
Magma 的一個示例是 Boolean 和 AND
運算的集合。
Semigroup(半群)
Semigroup 是具有一個附加要求的 Magma。二元運算對於集合的所有成員必須是"可結合"的:
∀ a, b, c ∈ S : a · (b · c) = (a · b) · c
一個 Semigroup 的例子是"非空字串"和"字串拼接"運算的集合。
Monoid(么半群)
Monoid 是包含一個附加條件的 Semigroup。集合中存在一個"么元"(Neutral Element),可以使用二元運算將其與集合的任何成員結合,而產生屬於相同集合的成員。
e ∈ M : ∀ a ∈ M, a · e = e · a = a
一個 Monoid 的例子是字串集合以及"字串拼接"運算。注意,集合中新增的空字串是"么元",並使 Semigroup 稱為 Monoid。
另一個 Monoid 的示例是非負整數和加法運算的集合。么元為 0
。
Group(群)
一個 Group 是包含一個附加條件的 Monoid. 集合中存在"逆",使得:
∀ a, b, e ∈ G : a · b = b · a = e
其中 e
是么元.
一個 Group 的例子是整數和加法運算的集合。 "逆"是負數,么元是 0
。
通過允許負數,我們將上面的 Monoid 的第二個示例變成了一個 Group。
引用: Math StackExchange question: What's the difference between a monoid and a group?
Haskell 中的 Monoids
Monoid typeclass(型別類)
在 Haskell Prelude (基於 GHC.Base
)中, Monoid typeclass 定義為:
class Monoid a where
mempty :: a
-- ^ 'mappend' 的么元
mappend :: a -> a -> a
-- ^ 一個"可結合"的操作
mconcat :: [a] -> a
-- ^ 使用 monoid 來摺疊一個列表.
-- 對於大多數型別,會使用 'mconcat' 的預設定義
-- 但該函式包含在類定義中,所以可以為特定型別提供優化的版本.
mconcat = foldr mappend mempty
其中 mempty
是么元, mappend
是二元可組合操作符. 這足以成為 Monoid,但為了方便新增了 mconcat
。 它有一個預設實現,使用二元運算 mappend
從么元 mempty
開始摺疊列表。
例項可以覆蓋這個預設實現,我們稍後會看到。
Monoid 例項
Monoid ()
一個簡單例子是僅包含 ()
的集合:
instance Monoid () where
mempty = ()
_ `mappend` _ = ()
mconcat _ = ()
這裡集合只包含一個么元 ()
。 所以 mappend
並不真正關心引數,只會返回 ()
。 意味著唯一有效的引數始終是 ()
,因為我們的集合只包含 ()
。
此外,為了提高效率,mconcat
函式被覆蓋從而忽略集合中的元素列表,因為它們都是()
,因此它只返回()
。 請注意,如果此處省略了 mconcat
,由於 mappend
的實現,預設實現將產生相同的結果。
Monoid ()
用例
用這個 Monoid 本身做不了做多少事情。
n :: ()
n = () `mappend` ()
ns :: ()
ns = mconcat [(), (), ()]
Monoid [a]
任意列表的 Monoid:
instance Monoid [a] where
mempty = []
mappend = (++)
mconcat xss = [x | xs <- xss, x <- xs]
mappend
是"拼接"運算,這意味著么元 mempty
只能是空列表,[]
。
著重要意識到 mconcat
從集合中獲取一份"元素"的列表,這裡是"列表的列表"。因此,它需要一個"列表的列表",因此引數名稱為 xss
。
我懷疑 List Comprehensions 比 foldr
更有效,否則沒有理由實現 mconcat
。
如果我們想一下,foldr
將重複用 2 個列表呼叫的 mappend
,由於對每個迭代返回的中間列表中的元素進行重複處理,因此效率不高。
使用 List Comprehension 將是一個低階操作,很可能只訪問每個子列表的每個元素一次。
Monoid [a] 用例
as :: [Int]
as = [1, 2, 3]
bs :: [Int]
bs = [4, 5, 6]
asbs :: [Int]
asbs = mconcat [as, bs] -- [1, 2, 3, 4, 5, 6]
(Monoid a, Monoid b) => Monoid (a, b)
任意 Monoid 的 2 元組的 Monoid:
instance (Monoid a, Monoid b) => Monoid (a,b) where
mempty = (mempty, mempty)
(a1,b1) `mappend` (a2,b2) = (a1 `mappend` a2, b1 `mappend` b2)
起初,mempty
的定義似乎令人困惑。 乍一看,該定義可能會被誤解為遞迴定義。
實際上這個元組中的第一個 mempty
是 a
型別的 mempty
。第二個 mempty
是 b
型別的 mempty
。
想象一下 a
是 ()
而 b
是 [Int]
。 那麼 mempty
將是 ( (), [] )
,即第一個是 ()
的 mempty
,第二個是 [Int]
的 mempty
。
mappend
的實現非常簡單。 它為 a
和 b
執行一個 mappend
,返回一個 (a, b)
的 2 元組。 因為 a
和 b
都是 Monoids,所以 Magmas 和 Monoids 的閉合約束得以延續。
Monoid (a, b) 用例
p1 :: ((), [Int])
p1 = ((), [1, 2, 3])
p2 :: ((), [Int])
p2 = ((), [4, 5, 6])
p1p2 :: ((), [Int])
p1p2 = mconcat [p1, p2] -- ((), [1, 2, 3, 4, 5, 6])
Monoid b => Monoid (a -> b)
"接受一個或多個引數, 返回 Monoid, 的任意函式"的 Monoid:
instance Monoid b => Monoid (a -> b) where
mempty _ = mempty
mappend f g x = f x `mappend` g x
這個定義如何處理帶有多個引數的函式並不明顯。可能需要給點提醒。
函式註解是右結合,即它們在右側結合:
f :: Int -> (Bool -> String) -- 不必要的括號
f s1 s2 = s1 ++ s2
Int -> (Bool -> String)
等價於 Int -> Bool -> String
,這就是我們不包含括號的原因。"右結合性"提示了這一點。
記住 String
等價於 [Char]
,我們知道 f
最終會返回一個 Monoid,因為我們已經在上面看到了 Monoid [a]
。
但沒那麼快。 我們首先必須按照 Monoid 例項中定義的 a -> b
來分解註解:
Int -> (Bool -> String)
a -> b
這裡 b
必須是 Monoid. 得益於 Monoid (a -> b)
,它是的。
現在檢視 b
,我們得到:
(Bool -> String)
( a -> b )
因此,重新應用 Monoid (a -> b)
能處理具有多個引數的函式,例如:
Int -> (String -> (Int -> String))
a -> ( b )
a -> (a' -> ( b' ))
a -> (a' -> (a'' -> b'' )
這裡 b
是 Monoid, 因為 b'
是 Monoid, 也因為 b''
是 String
是 Monoid, 還因為 String
是 [Char]
並且我們之前看到所有列表都是 Monoids。
再看定義:
instance Monoid b => Monoid (a -> b) where
mempty _ = mempty
mappend f g x = f x `mappend` g x
如願地 mempty
的定義現在更有意義了。 mempty
屬於 a -> b
型別,這就是它接收單個引數的原因。 它忽略引數並簡單地返回型別為 b
的 mempty
。
對於 Bool -> String
型別的函式,mempty
是 []
,即 Monoid [a]
的 mempty
。
對於型別為 Int -> Bool -> String
的函式,mempty
是遞迴的,即它首先以 Bool -> String
型別返回自身,因而會返回 []
。
注意 a
在這裡是無關緊要的。 事實上,函式的所有輸入型別都是無關緊要的。 這裡唯一重要的是返回值的型別。 這就是為什麼只有 b
必須是 Monoid。
因此,以下函式型別將具有 mempty
最終返回 []
,因為它們都返回 String
:
Int -> String
Int -> Int -> String
Int -> Bool -> Int -> Double -> String
類似地,mappend
將單個引數應用於全部兩個函式,然後呼叫 b
的 mappend
。
對於型別為 String -> String
的函式,mappend
使用輸入 String
呼叫全部兩個函式,然後為 Monoid [a]
的 String
呼叫 mappend
,即 (++)
。
對於型別為 String -> String -> String
的函式,mappend
使用第一個輸入引數 String
呼叫全部兩個函式,然後為 String -> String
呼叫 mappend
,它是 Monoid (a -> b)
,即它本身。
再接著,使用第二個輸入引數 String
呼叫全部兩個函式,然後對型別為 Monoid [a]
的 String
呼叫 mappend
,也即呼叫 (++)
。
Monoid (a -> b) 用例
import Data.Monoid ((<>))
parens :: String -> String
parens str = "(" ++ str ++ ")"
curlyBrackets :: String -> String
curlyBrackets str = "{" ++ str ++ "}"
squareBrackets :: String -> String
squareBrackets str = "[" ++ str ++ "]"
pstr :: String -> String
pstr = parens <> curlyBrackets <> squareBrackets
astr :: String
astr = pstr "abc"
注意 <>
操作符在 pstr
中使用。 這個操作符是從 Data.Monoid
匯入的,是 mappend
操作的別名(中綴)。
如果你回顧 Monoid 的 class
定義,你會看到 mappend
的型別是 a -> a -> a
。
由於 parens
和 curlyBrackets
都具有型別 -> String -> String
,因此 parens <> curlyBrackets
將具有 String -> String
型別,parens <> curlyBrackets <> squareBrackets
也將具有該型別。
pstr
將接收 String
並將其應用於 parens
、curlyBrackets
和 squareBrackets
拼接這些呼叫的結果。
因此,astr
是(abc){abc}[abc]
。
如果要應用的函式數量很大,使用 <>
方法會變得繁瑣。 這就是 Monoid class 為什麼有個輔助函式 mconcat
。
我們可以這樣重構程式碼:
pstr :: String -> String
pstr = mconcat [parens, curlyBrackets, squareBrackets]
astr :: String
astr = pstr "abc"
Monoid \<number-type\>
回顧 Monoid 的定義,我們必須選擇可結合的二元運算,但對於數字,它可以是加法或者是乘法。
如果我們選擇加法,那就會錯過乘法,反之亦然。
不巧的是,每種型別只能有 1 個 Monoid。
解決這個問題的方法是建立一個新型別,其中包含一個用於加法的 Num
和另一種用於乘法的型別。
這些型別可以在 Data.Monoid
中找到:
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
import GHC.Generics
newtype Sum a = Sum { getSum :: a }
deriving (Eq, Ord, Read, Show, Bounded, Generic, Generic1, Num)
newtype Product a = Product { getProduct :: a }
deriving (Eq, Ord, Read, Show, Bounded, Generic, Generic1, Num)
現在我們可以為每個建立 Monoids。
Monoid Sum(和)
{-# LANGUAGE ScopedTypeVariables #-}
import Data.Coerce
instance Num a => Monoid (Sum a) where
mempty = Sum 0
mappend = coerce ((+) :: a -> a -> a)
mempty
是 0
包裹在 Sum
中。
這裡 coerce
用於安全地將 Sum a
強制轉換為它的 "Representational type",例如 Sum Integer
將被強制轉換為 Integer
並使用適當的 +
運算。
ScopedTypeVariables
pragma 允許我們將 a -> a -> a
中的 a
等同於 instance
的範圍,從而等同於 Num a
中的 a
。
Monoid Sum 用例
sum :: Sum Integer
sum = mconcat [Sum 1, Sum 2] -- Sum 3
Monoid Product(積)
{-# LANGUAGE ScopedTypeVariables #-}
import Data.Coerce
instance Num a => Monoid (Product a) where
mempty = Product 1
mappend = coerce ((*) :: a -> a -> a)
mempty
是 0
包裹在 Product
中。
這裡 coerce
用於安全地將 Product a
強制轉換為它的 Representational type,例如 Product Integer
將被強制轉換為 Integer
並使用適當的 *
運算。
ScopedTypeVariables
pragma 允許我們將 a -> a -> a
中的 a
等同於 instance
的範圍,從而等同於 Num a
中的 a
。
Monoid Product 用例
product :: Product Integer
product = mconcat [Product 2, Product 3] -- Product 6
Monoid Ordering(排序)
在看這個 Monoid 之前,讓我們回顧一下排序和對比:
data Ordering = LT | EQ | GT
在使用 class Ord
中的 compare
時用到此型別,例如:
compare :: a -> a -> Ordering
其使用示例:
compare "abcd" $ "abed" -- LT
現在 Data.Ord
中有一個很棒的輔助函式用於比較,稱為 comparing
:
comparing :: (Ord a) => (b -> a) -> b -> b -> Ordering
comparing p x y = compare (p x) (p y)
該輔助函式在比較之前對每個元素應用一個函式。 這對於元組之類的東西非常有用:
comparing fst (1, 2) (1, 3) -- EQ
comparing snd (1, 2) (1, 3) -- LT
現在對於 Monoid:
-- lexicographical ordering
instance Monoid Ordering where
mempty = EQ
LT `mappend` _ = LT
EQ `mappend` y = y
GT `mappend` _ = GT
這個實現看起來很隨意。 為什麼有人會以這種方式實現 Monoid Ordering
?
好吧,如果你想在 sortBy
追加一部分對比,那麼你需要這個實現。
看一下 sortBy
:
sortBy :: (a -> a -> Ordering) -> [a] -> [a]
請注意,第一個引數與 compare
、comparing fst
、comparing snd
和 comparing fst `mappend` comparison snd
的型別相同。
為什麼? 因為 mappend
的型別是 a -> a -> a
,這裡的 a
是 (a, b) -> (a, b) -> Ordering
。
所以我們可以結合或 mappend
比較函式,我們將有一個整體的比較函式。
請記住,Monoid (a -> b)
要求 b
也是 Monoid
。
因此,如果我們希望能夠 mappend
我們的比較函式,我們必須將 Ordering
設定為 Monoid
,就像在上面做的那樣。
但是我們仍然沒有回答為什麼它有這個看似奇葩的定義。
好吧,評論有點線索,即“字典順序”。 這本質上意味著“字母順序”或“左優先”,即如果最左邊是 GT
或 LT
,那麼所有對於右邊的比較都不再生效。
但是,如果最左邊的是 EQ
,那麼我們需要向右看以確定組合比較的最終結果。
這正是該實現所做的。 這裡再次新增一些額外的註釋來說明這一點:
-- 字典序
instance Monoid Ordering where
mempty = EQ -- EQ 直到左邊或直到右邊, 對最終結果沒有影響
LT `mappend` _ = LT -- 如果左邊是 LT 則忽略右側
EQ `mappend` y = y -- 如果左邊是 EQ 則用右側
GT `mappend` _ = GT -- 如果左邊是 GT 則忽略右側
花點時間來好好理解這一點。 一旦你這樣做了,這將更容易理解:
sortBy (comparing fst <> comparing snd) [(1,0),(2,1),(1,1),(2,0)]
-- [(1,0),(1,1),(2,0),(2,1)]
要理解它是如何工作的,你必須記住 Monoid (a -> b)
。
我們是在對 (a, b) -> (a, b) -> Ordering
型別的函式做 mappend
. 一旦這兩個函式都執行完成,我們就將按照我們的“字典順序”返回的兩個 Ordering
值做 mappend
。
這意味著對比 fst
相較於對比 snd
更優先,這就是為什麼所有 (1, x)
都將在所有 (2, y)
之前,即使當 x > y
時也是如此。
我們可以做一個不同的比較,我們只關心比較 snd
:
sortBy (comparing snd) [(1,0),(2,1),(1,1),(2,0)]
-- [(1,0),(2,0),(2,1),(1,1)]
這裡 fst
術語不可預測的順序,而 snd
是升序的。
為了好玩,我們可以分別控制升序和降序。 首先讓我們定義一些輔助函式:
asc, desc :: Ord b => (a -> b) -> a -> a -> Ordering
asc = comparing
desc = flip . asc
現在我們可以對 fst
降序和 snd
升序排序:
sortBy (desc fst <> asc snd) [(1,0),(2,1),(1,1),(2,0)]
-- [(2,0),(2,1),(1,0),(1,1)]
優化 Monoid Ordering
示例排序都只使用少量的對比。 事實上,大多數排序只會使用少量的比較。
即便如此,即使第一個返回 LT
或 GT
,也必須執行 mappend
。 當只有很少量的比較時,這似乎沒什麼大不了的。 但它可能疊加成為一個大列表。
我們希望我們的對比走的是“短路”,這通常用布林二元運算 &&
和 ||
來完成。
Monoid Ordering
的當前定義不可能走短路,因為它依賴於預設的 mconcat
實現,該實現使用訪問每個列表元素的 foldr
函式。
如果我們編寫自己的 Moniod Ordering
並實現一個提前返回結果的 mconcat
,我們將有一個更高效的排序。
import Prelude hiding (Monoid, mempty, mappend, mconcat)
import Data.List
import Data.Maybe
import Control.Arrow
instance Monoid Ordering where
mempty = EQ
LT `mappend` _ = LT
EQ `mappend` y = y
GT `mappend` _ = GT
mconcat = find (/= EQ) >>> fromMaybe EQ
這個實現允許我們重構我們之前的排序:
sortBy (mconcat [desc fst, asc snd]) [(1,0),(2,1),(1,1),(2,0)]
-- [(2,0),(2,1),(1,0),(1,1)]
結果相同,但任何時候 dest fst
返回了 LT
或 GT
,那麼 asc snd
將被跳過。
注意: 我們的實現依賴 Data.List
、Data.Maybe
和 Control.Arrow
,如果在標準中實現它們會不必要地耦合 Data.Monoid
。 這個限制可以通過編寫一個專用的函式來克服(不是很 "Don't repeat yourself")。
但是,覆蓋標準實現的最大問題是我們必須遮蓋所有 Monoid 定義。
這些是針對邊緣情況進行優化的一些相當大的缺點。 但它同樣是一個很好的練習。 此外,如果我們嘗試排序的列表很大,那麼它可能是值得的。
引用:
可交換 Monoid (Abelian Monoid)
如開頭所述,如果我們向 Monoid
(或 Group
)再新增一個約束,我們可以並行執行操作。
該約束是"可交換性"。
∀ a, b ∈ M : a · b = b · a
通過施加該約束,我們可以按任何順序處理列表。 這可以交由編譯器並行化,藉助類庫甚至分發給其他機器。
這是定義:
class Monoid m => CommutativeMonoid m
沒有寫函式可能看起來很奇怪,但它的介面與 Monoid
相同,只是要求二元操作支援交換律。
不幸的是,在 Haskell 中沒有辦法要求這些約束。
Num a => CommutativeMonoid (Sum a)
這是定義:
instance Num a => CommutativeMonoid (Sum a)
Sum
(或 Product
)使用 CommutativeMonoid
而不是 Monoid
的原因:
- 更好地傳達如何使用
Monoid
- 呼叫需要一個
CommutativeMonoid
的函式
結論
Monoids 是拼接相似事物的強大抽象,這些抽象可以在程式設計中反覆地呈現。
希望這對 Monoids
是一個好介紹。 還有很多其他型別的 Monoid,但是一旦你有了大致的瞭解,研究這些其他特化的 Monoid 應該會容易很多。