这里发生的事情有两个部分。首先是如何组合几种不同类型的 monad 同时运行——正如已经指出的那样,这可以通过 monad 转换器来完成——第二个是允许你的每个玩家类型只访问他们需要的 monad。后一个问题的答案是类型类。
所以首先,让我们检查一下 monad 转换器。monad 转换器就像一个带有额外“内部”monad 的 monad。如果这个内部 monad 是 Identity monad(基本上什么都不做),那么行为就像常规 monad。出于这个原因,monad 通常被实现为转换器并包装在 Identity 中以导出普通的 monad。monad 的 Transformer 版本通常将 T 附加到类型的末尾,因此状态 monad 转换器称为 StateT。类型的唯一区别在于添加了内部 monad,State s a
vs Monad m => StateT s m a
。例如,一个带有整数列表作为状态的 IO monad 可以具有 type StateT [Int] IO
。
正确使用变压器还需要两点。首先是要影响内部 monad,您使用该lift
函数(任何现有的 monad 转换器都将定义该函数)。每次调用电梯都会将您移到变压器堆栈中。liftIO
是 IO monad 位于堆栈底部时的特殊快捷方式。(而且它不能在其他任何地方,因为没有您期望的 IO 转换器。)所以我们可以创建一个函数,从状态部分弹出 int 列表的头部并使用 IO 部分打印它:
popAndPrint :: StateT [Int] IO Int
popAndPrint = do
(x:xs) <- get
liftIO $ print x
put xs
return x
第二点是您需要运行函数的转换器版本,堆栈中的每个 monad 转换器都有一个。所以在这种情况下,为了证明我们需要在 GHCi 中的效果
> runStateT popAndPrint [1,2,3]
1
(1,[2,3])
如果我们将它包装在一个 Error monad 中,我们需要调用runErrorT $ runStateT popAndPrint [1,2,3]
等等。
这是对 monad 转换器的快速介绍,网上还有很多可用的。
但是,对您来说,这只是故事的一半,因为理想情况下,您希望区分不同玩家类型可以使用的 monad。Transformer 方法似乎为您提供了一切,您并不想仅仅因为需要它而让所有玩家都可以访问 IO。那么如何进行呢?
每种不同类型的玩家都需要访问变压器堆栈的不同部分。因此,为每个玩家创建一个类型类,只公开该玩家需要的内容。每个都可以放在不同的文件中。例如:
-- IOPlayer.hs
class IOPlayerMonad a where
getMove :: IO Move
doSomethingWithIOPLayer :: IOPlayerMonad m => m ()
doSomethingWithIOPLayer = ...
-- StatePlayer.hs
class StatePlayerMonad s a where
get :: Monad m => StateT s m s
put :: Monad m => s -> StateT s m ()
doSomethingWithStatePlayer :: StatePlayerMonad s m => m ()
doSomethingWithStatePlayer = ...
-- main.hs
instance IOPlayerMonad (StateT [Int] IO) where
getMove = liftIO getMoveIO
instance StatePlayerMonad s (StateT [Int] IO) where
get' = get
put' = put
这使您可以控制应用程序的哪些部分可以从整体状态访问什么,并且此控制全部发生在一个文件中。除了主要状态的特定实现之外,每个单独的部分都可以定义其接口和逻辑。
PS,你可能需要这些在顶部:
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
import Control.Monad.Trans.State
import Control.Monad.IO.Class
import Control.Monad
-
更新
对于您是否可以这样做并且仍然对所有玩家有一个共同的界面,有些困惑。我认为你可以。Haskell 不是面向对象的,所以我们需要自己做一些调度管道,但结果同样强大,您可以更好地控制细节并且仍然可以实现完全封装。为了更好地展示这一点,我提供了一个完整的玩具示例。
在这里,我们看到Play
该类为许多不同的播放器类型提供了一个单一的接口,每个类型在它们自己的文件中都有它们的逻辑,并且只在转换器堆栈上看到一个特定的接口。该接口在 Play 模块中控制,游戏逻辑只需要使用该接口即可。
添加新播放器包括为他们创建一个新文件,设计他们需要的界面,将其添加到 AppMonad,并在 Player 类型中使用新标签将其连接起来。
请注意,所有玩家都可以通过 AppMonadClass 类访问棋盘,该类可以扩展为包含任何必需的通用界面元素。
-- Common.hs --
data Board = Board
data Move = Move
data Player = IOPlayer | StackPlayer Int
class Monad m => AppMonadClass m where
board :: m Board
class Monad m => Play m where
play :: Player -> m Move
-- IOPlayer.hs --
import Common
class AppMonadClass m => IOPLayerMonad m where
doIO :: IO a -> m a
play1 :: IOPLayerMonad m => m Move
play1 = do
b <- board
move <- doIO (return Move)
return move
-- StackPlayer.hs --
import Common
class AppMonadClass m => StackPlayerMonad s m | m -> s where
pop :: Monad m => m s
peak :: Monad m => m s
push :: Monad m => s -> m ()
play2 :: (StackPlayerMonad Int m) => Int -> m Move
play2 x = do
b <- board
x <- peak
push x
return Move
-- Play.hs --
import Common
import IOPLayer
import StackPlayer
type AppMonad = StateT [Int] (StateT Board IO)
instance AppMonadClass AppMonad where
board = return Board
instance StackPlayerMonad Int AppMonad where
pop = do (x:xs) <- get; put xs; return x;
peak = do (x:xs) <- get; return x;
push x = do (xs) <- get; put (x:xs);
instance IOPLayerMonad AppMonad where
doIO = liftIO
instance Play AppMonad where
play IOPlayer = play1
play (StackPlayer x) = play2 x
-- GameLogic.hs
import Play
updateBoard :: Move -> Board -> Board
updateBoard _ = id
players :: [Player]
players = [IOPlayer, StackPlayer 4]
oneTurn :: Player -> AppMonad ()
oneTurn p = do
move <- play p
oldBoard <- lift get
newBoard <- return $ updateBoard move oldBoard
lift $ put newBoard
liftIO $ print newBoard
oneRound :: AppMonad [()]
oneRound = forM players $ (\player -> oneTurn player)
loop :: AppMonad ()
loop = forever oneRound
main = evalStateT (evalStateT loop [1,2,3]) Board