9

尝试学习 Haskell 我在 Haskell 中实现了一个Quarto游戏。我已经在 Python 中实现了这个游戏,作为我去年参加的一门课程的练习,当时我的想法是与三个不同的“AI”玩家一起实现游戏,一个随机玩家、一个新手玩家和一个 minimax 玩家。棋子逻辑和棋盘逻辑很容易实现,但我已经到了需要实现玩家的地步,我想知道如何最好地设计玩家,这样游戏逻辑就不需要知道任何东西关于特定的玩​​家,但仍然允许他们使用不同的单子。

问题是每个玩家需要不同的 monad,随机玩家需要在 State monad 或 RandomState monad 中工作。新手玩家可能还需要某种形式的状态,而极小极大玩家可以使用任一状态或纯状态(这会使其实施起来更慢且更棘手,但可以做到)另外我想要一个“人类" 需要在 IO monad 中工作才能从人类那里获得输入的播放器。一个简单的解决方案是将所有内容都放在 IO monad 中,但我觉得这在某种程度上使个人设计变得更加困难,并迫使每个玩家的设计不得不处理比他们应该处理的更多的事情。

我最初的想法是这样的:

class QuartoPlayer where
    place :: (Monad m) => QuartoPiece -> QuartoBoard -> m (Int, Int)
    nextPiece :: (Monad m) => QuartoBoard -> [QuartoPiece] -> m QuartoPiece

我不知道这是否可行,因为我还没有尝试过,但是如果我朝着正确的方向前进并且设计在 Haskell 中是否有意义,我想要一些输入。

4

1 回答 1

9

这里发生的事情有两个部分。首先是如何组合几种不同类型的 monad 同时运行——正如已经指出的那样,这可以通过 monad 转换器来完成——第二个是允许你的每个玩家类型只访问他们需要的 monad。后一个问题的答案是类型类。

所以首先,让我们检查一下 monad 转换器。monad 转换器就像一个带有额外“内部”monad 的 monad。如果这个内部 monad 是 Identity monad(基本上什么都不做),那么行为就像常规 monad。出于这个原因,monad 通常被实现为转换器并包装在 Identity 中以导出普通的 monad。monad 的 Transformer 版本通常将 T 附加到类型的末尾,因此状态 monad 转换器称为 StateT。类型的唯一区别在于添加了内部 monad,State s avs 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
于 2013-09-18T11:02:57.200 回答