`Data.Monoid` 中所有这些新类型包装器有什么实际价值?

18

当我查看 Data.Monoid时,我看到有各种newtype包装器,例如 AllSumProduct,它们编码了各种类型的单子。然而,在尝试使用这些包装器时,我不禁想知道与使用它们的非 Data.Monoid 对应物相比,有什么好处。例如,比较繁琐的求和操作

print $ getSum $ mconcat [ Sum 33, Sum 2, Sum 55 ]

与更简洁的惯用语变体相比

print $ sum [ 33, 2, 55 ]

但是这样做有什么意义呢?所有这些newtype包装器是否有任何实际价值?是否存在更令人信服的Monoidnewtype包装器使用示例,优于上面提到的那个示例?


6
我不太常用它们。 - augustss
2
sum = getSum . Data.Foldable.foldMap Sum - user2407038
5个回答

34

Monoid新类型:零空间无操作,告诉编译器该执行什么操作

Monoid很适合用于在新类型中包装现有数据类型,以告诉编译器你想要进行的操作。

由于它们是新类型,它们不占用任何额外空间,并且应用SumgetSum是一个无操作。

示例:Foldable中的Monoids

泛化foldr有不止一种方法(参见这个非常好的问题了解最通用的折叠方式,以及这个问题如果你喜欢下面的树示例,但想看到树的最通用折叠)。

一种有用的方式(不是最通用的方式,但绝对有用)是说如果你可以使用二元操作和起始/身份元素将其元素组合成一个元素,则可以将某些东西称为可折叠的。这就是Foldable类型类的重点。

与显式传递二元操作和起始元素不同,Foldable只要求元素数据类型是Monoid的实例。

乍一看,这似乎令人沮丧,因为我们只能针对每种数据类型使用一个二元操作-但是我们应该使用(+)0进行Int的求和,但永远不要使用乘法,还是反过来?也许我们应该对于Int使用((+),0),对于Integer使用(*),1,并在需要执行另一个操作时进行转换?那样会浪费很多宝贵的处理器周期吗?

Monoids出手相助

我们所需要做的就是如果我们想要添加,则使用Sum标记,如果我们想要乘以,则使用Product标记,或者甚至使用手动滚动的新类型进行标记,如果我们想要执行不同的操作。

让我们折叠一些树!我们将需要

fold :: (Foldable t, Monoid m) => t m -> m    
   -- if the element type is already a monoid
foldMap :: (Foldable t, Monoid m) => (a -> m) -> t a -> m
   -- if you need to map a function onto the elements first
