为什么我们需要单子(monads)?

407

在我看来,针对著名问题"什么是Monad?"的答案,尤其是得票最高的答案,试图解释什么是Monad,但没有清楚地说明为什么Monad真正必要。它们可以被解释为一个问题的解决方案吗?


7
http://cs.coloradocollege.edu/~bylvisaker/MonadMotivation/ - antoyo
2
http://blog.sigfpe.com/2006/08/you-could-have-invented-monads-and.html - effectfully
5
你已经做了哪些研究?你查找了哪些信息源?你找到了哪些资源?我们期望在问题中展示你所做的大量研究,同时不要忘记让问题表述清楚。有很多资源试图解释资源的动机--如果你还没有找到任何资源,你可能需要再多做一些研究。如果你找到了一些但它们没有帮助到你,那么如果你解释一下你找到了什么以及具体为什么不适用于你会使这个问题更好。 - D.W.
10
这个问题更适合发在Programmers.StackExchange,而不是StackOverflow。如果可以的话,我会投票将其迁移,但我不能。(=() - jpmc26
3
很可能它会在那里被关闭,因为“主要基于观点”;但是在这里至少有机会(正如巨大的赞数、昨天的快速重新开放和尚未再次关闭的情况所示)。 - Izkata
显示剩余3条评论
8个回答

637

为什么我们需要单子?

  1. 我们希望只使用函数进行编程(毕竟是“函数式编程”)。
  2. 然后,我们遇到了第一个大问题。这是一个程序:

    f(x) = 2 * x

    g(x,y) = x / y

    我们如何说出要先执行什么?我们如何使用不超过函数的方式形成一个有序函数序列(即程序)?

    解决方案:组合函数。如果你想先执行g然后再执行f,只需写f(g(x,y))。这样,“程序”也是一个函数:main = f(g(x,y))。好吧,但是...

  3. 更多问题:某些函数可能会失败(例如g(2,0),除以0)。在FP中我们没有“异常”(异常不是函数)。我们该如何解决?

    解决方案:让函数返回两种类型的结果:与其定义一个从两个实数到一个实数的函数g : Real,Real -> Real,不如允许g : Real,Real -> Real | Nothing(从两个实数到(实数或无)的函数)。

  4. 但是,函数应该(为了更简单)只返回一种类型的结果

    解决方案:让我们创建一个新的数据类型来返回,一个"封装类型",它可能包含一个实数或仅仅是无。因此,我们可以有g : Real,Real -> Maybe Real。好吧,但是...

  5. 现在对于 f(g(x,y)) 会发生什么情况?由于 f 并不支持接收一个 Maybe Real,我们也不想更改与 g 可能连接的每个函数来使其能够接受 Maybe Real
    解决方案: 让我们创建一个专门的函数来 "连接"/"组合"/"链接" 函数,这样我们就可以在幕后调整一个函数的输出以供后续函数使用。
    在我们的例子中: g >>= f(连接/组合 gf)。我们希望 >>= 获取 g 的输出并检查它,如果是 Nothing 就不调用 f 并返回 Nothing;或者相反,提取封装的 Real 并将其提供给 f。(这个算法只是 Maybe 类型下 >>= 的实现)。注意,>>= 必须每个 "封箱类型" 只写一遍(不同的封箱类型需要不同的适配算法)。
    许多其他问题也可使用此模式解决,例如:1. 使用 "盒子" 来编码/存储不同含义/值,并有像 g 这样返回这些 "封装值" 的函数。2. 有一个连接器 g >>= f 来帮助将 g 的输出连接到 f 的输入,这样我们就不必更改任何 f
    可以使用此技术解决的显着问题是:拥有全局状态,可以由函数序列(“程序”)中的每个函数共享:解决方案是 StateMonad
  6. 我们不喜欢“不纯的函数”:这些函数为相同的输入产生不同的输出。因此,让我们标记这些函数,使它们返回一个带有标签/框的值:IO 函子。

完美无缺的快乐!


66
@Carl,请写一篇更好的答案来启迪我们。 - XrXr
15
@Carl,我认为答案已经清楚地表明了许多问题可以从这个模式中受益(第6点),而IO单子仅仅是在这个列表中列出的其中一个问题(第7点)。另一方面,IO只出现了一次,并且出现在最后,所以我不理解你说的“大部分时间都在谈论 IO”的意思。 - cibercitizen1
4
函数组合不决定哪个函数会先执行(例如,写成 \x y -> f (g x y) 并不意味着 g 会在 f 之前被执行)。这由求值策略决定。为了证明这一点,请在 GHCi 中评估以下内容:let assert = const True . errorassert "Function composition doesn't determine evaluation order." 结果是 True,而不是一个错误。在严格的语言中,它会抛出一个错误。但是因为 Haskell 默认是非严格的,所以它不会这样做。相反,它首先计算 const,并且根本不计算 error - Aadit M Shah
5
关于单子的重要误解:单子涉及状态;单子涉及异常处理;在无单子的纯函数式编程语言中无法实现IO;单子是明确的(反例是Either)。大部分答案都是关于“为什么我们需要函子?”的。 - vlastachu
4
这个翻译不太准确。在使用“单子合成”时,原来的代码f(g(x,y))中的f可以产生任何类型的输出。例如,f:: Real -> String。但是,在使用“单子合成”后,必须将f修改为产生Maybe String类型的输出,否则类型无法匹配。此外,运算符>>=本身也无法实现合成操作,应该使用>=>运算符。请参考与Carl回答下dfeuer讨论的内容。 - Will Ness
显示剩余12条评论

239
答案当然是"我们不需要"。与所有抽象一样,它并非必需品。
Haskell不需要单子抽象。在纯语言中执行IO并不必要。 IO类型本身就可以很好地处理它。现有的do块的单子解糖可以替换为在GHC.Base模块中定义的bindIOreturnIOfailIO的解糖。(这不是hackage上的一个记录模块,所以我得指向它的源代码进行文档说明。) 因此,单子抽象是不必要的。
那么,如果不需要,为什么还存在呢?因为发现许多计算模式形成了单子结构。结构的抽象允许编写适用于该结构的所有实例的代码。更简洁地说-代码重用。
在函数式语言中,用于代码重用的最强大工具是函数组合。传统的 (.) :: (b -> c) -> (a -> b) -> (a -> c) 运算符非常强大。它使得编写小型函数并使用最少的语法或语义开销将它们粘合在一起变得容易。
但有些情况下类型无法完全匹配。当你拥有 foo :: (b -> Maybe c)bar :: (a -> Maybe b) 时该怎么办?foo . bar 不会通过类型检查,因为 bMaybe b 不是相同的类型。
但是...它几乎是正确的。您只需要一点余地。您希望能够将Maybe b视为基本上b。直接将它们视为相同类型的想法很糟糕,这与空指针几乎相同,Tony Hoare 曾经称之为十亿美元的错误。因此,如果您无法将它们视为相同类型,则可以尝试找到扩展组合机制(.)提供的方法。

在这种情况下,真正审查理论是很重要的(.)。幸运的是,已经有人为我们做了这件事。事实证明,(.)id的组合形成了一种数学结构,称为category。但是还有其他形成类别的方法。例如,Kleisli类别允许被组合的对象稍微增强一些。对于Maybe的Kleisli类别将包括(.) :: (b -> Maybe c) -> (a -> Maybe b) -> (a -> Maybe c)id :: a -> Maybe a。也就是说,类别中的对象使用Maybe增强了(->),因此(a -> b)变成了(a -> Maybe b)

And suddenly,我们把组合的能力扩展到了传统的(.)操作无法处理的事物上。这是新的抽象能力的源泉。Kleisli范畴可以使用更多类型,而不仅仅是Maybe类型。它们可以与任何可以组装成适当范畴并遵守范畴法则的类型一起工作。
1. 左恒等式:id . f = f 2. 右恒等式:f . id = f 3. 结合律:f . (g . h) = (f . g) . h
只要您能证明您的类型遵守这三个定律,就可以将其转换为Kleisli类别。那有什么大不了的呢?嗯,事实证明,单子(monads)正是与Kleisli类别完全相同的东西。Monadreturn与Kleisli的id相同。Monad(>>=)与Kleisli的(.)并不相同,但事实证明很容易用彼此来编写每个函数。当您在(>>=)(.)之间进行翻译时,类别法则与单子法则相同。

那么为什么要费这么大劲呢?为什么要在语言中使用Monad抽象呢?如我上面所提到的,它可以实现代码重用。它甚至可以在两个不同的维度上实现代码重用。

代码重用的第一维直接来自于抽象的存在。您可以编写适用于抽象所有实例的代码。整个monad-loops包由适用于任何Monad实例的循环组成。

第二维是间接的,但它源于组合的存在。当组合容易时,自然而然地会编写小型可重用代码块。这与具有函数 (.) 运算符鼓励编写小型可重用函数的方式相同。

那么为什么会存在抽象呢?因为它被证明是一种促进代码更多组合的工具,从而创建可重用代码并鼓励创建更多可重用代码。代码重用是编程的圣杯之一。单子抽象的存在是因为它将我们稍微向着那个圣杯迈进了一步。


3
你能解释一下范畴与克莱斯利范畴之间的关系吗?你所描述的三个定律适用于任何范畴。 - dfeuer
1
@dfeuer 哦。用代码来表达,newtype Kleisli m a b = Kleisli (a -> m b)。Kleisli 类别是函数,其中范畴返回类型(在本例中为 b)是类型构造器 m 的参数。当且仅当 Kleisli m 形成一个类别时,m 才是一个 Monad。 - Carl
2
什么是分类返回类型?Kleisli m似乎形成了一个范畴,其对象是Haskell类型,并且从ab的箭头是从am b的函数,其中id = return(.) = (<=<)。这样对吗,还是我混淆了不同层次的东西或其他什么东西? - dfeuer
1
@dfeuer 正确。这些对象都是类型,而态射是在类型 ab 之间的,但它们不是简单的函数。它们在函数的返回值中装饰了额外的 m - Carl
2
范畴论术语真的有必要吗?也许,如果你把类型转化为图片,其中类型将成为绘制图片的DNA(尽管是依赖类型),然后你可以使用图片编写程序,名称作为小型Ruby字符出现在图标上,这样Haskell会更容易些。 - aoeu256

24

本杰明·皮尔斯在TAPL中说:

类型系统可以被视为计算程序中术语的运行时行为的一种静态近似。

这就是为什么配备强大类型系统的语言比弱类型语言更具表现力。你可以用相同的方式思考单子。

正如@Carl和sigfpe所指出的,您可以为数据类型提供所有所需的操作,而无需使用单子、类型类或其他抽象内容。然而,单子不仅允许您编写可重用代码,还可以将所有冗余细节抽象化。

例如,假设我们想要过滤列表。最简单的方法是使用filter函数:filter (> 3) [1..10],它等于[4,5,6,7,8,9,10]

一个稍微复杂一点的 filter 版本,还会从左到右传递累加器,如下:

swap (x, y) = (y, x)
(.*) = (.) . (.)

filterAccum :: (a -> b -> (Bool, a)) -> a -> [b] -> [b]
filterAccum f a xs = [x | (x, True) <- zip xs $ snd $ mapAccumL (swap .* f) a xs]

为了得到所有的i,使得i <= 10, sum [1..i] > 4, sum [1..i] < 25,我们可以写成:
filterAccum (\a x -> let a' = a + x in (a' > 4 && a' < 25, a')) 0 [1..10]

