列出来自 IO 的第一个值

List with first value from IO

我创建了一个无限列表,它的第一个元素需要一些时间才能生成:

slowOne = do
  threadDelay (10 ^ 6)
  return 1

infiniteInts :: [IO Integer]
infiniteInts = loop slowOne
where
  loop :: IO Integer -> [IO Integer]
  loop ioInt = ioInt : loop (fmap (+1) ioInt)

当我打印列表时,我可以观察到不仅在第一个元素而且在 所有 个元素发生的延迟:

main =
  mapM_
      (\ioInt -> do
        i <- ioInt
        print i
      )
    infiniteInts

我正在努力提高我对 IO 的直觉:为什么每个元素都有延迟,而不仅仅是 slowOne 生成的第一个元素?

警告:

关于 运行ning 表达式,此答案不正确。请先参考 以更好地理解此 IO 值列表的工作原理。


我们可以通过

来理解这种行为

这里重写了 infiniteInts 以获得不同数量的元素:

获取一个元素

slowOne : loop (fmap (+1) slowOne)

由于 Haskell 的 : 运算符是非严格的,我们 运行 slowOne 一次 → 这需要一秒钟。

获取两个元素

slowOne : (fmap (+1) slowOne) : loop (fmap (+1) (fmap (+1) slowOne))

取两个元素(直到第二个 :)导致 slowOne 被调用两次 → 这需要两秒钟。

获取三个元素

slowOne : (fmap (+1) slowOne) : (fmap (+1) (fmap (+1) slowOne)) : loop (fmap (+1) (fmap (+1) (fmap (+1) slowOne)))

取三个元素(直到第三个 :)导致 slowOne 被调用三次 → 这需要三秒钟。

总结

从重写中我们可以看到 slowOne 为每个元素调用(例如,三个元素调用三次)并且假设 GHC 不会缓存在 IO 内部创建的结果(slowOne) 因此每个元素都需要一秒钟的时间来创建。

每个号码都有延迟,因为它们都来自 slowOne。考虑你的 loop:

loop ioInt = ioInt : loop (fmap (+1) ioInt)
     ^----This is slowOne              ^
                                       └----This is also slowOne

我对你的 fmap 正在做什么的直觉只是作用于 IO 值(IO Integer 中的 Integer),但保留其所有上下文( "IO" 部分)完好无损。

正如 Will Ness 评论的那样:

fmap (1+) takes an IO value (a pure value of type IO t for some t) which describes I/O action that will return a pure value x :: t when that action will run; and creates a new pure IO value describing an augmented I/O action that will return the pure value x+1 after performing the I/O actions as described by the first IO value.

例如,我们可以用另一个函数 timedOne:

替换 slowOne
timedOne = do
  time <- getPOSIXTime
  putStrLn $ "time: " ++ show time
  return 1

timedOne 而不是 slowOne 调用 loop 会打印出来,显示 fmap 如何在不影响上下文的情况下影响值:

time: 1583715559.051068s
1
time: 1583715559.051705s
2
time: 1583715559.052311s
3
... and so on

你看每个被使用的号码,仍然携带着自己的IO "baggage",只是这次行李是"get the time from the system clock and print it out"。如果要更改此行为以便仅延迟第一个数字,则需要清除 loop 构建的列表的尾部任何线程延迟 IO 包袱。一种方法是使用纯列表并将每个元素包装在 IO:

loop ioInt = ioInt : (return <$> [2..])

我不确定你的直觉(正如你在自己写的 to this question) is all that accurate. Let me try to give you some better intuition. You may also find 中描述的那样很有帮助,尽管那里的问题完全不同。

在 Haskell 中,类型 IO a 的值(对于任何类型 a,因此 IO IntIO String 或其他)有时是称为 "IO action",但正如@WillNess 在评论中提到的,最好将其视为 "IO recipe"。对于这些配方,我们认为 "evaluation" 和 "execution" 是完全独立的操作。 评估类型的表达式IO a就像写下食谱。 评估类型IO Int的表达式的结果是类型IO Int的值。生成此值不会执行任何 I/O 并且无需花费任何时间,即使基础 I/O 涉及延迟或其他缓慢的操作。这个评估的 IO a 值可以传递、存储、复制、修改、与其他 IO 配方结合,或者完全忽略,所有这些都不需要执行任何实际的 I/O.

相比之下,执行生成的配方是实际执行I/O操作的过程。 执行一个IO Int的结果是一个Int。如果涉及 20 分钟的延迟、文件访问、and/or 信鸽申请以获得 Int,操作将需要一段时间。如果您执行 相同的食谱两次,第二次它不会更快。

我们在 Haskell 中编写的几乎所有代码都会评估 IO 配方而不执行它们。

当代码:

slowOne = do
  threadDelay (10 ^ 6)
  return 1

是运行,它实际上只是评估(写下)一个IO配方。评估这个配方显然涉及评估一个 do-block。这不会 I/O;它只是评估(写下)do-block 中的每个配方,并将它们组合成一个更大的书面配方。

