系列第七篇,介绍了更一般性的折叠以及幺半群。
折叠,又见折叠
我们已经知道怎么折叠一个列表了,但我们也可以将折叠思想更一般性地用于其它数据类型。比如对于下面这个二叉树,考虑一些函数:
1 | data Tree a = Empty |
写一个函数来计算树的节点数:
1 | treeSize :: Tree a -> Integer |
计算一个Tree Integer的数据总和:
1 | treeSum :: Tree Integer -> Integer |
计算树的高度:
1 | treeDepth :: Tree a -> Integer |
将树内元素展开成一个列表:
1 | flatten :: Tree a -> [a] |
你是否从中看出一些相似的模式?对于上述每个函数,有:
- 接受一个树作为输入
- 对输入的树进行模式匹配
- 对于
Empty节点,返回一个简单的值 - 对于
Node节点:- 递归的处理左右子树
- 以某种方式组合递归的结果,并生成最终结果
作为一名好的程序员,我们总是希望将抽象出重复的模式。首先需要将各例子中变化的部分作为参数,它们是:
- 返回类型
- 空节点的值
- 组合递归调用的方式
设树处理的类型为a,函数的返回类型为b,有:
1 | treeFold :: b -> (b -> a -> b -> b) -> Tree a -> b |
有了这个折叠函数,我们就可以更轻易地定义上面的几个例子了:
1 | treeSize' :: Tree a -> Integer |
我们也可以轻松实现其它的树折叠函数:
1 | treeMax :: (Ord a, Bounded a) => Tree a -> a |
这样感觉就好多了,去除了大量重复模式,非常优雅。
折叠表达式
回想下Homework5中的ExprT类型和相应的eval函数:
1 | data ExprT = Lit Integer |
看着就欠抽象!来试试这样写:
1 | exprTFold :: (Integer -> b) -> (b -> b -> b) -> (b -> b -> b) -> ExprT -> b |
现在我们可以做一些别的事,比如计算表达式中数字的个数:
1 | numLiterals :: ExprT -> Int |
普适的折叠
这里透露的信息是我们可以为很多(并非全部)数据类型创建折叠操作。作用于T类型的折叠操作会为T的每个构造器取一个(高层面的)参数,考虑怎么把构造器中的数据类型转换成返回值的类型——直到所有递归过程被折叠成一个结果。
很多我们可能想为T实现的的函数在折叠操作下会很易于表达。
幺半群(Monoids)
离散数学里接触过幺半群的概念,定义如下:
- 幺半群是一个带有二元运算`* : M * M -> M
的集合M`,其符合以下公理- 结合律:对任意
M内的元素a、b、c,有(a * b) * c = a * (b * c) - 单位元:存在
M内的元素e,使任一存于M内的元素a满足a * e = e * a = a - 封闭性(内含于二元运算中):对任意在
M内的元素a、b,a*b也在M中
- 结合律:对任意
Haskell中幺半群是一种基本类型类,定义在Data.Monoid模块里:
1 | class Monoid m where |
其中mempty相当于单位元的定义,mappend与其符号简写<>为幺半群中的二元运算。mconcat用于将整个列表折叠成一个值,默认使用foldr来实现,但由于对某种特定的Monoid类型可能存在更高效的实现,模块中提供了它的定义供修改。
正如之前提到的幺半群的性质,对任何Monoid类型的值x、y、z有:
1 | mempty <> x = x |
Monoid 实例
在知道这些概念后就会发现,Monoid无处不在。比如一个列表:
1 | instance Monoid [a] where |
考虑下会发现这是完美符合Monoid性质的。同理可以发现数值类型的加法和乘法也完美符合Monoid的性质。但要怎样分别实现数值加法和乘法的Monoid呢?我们不能在一个类型类中创建同一个类型的两个不同实例,即以下方法:
1 | instance Num a => Monoid a where |
是非法的,因为有重复定义。为解决这个问题,我们可以创建两个新类型作为数值类型的不同封装:
1 | newtype Sum a = Sum a |
类型的定义方式:
data: ADT
newtype: 单构造器的零代价ADT
type: 类型别名
在上述定义后,我们可以使用以下方式计算一个数列中所有元素的乘积:
1 | lst :: [Integer] |
当然这个例子显得舍近求远,非常地蠢。但这个模式可以方便的说明Monoid的应用方式。
两个可以作为Monoid实例的类组成的Pair也可以作为Monoid的实例,如下:
1 | instance (Monoid a, Monoid b) => Monoid (a, b) where |
试图构造一个Bool类型的Monoid,如下:
1 | newtype Or = Or {getOr :: Bool} |
这个定义确实没错,但是无法通过语法检查。原因是No instance for (Semigroup Or)。
补充:半群(Semigroup)
上面的报错信息意为Or类型不是Semigroup类型类的实例,而Semigroup是半群的意思。这是怎么回事呢?
我们知道,幺半群就是有单位元的半群,则半群定义为一个带有符合结合律的二元运算符* : M * M -> M的集合。因此Haskell把幺半群的二元运算符部分抽象出来作为半群类型类,如下:
1 | class Semigroup a where |
而幺半群的真实定义则为:
1 | class Semigroup a => Monoid a where |
关于<>与mappend的关系更准确的说法是,mappend是<>的别名。因而,<>才是主要定义,就是说一个类要成为Monoid的实例就必须也成为Semigroup的实例。则Bool类型的Monoid应定义为:
1 | newtype Or = Or {getOr :: Bool} |
甚至可以实现函数类型的Monoid:
1 | newtype Dot a = Dot {run :: a -> a} |