这等同于[3,4,5,6]

或者我们可以用filterAccum重新定义nub函数,该函数从列表中删除重复元素:

nub' = filterAccum (\a x -> (x `notElem` a, x:a)) []

nub' [1,2,4,5,4,3,1,8,9,4]等于[1,2,4,5,3,8,9]。这里传递了一个列表作为累加器。该代码的工作原理是,可以离开列表单子,因此整个计算保持纯净(notElem实际上并未使用>>=,但它可以)。但是,不可能安全地离开IO单子(即不能执行IO操作并返回纯值——该值始终将包装在IO单子中)。另一个例子是可变数组:在离开了一个可变数组所在的ST单子后,您无法再以常数时间更新该数组。因此,我们需要从Control.Monad模块进行单子过滤:

filterM          :: (Monad m) => (a -> m Bool) -> [a] -> m [a]
filterM _ []     =  return []
filterM p (x:xs) =  do
   flg <- p x
   ys  <- filterM p xs
   return (if flg then x:ys else ys)

filterM在列表中执行一个单子动作,对于单子动作返回True的元素产生结果。

下面是使用数组进行过滤的示例:

nub' xs = runST $ do
        arr <- newArray (1, 9) True :: ST s (STUArray s Int Bool)
        let p i = readArray arr i <* writeArray arr i False
        filterM p xs

