IO 与引用透明性

IO vs referential transparency

抱歉,这里是新问题,但是 Haskell 怎么知道不将引用透明应用于例如readLn 或当 putStrLn-ing 同一个字符串两次?是因为涉及到IO吗? IOW,编译器不会将引用透明性应用于返回 IO 的函数吗?

由于 return 值被包装到 IO 中,你不能重复使用它们,除非你 "pull" 它们出来,实际上 运行 IO 操作:

readLn :: IO String

twoLines = readLn ++ readLn -- can't do this, because (++) works on String's, not IO String's

twoLines' = do
  l1 <- readLn
  l2 <- readLn -- "pulling" out of readLn causes console input to be read again
  return (l1 ++ l2) -- so l1 and l2 have different values, and this works

IO类型定义为:

newtype IO a = IO (State# RealWorld -> (# State# RealWorld, a #))

Notice 与 State Monad 非常相似,其中状态是 现实世界的状态,所以恕我直言,您可以将 IO 视为引用透明和纯粹的,不纯粹的是运行 IO 操作(代数)的 Haskell 运行时(解释器)。

看看 Haskell wiki,它更详细地解释了 IO:IO Inside

有点。你可能听说过 IO 是一个 monad,这意味着包裹在其中的值必须使用 monadic 操作,例如绑定(>>=)、顺序组合(>>)和 return。所以,你可以写一个这样的问候程序:

prompt :: IO String
prompt = putStrLn "Who are you?" >>
         getLine >>=
         \name ->
           return $ "Hello, " ++ name ++ "!"

main :: IO ()
main = prompt >>=
       putStrLn

你更有可能看到它与等效的 do 符号,这只是编写完全相同程序的另一种方式。不过,在这种情况下,我认为未加糖的版本更清楚地表明计算是一系列与 >>>>= 链接在一起的语句,当我们想抛出时我们使用 >>输出上一步的结果,当我们想将结果传递给链中的下一个函数时 >>=。如果我们需要给那个结果一个名字,我们可以将它捕获为一个参数,就像在 prompt 里面的 lambda 表达式 \name -> 一样。如果我们需要将一个简单的 String 提升为 IO String,我们使用 return.

do 表示法中的等价物,顺便说一句,是:

prompt :: IO String
prompt = do
  putStrLn "Who are you?"
  name <- getLine
  return $ "Hello, " ++ name ++ "!"

main :: IO ()
main = do
  message <- prompt
  putStrLn message

那么它是如何知道 main 的 return 不是引用透明的,而 prompt 是 return 的 [=24] =],也不是吗? IO 有一些特别之处,或者至少 IO 缺少一些东西:对于许多其他单子,例如 StateMaybe,有一种方法可以进行惰性计算在 monad 内部并丢弃包装器,返回一个纯值。您可以在其中声明一个 State Int monad,do 确定性的、顺序的、有状态的计算一段时间,然后使用 evalState 返回纯 Int 结果。您可以进行 Maybe Char 计算,例如在字符串中搜索字符,检查它是否有效,如果有效,则读取纯 Char 返回。

使用 IO,您不能这样做。如果你有一个 IO String,你所能做的就是将它绑定到一个带有 String 参数的 IO 函数,例如 PutStrLn,或者将它传递给一个带有 IO String 参数的函数。如果你第二次调用 prompt,它不会默默地给你相同的结果;它实际上会再次 运行 。如果你告诉它暂停几毫秒,它不会懒惰地等到你稍后在程序中需要一些 return 值时才这样做。如果它 return 是一个像 IO () 这样的空值,编译器将不会仅通过 return 那个常量来优化它。

其内部工作方式是将对象与每次调用都不同的世界状态参数包装在一起。这意味着对 getLine 的两次不同调用取决于世界的不同状态,而 main 的 return 值需要计算世界的最终状态,这取决于之前的所有 IO 操作。

你需要区分评估执行

如果您计算 2 + 7,结果为 9。如果您将一个计算结果为 9 的表达式替换为另一个计算结果也为 9 的不同表达式,则含义该程序没有改变。这就是引用透明性所保证的。我们可以将几个共享表达式共同化为9个,或者将一个共享表达式复制成多个副本,程序的意义并没有改变。 (性能可能,但不是最终结果。)

如果您求值 readLn,它求值为I/O命令对象。您可以将其想象成一个数据结构,描述您要执行的 I/O 操作。但对象本身只是数据。如果您计算 readLn 两次,它会 returns 相同的 I/O 命令对象两次。您可以将多个副本合并为一个副本;您可以将一份副本分成几份。它不会改变程序的含义。

现在,如果您想执行 I/O 操作,那就是另一回事了。显然,I/O 操作需要完全按照程序指定的方式执行,而不是随机复制或重新排列。但这没关系,因为它不是 Haskell 表达式求值引擎。您可以假装 Haskell 运行时运行 main,它构建了一个巨大的 I/O 命令对象来表示整个程序。 Haskell 运行时然后读取此数据结构并按照指定的顺序执行它请求的 I/O 操作。 (实际上不是它是如何工作的,而是一个有用的心智模型。)

通常你不需要费心考虑评估 readLn之间的严格分离以获得I/O命令对象然后执行生成的I/O命令对象以获得结果。但严格来说,这就是它的作用。

(您可能还听说过 I/O "forms a monad"。这只是一种奇特的说法,有一组特定的运算符可以将 I/O 命令对象一起变成更大的 I/O命令对象。理解评估和执行之间的分离不是核心。)