状态和 IO Monad

State and IO Monads

我一直在努力思考 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,其中许多作用于 return 新的 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 符号内非常容易地编写编辑动作。

但是,现在我正在努力将其用于实际应用程序中。假设我想在执行某些 IO 的应用程序中使用此 Editor。假设我想在用户每次按下键盘上的 l 键时操作 Editor 的实例。

我需要另一个 State monad 来代表整个应用程序状态,它包含一个 Editor 实例和一种使用 IO monad 读取的事件循环从键盘调用 moveHorizontally' 通过修改其 Editor.

来修改当前 AppState

我已经阅读了一些关于这个主题的内容,看来我需要使用 Monad Transformers 来构建一堆底部带有 IO 的 monad。我以前从未使用过 Monad 变形金刚,我不知道从这里可以做什么?我还发现 State monad 已经实现了一些功能(这似乎是 Monad Transformer 的特例?)但我对如何使用它感到困惑?

使用 mtl 你不需要特别提交任何 monad 堆栈,直到你的程序真正 运行 效果为止。这意味着您可以轻松更改堆栈以添加额外层、选择不同的错误报告策略等。

您需要做的就是通过在文件顶部添加以下行来启用 -XFlexibleContexts 语言扩展:

 {-# LANGUAGE FlexibleContexts #-}

导入模块定义 MonadState class:

import Control.Monad.State

更改程序的类型注释以反映您现在正在使用此方法的事实。 MonadState Editor m => 约束表示 m 是一个 monad,其中某处具有 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 }

首先,让我们回顾一下。隔离问题总是最好的。让纯函数与纯函数分组,State - with State 和 IO - with 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 实用程序,如 gets and modify 将已经实现的功能简单地提升到 State monad。

然而,实际上提到的实用程序也适用于 StateT monad-transformer,其中 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

因此,我们刚刚实现了关注点的精细隔离和代码库的极大灵活性。

当然,到目前为止,这是一个非常原始的例子。通常最终的 monad-transformer 栈有更多的层次。例如,

type Session = ExceptT Text (ReaderT Database (StateT A.Editor IO))

要在所有这些级别之间跳转,典型的工具集是 the lift function or the "mtl" library,它提供类型 类 以减少 lift 的使用。不过我不得不提的是,并不是每个人(包括我自己)都是 "mtl" 的粉丝,因为在减少代码量的同时,它引入了一定的歧义和推理复杂性。我更喜欢明确地使用 lift

transformers 的意义在于允许您以特定方式使用一些新功能扩展现有的 monad(transformer 堆栈也是 monad)。

关于您关于扩展应用程序状态的问题,您只需将另一个 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)