main = print $ nub' [1,2,4,5,4,3,1,8,9,4]

我将输出[1,2,4,5,3,8,9],与预期相符。

还有一个使用IO单子的版本,它会询问要返回哪些元素:

main = filterM p [1,2,4,5] >>= print where
    p i = putStrLn ("return " ++ show i ++ "?") *> readLn

例如。

return 1? -- output
True      -- input
return 2?
False
return 4?
False
return 5?
True
[1,5]     -- output

作为最后的例子,可以用filterM来定义filterAccum
filterAccum f a xs = evalState (filterM (state . flip f) xs) a

使用在幕后的StateT单子,只是一个普通的数据类型。
这个例子说明了,单子不仅允许你抽象计算上下文和编写干净可重用的代码(由于单子的可组合性,如@Carl所解释的那样),而且还可以统一处理用户定义的数据类型和内置原语。

1
本答案解释了我们为什么需要Monad类型类。理解我们为什么需要单子而不是其他东西的最好方法是阅读单子和适用函子之间的区别: - effectfully

22

我认为 IO 并不应该被看作一个特别出色的单子,但对于初学者来说它肯定是其中更令人惊叹的单子之一,因此我将使用它来进行解释。

天真地为 Haskell 构建 IO 系统

纯函数式语言最简单可行的 IO 系统(实际上也是 Haskell 最初采用的)如下所示:

