我们正在开发一个内部使用状态monad的模型文件系统。我们有一个类型类,其操作如下:
class Monad m => FS m where
isDirectory :: Path -> m Bool
children :: Path -> m [Path]
...
我们正在开发一个小型交互式解释器,它将提供cd
,ls
,cat
等命令。解释器中的操作可以这样写:
fsop :: FS m => Operation -> m Response
Operation
和Response
的定义并不重要;如果你愿意,可以把它们当作弦乐。
我试图解决的问题是在I / O monad中编写一个顶层循环来解释文件系统Operation
并打印响应。如果IO是FS的一个实例(也就是说,如果我们直接使用IO monad),生活会很简单:我们可以编写
loop :: Path -> IO ()
loop currentDir = do
op <- getLine
case read op of
ChangeDir d -> loop d -- should test 'isDirectory d', but let's not
Ls -> do { files <- children currentDir
; mapM_ putStrLn files
; loop currentDir }
Exit -> return ()
但这不是我想要的。我想使用Control.Monad.State
:
newtype Filesystem a = Filesystem (State (Data.Map.Map Path Contents) a)
并宣布
instance Monad Filesystem ...
instance FS Filesystem ...
使用FS
抽象,我可以编写一个适用于任何实例的单步函数,实际上下面的代码编译:
step :: FS fs => Path -> Operation -> fs (Path, Response)
step currentDir op =
case op of
ChangeDir d -> return (d, "")
Ls -> do { files <- children currentDir
; return (currentDir, unlines files) }
此时我完全陷入困境。我想要做的是在IO monad中编写一个交互式循环,它可以读取Operation
和打印Response
,但它适用于不必须IO的状态monad。 (使模型不在IO中的原因之一是我们可以测试QuickCheck属性。)
我觉得这必须是一个标准问题 - 在有状态抽象之上的交互式读取 - 评估 - 打印循环 not IO
- 但我必须遗漏某些东西令人惊讶的显而易见,因为我似乎无法弄明白。我看过网上但没有开悟。
任何帮助编写可以调用step
的交互式IO执行计算的帮助都将非常感激。
答案 0 :(得分:7)
使用monad变形金刚怎么样?它们或多或少是组合monad的标准方法。这是一个简单的例子:
type Foo a = StateT String IO a
replT :: Foo ()
replT = do
str <- liftIO getLine
state <- get
liftIO $ putStrLn ("current state: " ++ state)
liftIO $ putStrLn ("setting state: " ++ str)
put str
replT
以下是从ghci中运行replT的结果。
*Main> runStateT replT "Initial state"
asd
current state: Initial state
setting state: asd
zxc
current state: asd
setting state: zxc
asdasd
有三个monad变换器库。 mtl,变形金刚和monadLib。因为我不使用它们,所以我不能推荐它们。
答案 1 :(得分:6)
免责声明:我不能保证以下是良好的方式,但通过它可以发现有趣。让我们把它旋转一下,好吗?
一些强制性进口
首先,让我们抛出一些数据类型。我将填写一些细节并稍微调整一下,以便定义一个我们可以实际交互的简单“文件系统”。
type Path = String
type Response = Maybe String
type Contents = [String]
data Operation = Cd Path
| Ls
| MkDir Path
| Quit
deriving (Read, Show)
接下来,我们会做一些 edgy ......删除所有monad。什么?这太疯狂了!也许,但有时>>=
所提供的所有隐藏的管道都隐藏了很多东西太多。
对于文件系统本身,我们只会存储当前工作目录和路径到子项的映射。我们还需要一些功能来与它进行交互。
data Filesystem = Filesystem { wd :: Path, files :: M.Map Path Contents }
deriving Show
newFS = Filesystem "/" (M.singleton "/" [])
isDirectory p fs = M.member p $ files fs
children p fs = fromMaybe [] . M.lookup p $ files fs
cd p fs = fs { wd = p }
create p fs = let newPath = wd fs ++ p ++ "/"
addPath = M.insert newPath [] . M.adjust (p:) (wd fs)
in (newPath, fs { files = addPath $ files fs })
现在为step
函数的无monad版本。它需要Operation
和Filesystem
,并返回Response
和(可能已修改)Filesystem
:
step :: Operation -> Filesystem -> (Response, Filesystem)
step (Cd d) fs = (Just "Ok\n", cd d fs)
step (MkDir d) fs = first (\d -> Just $ "Created " ++ d ++ "\n") $ create d fs
step Ls fs = let files = children (wd fs) fs
in (Just $ unlines files, fs)
step Quit fs = (Nothing, fs)
......嗯,那种类型的签名看起来很像State
monad的胆量。哦,好吧,暂时忽略它,然后盲目地向前充电。
现在,我们想要的是一个为Filesystem
解释器提供通用接口的函数。特别是,我们希望接口至少有点自包含,这样无论使用什么接口都不需要手动操作,但我们希望接口对使用它的代码完全不了解我们可以将它连接到IO
monad,其他Monad
,甚至根本没有monad。
这主要告诉我们的是,我们需要以某种方式将外部代码与解释器交错,而不是让任何一部分处于控制之中。现在,Haskell是一个函数式语言,这意味着使用大量高阶函数是好的,对吧?对我来说听起来似乎有道理,所以这里是我们将要使用的策略:如果一个函数不知道下一步该做什么,我们将把它交给另一个我们假设的函数。重复直到每个人都知道什么是继续一个完美的计划,不是吗?
这一切的核心是step
函数,所以我们首先调用它。
interp1 :: Operation -> Filesystem -> (Response, Filesystem)
interp1 op fs = step op fs
......好吧,这是一个开始。我猜。但等等,Operation
来自哪里?我们需要外部代码才能提供,但我们不能只是询问,而不会让所有与IO
等令人讨厌的字符混淆。所以我们得到另一个功能来为我们做脏工作:
interp2 :: ((Operation -> (Response, Filesystem)) -> t) -> Filesystem -> t
interp2 inp fs = inp (\op -> step op fs)
当然,现在我们所拥有的只是一些愚蠢的t
我们甚至不知道它是什么。我们知道它必须在某个地方有一个Response
和一个Filesystem
,但我们不能做任何事情,所以我们会把它交还给另一个函数,以及关于如何进行的一些说明......这当然涉及传递更多功能。你知道,它的功能一直在下降。
interp3 :: ((Operation -> (Response, Filesystem)) -> a)
-> (a -> ((Response, Filesystem) -> b) -> c)
-> (Filesystem -> b)
-> (String -> Filesystem -> b)
-> Filesystem
-> c
interp3 inp check done out fs = check (inp (\op -> step op fs)) test
where test (Nothing, fs) = done fs
test (Just s, fs) = out s fs
......那很难看。但不要担心,一切都按计划进行。我们接下来可以做一些观察:
a
仅存在于inp
和check
之间,所以事后看来,我们也可以提前将它们组合起来,然后将组合函数传递给解释器。done
时,它应该完全意味着它在锡上所说的内容。因此done
的返回类型应与整个解释器相同,这意味着b
和c
应该是相同的类型。现在,如果done
结束整个事情,out
是什么?正如名字所暗示的那样,它正在为外部代码提供输出,但在那之后它会去哪里?它需要以某种方式循环回解释器,我们可能会注意到我们的解释器还没有递归。前进的道路很明确 - 翻译就像Jormungand一样,抓住自己的尾巴;无限循环回到解释完成(或直到拉格纳罗克,以先到者为准)。
interp4 :: ((Operation -> (Response, Filesystem))
-> ((Response, Filesystem) -> r) -> r)
-> (Filesystem -> r)
-> (String -> Filesystem -> (Filesystem -> r) -> r)
-> Filesystem
-> r
interp4 checkInp done out fs = checkInp (\op -> step op fs) test
where loop = interp4 checkInp done out
test (Nothing, fs) = done fs
test (Just s, fs) = out s fs loop
...哦,我提到它现在有效吗?不,真的!
以下是使用该界面的一些IO
代码:
ioIn f k = putStr "> " >> (k . f =<< readLn)
ioDone fs = putStrLn "Done" >> return fs
ioOut x fs k = putStr x >> k fs
ioInterp :: IO Filesystem
ioInterp = interp4 ioIn ioDone ioOut newFS
这是运行命令列表的代码,生成输出字符串列表:
scriptIn f k (x:xs) = k (f x) xs
scriptDone fs xs = ["Done\n"]
scriptOut r fs k xs = r : k fs xs
scriptInterp :: [Operation] -> [String]
scriptInterp = interp4 scriptIn scriptDone scriptOut newFS
running both in GHCi here的示例,如果只是代码没有充分发挥你的想象力。
嗯,就是这样。或者是吗?坦率地说,那个翻译只是一个母亲可以爱的代码。有什么东西能把它们优雅地结合在一起吗?有什么东西可以揭示代码的底层结构吗?
...好吧,所以这一点非常明显。在圆圈中相互尾调用的函数的整体设计看起来非常像延续传递样式,而不是一次,但在解释器的类型签名中两次可以找到特征模式(foo -> r) -> r
,更好地称为延续monad。
不幸的是,即使在所有这些之后,继续让我头疼,我不确定如何最好地将解释器的特殊结构解析为在MonadCont
中运行的计算。
答案 2 :(得分:2)
我可以在这里想到两个解决方案:
1)使用monad变压器库。除了图书馆的一些细节外,我无法改善Shimuuar的回复。变形金刚本身并不提供必要的实例;你需要使用变换器和monads-tf或monads-fd,它们分别提供基于类型族和fundep的实现。如果你走这条路,我更喜欢monads-tf。 api几乎与mtl相同。我没有使用MonadLib的经验,但它看起来也很不错。
2)在IO中编写主循环,并为每个循环迭代调用runState来评估状态monad。如下所示:
loop path state = do
op <- readOp
let ((newpath, resp), newstate) = runState (step path op) state
print resp
loop newpath newstate
这应该可行,但它远不如使用monad变换器那么惯用。
答案 3 :(得分:0)
要求FS
的实例成为MonadIO
的实例,而不仅仅是Monad
:
class MonadIO m => FS m where ...
然后,您将使用liftIO
方法将FS
提升为IO
:
liftIO :: MonadIO m => m a -> IO a
所以你可以写IO
monad:
files <- liftIO $ children currentDir
等。当然,这意味着您需要实施liftIO
在你甚至编写FS实例之前为每个FS
,但为
这个应用程序(没有看到实际的细节)
听起来应该很简单。