为什么序列 [getLine, getLine, getLine] 不是惰性求值?

9
main = do
  input <- sequence [getLine, getLine, getLine]
  mapM_ print input

让我们看看这个程序实际运行的情况:

m@m-X555LJ:~$ runhaskell wtf.hs
asdf
jkl
powe
"asdf"
"jkl"
"powe"

让我感到惊讶的是,这里似乎没有懒惰现象。相反,所有3个getLine都会被急切地评估,读取的值将存储在内存中,然后在打印之前,才一起输出。

与此相比:

main = do
  input <- fmap lines getContents
  mapM_ print input

让我们看看这个实例:

m@m-X555LJ:~$ runhaskell wtf.hs
asdf
"asdf"
lkj
"lkj"
power
"power"

完全不同的东西。逐行读取并逐行打印。这对我来说很奇怪,因为我真的看不出这两个程序之间有任何区别。
从LearnYouAHaskell中:
当与I/O操作一起使用时,sequenceA和sequence是相同的!它接受一个I/O操作列表,并返回一个I/O操作,该操作将执行每个操作,并将其结果作为I/O操作的结果的列表。这是因为要将[IO a]值转换为IO [a]值,以生成在执行时产生结果列表的I/O操作,所有这些I/O操作都必须被序列化,以便在强制评估时一个接一个地执行它们。您无法在执行它之前获得I/O操作的结果。
我很困惑。我不需要执行所有的IO操作才能得到一个结果。
在几段话之前,书中展示了sequence的定义:
sequenceA :: (Applicative f) => [f a] -> f [a]  
sequenceA [] = pure []  
sequenceA (x:xs) = (:) <$> x <*> sequenceA xs

递归优秀;没有任何迹象表明这个递归不应该是惰性的;就像在任何其他递归中一样,为了获取返回列表的头部,Haskell 不必穿越所有递归步骤!

对比:

rec :: Int -> [Int]
rec n = n:(rec (n+1))

main = print (head (rec 5))

实际应用场景:

m@m-X555LJ:~$ runhaskell wtf.hs
5
m@m-X555LJ:~$

显然,在这里递归是惰性执行的,而不是急切执行。

那么为什么在 sequence [getLine, getLine, getLine] 的例子中递归是急切执行的呢?


至于为什么重要的是IO操作按顺序运行,无论结果如何:想象一个动作createFile :: IO ()writeToFile :: IO ()。当我做一个sequence [createFile,writeToFile]时,我希望它们都按顺序完成,即使我根本不关心它们实际的结果(它们的结果都是非常无聊的值())!

我不确定这如何适用于这个问题。

也许我会这样表达我的问题...

在我看来,这个:

do
    input <- sequence [getLine, getLine, getLine]
    mapM_ print input

应该将其恶化为以下内容:
do
    input <- do
       input <- concat ( map (fmap (:[])) [getLine, getLine, getLine] )
       return input
    mapM_ print input

于是,它应该会退化成类似于下面的内容(伪代码,抱歉):

do
    [ perform print on the result of getLine,
      perform print on the result of getLine,
      perform print on the result of getLine
    ] and discard the results of those prints since print was applied with mapM_ which discards the results unlike mapM
2个回答

6

getContents是惰性的,getLine不是。惰性IO并不是Haskell本身的特性,而是某些特定IO操作的特性。

我很困惑。我不需要执行所有IO操作,只需要得到一个结果即可。

是的,你确实需要!这是IO最重要的特性之一,如果你写a >> b或同义词,

do a
   b

如果您确保在b之前一定会运行a,那么可以确定(见脚注),getContents实际上是相同的,它会在其后面的任何内容之前“运行”... 但是它返回的结果是一个潜在的结果,在您尝试评估它时会偷偷地执行更多IO操作。 这实际上是惊人的事情,并且在实践中可能导致一些非常有趣的结果(例如您正在读取内容的文件在处理getContents结果时被删除或更改),因此在实际程序中,您可能不应该使用它,它主要存在于那些您不关心这些事情的程序中(例如代码高尔夫,一次性脚本或教学)。

至于为什么按顺序运行IO操作很重要而不考虑结果:想象一个动作createFile :: IO()writeToFile :: IO()。 当我执行sequence [createFile,writeToFile]时,我希望它们按顺序完成,即使我根本不关心它们的实际结果(它们都是非常无聊的值())!


