在编程中,有一种模式出现得非常频繁--把两个相同类型的东西放在一起,得到另一个该类型的东西。
a -> a -> a
考虑到它的频率,最好能有一些有用的抽象来处理它。
而在Haskell中,我们做到了。有一个叫做Monoid 的类型类,它抽象出了 "把东西砸在一起 "的概念。它从一个同名的数学结构中获得了灵感。
在这篇文章中,我们将介绍这个类型类和这个结构。
在文章的最后,你会知道:
- 什么是单体。
- 什么是Haskell中的
Monoid类型类。 - 如何使用来自
Data.Monoid的预定义单体实例。 - 如何定义你自己的
Monoid实例。 - 为什么单子体是有用的。
积累直觉
对于像单体这样的数学术语,在阅读定义之前,最好先看一下例子。
因此,让我们来看看Haskell中类似单体行为的三个例子。
首先,带有++ (连接)操作符的列表。
Prelude> [1,2] ++ [3, 4] -- You can concatenate lists.
[1,2,3,4]
Prelude> [1,2] ++ [] -- Concatenating an empty list to a list doesn't change the result.
[1,2]
Prelude> [] ++ [1,2]
[1,2]
Prelude> [1,2] ++ ([3,4] ++ [5,6]) -- It doesn't matter in which order you concatenate the lists, you get the same result either way.
[1,2,3,4,5,6]
Prelude> ([1,2] ++ [3,4]) ++ [5,6]
[1,2,3,4,5,6]
之后,使用+ 操作符的数字。
Prelude> 1 + 2 -- You can sum two natural numbers together.
3
Prelude> 1 + 0 -- Adding a 0 doesn't change the sum.
1
Prelude> 0 + 1
1
Prelude> (1 + 2) + 3 -- It doesn't matter in which order you sum the numbers, you get the same result either way.
6
Prelude> 1 + (2 + 3)
6
(另外,我们也可以用*操作符来做数字。在这种情况下,不改变乘积的元素将是1)。
最后,用运算符&& (代表and )来表示布尔。
Prelude> True && False -- You can join two booleans via &&.
False
Prelude> a = False
Prelude> a && True -- Adding && True doesn't impact the resulting boolean.
False
Prelude> True && a
False
Prelude> (True && True) && False -- It doesn't matter in which order you resolve the &&s, you get the same result either way.
False
Prelude> True && (True && False)
False
(另外,我们也可以用或运算符做布尔运算:|| 。在这种情况下,不会改变结果的元素将是False)。
正如你所看到的,这三样东西的作用是相似的。它们遵循同样的规律。
现在,让我们来看看这些规律到底是什么。
什么是单体?
在数学中,单体是一种结构,由一个元素集合(如数字或布尔)和该集合的二元运算(如+ 或&& )组成。
此外,一个单体满足以下属性:
- 有一个身份元素,它 "不做任何事情"。用更正式的术语来说,如果你从单体的元素集中取出任何元素x,并对该元素和身份元素使用单体的二元运算,你会得到相同的元素--x--回来。例如, 1+0=11+ 0 = 1 1+ 0 = 1, 2+0=22+ 0 = 2 2+ 0= 2,等等。
- 关联性。这个属性保证了重新排列方程中的括号不会改变方程的结果。例如, (1+2)+3=1+(2+3)(1+2)+3=1+(2+3) (1+ 2 )+ 3 = 1 + (2+ 3 )。
Monoid Haskell中的类型类
单体在Haskell中是怎样的?:info 侦探正在调查。🕵️♂️
Prelude> :info Monoid
type Monoid :: * -> Constraint
class Semigroup a => Monoid a where
mempty :: a
mappend :: a -> a -> a
mconcat :: [a] -> a
我们可以看到,Monoid 在Haskell中是一个类型类,有三个方法:mempty,mappend, 和mconcat 。
[mempty](https://hackage.haskell.org/package/base-4.16.0.0/docs/Prelude.html#v:mempty)是一个与二进制操作一起使用时不影响结果的值。换句话说,它是单体的身份元素。[mappend](https://hackage.haskell.org/package/base-4.16.0.0/docs/Prelude.html#v:mappend)(或<>)是一个将两个单体放在一起的函数。换句话说,它是单体的二元操作。[mconcat](https://hackage.haskell.org/package/base-4.16.0.0/docs/Prelude.html#v:mconcat)是一个将单体列表还原成一个值的函数。默认情况下,它是foldr mappend mempty。这对大多数数据类型来说是没有问题的,而且没有必要定义mconcat来定义一个实例。但有时你可能想在默认实现不理想时定义你自己的函数实现。
关于mappend 和<> 的说明:
虽然这个类型类没有定义一个名为<> 的函数,但它是由Semigroup 定义的,即我在文章后面要介绍的Monoid 的超类。就所有的意图和目的而言,这些函数应该是相同的。在GHC的未来版本中,mappend 将被删除,所以建议你使用<> 。
如何使用预定义的单体实例
让我们试着用我们前面例子中的数据类型来使用这些单体方法。
它对列表有效。
Prelude> [1,2] <> [3,4]
[1,2,3,4]
Prelude> mempty :: [a]
[]
Prelude> [1,2] <> mempty
[1,2]
但是如果你试图使用1 <> 3 ,你会在 GHCi 中遇到一个错误。
<interactive>:1:1: error:
• Ambiguous type variable 'a0' arising from a use of 'print'
...
🤔
这是因为对于数字来说,没有一个Monoid 的实例。
你可以有一个和单体,一个积单体,以及更多,这取决于你选择的二进制操作。GHC没有办法知道你想使用哪种操作。
所以我们需要包装我们的数据类型,并根据这些类型将被使用的上下文制作单体实例。 [Data.Monoid](https://hackage.haskell.org/package/base-4.16.0.0/docs/Data-Monoid.html)我们为常用的单体定义了这样的包装类型,如 [Sum](https://hackage.haskell.org/package/base-4.16.0.0/docs/Data-Monoid.html#t:Sum), [Product](https://hackage.haskell.org/package/base-4.16.0.0/docs/Data-Monoid.html#t:Product),等等。
newtype Sum a = Sum { getSum :: a }
让我们看看它们是如何工作的。
Prelude> import Data.Monoid
Prelude Data.Monoid> Sum 1 <> Sum 3
Sum {getSum = 4}
Prelude Data.Monoid> Product 2 <> Product 5
Product {getProduct = 10}
同样地,布尔运算也有All 和Any 单元为它们定义。
Prelude Data.Monoid> All True <> All False
All {getAll = False}
Prelude Data.Monoid> Any True <> Any False
Any {getAny = True}
你可以使用mconcat 来对这些单数的列表进行求和。
result = mconcat [Sum 4, Sum 6, Sum 8, mempty]
而为了得到一个解包的结果,你可以通过它们的记录名来解包,方便地称为getX 。
Prelude Data.Monoid> getSum result
18
如何在Haskell中创建一个单体实例
让我们试着在Haskell中创建我们自己的单体实例。
首先,我们将创建一个名为Move 的自定义数据类型,表达一个机器人在二维领域中移动的指令。
data Move = Move Int Int deriving (Show, Eq)
要为一个数据类型创建一个类单体实例,你首先需要为它创建一个 Semigroup实例,因为Semigroup 是Monoid 的一个超类(从GHC 8.4开始)。
Prelude> :info Monoid
type Monoid :: * -> Constraint
class Semigroup a => Monoid a where
...
Prelude> :info Semigroup
type Semigroup :: * -> Constraint
class Semigroup a where
(<>) :: a -> a -> a
但是不要担心,这里没有什么新东西。创建一个Semigroup 实例只是定义mappend 的一种迂回方式。
之所以如此,是因为半群是一个没有身份元素的单体(mempty)。 它定义了一个方法--<> --与mappend 相同,是合并两个值的二进制操作。
因此,让我们定义一个Semigroup 的实例。为了追加两个动作,我们将把它们各自的x 和y 的值相加。
instance Semigroup Move where
Move x1 y1 <> Move x2 y2 = Move (x1 + x2) (y1 + y2)
之后,我们可以定义Monoid 的实例。要定义这个实例,你只需要提供mempty ,因为mappend 将与<> 相同。
在我们的例子中,有意义的mempty ,就是不移动任何地方:Move 0 0 。
instance Monoid Move where
mempty = Move 0 0
注意: 在创建你的单体实例时,你需要注意遵循单体法则。Haskell(除了基于属性的测试)没有办法检查我们的单体实例是否有意义。例如,我们可以将mempty 定义为Move 0 (-1) ,这将导致一些奇怪的行为。
这就是我们所需要的Move ,它是一个单体!🎉
现在,我们可以在GHCi中玩一玩。
*Main> Move 3 4 <> Move 8 9
Move 11 13
*Main> Move 3 4 <> mempty
Move 3 4
我们还可以使用mconcat 方法来折叠一个移动列表。
*Main> stay = mempty :: Move
*Main> moves = [Move 1 2, Move (-3) 5, Move (-6) 3, stay, Move 2 3]
*Main> mconcat moves
Move (-6) 13
为什么你需要单体?
一般来说,单体并不高级,也不有趣,更不酷。
然而,它们确实很有用,而且相当简单。<> ,用于连接很多不同的类型,可以被认为是一个基本的构建模块。这是一个你在每个Haskell项目中都会遇到的操作符。
如果你正在寻找现实生活中的使用案例,下面的进一步学习部分有一些资源,涵盖了使用单体来制作聊天过滤器和计算选举投票分布的情况。
总结
以上是对Haskell中Monoid 类型类的快速介绍。希望你现在知道什么是单体定律,如何在Haskell中使用单体,以及如何定义你自己的Monoid 类型类的实例。