Haskell:重复的函数(+)和(++),mappend

12

(+)(++) 只是 mappend 的特化版本,我说得对吗?为什么需要它们呢?这是无用的重复,因为 Haskell 有这些强大的类型类和类型推断。

假设我们删除 (+)(++),并将 mappend 重命名为 (+) 以便视觉上更方便和打字更快。

对于初学者来说,编码会更加直观、简短和易于理解:

--old and new
1 + 2
--result
3

--old
"Hello" ++ " " ++ "World"
--new
"Hello" + " " + "World"
--result
"Hello World"

--old
Just [1, 2, 3] `mappend` Just [4..6]
--new
Just [1, 2, 3] + Just [4..6]
--result
Just [1, 2, 3, 4, 5, 6]

(这让我想入非非。) 对于同一件事情有三个或更多的函数,对于一个坚持抽象和其他Haskell语言来说并不是好事情。

我也看到了关于单子的相同重复: fmapmap(.)liftMmapMforM几乎相同。

我知道fmap有历史原因,但是对于幺半群呢? Haskell委员会是否计划对此进行一些改进? 这将破坏一些代码,但我听说(虽然不确定)即将推出一些变化很大的新版本,这是一个很好的机会。真是太可惜了... 至少,分叉是可以承担的吗?

编辑 在我读到的答案中,有一个事实是对于数字,(*)(+)都可以放在mappend中。 实际上,我认为(*)应该成为Monoid的一部分! 看:

目前,如果忽略函数memptymconcat,我们只有mappend

class Monoid m where
    mappend :: m -> m -> m

但是我们可以这样做:

class Monoid m where
    mappend :: m -> m -> m
    mmultiply :: m -> m -> m

它将(也许,我还没有好好考虑过)按照以下方式表现:

3 * 3
mempty + 3 + 3 + 3
0 + 3 + 3 + 3
9

Just 3 * Just 4
Just (3 * 4)
Just (3 + 3 + 3 +3)
Just 12

[1, 2, 3] * [10, 20, 30]
[1 * 10, 2 * 10, 3 * 10, ...]
[10, 20, 30, 20, 40, 60, ...]
实际上,“mmultiply”只需根据“mappend”定义即可,因此对于Monoid的实例,无需重新定义它!然后,Monoid更接近数学;也许我们也可以将(-)(/)添加到类中! 如果这样做有效,我认为它将解决SumProduct以及函数重复的情况:mappend变成了(+),而新的mmultiply就是(*)。 基本上,我建议使用“提取”来重构代码。 哦,我们还需要一个新的mempty用于(*)。 我们可以在类MonoidOperator中抽象这些运算符,并定义Monoid如下:
class (Monoid m) => MonoidOperator mo m where
    mempty :: m
    mappend :: m -> m -> m

instance MonoidOperator (+) m where
    mempty = 0
    mappend = --definition of (+)

instance MonoidOperator (*) where
    --...

class Monoid m where
    -...

我不知道如何做到这一点,但我认为有一个很酷的解决方案可以解决所有这些问题。


3
数字可以通过两种不同的方式成为Monoid的一个实例 - 加法和乘法。确实存在一些重复的函数,应该将它们合并在一起(mapfmapliftMliftA(<*>)ap和许多其他函数),但我认为mappend(或在较新版本中的(<>))不是需要合并的函数之一。 - Vitus
我很欣赏你的热情,但按照你的建议改变Monoid类型类将是一件真正可惜的事情:有许多类型在两种不同(有用的)方式下并不是单子。 - Daniel Wagner
3
数字可以以多种不同的方式形成一个“幺半群”。如果将“mmultiply”添加到“Monoid”类中,则它不再是一个幺半群。至于“(++)”和“mappend”,它们确实可以是相同的。在旧版本的Haskell中,它们确实是相同的。将“(++)”仅适用于列表的原因是出于教学目的;对于初学者来说,太多类型类用于简单操作会很困难。这也是为什么“fmap”不叫做“map”的原因。 - augustss
@DanielWagner:是的,我明白这不是Haskell特有的东西,而且还有很多我甚至不知道存在的东西:o。 - L01man
@augustss:我还没有接触过Hugs,但我认为保留mappend(++)在其中一个实现中会更好,但在其他实现中放弃前者。此外,我不确定保留两者的教学用途是否合适:这使得解释类型类更加困难,因为初学者会问“我必须为每个实例创建重复函数吗?[...]那么为什么Haskell,这种超级干净的语言,要这样做?”从实际角度来看,这并不方便... - L01man
显示剩余3条评论
4个回答

11

你在混淆有些分离的概念。

算术和列表连接是非常实用、直接的操作。如果你写下:

[1, 2] ++ [3, 4]

...你知道你会得到[1, 2, 3, 4]作为结果。


Monoid是一个更抽象层次的数学代数概念。这意味着mappend不必字面意思上的“将这个附加到那个”;它可以有很多其他的含义。当你写下:

[1, 2] `mappend` [3, 4]

这些是该操作可能产生的一些有效结果:

[1, 2, 3, 4] -- concatenation, mempty is []

[4, 6]       -- vector addition with truncation, mempty is [0,0..]

[3, 6, 4, 8] -- some inner product, mempty is [1]

[3, 4, 6, 8] -- the cartesian product, mempty is [1]

[3, 4, 1, 2] -- flipped concatenation, mempty is []

[]           -- treating lists like `Maybe a`, and letting lists that
             -- begin with positive numbers be `Just`s and other lists
             -- be `Nothing`s, mempty is []

