翻译自 https://gist.github.com/cscalfani/b0a263cf1d33d5d75ca746d81dac95c5

为什么程序员应该关心 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 应该会容易很多。


题叶
17.3k 声望2.6k 粉丝

Calcit 语言作者


引用和评论

0 条评论