Haskell中真正的惰性IO

8
考虑以下片段:
getLine >>= \_ -> getLine >>= putStr

它进行了合理的操作,要求两次输入字符串,然后打印最后一次输入。由于编译器无法确定getLine有什么外部影响,必须执行这两个操作,即使我们抛弃第一个操作的结果。

我需要将IO单子包装到另一个单子M中,以便IO计算仅在其返回值被使用时有效。因此,上面的程序可以重写为以下内容-

runM $ lift getLine >>= \_ -> lift getLine >>= lift putStr

在哪里

runM :: M a -> IO a
lift :: IO a -> M a

用户仅被要求输入一次。

然而,我无法弄清如何编写这个Monad以达到我想要的效果。我不确定它是否可能。请有人帮忙吗?


我刚想到一个主意 - 定义 m>>=f 具有以下行为 - 评估 f _|_,并查看结果是否不是 _|_。如果是,则该结果即为答案。否则,结果为 m>>=f。但我不确定是否涵盖了所有情况。那么如何与 _|_ 进行比较呢?使用 try catch 吗? - Anupam Jain
这将对副作用造成很大的影响。嵌套时您会按深度优先还是深度最后的顺序进行工作?无论如何,都很糟糕。我不认为任何顺序都适用于您的例子和lift getLine >>= \s -> lift getLine >> putStr s - rampion
3个回答

11

通常使用 unsafeInterleaveIO :: IO a -> IO a 实现延迟 IO 操作的副作用,直到需要其结果时才执行,所以我们可能需要使用它,但让我们先解决一些小问题。

首先,lift putStr 会导致类型检查失败,因为 putStr 的类型为 String -> IO (),而 lift 的类型为 IO a -> M a。我们将不得不改用像 lift . putStr 这样的形式。

其次,我们需要区分哪些 IO 操作应该是惰性的,哪些不应该是。否则,putStr 将永远不会被执行,因为我们没有在任何地方使用它的返回值 ()

考虑到这一点,至少对于您的简单示例来说,这似乎运行良好。

{-# LANGUAGE GeneralizedNewtypeDeriving #-}

import System.IO.Unsafe

newtype M a = M { runM :: IO a }
    deriving (Monad)

lazy :: IO a -> M a
lazy = M . unsafeInterleaveIO

lift :: IO a -> M a
lift = M

main = runM $ lazy getLine >> lazy getLine >>= lift . putStr

然而,正如C. A. McCann指出的,你应该避免在任何严肃场合使用这种方法。Lazy IO已经被人们所不赞成,因为它使得很难推断出副作用实际发生的顺序,这会让问题变得更加复杂。

考虑以下示例:

main = runM $ do
    foo <- lazy readLn
    bar <- lazy readLn
    return $ foo / bar

这两个数字的顺序读取完全是未定义的,可能会因编译器版本、优化或星座排列而改变。名称unsafeInterleaveIO之所以冗长且难看,是有充分理由的:为了提醒您使用它的危险性。在单子中隐藏不使用时,让人们知道何时使用它是一个好主意。


5
就像readFile之类的“懒IO”在特定(有些常见)情况下表现得足够良好,并且如果使用得当,不是普遍被反感的。我不太喜欢这个概念,但意见不同。另一方面,在任意位置放置unsafeInterleaveIO是非常值得怀疑的。 - C. A. McCann
11
这个单子是恶魔的化身,会在我的噩梦中出现。 - John L

8

实际上,没有明智的方法来做到这一点,因为说实话,这不是一件明智的事情。引入单子 I/O 的整个目的就是在惰性求值的情况下给效果一个定义良好的排序。如果您确实需要放弃这一点,那么除了使编写混乱而有 bug 的代码更容易之外,我不确定这种做法会解决什么实际问题。

话虽如此,“惰性 I/O”已经以受控方式引入了这种类型的操作。其“原始”操作是 unsafeInterleaveIO,它的实现大致为 return . unsafePerformIO,加上一些细节,让事情变得更加顺畅。通过将 unsafeInterleaveIO 应用于所有内容,通过在 "lazy IO" 单子的绑定操作中隐藏它,可能会实现你所期望的不明智的观点。


我认为作者的意图并非恶意。对我而言,懒惰 I/O 的问题似乎很重要(构建脚本可能是一个例子),应该有一些好的方法来解决它(不一定要使用像unsafeInterleaveIO这样可疑的东西)。 - Rotsor
一个独立的问题是unsafeInterleaveIO为什么不安全。我不明白它比forkIO更糟糕在哪里。 - Rotsor
@Rotsor:这不是恶意行为的问题,只是被误导了。现有的惰性IO函数在某些情况下运作良好,但扩展该概念将导致程序的行为无法清晰地推理,并且无法应对后续添加某些功能。我相信作者试图解决一个合理的问题,但这是一个不合理的解决方案。 - C. A. McCann
@Rotsor:与其重复我自己,不如看看我之前的这篇帖子,我在hammar的回答评论中提到过,其中展示了一种懒惰IO失败的情况以及我更喜欢的另一种更明确的风格的例子。 - C. A. McCann
@Rotsor:关于与forkIO的比较,使用unsafeInterleaveIO更糟糕,因为它是隐式和非本地的。新线程是一个独立运行的有形物体,而延迟的IO操作在(可能不清楚的)点上隐含发生,这些点是由纯计算的评估引起的。这引入了所有关于惰性求值的常规困难,加上担心副作用的奖励。保留了更弱的纯度概念,但理智迅速减少... - C. A. McCann

5

如果你不想使用像 unsafeInterleaveIO 这样的不安全内容,那么你所寻找的并不是一个单子(monad)。

相反,这里有一个更加清晰的抽象——箭头(Arrow)。
我认为,以下内容可能会对你有所帮助:

data Promise m a
    = Done a
    | Thunk (m a)

newtype Lazy m a b =
    Lazy { getLazy :: Promise m a -> m (Promise m b) }

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