为什么列表的 mappend 只是连接列表?因为这是Haskell报告的作者选择的单子定义的默认实现,可能是因为它适用于列表的所有元素类型。确实,您可以通过将列表包装在各种新类型中使用替代的列表单子实例;例如,有一种替代的列表单子实例,对它们执行笛卡尔积。

"单子"这个概念在数学中有固定的含义和悠久的历史,在Haskell中改变其定义意味着偏离数学概念,这是不应该发生的。单子不仅仅是描述空元素和(字面意义上的)附加/连接操作的概念;它是广泛的概念的基础,这些概念遵循单子提供的接口。


您要查找的概念是特定于数字的(因为您无法为所有Maybe a实例定义像mmultiplymproduce/mproduct这样的东西),这个概念在数学中被称为Semiring(好吧,您并没有在问题中涵盖结合性,但您在不同的示例之间跳来跳去,有时遵循结合性,有时不遵循,但总体思想是相同的)。

在Haskell中已经有Semirings的实现,例如在algebra包中。

然而,单子通常不是半环,除加法和乘法之外,对于实数还有多个Semirings的实现。将广义添加到非常明确定义的类型类(如Monoid)中,不应该仅仅因为它“很好”或“可以节省一些击键数”;我们单独拥有(++)(+)mappend,因为它们代表完全不同的计算思想。


谢谢,我现在对整个事情有了更好的理解。我想等我学了更多的数学后再回来 :}。 - L01man
事实上,Num中的所有操作都在同一个类型类中,这是不幸的。一方面,这意味着对于Complex Integer应该明确定义的操作无法使用! - Jeremy List
1
@deflemstr 在阅读了您的回答后,我有一种印象,即您对于“Num”实例与“List”实例之间的区别留下了疑问。我的意思是,您说“List”有许多可能的“mappend”,同样适用于“Num”,但是为“List”选择了一个默认值,而没有为“Num”选择默认值。那么,将“+”作为“Num”的“mappend”有什么问题呢?您仍然可以使用新类型进行乘法运算。 - Gus

9
关于将mappend重命名为(+)/(*) 尽管(+)(*)都是幺半群,它们还具有额外的分配律,将这两个操作关联起来,以及可消去律例如0 * x = 0。本质上,(+)(*)形成了一个。其他类型上的两个幺半群可能不满足这些环(甚至是更弱的半环)属性。运算符(+)(*)的命名提示了它们的额外(相互关联的)属性。因此,我会避免通过将mappend重命名为+*来颠覆传统数学直觉,因为这些名称表明可能不成立的附加属性。有时候过度重载(即过度泛化)会导致直觉丧失,从而导致可用性下降。
如果您确实有两个形成某种环的幺半群,那么您可能想从中导出Num的实例,因为名称“+”和“*”表明了附加属性。
关于混淆(++)和mappend
mappend重命名为(++)可能更合适,因为与(++)相比,其附加的心理负担更小。事实上,由于列表是自由幺半群(即,没有额外属性的幺半群),因此使用传统的列表连接运算符(++)来表示幺半群的二元操作似乎不是一个可怕的想法。
关于为一个类型定义多个幺半群
正如您所指出的,(+)(*)都是幺半群,但两者都不能为同一类型tMonoid的实例。其中一个解决方案是,在Monoid类中添加一个额外的类型参数以区分两个幺半群。请注意,类型类只能由类型参数化,而不是像您在问题中展示的那样由表达式参数化。一个适当的定义可能是:
class Monoid m variant where
 mappend :: variant -> m -> m -> m
 mempty :: variant -> m

data Plus = Plus
data Times = Times

instance Monoid Int Plus where
    mappend Plus x y = x `intPlus` y
    mempty = 0

instance Monoid Int Times where
    mappend Times x y = x `intTimes` y
    mempty = 1

(+) = mappend Plus
(*) = mappend Times 

为了使mappend/mempty的应用程序能够解析到特定操作,每个操作都必须采用一个值来证明其类型,该类型指示特定的单子“变体”。
另外:你提出的问题标题中提到了mconcat。这是与mappend完全不同的操作-mconcat是从自由单子到其他单子的单子同态,即用mappend替换cons,用mempty替换nil

4
如果您查看Hackage,您会发现有许多替代的Prelude实现,旨在解决这些问题。其中包括:numeric-prelude、np-extras、prelude-extras、prelude-generalize、yap、prelude-plus、simpleprelude和ClassyPrelude。

3
有两个用于数字的幺半群,一个是“积”(Product),另一个是“和”(Sum)。你会如何处理?
在 Haskell 这样一个强调抽象和其他东西的美丽语言中,有三个或更多函数执行相同的任务并不是件好事。
抽象并不意味着消除代码重复。算术和幺半群操作是两个不同的概念,尽管它们有共享语义的情况,但合并它们并没有任何好处。

5
“Product”和“Sum”并不是数字上可能的两个单子代数中仅有的两个,它们只是最常用的两个单子代数。 - Daniel Wagner
我刚刚看到Num不是Monoid的实例。我觉得为“操作”而不是数字本身设置monoid很奇怪。 - L01man
@L01man:因为那样问题就变成了:数字的单子操作是什么?由于这个问题没有最佳答案,所以我们使用newtype包装器。 - Asherah
我意识到这个问题,但即使我不知道如何处理它,我觉得Num不是一个Monoid,[]是,Sum和Product也是,但(++)不是,有点尴尬。等等...我想我终于明白了,如果我们把Sum、Product、[]和Maybe看作盒子的话,那么在instance Monoid [a]中,a不是一个Monoid,就像在instance Monoid (Sum a)中一样。我只是困惑为什么SumProduct(Num a)[]没有。 那么,(+)(*)是不好的函数吗? - L01man

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接