main₀ :: String -> String
main₀ _ = "Hello World"

使用延迟计算,这个简单的签名就足以构建交互式终端程序——尽管非常有限。最令人沮丧的是,我们只能输出文本。如果我们添加了一些更令人兴奋的输出方式呢?

data Output = TxtOutput String
            | Beep Frequency

main₁ :: String -> [Output]
main₁ _ = [ TxtOutput "Hello World"
          -- , Beep 440  -- for debugging
          ]

很可爱,但当然更现实的“替代输出”是将其写入文件。但接下来您也会希望有一种从文件中读取的方式。有机会吗?

好的,当我们将我们的main₁程序简单地通过操作系统设施将文件传输到进程中时,我们实际上已经实现了文件读取。如果我们可以从Haskell语言内部触发该文件读取...

readFile :: Filepath -> (String -> [Output]) -> [Output]

可以使用“交互式程序”String->[Output],将从文件中获得的字符串提供给它,并产生一个非交互式程序,简单地执行给定的程序。

这里有一个问题:我们并没有真正意识到文件何时被读取。 [Output]列表确实为输出提供了一个很好的顺序,但是我们没有得到关于输入完成时间的顺序。

解决方法:将输入事件也作为待完成事项列表中的项目之一。

data IO₀ = TxtOut String
         | TxtIn (String -> [Output])
         | FileWrite FilePath String
         | FileRead FilePath (String -> [Output])
         | Beep Double

main₂ :: String -> [IO₀]
main₂ _ = [ FileRead "/dev/null" $ \_ ->
             [TxtOutput "Hello World"]
          ]

现在可能会出现一个不平衡的情况:您可以读取文件并使输出依赖于它,但无法使用文件内容来决定是否例如也要读取另一个文件。明显的解决方案是:将输入事件的结果也作为类型为IO而不仅仅是Output的东西。这当然包括简单文本输出,但也允许读取其他文件等等。

data IO₁ = TxtOut String
         | TxtIn (String -> [IO₁])
         | FileWrite FilePath String
         | FileRead FilePath (String -> [IO₁])
         | Beep Double