DeriveFunctorDeriveFoldable 扩展 ({-# LANGUAGE DeriveFunctor, DeriveFoldable #-}) 非常适合您想要在不编写繁琐实例的情况下映射和折叠自己的 ADT。请注意保留 HTML 标签。
import Data.Monoid
import Data.Foldable
import Data.Tree
import Data.Tree.Pretty -- from the pretty-tree package

see :: Show a => Tree a -> IO ()
see = putStrLn.drawVerticalTree.fmap show

numTree :: Num a => Tree a
numTree = Node 3 [Node 2 [],Node 5 [Node 2 [],Node 1 []],Node 10 []]

familyTree = Node " Grandmama " [Node " Uncle Fester " [Node " Cousin It " []],
                               Node " Gomez - Morticia " [Node " Wednesday " [],
                                                        Node " Pugsley " []]]

使用示例

字符串已经使用(++)[]作为幺半群,因此我们可以使用fold来处理它们,但数字不是幺半群,因此我们将使用foldMap对其进行标记。

ghci> see familyTree
               " Grandmama "                
                     |                      
        ----------------------              
       /                      \             
" Uncle Fester "     " Gomez - Morticia "   
       |                      |             
 " Cousin It "           -------------      
                        /             \     
                  " Wednesday "  " Pugsley "
ghci> fold familyTree
" Grandmama  Uncle Fester  Cousin It  Gomez - Morticia  Wednesday  Pugsley "
ghci> see numTree       
     3                  
     |                   
 --------               
/   |    \              
2   5    10             
    |                   
    --                  
   /  \                 
   2  1                 

ghci> getSum $ foldMap Sum numTree
23
ghci> getProduct $ foldMap Product numTree
600
ghci> getAll $ foldMap (All.(<= 10)) numTree
True
ghci> getAny $ foldMap (Any.(> 50)) numTree
False

自定义Monoid

但是如果我们想要找到最大的元素呢?我们可以定义自己的monoid。我不确定为什么Max(和Min)没有被包含在内。也许是因为没有人喜欢考虑Int被限制或者他们不喜欢基于实现细节的身份元素。无论如何,这里是代码:

newtype Max a = Max {getMax :: a}

instance (Ord a,Bounded a) => Monoid (Max a) where
   mempty = Max minBound
   mappend (Max a) (Max b) = Max $ if a >= b then a else b

ghci> getMax $ foldMap Max numTree :: Int  -- Int to get Bounded instance
10

结论

我们可以使用newtype Monoid包装器告诉编译器如何将两个事物组合在一起。

标签本身没有作用,只是展示了要使用哪种组合函数。

这就像将函数隐式地作为参数传入,而不是显式地传入(因为这也是类型类的作用)。


9
在这样的情况下怎么办呢:
myData :: [(Sum Integer, Product Double)]
myData = zip (map Sum [1..100]) (map Product [0.01,0.02..])

main = print $ mconcat myData

或者不使用newtype包装和Monoid实例:

myData :: [(Integer, Double)]
myData = zip [1..100] [0.01,0.02..]

main = print $ foldr (\(i, d) (accI, accD) -> (i + accI, d * accD)) (0, 1) myData

这是因为 (Monoid a,Monoid b) => Monoid (a,b)。现在,如果您有自定义数据类型,并且想要对这些值的元组进行折叠并应用二进制操作,您可以简单地编写一个新类型包装器,并使用该操作使其成为Monoid的实例,构造您的元组列表,然后只需使用mconcat跨它们进行折叠。还有许多其他函数也适用于Monoid,不仅仅是mconcat,因此肯定有无数的应用。
您还可以查看Maybe aFirstLast新类型包装器,我可以想到许多用途。如果您需要组合大量函数,则Endo包装器很好,AnyAll包装器适用于布尔值的处理。

1
顺便提一下,第一个示例的输出与第二个示例不同。 - danom
@danom 你确定吗?我刚刚将它复制/粘贴到GHCi中,结果也是一样的。对于第一个示例,我没有解包结果,但那很简单。 - bheklilr
2
@danom 你可能会发现这篇文章也很有帮助,它描述了使用Writer (Sum Integer) Integer而不是State Integer Integer来计算一个计算过程所需的步骤数,例如。 它在类型签名中明确说明我们不能将该值乘以或执行除添加另一个数字之外的任何操作。 - bheklilr

6
假设您正在使用Writer单子,并且希望存储您tell的所有内容的总和,那么您将需要newtype包装器。
您还需要newtype来使用像foldMap这样具有Monoid约束的函数。 Control.Lens.Wrapped中的alaalaf组合器在lens软件包中可以使处理这些新类型更加愉快。 从文档中得知:
>>> alaf Sum foldMap length ["hello","world"]
10

>>> ala Sum foldMap [1,2,3,4]
10

4
有时您可能需要一个特定的Monoid 来填充类型约束。有时候这会表现出来,就是只有当 Const 存储一个 Monoid 时,它才具有 Applicative 实例。
instance Monoid m => Applicative (Const m) where
  pure _ = Const mempty
  Const a <*> Const b = Const (a <> b)

显然有点奇怪,但有时候这就是你需要的。我知道最好的例子在 lens 中,那里你最终会得到像这样的类型:

type Traversal s a = forall f . Applicative f => (a -> f a) -> (s -> f s)

如果你使用Monoid的新类型Firstf专门化为像Const First这样的形式

newtype First a = First { getFirst :: Maybe a }

-- Retains the first, leftmost 'Just'
instance Monoid (First a) where
  mempty = First Nothing
  mappend (First Nothing)  (First Nothing) = First Nothing
  mappend (First (Just x)) _               = First (Just x)

那么我们可以解释那个类型。
(a -> Const (First a) a) -> (s -> Const (First a) s)

浏览s并拾取其中第一个a,这是一种扫描的方法。


因此,虽然这是一个非常具体的答案,但广义上的回答是,有时候能够谈论许多不同的默认Monoid行为是有用的。毕竟,必须要有人编写所有明显的Monoid行为,并且它们最好放在Data.Monoid中。


优秀的例子;它并不怪异,反而很有用。它被解释为一种在将applicative转换/组合时向应用程序添加日志记录的方式,并且值得一读的是来自Comonad Reader的使用Applicatives进行抽象 - AndrewC
哦,那是一个简单得多的例子。谢谢指出! - J. Abrahamson
“instance Monoid m => Applicative (Const m)” 也是一个很好的非单子应用实例 - 没有 a 可以绑定。感谢您提出这个问题! - AndrewC
这是我两个常用示例之一!另一个是纯应用程序 Either。 - J. Abrahamson

2

我认为基本思路是,您可以拥有类似于

reduce = foldl (<>) mempty

它适用于任何那些被包裹的东西列表。


2
“reduce” 不就是用 foldl 而非 foldr 实现的 mconcat 吗? - danom

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