同一单子变换器的不同排序之间有什么区别?

28

我正在尝试定义一个API来表达程序中特定类型的过程。

newtype Procedure a = { runProcedure :: ? }

有一个状态,由将ID映射到记录的方式组成:

type ID = Int
data Record = { ... }
type ProcedureState = Map ID Record

有三个基本操作:

-- Declare the current procedure invalid and bail (similar to some definitions of fail for class Monad)
abort :: Procedure ()
-- Get a record from the shared state; abort if the record does not exist.
retrieve :: ID -> Procedure Record
-- Store (or overwrite) a record in the shared state.
store :: ID -> Record -> Procedure ()

我有几个目标与这些操作相关:

  • 通过过程可以对可用记录作出假设(不像原始的 Map.lookup 调用),如果假设错误,整个过程将返回失败。
  • 一系列的过程可以使用 <|>(从 Alternative 类中)链接在一起,以便回退到做出不同假设的过程。(类似于 STM 的 orElse

考虑到这些目标,我认为我需要一些 StateMaybe monad 的组合。

-- Which to choose?
type Procedure a = StateT ProcedureState Maybe a
type Procedure a = MaybeT (State ProcedureState) a

我无法理解MaybeState这两个元素的不同排序会如何产生不同的行为。请问有谁能够解释这两种排序之间的差异吗?

如果您认为我的思路存在问题(也许我过于复杂),请指出。

结论: 三个答案都很有帮助,但有一个共同的想法帮助我决定我想要的排序方式。通过查看 runMaybeT/runStateT 的返回类型,可以轻松地看出哪种组合具有我想要的行为。(在我的情况下,我想要的返回类型是 Maybe (ProcedureState, a))。

5个回答

27

编辑:我最初搞混了情况,现在已经修正。

单子变换器堆栈的排序差异只有在削减堆栈层数时才真正有意义。

type Procedure a = MaybeT (State ProcedureState) a

在这种情况下,你首先运行MaybeT,它会产生一个有状态的计算,并返回一个Maybe a

type Procedure a = StateT ProcedureState Maybe a

这里的 StateT 是外层的单子,这意味着在使用初始状态运行 StateT 后,您将得到一个 Maybe (a, ProcedureState)。也就是说,计算可能成功,也可能失败。

因此,您选择使用哪种取决于您如何处理部分计算。如果在最外层使用 MaybeT,则无论计算成功与否,您始终会得到某种返回状态,但这可能是有用的,也可能不是。如果在最外层使用 StateT,则可以保证所有有状态的交易都是有效的。根据您的描述,我可能会自己使用 StateT 变体,但我认为任何一种都可以工作。

对于单子变换器的排序,唯一的规则是如果涉及到 IO(或另一个非变换器单子),它必须位于堆栈底部。通常,如果需要,人们会将 ErrorT 用作下一个较低级别。


1
如果您将这两个单子变换堆栈通过lambdabot的@unmtl运行,那么第一个将变成ProcedureState -> (Maybe a, ProcedureState),第二个将变成ProcedureState -> Maybe (a, ProcedureState),所以我认为它们被反过来了。 run...T函数的类型很令人困惑! - Reid Barton
@Reid Barton,非常感谢。你完全正确,我把它们搞反了。已经编辑过来进行更正。 - John L

18
为了补充其他答案,我想描述一下如何在一般情况下解决这个问题。也就是说,给定两个变压器,它们的两个组合的语义是什么?
上周我在一个解析项目中开始使用monad transformers时,遇到了很多麻烦。我的方法是创建一个转换类型的表格,每当我不确定时就会查阅。以下是我的做法: 步骤1:创建一个基本monad类型及其相应变压器类型的表格:
transformer           type                  base type (+ parameter order)

---------------------------------------------------------------

MaybeT   m a        m (Maybe a)            b.    Maybe b

StateT s m a        s -> m (a, s)          t b.  t -> (b, t)

ListT    m a        m [a]                  b.    [] b

ErrorT e m a        m (Either e a)         f b.  Either f b

... etc. ...

步骤2: 将每个单子变换器应用于每个基本单子,并替换 m 类型参数:

inner         outer         combined type

Maybe         MaybeT        Maybe (Maybe a)
Maybe         StateT        s -> Maybe (a, s)      --  <==  this !!
... etc. ...

State         MaybeT        t -> (Maybe a, t)      --  <== and this !!
State         StateT        s -> t -> ((a, s), t)
... etc. ...

(这一步有点痛苦,因为组合的数量是二次的...但对我来说是个好练习,而且我只需要做一次。) 对我来说关键在于我将组合类型展开了,没有那些烦人的MaybeT、StateT等包装器。这样看和思考类型就容易多了,没有那些样板文件。

回答你最初的问题,这张图表说明了:

  • MaybeT + State :: t -> (Maybe a, t) 一个可能没有值,但总会有(可能修改后的)状态输出的有状态计算

  • StateT + Maybe :: s -> Maybe (a, s) 既可以缺少状态也可以缺少值的计算


优秀的答案。 - paulotorrens

8
假设不使用State/StateT来存储您的程序状态,而是在IO单子中使用IORef。理论上,在IOMaybe单子结合的情况下,有两种方式可以设置mzero(或fail)行为:
- mzero清除整个计算,使mzero <|> x = x; - mzero导致当前计算不返回值,但保留了IO类型的效果。
似乎你想要第一种方法,以便一个过程设置的状态能够在<|>链中的下一个过程中"展开"。当然,这种语义无法实现。在运行计算之前,我们不知道计算是否会调用mzero,但这样做可能会产生任意的IO效果,例如launchTheMissiles,我们无法回滚它们。
现在,让我们尝试从MaybeIO构建两种不同的单子变换堆栈:
- IOT Maybe-糟糕的是,这个不存在! - MaybeT IO 存在的那一个( MaybeT IO)提供了可能的mzero行为,而不存在的IOT Maybe则对应于另一种行为。
幸运的是,您使用的是State ProcedureState,它的效果可以回滚,而不是IO。您需要的单子变换堆栈是StateT ProcedureState Maybe

4

如果你尝试为两个版本编写“run”函数,你就能回答这个问题 - 我没有安装MTL + transformers,所以无法自行完成。其中一个将返回(Maybe a,state),另一个将返回Maybe(a,state)

编辑 - 我已经截断了我的回答,因为它会增加可能会让人困惑的细节。John的回答一针见血。


3

摘要:不同的堆栈顺序产生不同的业务逻辑

也就是说,堆栈中不同的单子变换器顺序不仅影响评估顺序,还影响程序的功能。

在演示顺序的影响时,人们通常使用最简单的转换器,如ReaderTWriterTStateTMaybeTExceptT。它们的不同顺序并不会给业务逻辑带来戏剧性的不同,因此很难清楚地理解其影响。此外,它们的某些子集是可交换的,即没有功能差异。

为了演示目的,建议使用StateTListT,它们揭示了单子堆栈中变换器顺序的巨大差异。

背景:StateTListT

  • StateTState单子在For a Few Monads More中有很好的解释。StateT只是在其底层m使用单子操作时提供了更多的能力。如果您知道evalStateTputgetmodify,这就足够了,这些在许多State单子教程中都有解释。
  • ListTList,即[],是一个单子(在A Fistful of Monads中有解释)。ListT m a(在包list-t中)给您类似于[a]加上底层单子m的所有单子操作。棘手的部分是执行ListT(与evalStateT相当可比):有很多种执行方式。想想您在使用evalStateTrunStateTexecState时关心的不同结果,List单子的上下文有很多潜在的消费者,例如just go over them,即traverse_fold them,即fold,等等。

实验:了解单子变换器顺序的影响

我们将使用StateTListT构建一个简单的两层单子变换器堆栈,放在IO之上,以实现一些功能进行演示。

任务描述

对流中的数字求和

这个流将被抽象为一个Integer列表,因此我们需要使用ListT。为了对它们求和,我们需要在处理流中的每个项目时保持总和的状态,这就是StateT的作用。

两种堆栈

我们有一个简单的状态Int来保持总和

  • ListT(StateT Int IO)a
  • StateT Int(ListT IO)a

完整程序

#!/usr/bin/env stack
-- stack script --resolver lts-11.14 --package list-t --package transformers

import ListT (ListT, traverse_, fromFoldable)
import Control.Monad.Trans.Class (lift)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.State (StateT, evalStateT, get, modify)

main :: IO()
main =  putStrLn "#### Task: summing up numbers in a stream"
     >> putStrLn "####       stateful (StateT) stream (ListT) processing"
     >> putStrLn "#### StateT at the base: expected result"
     >> ltst
     >> putStrLn "#### ListT at the base: broken states"
     >> stlt



-- (ListT (StateT IO)) stack
ltst :: IO ()
ltst = evalStateT (traverse_ (\_ -> return ()) ltstOps) 10

ltstOps :: ListT (StateT Int IO) ()
ltstOps = genLTST >>= processLTST >>= printLTST

genLTST :: ListT (StateT Int IO) Int
genLTST = fromFoldable [6,7,8]

processLTST :: Int -> ListT (StateT Int IO) Int
processLTST x = do
    liftIO $ putStrLn "process iteration LTST"
    lift $ modify (+x)
    lift get

printLTST :: Int -> ListT (StateT Int IO) ()
printLTST = liftIO . print



-- (StateT (ListT IO)) stack
stlt :: IO ()
stlt = traverse_ (\_ -> return ())
     $ evalStateT (genSTLT >>= processSTLT >>= printSTLT) 10

genSTLT :: StateT Int (ListT IO) Int
genSTLT = lift $ fromFoldable [6,7,8]

processSTLT :: Int -> StateT Int (ListT IO) Int
processSTLT x = do
    liftIO $ putStrLn "process iteration STLT"
    modify (+x)
    get

printSTLT :: Int -> StateT Int (ListT IO) ()
printSTLT = liftIO . print

结果和解释

$ ./order.hs   
#### Task: summing up numbers in a stream
####       stateful (StateT) stream (ListT) processing
#### StateT at the base: expected result
process iteration LTST
16
process iteration LTST
23
process iteration LTST
31
#### ListT at the base: broken states
process iteration STLT
16
process iteration STLT
17
process iteration STLT
18

第一堆栈 ListT (StateT Int IO) a 产生了正确的结果,因为在评估 StateT 之前,运行时系统已经评估了 ListT 的所有操作 - 使用流 [6,7,8] 填充堆栈,通过 traverse_ 遍历它们。这里的“评估”意味着 ListT 的效果已经消失,现在 ListT 对于 StateT 是透明的。
第二个堆栈 StateT Int (ListT IO) a 没有得到正确的结果,因为 StateT 的生命周期太短了。在每次 ListT 评估(即 traverse_)迭代中,状态被创建、评估和消失。在此堆栈结构中的 StateT 没有实现保持列表/流项操作之间状态的目的。

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