main₃ :: String -> [IO₁]
main₃ _ = [ TxtIn $ \_ ->
             [TxtOut "Hello World"]
          ]

这样实际上允许您在程序中表达任何文件操作(尽管可能性能不佳),但它有点过于复杂:

  • main₃ 生成整个操作列表。我们为什么不直接使用带有此列表作为特殊情况的 :: IO₁ 签名?

  • 列表不能再可靠地概述程序流程:大多数后续计算将只作为某些输入操作的结果“通知”。因此,我们可以放弃列表结构,而是简单地将“然后执行”添加到每个输出操作中。

data IO₂ = TxtOut String IO
         | TxtIn (String -> IO₂)
         | Terminate

main₄ :: IOmain₄ = TxtIn $ \_ ->
         TxtOut "Hello World"
          Terminate

还不错!

那么这一切与单子有什么关系呢?

在实践中,您不会想使用简单的构造函数来定义所有程序。可能需要有一些基本构造函数,但对于大多数高级功能,我们希望编写具有一些漂亮高级签名的函数。结果发现,其中大多数看起来非常相似:接受某种有意义类型的值,并产生IO操作作为结果。

getTime :: (UTCTime -> IO₂) -> IO₂
randomRIO :: Random r => (r,r) -> (r -> IO₂) -> IO₂
findFile :: RegEx -> (Maybe FilePath -> IO₂) -> IO₂

显然这里有一个模式,而我们最好将其写成:

type IO₃ a = (a -> IO₂) -> IO-- If this reminds you of continuation-passing
                                  -- style, you're right.

getTime :: IOUTCTime
randomRIO :: Random r => (r,r) -> IO₃ r
findFile :: RegEx -> IO₃ (Maybe FilePath)

现在看起来有些熟悉了,但是我们仍然只是在处理伪装成普通函数的东西,并且这很危险:每个“值-动作”都有责任传递任何包含函数的结果动作(否则整个程序的控制流容易被中间的一个行为不良的动作打乱)。我们最好明确这个要求。嗯,事实证明这些是 单子定律,虽然我不确定我们是否真的能够在没有标准绑定/连接操作符的情况下制定它们。

无论如何,我们现在已经得到了一个具有正确单子实例的IO公式:

data IO₄ a = TxtOut String (IOa)
           | TxtIn (String -> IO₄ a)
           | TerminateWith a

txtOut :: String -> IO₄ ()
txtOut s = TxtOut s $ TerminateWith ()

txtIn :: IOString
txtIn = TxtIn $ TerminateWith

instance Functor IOwhere
  fmap f (TerminateWith a) = TerminateWith $ f a
  fmap f (TxtIn g) = TxtIn $ fmap f . g
  fmap f (TxtOut s c) = TxtOut s $ fmap f c

instance Applicative IOwhere
  pure = TerminateWith
  (<*>) = ap

instance Monad IOwhere
  TerminateWith x >>= f = f x
  TxtOut s c >>= f = TxtOut s $ c >>= f
  TxtIn g >>= f = TxtIn $ (>>=f) . g

显然这不是一种高效的IO实现方式,但原则上它是可以使用的。


1
@jdlugosz:IO3 a ≡ Cont IO2 a。但我的意思更多地是向那些已经了解延续单子的人点头致意,因为它并不被认为是适合初学者的内容。 - leftaroundabout

7

单子主要用于将函数链接在一起形成链。就是这样。

现有的单子在组合方式上存在差异,因此会导致不同的行为(例如,在状态单子中模拟可变状态)。

关于单子的困惑在于它们非常通用,即作为组合函数的机制,它们可以用于许多事情,从而使人们相信单子与状态、IO等有关,但实际上它们只涉及“组合函数”。

现在,单子的一个有趣之处在于组合的结果始终是类型为“M a”的值,即带有“M”标记的封装内部的值。这个特性非常适合实现纯代码和非纯代码之间的明确分离:将所有非纯操作声明为类型为“IO a”的函数,并在定义IO单子时不提供任何函数来从“IO a”内部取出“a”值。结果是,没有函数既能保持纯净,又能从“IO a”中取出值,因为在保持纯净的同时无法获取该值(必须在“IO”单子内部使用该值的函数)。 (注意:好像没有什么是完美的,因此可以使用“unsafePerformIO : IO a -> a”来打破“IO约束”,从而污染本应是纯函数的内容,但这应该非常谨慎地使用,并且当您确实知道不会引入任何带有副作用的非纯代码时才使用。)