具体来说,评估 slowOne 涉及:

  1. 正在评估配方 threadDelay (10 ^ 6)。这涉及计算算术表达式 10 ^ 6 并对其调用函数 threadDelay。该函数实现(对于非线程运行时间)为:

     threadDelay :: Int -> IO ()
     threadDelay time = IO $ \s -> some_function_of_s
    

    也就是说,它将一个函数包装在一个 IO 构造函数中以产生一个 IO () 类型的值。至关重要的是,它实际上并没有延迟线程。它只是创建一个(包装的)功能值。顺便说一句,IO 构造函数并没有什么神奇之处。这个 threadDelay 函数类似于编写同样非魔法的:

     justAFunction :: Int -> Maybe (Int -> Int)
     justAFunction c = Just (\x -> c*x)
    
  2. 正在评估配方 return 1。这也只是创建一个包装在 IO 构造函数中的值。具体来说,它是(包装且完全非魔法的)功能值,看起来像:

     IO (\s -> (s, 1))
    
  3. 将这两个评估的配方按顺序组合成一个更长的配方。这个新的组合配方是类型 IO Int 的值,将分配给 slowOne.

类似地,当对下面的代码求值时:

infiniteInts :: [IO Integer]
infiniteInts = loop slowOne
  where
    loop :: IO Integer -> [IO Integer]
    loop ioInt = ioInt : loop (fmap (+1) ioInt)

您没有执行任何 IO。您只是在评估 IO 配方和包含 IO 配方的数据结构。具体来说,您正在将此表达式计算为 [IO Integer] 类型的值,该值由 IO Integer values/recipes 的无限列表组成。列表中的第一个食谱是 slowOne。列表中的第二个食谱是:

fmap (+1) slowOne

这需要一个词的解释。评估此表达式时,它会构造一个新配方,该配方可以使用等效的 do 块编写:

fmap_plus_one_of_slowOne = do
  x <- slowOne
  return (x + 1)

鉴于 slowOne 的定义方式,这实际上等同于我们通过评估得到的独立配方:

fmap_plus_one_of_slowOne = do
  threadDelay (10 ^ 6)
  return 2

同样,列表中的第三个食谱:

fmap (+1) (fmap (+1) slowOne)

相当于配方的计算结果:

fmap_plus_one_of_fmap_plus_one_of_slowOne = do
  threadDelay (10 ^ 6)
  return 3

现在,您程序的最后一部分是:

mapM_
    (\ioInt -> do
      i <- ioInt
      print i
    )
  infiniteInts

您可能会惊讶地听到,在评估此代码时,我们仍然只是评估而不是正在执行 食谱。评估此 mapM_ 函数时,它会构建一个新配方。它构建的配方可以用文字描述为:

"Take each recipe in the list infiniteInts. Sorry about the bad choice of name -- this isn't a list of integers, but a list of IO recipes for making integers. It's a good thing you're a computer and won't get confused by this, huh? Anyway, take each of those recipes in sequence and pass them to this function I have here to generate a new recipe. Then, run that list of recipes in order. You're writing this down, right? Stop, don't execute anything yet! Just write it down!"

那么,让我们回顾一下:

  • slowOne是食谱

    do threadDelay (10 ^ 6)
       return 1
    
  • fmap (+1) slowOne与方子相同:

    do threadDelay (10 ^ 6)
       return 2
    
  • 同样,fmap (+1) (fmap (+1) slowOne)真的只是食谱

    do threadDelay (10 ^ 6)
       return 3
    

    等等

  • 因此,infiniteInts 是食谱列表:

    infiniteInts =
      [ do { threadDelay (10 ^ 6); return 1 }
      , do { threadDelay (10 ^ 6); return 2 }
      , do { threadDelay (10 ^ 6); return 3 }
      , ... ]
    

鉴于 mapM_ ... 秘诀的含义,如果 Haskell 允许无限长的程序,我们可以从头开始编写整个秘诀,如下所示:

do   -- first recipe
     threadDelay (10 ^ 6)
     i <- return 1
     print i

     -- second recipe
     threadDelay (10 ^ 6)
     i <- return 2
     print i

     -- third recipe
     threadDelay (10 ^ 6)
     i <- return 3
     print i

     -- etc.

这是计算 mapM_ ... 表达式的结果。

然后,最后,我们到达 程序的唯一部分 执行 IO 配方,而不是简单地评估它。那部分是:

main = ...

当您将食谱命名为 main 时,您告诉 Haskell 在程序为 运行 时执行它。从分配给 main 的配方的评估值可以看出,它是一个包含交错 threadDelayprint 配方的组合配方,因此当它被执行时,它会打印一个递增的在每个整数之前都有延迟的整数列表。

关于懒惰与严格的说明...懒惰在上述过程中没有任何作用(好吧,除了允许我们在不锁定机器的情况下构造无限列表)。当我在上面说 "evaluate" 时,无论评估是否严格并立即发生,或者评估在技术上延迟到需要时,都没有区别。需要它的时间点可能是在它被执行的时候,但是评估(编写配方)和执行(遵循配方)仍然是不同的过程,即使它们发生在一个正确的位置一个接着一个。