状态和IO单子

9

我一直在努力理解monad的概念,并尝试使用以下示例进行实验:

我有一个Editor数据类型,它代表文本文档的状态以及一些用于处理其的函数。

data Editor = Editor {
  lines :: [Line],    -- editor contents are kept line by line      
  lineCount :: Int,   -- holds length lines at all times
  caret :: Caret      -- the current caret position
  -- ... some more definitions
} deriving (Show)

-- get the line at the given position (first line is at 0)
lineAt :: Editor -> Int -> Line
lineAt ed n = ls !! n
  where
    ls = lines ed

-- get the line that the caret is currently on
currentLine :: Editor -> Line
currentLine ed = lineAt ed $ currentY ed

-- move the caret horizontally by the specified amount of characters (can not
-- go beyond the current line)
moveHorizontally :: Editor -> Int -> Editor
moveHorizontally ed n = ed { caret = newPos }
  where
    Caret x y = caret ed
    l = currentLine ed
    mx = fromIntegral (L.length l - 1)
    newX = clamp 0 mx (x+n)
    newPos = Caret newX y


-- ... and lots more functions to work with an Editor

所有这些功能都作用于一个 Editor,其中许多返回一个新的 Editor(其中光标已移动或某些文本已更改),因此我认为这可能是使用 State monad 的一个好应用,并且我已经重新编写了大多数 Editor 函数,现在看起来像这样:

lineAt' :: Int -> State Editor Line
lineAt' n = state $ \ed -> (lines ed !! n, ed)

currentLine' :: State Editor Line
currentLine' = do
  y <- currentY'
  lineAt' y

moveHorizontally' :: Int -> State Editor ()
moveHorizontally' n = do
  (Caret x y) <- gets caret
  l <- currentLine'
  let mx = fromIntegral (L.length l - 1)
  let newX = clamp 0 mx (x+n)
  modify (\ed -> ed { caret = Caret newX y })

moveHorizontally' :: Int -> State Editor ()
moveHorizontally' n = do
  (Caret x y) <- gets caret
  l <- currentLine'
  let mx = fromIntegral (L.length l - 1)
  let newX = clamp 0 mx (x+n)
  modify (\ed -> ed { caret = Caret newX y })

这样做很棒,因为它让我可以在do-notation中轻松地组合编辑操作。
然而,现在我正在努力将其应用到实际应用程序中。假设我想在执行某些IO的应用程序中使用此Editor。假设我想每次用户按下键盘上的l键时操纵一个Editor实例。
我需要另一个代表整个应用程序状态的State monad,它包含一个Editor实例和一种类似事件循环的方法,使用IO monad读取键盘并调用moveHorizontally'通过修改其Editor来修改当前AppState。
我已经阅读了有关这个主题的一些内容,并且似乎需要使用Monad Transformers来构建具有IO底部的monad堆栈。我以前从未使用过Monad Transformers,也不知道接下来该怎么做?我还发现State monad已经实现了一些功能(它似乎是Monad Transformer的特殊情况?),但我对如何使用它感到困惑?

所有这些函数都作用于编辑器,并且其中许多函数会返回一个新的编辑器(其中插入符已经移动或某些文本已更改)。这是一件好事!很不可能一个简单的IO循环应用您原始的纯函数会在内存中产生一个新的编辑器。只有当需要时,GHC才会复制结构,并且如果旧引用未使用,则会进行就地更新。您不一定需要在此处使用转换器,并且如果没有它们,您的代码将更清晰。 - thumphries
@DeX3 FYI,提交一个自包含的帖子会让人们更容易编写代码来回答你的问题。 - gallais
2个回答

6
首先,让我们稍微退后一步。最好将问题隔离开来。将纯函数与纯函数分组,状态(State)与状态(State)分组,IO操作(IO)与IO操作(IO)分组。交织多个概念是制造代码意大利面的必然配方。你不想要那样的餐点。
话虽如此,让我们恢复你所拥有的纯函数并将它们分组到一个模块中。但我们会应用小修改使它们符合Haskell的惯例——即,我们会改变参数顺序。
-- |
-- In this module we provide all the essential functions for 
-- manipulation of the Editor type.
module MyLib.Editor where

data Editor = ...

lineAt :: Int -> Editor -> Line

moveHorizontally :: Int -> Editor -> Editor

现在,如果你真的想要恢复你的State API,那么在另一个模块中实现它是微不足道的。
-- |
-- In this module we address the State monad.
module MyLib.State where

import qualified MyLib.Editor as A

lineAt :: Int -> State A.Editor Line
lineAt at = gets (A.lineAt at)

moveHorizontally :: Int -> State A.Editor ()
moveHorizontally by = modify (A.moveHorizontally by)

正如您现在所看到的,遵循标准约定使我们能够使用标准的State实用程序,例如getsmodify,以轻松提升已经实现的函数到State单子中。但是,实际上,这些实用程序也适用于StateT单子变换器,其中State实际上只是一种特殊情况。因此,我们同样可以以更一般的方式实现相同的功能:
-- |
-- In this module we address the StateT monad-transformer.
module MyLib.StateT where

import qualified MyLib.Editor as A

lineAt :: Monad m => Int -> StateT A.Editor m Line
lineAt at = gets (A.lineAt at)