5
如果您有一个类型构造函数和返回该类型系列值的函数,那么您需要单子(monads)。最终,您希望将这些函数组合在一起。这些是回答“为什么”的三个关键元素。
让我详细说明一下。您拥有Int、String和Real以及类型为Int -> String、String -> Real等的函数。您可以轻松地组合这些函数,最终得到Int -> Real。生活很美好。
然后,有一天,您需要创建一种新的类型系列(family of types)。这可能是因为您需要考虑不返回任何值(Maybe),返回错误(Either)、多个结果(List)等情况。
请注意,Maybe是一种类型构造器(type constructor)。它接受一个类型,如Int,并返回一个新类型Maybe Int。首先要记住的一件事是,没有类型构造器就没有单子。
当然,您希望在代码中使用类型构造器,并很快就会得到诸如Int -> Maybe String和String -> Maybe Float之类的函数。现在,您无法轻松地组合您的函数。生活不再美好。
这就是单子的用处所在。它们允许您再次组合此类函数。你只需要将组合.更改为> ==即可。

3
这与字体家族无关。你实际上在谈论什么? - dfeuer

5

Monad只是解决一类重复问题的方便框架。首先,functor必须是monad的基础条件(即必须支持映射而不查看元素(或其类型)),它们还必须有一个binding(或链式操作)和通过元素类型创建monadic值的方法(return)。最后,bindreturn必须满足两个等式(左右身份),也称为单子律。(相反地,可以将monad定义为具有flattening operation而不是binding。)

常用于处理非确定性的是list monad。bind操作选择列表中的一个元素(直观上所有元素在平行世界中),让程序员对它们进行一些计算,然后将所有世界中的结果合并成单个列表(通过连接或展开嵌套列表)。下面是如何在Haskell的monadic framework中定义一个排列函数:

perm [e] = [[e]]
perm l = do (leader, index) <- zip l [0 :: Int ..]
            let shortened = take index l ++ drop (index + 1) l
            trailer <- perm shortened
            return (leader : trailer)

这里是一个示例 REPL会话:

*Main> perm "a"
["a"]
*Main> perm "ab"
["ab","ba"]
*Main> perm ""
[]
*Main> perm "abc"
["abc","acb","bac","bca","cab","cba"]

值得注意的是,列表单子并不是一种具有副作用的计算。一个数学结构成为一个单子(即符合上述接口和规则)并不意味着副作用,尽管产生副作用的现象通常很好地适应单子框架。


2
我们为什么需要单子类型?
因为在像Haskell这样的非严格语言中,I/O及其可观察的效果是引起单子接口如此重要的困境。
  • [...] Monad 被用来解决涉及状态、输入/输出、回溯等返回值的更一般性计算问题:它们并没有直接解决任何输入/输出问题,而是提供了一个优雅且灵活的抽象来处理相关问题的许多解决方案。例如,在Imperative functional programming这篇最初提出“基于 monad 在非严格、纯函数式语言中执行输入/输出的新模型”的论文中,至少使用了三种不同的输入/输出方案来解决这些基本问题。[...]

    这样的输入/输出方案仅提供框架,使得副作用操作可以安全地使用,并保证有序执行,而不会影响语言中纯函数部分的属性。

    Claus Reinke(第96-97页,共210页)。

    (我加粗了重点部分。)

  • [...] 当我们编写有副作用的代码 - 无论是否使用 monads - 我们必须不断记住我们传递的表达式的上下文。

    使用 monadic 符号时,“desugaring”(指实现为无副作用代码)是无关紧要的。当我们在该符号内编程时,我们是在该符号内编程 - 而不考虑该符号 desugars 成什么。思考 desugared 代码会破坏 monadic 抽象。无副作用的 applicative 代码通常会被编译成 C 或机器代码(即 desugar 成这些代码)。如果 desugaring 论证有任何力量,它同样可以适用于 applicative 代码,从而得出结论:所有问题都归结为机器代码,因此所有编程都是命令式的。

    [...] 从个人经验来看,我注意到编写 monadic 代码时犯的错误正是我在编写 C 代码时犯的错误。实际上,monadic 错误往往更严重,因为与典型的命令式语言相比,monadic 符号笨拙且难以理解。

    Oleg Kiselyov(第21页,共26页)。

  • 最难理解的构造是 monad。我介绍 IO 而不提及 monads。

    Olaf Chitil.

