为什么在单子中使用这种奇怪的函数类型?

20

我刚开始学习 Haskell,正在尝试理解 Monad。 Monad 绑定运算符 -- >>= -- 有一个非常奇怪的类型签名:

(>>=) :: Monad m => m a -> (a -> m b) -> m b
为了简化,我们将m替换为Maybe:
(>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b

请注意,这个定义可能以三种不同的方式书写:

(>>=) :: Maybe a -> (Maybe a -> Maybe b) -> Maybe b
(>>=) :: Maybe a -> (      a -> Maybe b) -> Maybe b
(>>=) :: Maybe a -> (      a ->       b) -> Maybe b

这三个中间的那个最不对称。然而,如果我们想要避免(LYAH所谓的样板代码),我理解第一个有点无意义。然而,在接下来的两个中,我更喜欢最后一个。对于Maybe,它应该如下所示:

当定义为:

(>>=) :: Maybe a -> (a -> b) -> Maybe b

instance Monad Maybe where
   Nothing  >>= f = Nothing
   (Just x) >>= f = return $ f x
在这里,a -> b 是一个普通函数。此外,我没有立即看到任何不安全的地方,因为 Nothing 在函数应用之前捕获了异常,所以除非得到 Just a,否则不会调用 a -> b 函数。

那么也许有一些对我不明显的东西导致了 (>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b 定义优先于更简单的 (>>=) :: Maybe a -> (a -> b) -> Maybe b 定义?是否存在某些固有问题与(我认为是)更简单的定义相关联?


当你说绑定的定义可以不同的时候,你可能是错误的;请注意f :: Maybe a -> (Maybe a -> Maybe b) -> Maybe b == flip ($); and g :: Maybe a -> ( a -> b) -> Maybe b == flip fmap。因此,你列出的其他函数都是有用的且存在的,它们只是存在于Monad`之外的另一个位置。 - user2407038
也许 Maybe a -> (a -> b) -> Maybe b 不能绑定返回 Maybe b 的函数。在计算过程中,你将无法得到 Nothing - user253751
1
另一种可能性是 Maybe a -> Maybe (a -> b) -> Maybe b,这基本上是来自 Applicative<*>(介于 Functor 和 Monad 之间)。 - Matt Fenwick
5个回答

25

如果你使用以下导出函数(来自Control.Monad),那么思考会更加对称:

(>=>) :: Monad m => (a -> m b) -> (b -> m c) -> (a -> m c)
(f >=> g) x = f x >>= g

这个函数之所以重要,是因为它符合三个有用的方程式:

-- Associativity
(f >=> g) >=> h = f >=> (g >=> h)

-- Left identity
return >=> f = f

-- Right identity
f >=> return = f

这些是范畴法则,如果你将它们翻译成使用(>>=)而不是(>=>),你就得到了三个单子律:

(m >>= g) >>= h = m >>= \x -> (g x >>= h)

return x >>= f = f x

m >>= return = m

所以实际上不是 (>>=) 是优雅的运算符,而是 (>=>) 是您要寻找的对称运算符。然而,我们通常之所以考虑使用 (>>=) 是因为这是 do 符号的语法糖。


谢谢你的回答。我有点明白你在说什么。在完全理解之前,我可能需要通过解决一堆实例来弄懂你的意思,但我很感谢你的回复。 - ssm

10

让我们考虑一下Maybe单子的常见用途之一:处理错误。假设我想要安全地除两个数字。我可以编写这个函数:

safeDiv :: Int -> Int -> Maybe Int
safeDiv _ 0 = Nothing
safeDiv n d = n `div` d

然后使用标准的Maybe单子,我可以这样做:
foo :: Int -> Int -> Maybe Int
foo a b = do
  c <- safeDiv 1000 b
  d <- safeDiv a c  -- These last two lines could be combined.
  return d          -- I am not doing so for clarity.

注意,每个步骤中,safeDiv 可能会失败,但在两个步骤中,safeDiv 接受的是 Int 而不是 Maybe Int。如果 >>= 具有这样的签名:
(>>=) :: Maybe a -> (a -> b) -> Maybe b

您可以将函数组合在一起,然后给它一个NothingJust,它会解包Just,通过整个管道并重新包装成Just,或者只是传递Nothing而不做任何改变。这可能很有用,但它不是一个monad。为了有任何用处,我们必须能够在中间失败,这就是这个签名给我们的东西:
(>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b

顺便提一下,您设计签名的东西确实存在:
flip fmap :: Maybe a -> (a -> b) -> Maybe b

3
确实,fmap :: (Functor f) => (a -> b) -> f a -> f b非常有用,但在上述意义下,它比(>>=)的能力严格弱。 - duplode
+1 我了解到我正在查看的定义已经是一个函子?这是正确的吗?如果是这样,我将不得不查找函子和单子之间的区别以及为什么单子更好的原因。我并不立即理解所有内容,但现在我知道该去哪里寻找答案了。感谢您的回答。 - ssm
4
@ssm:单子并不是比其他东西更好,只是在某种意义上你可以用它们做更多的事情。所有的单子都是函子,因此一个单子是带有一些额外内容的函子。同样,应用函子也是如此。事实上,所有的单子都是应用函子,所有的应用函子都是函子。 - David Young

3
更复杂的函数使用 a -> Maybe b 是更通用和更实用的,并且可以用来实现简单的函数。反之则不行。
你可以从一个函数 f :: a -> b 构建一个 a -> Maybe b 函数:
f' :: a -> Maybe b
f' x = Just (f x)

或者用return的说法(对于Maybe来说就是Just):

f' = return . f

另一种方式未必可行。如果你有一个函数 g :: a -> Maybe b 并希望使用“简单”的 bind,你首先需要将其转换为函数 a -> b。但通常这并不起作用,因为 g 可能返回 Nothing,而 a -> b 函数需要返回一个 b 值。
所以通常情况下,“简单”绑定可以用“复杂”的绑定实现,但反过来通常是不可能的。此外,“复杂”的绑定通常很有用,没有它会使许多事情变得不可能。因此通过使用更通用的绑定,monad 可适用于更多的情况。

2

使用 (>>=) 的替代类型签名存在问题,因为它只在 Maybe 单子中偶然起作用。如果您尝试在另一个单子中使用它(例如 List 单子),您会发现它在一般情况下会崩溃,因为它无法描述单子绑定并且不符合单子法则。

import Prelude hiding (Monad, return)

-- assume monad was defined like this
class Monad m where
  (>>=)  :: m a -> (a -> b) -> m b
  return :: a -> m a

instance Monad Maybe where
  Nothing  >>= f = Nothing
  (Just x) >>= f = return $ f x

instance Monad [] where
  m >>= f   =  concat (map f m)
  return x  =  [x]

出现类型错误,导致失败:
    Couldn't match type `b' with `[b]'
      `b' is a rigid type variable bound by
          the type signature for >>= :: [a] -> (a -> b) -> [b]
          at monadfail.hs:12:3
    Expected type: a -> [b]
      Actual type: a -> b
    In the first argument of `map', namely `f'
    In the first argument of `concat', namely `(map f m)'
    In the expression: concat (map f m)

1
那么,简单地说 m >>= f = map f m 怎么样? - ssm
1
@ssm:这是一个很好的演示,证明了您提出的>>=类型签名在功能上与Functor完全等效(现有的MonadFunctor更强大)。 - David Young
这正是fmap的Functor定义,而不是Monad。 - Stephen Diehl
2
@ssm,你可能会发现在 Functor 之后、Monad 之前查看 Applicative 类型类也很有帮助。Applicative 函子有点像一个较弱的 Monad,可能有助于你理解使用 Monad 的动机。 - unfoldr
2
此外,定义你想要的函数(fmap)与>>=之间的差异的“缺失”步骤是一个函数join :: (Monad m) => m(m a)->m a,其中m >>= f等同于join(fmap f m)。因此,当你重新审视Monad时,如果你希望了解绑定相对于fmap提供了什么,分析在某些示例中join所做的事情可能会给你一些见解。如果这太令人困惑,可以暂时忽略它。 - unfoldr
显示剩余3条评论

1
一件使单子成为单子的事情就是'join'的工作方式。回想一下,join的类型如下:
join :: m (m a) -> m a

"join" 的作用是将返回一个单子动作的单子动作解释为一个单子动作。因此,你可以想象它剥离了单子的一层(或更好地说,将内层的内容拉出到外层)。这意味着 'm' 形成了一个“堆栈”,就像“调用堆栈”的概念一样。每个 'm' 代表一个上下文,'join' 允许我们按顺序将上下文连接在一起。
那么,这与 bind 有什么关系?回想一下:
(>>=) :: m a -> (a -> m b) -> m b

现在考虑 f :: a -> m b 和 ma :: m a 的情况:
fmap f ma :: m (m b)

即,将 f 直接应用于 ma 中的 a 的结果是 (m (m b))。我们可以应用 join 来获取 m b。简而言之,
ma >>= f = join (fmap f ma)

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