为记录类型定义一个幺半群实例

Defining a monoid instance for a record type

假设我有一个像

这样的类型
data Options = Options
  { _optionOne :: Maybe Integer
  , _optionTwo :: Maybe Integer
  , _optionThree :: Maybe String
  } deriving Show

还有更多字段。我想为此类型定义一个 Monoid 实例,其 mempty 值是一个 Options,所有字段为 Nothing。有没有比

更简洁的写法
instance Monoid Options where
  mempty = Options Nothing Nothing Nothing
  mappend = undefined

当我的 Options 有更多的字段时,这将避免编写一堆 Nothing 的需要?

我建议只写 Nothings,甚至明确拼出所有记录字段,这样您就可以确保在添加具有不同 [=16 的新字段时不会错过任何一个案例=] 值,或重新排序字段:

mempty = Options
  { _optionOne = Nothing
  , _optionTwo = Nothing
  , _optionThree = Nothing
  }

我之前没试过,不过看来你可以使用generic-deriving包来达到这个目的,只要你记录的所有字段都是Monoids。您将添加以下语言编译指示和导入:

{-# LANGUAGE DeriveGeneric #-}
import GHC.Generics (Generic)
import Generics.Deriving.Monoid

deriving (Generic) 添加到您的数据类型,并将所有非 Monoid 字段包装在 Data.Monoid 的类型中,并具有您想要的组合行为,例如 First , Last, Sum, 或 Product:

data Options = Options
  { _optionOne :: Last Integer
  , _optionTwo :: Last Integer
  , _optionThree :: Maybe String
  } deriving (Generic, Show)

示例:

  • Last (Just 2) <> Last (Just 3) = Last {getLast = Just 3}
  • First (Just 2) <> First (Just 3) = First {getFirst = Just 2}
  • Sum 2 <> Sum 3 = Sum {getSum = 5}
  • Product 2 <> Product 3 = Product {getProduct = 6}

然后使用 Generics.Deriving.Monoid 中的以下函数创建您的默认实例:

memptydefault :: (Generic a, Monoid' (Rep a)) => a
mappenddefault :: (Generic a, Monoid' (Rep a)) => a -> a -> a

在上下文中:

instance Monoid Options where
  mempty = memptydefault
  mappend = ...

如果您的记录类型的 Monoid 实例自然地遵循记录字段的 Monoid 实例,那么您可以使用 Generics.Deriving.Monoid。代码可能如下所示:

{-# LANGUAGE DeriveGeneric #-}

import GHC.Generics
import Generics.Deriving.Monoid

data Options = { .. your options .. }
             deriving (Show, Generic)

instance Monoid Options where
  mempty = memptydefault
  mappend = mappenddefault

请注意,记录字段也必须是 Monoid,因此您必须将 Integer 包装成 SumProduct(或者可能是其他一些newtype) 取决于您想要的确切行为。

然后,假设您希望生成的幺半群与 Integer 之上的加法同步并使用 Sum 新类型,结果行为将是:

> mempty :: Options
Options {_optionOne = Nothing, _optionTwo = Nothing, _optionThree = Nothing}
> Options (Just $ Sum 1) (Just $ Sum 2) (Just $ Sum 3) <> Options (Just $ Sum 1) (Just $ Sum 2) Nothing
Options {_optionOne = Just (Sum {getSum = 2}), _optionTwo = Just (Sum {getSum = 4}), _optionThree = Just (Sum {getSum = 3})}

查看 generic-monoid package on hackage. Specifically, the Data.Monoid.Generic module. We can automatically derive the semigroup and monoid instances with the DerivingVia 扩展。这样,当您的记录很大并且记录中的每个字段都已经是幺半群时,您可以避免编写大量的 mappendmempty 函数。该文档给出了以下示例:

data X = X [Int] String
  deriving (Generic, Show, Eq)
  deriving Semigroup via GenericSemigroup X
  deriving Monoid    via GenericMonoid X

这是可行的,因为 [Int] 是一个幺半群,而 String 是一个幺半群。在这两个字段中,mappend 是串联,mempty 是空列表 [] 和空字符串 ""。因此我们可以使 X 成为一个幺半群。

X [] "" == (mempty :: X)
True

请记住,Haskell 要求如果你想定义一个幺半群,你需要一个半群。我们看到 typeclass of Monoid 具有 Semigroup 约束:

class Semigroup a => Monoid a where
 ...

不幸的是,在您的 Option 记录中并非所有字段都是幺半群。具体来说,Maybe Int 不满足 Semigroup 约束 out-of-the-box 因为 Haskell 不知道你要如何 mappend 两个 Int,也许你会添加 (+) 它们或者你可能想乘以 (*) 它们等等。我们可以通过从 Data.Monoid 借用常见的幺半群(或编写我们自己的)并制作所有Option 个幺半群的字段。

{-# DeriveGeneric #-}
{-# DerivingVia   #-}

import GHC.Generics
import Data.Monoid
import Data.Monoid.Generic

data Options = Options
  { _optionOne   :: First Integer
  , _optionTwo   :: Sum   Integer
  , _optionThree :: Maybe String
  } 
  deriving (Generic, Show, Eq)
  deriving Semigroup via GenericSemigroup Options
  deriving Monoid    via GenericMonoid Options

您在问题中未定义 mappend 函数,所以我只是随机选择了一些幺半群来展示多样性(您可能会发现 Maybe wrappers 很有趣,因为它们的 memptyNothing). Firstmappend 总是选择第一个参数而不是第二个参数,它的 memptyNothingSummappend 只是添加 Integers 而其 mempty 为零 0Maybe String 已经是一个幺半群,mappend 作为 String 连接,mempty 作为 Nothing。一旦每个域都是幺半群,我们就可以通过 GenericSemigroupGenericMonoid.

推导出半群和幺半群
mempty :: Options
Options {
  _optionOne = First { getFirst = Nothing },
  _optionTwo = Sum { getSum = 0 },
  _optionThree = Nothing
}

的确,mempty 符合我们的预期,我们不必为 Options 类型编写任何幺半群或半群实例。 Haskell 能够为我们导出它!

P.S。关于使用 Maybe a 作为幺半群的快速说明。它的 memptyNothing,但它还要求 a 是一个半群。如果 mappend 的任何一个参数(或者因为我们正在谈论半群,它的 <>)是 Nothing,那么选择另一个参数。但是,如果两个参数都是 Just,我们使用 a 的底层半群实例的 <>.

instance Semigroup a => Semigroup (Maybe a) where
    Nothing <> b       = b
    a       <> Nothing = a
    Just a  <> Just b  = Just (a <> b)

instance Semigroup a => Monoid (Maybe a) where
    mempty = Nothing