更一般地说:
尽管函数式编程世界引入单子(monads)的概念已经有25年之久,但初学者仍然难以理解单子的概念。这种困难在许多博客文章中得到体现,这些文章描述了学习单子的努力。从我们自己的经验来看,即使在大学本科阶段,学生们也常常难以理解单子,并在与单子相关的考试题上得分较低。
考虑到单子的概念不太可能很快从函数式编程领域消失,因此作为函数式编程社区,我们必须设法克服初学者在首次学习单子时遇到的问题。Tim Steenvoorden, Jurriën Stutterheim, Erik Barendsen and Rinus Plasmeijer. 如果有另一种方法可以在Haskell中指定“保证执行顺序”,同时保持将常规Haskell定义与涉及I/O(及其可观察效果)的定义分开的能力,那该多好啊。这是对Philip Wadlerecho的一种变体的翻译。
val echoML    : unit -> unit
fun echoML () = let val c = getcML () in
                if c = #"\n" then
                  ()
                else
                  let val _ = putcML c in
                  echoML ()
                end

fun putcML c  = TextIO.output1(TextIO.stdOut,c);
fun getcML () = valOf(TextIO.input1(TextIO.stdIn));

"......然后可以简单地如下:"
echo :: OI -> ()                         
echo u = let !(u1:u2:u3:_) = partsOI u in
         let !c = getChar u1 in          
         if c == '\n' then               
           ()                            
         else                            
           let !_ = putChar c u2 in      
           echo u3                       

where:

data OI  -- abstract

foreign import ccall "primPartOI" partOI :: OI -> (OI, OI)
                      ⋮

foreign import ccall "primGetCharOI" getChar :: OI -> Char
foreign import ccall "primPutCharOI" putChar :: Char -> OI -> ()
                      ⋮

并且:

partsOI         :: OI -> [OI]
partsOI u       =  let !(u1, u2) = partOI u in u1 : partsOI u2 

这是如何工作的?在运行时,Main.main接收一个初始的OI 伪数据值作为参数:
module Main(main) where

main            :: OI -> ()
          ⋮

使用partOIpartsOI可以产生其他OI值。您所要做的就是确保每个新的OI值最多在每个对基于OI的定义(国内或其他)的调用中使用一次。作为回报,您将得到一个普通的结果-它并没有与某些奇怪的抽象状态配对,也不需要使用回调续传等。

与Standard ML使用单元类型()不同,使用OI意味着我们可以避免始终使用单子界面:

一旦你进入IO单子,你就会永远被困在那里,并且被降级到Algol-style命令式编程。

Robert Harper.

但是如果您真的需要它:

type IO a       =  OI -> a

unitIO          :: a -> IO a
unitIO x        =  \ u -> let !_ = partOI u in x

bindIO          :: IO a -> (a -> IO b) -> IO b
bindIO m k      =  \ u -> let !(u1, u2) = partOI u in
                          let !x        = m u1 in
                          let !y        = k x u2 in
                          y

                      ⋮

因此,单子类型并非总是必需的 - 还有其他接口可用:
LML在约1989年就已经实现了在多处理器(Sequent Symmetry)上运行的神谕完整实现。Fudgets论文中提到了这个实现。它非常易于使用且相当实用。
[...]
现在所有东西都是用单子做的,因此其他解决方案有时会被遗忘。Lennart Augustsson(2006)。
稍等一下:既然它与标准ML的效果直接使用如此相似,那么这种方法及其使用的“伪数据”是否具有引用透明性?
当然 - 只需找到一个合适的“引用透明性”的定义即可;有很多可以选择...

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