翻译自 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 \
回顾 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 应该会容易很多。