moveHorizontally :: Monad m => Int -> StateT A.Editor m ()
moveHorizontally by = modify (A.moveHorizontally by)

正如您所看到的,唯一改变的是类型签名。
现在您可以在转换器堆栈中使用这些通用函数。例如,
-- |
-- In this module we address the problems of the transformer stack.
module MyLib.Session where

import qualified MyLib.Editor as A
import qualified MyLib.StateT as B

-- | Your trasformer stack
type Session = StateT A.Editor IO

runSession :: Session a -> A.Editor -> IO (a, A.Editor)
runSession = runStateT

lineAt :: Int -> Session Line
lineAt = B.lineAt

moveHorizontally :: Int -> Session ()
moveHorizontally = B.moveHorizontally

-- |
-- A function to lift the IO computation into our stack.
-- Luckily for us it is already presented by the MonadIO type-class.
-- liftIO :: IO a -> Session a

因此,我们刚刚实现了对关注点的细粒度隔离和代码库的高度灵活性。
当然,这只是一个相当简单的示例。通常,最终的单子变换器堆栈具有更多层级。例如,
type Session = ExceptT Text (ReaderT Database (StateT A.Editor IO))

要在所有这些级别之间跳转,典型的工具集是 lift函数"mtl"库,它提供了类型类来减少对 lift 的使用。但我必须提到的是,并不是每个人(包括我自己在内)都喜欢 "mtl",因为虽然它减少了代码量,但也引入了一定的歧义和推理复杂性。我更喜欢显式地使用 lift

变换器的目的是允许您以临时方式扩展现有的单子(变换器堆栈本身也是一个单子)并添加一些新功能。

至于您关于扩展应用程序状态的问题,您可以简单地向堆栈中添加另一个 StateT 层:

-- |
-- In this module we address the problems of the transformer stack.
module MyLib.Session where

import qualified MyLib.Editor as A
-- In presence of competing modules,
-- it's best to rename StateT to the more specific EditorStateT
import qualified MyLib.EditorStateT as B
import qualified MyLib.CounterStateT as C

-- | Your trasformer stack
type Session = StateT Int (StateT A.Editor IO)

lineAt :: Int -> Session Line
lineAt = lift B.lineAt

moveHorizontally :: Int -> Session ()
moveHorizontally = lift B.moveHorizontally

-- | An example of addressing a different level of the stack.
incCounter :: Session ()
incCounter = C.inc

-- | An example of how you can dive deeply into your stack.
liftIO :: IO a -> Session a
liftIO io = lift (lift io)

好的,这非常有帮助。我理解了步骤1和2,并相应地清理了我的代码。然而,最后如何将它们整合在一起还是有点困惑。作为一个起点,moveHorizontally :: Monad m => Int -> StateT A.Editor m () 这个类型签名到底表达了什么?moveHorizontally 是一个函数,接受一个 Int 参数并返回一个状态转换器,其中状态是 A.Editor,立即返回值是某个持有空类型的 Monad(因为 moveHorizontally 只修改状态,没有任何直接结果)?对吗? - DeX3
你把 StateT a m bState a (m b) 搞混了,它们是不同的东西。State a b 等价于 a -> (b, a) 函数。因此,State a (m b) 等价于这个函数:a -> (m b, a)。另一方面,StateT a m b 等价于 a -> m (b, a)Hackage 文档 可以帮助你更清楚地理解这些概念。我还有一篇使用解析器训练直觉的文章 - Nikita Volkov
是的,你说得对 - 我在那里混淆了 StateStateT。感谢你的解释。 - DeX3
如果我理解正确,Session 基本上是一个“更大”的应用程序状态,对吗? 如果我想要向其中添加更多有状态的属性,而这些属性并不适合放置在 Editor 中(例如,一些计数器,用于跟踪用户按下某个键的次数或类似的东西),该怎么办? - DeX3
mtlMonadIO 类相对来说歧义较小,因为 IO 总是在底部。它也是一个非常方便的类。 - dfeuer
显示剩余2条评论

1
使用 mtl,在实际运行效果之前,您不需要承诺任何特定的单子堆栈。这意味着您可以轻松更改堆栈以添加额外的层,选择不同的错误报告策略等等。您只需要通过在文件顶部添加以下行来启用 -XFlexibleContexts 语言扩展即可:
 {-# LANGUAGE FlexibleContexts #-}

导入定义MonadState类的模块:

import Control.Monad.State

更改程序的类型注释以反映您现在使用的方法。 MonadState Editor m => 约束表示 m 是一个具有类型为 Editor 的状态的单子。

lineAt'      :: MonadState Editor m => Int -> m Line
currentY'    :: MonadState Editor m => m Int
currentLine' :: MonadState Editor m => m Line

假设你现在想从 stdin 读取一行并将其推入行列表中(实际上,你可能希望在当前插入符之后插入字符并相应地移动它,但总体思路是相同的)。您可以简单地使用 MonadIO 约束来指示您需要一些 IO 功能来执行此函数:

newLine :: (MonadIO m, MonadState Editor m) => m ()
newLine = do
  nl <- liftIO getLine
  modify $ \ ed -> ed { lines = nl : lines ed }

你如何使用newLine运行State?我们可以这样做吗:runState newLine editor - vdj4y
1
@vdj4y 你需要使用 runStateT,这将产生一个 MonadIO m => m ((), editor) - gallais

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