在Haskell中组合monad

12

我正试图编写一个蜘蛛纸牌的Haskell玩家来进行学习练习。

我的main函数将使用mapM依次调用playGame函数进行每个游戏,传递游戏编号和一个随机生成器(StdGen)。playGame函数应该返回一个Control.Monad.State单子和一个包含展示游戏局面的String以及表示游戏是否赢得或输掉的Bool的IO单子。

如何将State单子与IO单子组合为返回值?对于`playGame`,类型声明应该是什么?

playGame :: Int -> StdGen a -> State IO (String, Bool)

State IO (String, Bool)这种类型定义正确吗?如果不是,应该是什么?

main函数中,我计划使用它。

do
  -- get the number of games from the command line (already written)
  results <- mapM (\game -> playGame game getStdGen) [1..numberOfGames]

这是调用playGame的正确方式吗?


你可能也会喜欢 MonadRandom 包中的 RandT - Daniel Wagner
3个回答

17
你需要的是 StateT s IO (String, Bool),其中 StateTmtl 包和 transformers 包中的 Control.Monad.StateControl.Monad.Trans.State 提供。
这种普遍现象被称为单子变换器,你可以在 单子变换器逐步介绍 中阅读一个很好的介绍。
有两种定义它们的方法。其中一种方法在 transformers 包中找到,它使用 MonadTrans 类来实现它们。第二种方法在 mtl 类中找到,并为每个单子使用一个单独的类型类。 transformers 方法的优点是使用单个类型类来实现所有内容(在 此处 找到)。
class MonadTrans t where
    lift :: Monad m => m a -> t m a

lift 具有两个很好的特性,任何 MonadTrans 的实例都必须满足这些特性:

(lift .) return = return
(lift .) f >=> (lift .) g = (lift .) (f >=> g)

这些是伪装成函数子定律的内容,其中(lift .) = fmapreturn = id(>=>) = (.)mtl类型类方法也有其优点,有些问题只能使用mtl类型类干净地解决,但缺点是每个mtl类型类都有自己的一套规则,你必须记住它们,以便实现它们的实例。例如,MonadError类型类(在这里找到)被定义为:
class Monad m => MonadError e m | m -> e where
    throwError :: e -> m a
    catchError :: m a -> (e -> m a) -> m a

这个类也有它的规则:

m `catchError` throwError = m
(throwError e) `catchError` f = f e
(m `catchError` f) `catchError` g = m `catchError` (\e -> f e `catchError` g)

这只是伪装成单子律的东西,其中throwError = returncatchError = (>>=)(而单子律则是类别法则的伪装,其中return = id(>=>) = (.))。

对于您的具体问题,您编写程序的方式将是相同的:

do
  -- get the number of games from the command line (already written)
  results <- mapM (\game -> playGame game getStdGen) [1..numberOfGames]

...但是当你编写playGame函数时,它可能看起来像:

-- transformers approach :: (Num s) => StateT s IO ()
do x <- get
   y <- lift $ someIOAction
   put $ x + y

-- mtl approach :: (Num s, MonadState s m, MonadIO m) => m ()
do x <- get
   y <- liftIO $ someIOAction
   put $ x + y

当您开始堆叠多个单子变换器时,这些方法之间的差异会变得更加明显,但我认为现在这是一个很好的开始。


非常好的和完整的答案。谢谢。 - Ralph
1
StateT IO (String, Bool) 是不正确的 - 类型不匹配。应该是 StateT s m a,其中 s 是状态类型,m 是一个单子,而 a 是结果类型。 - Ben Millwood
1
此外,“mtl”方法和“transformers”方法并不是做同一件事情的不同方式——MonadError实现的目标与MonadTrans不同。而且,这些法则并不是伪装成Monad法则——它们非常相似,但所涉及的事物类型及其含义是不同的。我的意思是,它们基本上是一个单位法则和一个关联法则,但是针对完全不同的操作。 - Ben Millwood
@benmachine 谢谢,已修复。此外,MonadError法则实际上只是受类型限制的单子法则,因为通过函数依赖,e被隐藏在m中,所以您无法访问它们的全部功能,但您仍然可以使用这些法则来验证您正确地实现了一个实例。 - Gabriella Gonzalez
我不确定你的新类型是否是他想要的 - s代表什么?关于MonadError的东西,我有点明白你的意思,但我不确定类比是否像你所说的那样基本 - 我认为错误类型由monad而不是用户确定是一个重要的区别。我坚持我的评论,即mtltransformers方法非常解决不同的问题 - 请注意,现在mtl 大量导入和使用transformers,因此暗示它们正在竞争是奇怪的。 - Ben Millwood
显示剩余3条评论

