翻译自 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 -> StringInt -> Int -> StringInt -> 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 -> Stringparens str = "(" ++ str ++ ")"curlyBrackets :: String -> StringcurlyBrackets str = "{" ++ str ++ "}"squareBrackets :: String -> StringsquareBrackets str = "[" ++ str ++ "]"pstr :: String -> Stringpstr = parens <> curlyBrackets <> squareBracketsastr :: Stringastr = 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 -> Stringpstr = mconcat [parens, curlyBrackets, squareBrackets]astr :: Stringastr = pstr "abc"

Monoid \<number-type\>

回顾 Monoid 的定义,咱们必须抉择可联合的二元运算,但对于数字,它能够是加法或者是乘法。

如果咱们抉择加法,那就会错过乘法,反之亦然。

不巧的是,每种类型只能有 1 个 Monoid。

解决这个问题的办法是创立一个新类型,其中蕴含一个用于加法的 Num 和另一种用于乘法的类型。

这些类型能够在 Data.Monoid 中找到:

{-# LANGUAGE DeriveGeneric #-}{-# LANGUAGE GeneralizedNewtypeDeriving #-}import GHC.Genericsnewtype 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.Coerceinstance 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 Integersum = mconcat [Sum 1, Sum 2] -- Sum 3

Monoid Product(积)

{-# LANGUAGE ScopedTypeVariables #-}import Data.Coerceinstance 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 Integerproduct = 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 -> Orderingcomparing p x y = compare (p x) (p y)

该辅助函数在比拟之前对每个元素利用一个函数。 这对于元组之类的货色十分有用:

comparing fst (1, 2) (1, 3) -- EQcomparing snd (1, 2) (1, 3) -- LT

当初对于 Monoid:

-- lexicographical orderinginstance 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 -> Orderingasc = comparingdesc = 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.Listimport Data.Maybeimport Control.Arrowinstance 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 定义。

这些是针对边缘状况进行优化的一些相当大的毛病。 但它同样是一个很好的练习。 此外,如果咱们尝试排序的列表很大,那么它可能是值得的。

援用:

  • The Monoid Instance for Ordering
  • Monoids in Haskell

可替换 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 应该会容易很多。