Haskell Monoid(么半群)的介紹

題葉發表於2022-04-21
翻譯自 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 的定義似乎令人困惑。 乍一看,該定義可能會被誤解為遞迴定義。

實際上這個元組中的第一個 memptya 型別的 mempty。第二個 memptyb 型別的 mempty

想象一下 a()b[Int]。 那麼 mempty 將是 ( (), [] ),即第一個是 ()mempty,第二個是 [Int]mempty

mappend 的實現非常簡單。 它為 ab 執行一個 mappend,返回一個 (a, b) 的 2 元組。 因為 ab 都是 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 型別,這就是它接收單個引數的原因。 它忽略引數並簡單地返回型別為 bmempty

對於 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 將單個引數應用於全部兩個函式,然後呼叫 bmappend

對於型別為 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

由於 parenscurlyBrackets 都具有型別 -> String -> String,因此 parens <> curlyBrackets 將具有 String -> String 型別,parens <> curlyBrackets <> squareBrackets 也將具有該型別。

pstr 將接收 String 並將其應用於 parenscurlyBracketssquareBrackets 拼接這些呼叫的結果。

因此,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)

mempty0 包裹在 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)

mempty0 包裹在 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]

請注意,第一個引數與 comparecomparing fstcomparing sndcomparing fst `mappend` comparison snd 的型別相同。

為什麼? 因為 mappend 的型別是 a -> a -> a,這裡的 a(a, b) -> (a, b) -> Ordering

所以我們可以結合或 mappend 比較函式,我們將有一個整體的比較函式。

請記住,Monoid (a -> b) 要求 b 也是 Monoid

因此,如果我們希望能夠 mappend 我們的比較函式,我們必須將 Ordering 設定為 Monoid,就像在上面做的那樣。

但是我們仍然沒有回答為什麼它有這個看似奇葩的定義。

好吧,評論有點線索,即“字典順序”。 這本質上意味著“字母順序”或“左優先”,即如果最左邊是 GTLT,那麼所有對於右邊的比較都不再生效。

但是,如果最左邊的是 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

示例排序都只使用少量的對比。 事實上,大多數排序只會使用少量的比較。

即便如此,即使第一個返回 LTGT,也必須執行 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 返回了 LTGT,那麼 asc snd 將被跳過。

注意: 我們的實現依賴 Data.ListData.MaybeControl.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 的原因:

  1. 更好地傳達如何使用 Monoid
  2. 呼叫需要一個 CommutativeMonoid 的函式

結論

Monoids 是拼接相似事物的強大抽象,這些抽象可以在程式設計中反覆地呈現。

希望這對 Monoids 是一個好介紹。 還有很多其他型別的 Monoid,但是一旦你有了大致的瞭解,研究這些其他特化的 Monoid 應該會容易很多。