8

State是一个单子,而IO是一个单子。你试图从头开始编写的内容被称为“单子变换器”,而Haskell标准库已经定义了你需要的内容。

看一下状态单子变换器StateT:它有一个参数,即你想要包装到State中的内部单子。

每个单子变换器实现了一堆类型类,使得对于每个实例,变换器在每次能够处理它时处理它(例如,状态变换器只能直接处理与状态相关的函数),或者以这样一种方式传播调用到内部单子,使得当你可以堆叠所有你想要的变换器时,你可以拥有一个统一的接口来访问它们所有的特性。如果你想这样看的话,这是一种责任链的形式。

如果你在chain of responsibility上查看hackage,或者在stack overflow或google上进行快速搜索,你会找到很多使用StateT的示例。

编辑:另一个有趣的阅读材料是Monad Transformers Explained


1
我喜欢在学习Haskell的过程中多次重新发现轮子...实际上,发现一个问题的解决方案也是一种通用的设计模式,这真的很不错。 - fuz
1
@FUZxxl:是的,确实是 :) - Riccardo T.

2

好的,这里有几件事情需要澄清:

  • You can't "return a monad". A monad is a kind of type, not a kind of value (to be precise, a monad is a type constructor that has an instance of the Monad class). I know this sounds pedantic, but it might help you sort out the distinction between things and types-of-things in your head, which is important.
  • Note that you can't do anything with State that is impossible without it, so if you're confused about how to use it, then don't feel you need to! Often, I just write the ordinary function type I want, and then if I notice I have a lot of functions shaped like Thing -> (Thing, a) I would go "aha, this looks a bit like State, maybe this can be simplified to State Thing a". Understanding and working with plain functions is an important first step on the road to using State or its friends.
  • IO, on the other hand, is the only thing that can do its job. But the name playGame doesn't immediately spring out at me as the name of something that needs to do I/O. In particular, if you only need (pseudo-)random numbers, you can do that without IO. As a commenter has pointed out, MonadRandom is great for making this simple, but again you can just use pure functions that take and returning a StdGen from System.Random. You just have to make sure you thread your seed (the StdGen) correctly (doing this automatically was basically why State was invented; you might find you understand it better after trying to program without it!)
  • Finally, you're not quite using getStdGen correctly. It's an IO action, so you need to bind its result with <- in a do-block before using it (technically, you don't need to, you have lots of options, but that's almost certainly what you want to do). Something like this:

    do
      seed <- getStdGen
      results <- mapM (\game -> playGame game seed) [1..numberOfGames]
    

    Here playGame :: Integer -> StdGen -> IO (String, Bool). Notice, however, that you're passing the same random seed to each playGame, which may or may not be what you want. If it isn't, well, you could return the seed from each playGame when you were done with it, to pass to the next one, or repeatedly get new seeds with newStdGen (which you could do from inside playGame, if you decide to keep it in IO).

总之,这并不是一个非常有条理的回答,为此我道歉,但我希望它能给你一些思考。


“Monad是一种类型,而不是一种值。”这句话的意思是,Monad是一种类型概念,并不是一个具体的数值。说它是一种类型构造器是否更准确呢? - Asherah
1
是的,Monad 是一种类型构造器,但我不想听起来太技术化 - 我只想强调“Monad 属于类型世界”。我会编辑它以使其更加精确。 - Ben Millwood

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