回答编辑:

should detoriate to something like this:

do
    input <- do
       input <- concat ( map (fmap (:[])) [getLine, getLine, getLine] )
       return input
    mapM_ print input
不,实际上它会变成这样:
do 
  input <- do
    x <- getLine
    y <- getLine
    z <- getLine
    return [x,y,z]
  mapM_ print input

实际上,sequence 的定义大致如下:
sequence [] = return []
sequence (a:as) = do
  x <- a
  fmap (x:) $ sequence as

好的...我问了所有三个问题,现在正在阅读答案 :) 是的,您在之前的回答中说getLine不是惰性的。我明白了。但是我感觉我在这个问题中询问的不是我在第一个问题中询问的内容。这里的问题是:鉴于sequence的定义方式(对getLines列表进行递归),为什么列表不是惰性评估的?(而不是:为什么任何特定的getLine不是惰性评估的?)也就是说-为什么第二个getLine会在第一个getLine之后立即被评估,而不是只有在需要时才评估?请参见我的递归生成无限列表的示例。 - user4385532
看到了你的编辑。评论里无法解决,因为太长了。如果可以的话,我会编辑我的问题来回应你的编辑。 - user4385532
@gaazkam 在 IO 中,a >> b 的结果是一个 IO 操作,按照定义首先执行 a,然后执行 bsequence as 生成的 IO 操作看起来像这样:as !! 0 >> as !! 1 >> ... >> as !! (length as - 1)(它还将结果放在列表中,我在这里省略了这部分,因为它不重要)。无论 as 还是 sequence 是否惰性求值,结果值仍然相同,就像 sum [1,2,3] 总是 6 一样,无论 [1,2,3] 如何惰性求值。 - Cubic
@gaazkam,我不确定是哪个部分让您感到困惑,我尽可能地回答了您的编辑。 - Cubic

4

从技术上讲,在

sequenceA (x:xs) = (:) <$> x <*> sequenceA xs

我们发现<*>,它首先在左边执行操作,然后在右边执行操作,最后将它们的结果应用在一起。这就是使列表中第一个效果首先出现的原因。确实,在单子上,f <*> x等价于
do theF <- f
   theX <- x
   return (theF theX)

总的来说,需要注意的是所有的IO操作通常按照顺序依次执行,从第一个到最后一个(关于一些罕见情况请见下文)。完全采用惰性方式进行IO将会给程序员带来噩梦般的体验。例如,考虑以下代码:

do let aX = print "x" >> return 4
       aY = print "y" >> return 10
   x <- aX
   y <- aY
   print (x+y)

Haskell保证输出结果的顺序为。如果我们使用完全惰性的IO,我们也可以得到< y x 14 >,这取决于哪个参数先被< + >强制。在这种情况下,我们需要确切地知道每个操作要求惰性thunk的顺序,而程序员肯定不想关心这些细节语义。在这样详细的语义下,< x + y >不再等同于< y + x >,在很多情况下破坏了等式推理。

现在,如果我们想要强制IO是惰性的,我们可以使用其中一个禁止的函数,例如

do let aX = unsafeInterleaveIO (print "x" >> return 4)
       aY = unsafeInterleaveIO (print "y" >> return 10)
   x <- aX
   y <- aY
   print (x+y)

上述代码将aXaY变成了延迟IO操作,而输出的顺序现在取决于编译器和+的库实现。这通常是很危险的,因此延迟IO是不安全的。

现在,关于异常。一些只从环境中读取的IO操作,例如getContents,是使用了延迟IO(unsafeInterleaveIO)实现的。设计者认为对于这样的读取,延迟IO是可以接受的,并且在许多情况下精确的读取时间并不那么重要。

如今,这是有争议的。虽然它可能很方便,但在许多情况下延迟IO可能过于不可预测。例如,我们无法知道文件何时关闭,如果我们从套接字中读取数据,这可能很重要。我们还需要非常小心地不要过早强制读取:这通常会导致从管道中读取数据时发生死锁。如今,通常优先避免使用延迟IO,并采用某些像pipesconduit这样的库进行“流式”操作,其中没有歧义。


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