为什么我们需要IO?

Why do we need IO?

Tackling the Awkward Squad: monadic input/output, concurrency, exceptions, and foreign-language calls in Haskell 中,SPJ 指出:

For example, perhaps the functional program could be a function mapping an input character string to an output string:

main :: String -> String

Now a "wrapper" program, written in (gasp!) C, can get an input string from somewhere [...] apply the function to it, and store the result somewhere [...]

然后他继续说,这在包装器中找到了“罪恶”,这种方法的问题是一个罪恶导致另一个罪恶(例如,不止一个输入、删除文件、打开套接字等) .

我觉得这很奇怪。我原以为 Haskell 完全以这种方式处理时会是最强大的,甚至可能是最有用的。即输入是位于文件中的字符串,输出是新文件中的新字符串。如果输入字符串是一些与数据连接的数学表达式,输出字符串是(非Haskell)代码,那么您可以完成任务。换句话说,为什么不总是将 Haskell 程序视为翻译器? (或者作为编译器,但作为翻译器,您可以将真正的 I/O 混合到最终的可执行文件中。)

无论将此作为一般策略是否明智(我理解我们可能想要完成的某些事情可能不是从数学开始的),我真正的问题是:如果这确实是方法,我们是否可以避免IO a 类型?我们需要其他语言的包装器吗?真的有人这样做吗?

重点是 String -> String 是一个相当糟糕的程序模型。

如果您正在编写一个 http 服务器,它接受并发流水线请求并同时响应每个流水线,同时交织来自流水线响应的写入和下一个请求的读取,该怎么办?这是http服务器工作的并发级别。

也许,只是也许,您可以将其填充到 String -> String 程序中。您可以将管道多路复用到您的单个通道中。但是超时呢? Web 服务器会暂停流入的连接,以防止懒猴攻击。你要怎么解释呢?也许您的输入字符串有一系列定期添加的时间戳,而不管其他输入?哦,但是接收者只从他们的接收缓冲区中读取的变体呢?你怎么知道你在等待发送缓冲区耗尽时被阻塞了?

如果你追求所有潜在的问题并将它们塞进一个 String -> String 程序中,你最终会得到几乎所有有趣的服务器部分都存在于你的 haskell 程序之外。毕竟,某些东西必须进行多路复用,必须进行错误检测和报告,必须进行超时。如果你在 Haskell 中编写一个 http 服务器,如果它实际上是在 Haskell.

中编写的,那就太好了

当然,none 意味着目前存在的 IO 类型是最佳答案。可以对此提出合理的投诉。但它至少可以让您在 Haskell 内解决所有这些问题。

该包装器存在。它被称为 Prelude.interact。我经常使用它的 Data.ByteString 版本。纯 String -> String 函数的包装器可以工作,因为字符串是惰性求值的单链表,可以在读入时处理每一行输入,但是 UCS-4 字符的单链表是非常低效的数据结构。

您仍然需要使用 IO 作为包装器,因为这些操作取决于宇宙的状态,需要与外界同步。特别是,如果您的程序是交互式的,您希望它立即响应新的键盘命令,并且 运行 所有 OS 系统调用按顺序进行,而不是(比方说)处理所有输入并显示所有准备退出程序时立即输出。

一个简单的演示程序是:

module Main where
import Data.Char (toUpper)

main :: IO ()
main = interact (map toUpper)

尝试运行以交互方式进行此操作。在 Linux 或 MacOS 控制台上键入 control-D 退出,在 Windows.

上键入 control-Z 退出

正如我之前提到的,尽管 String 根本不是一个有效的数据结构。对于更复杂的示例,这是我编写的用于将 UTF-8 输入规范化为 NFC 格式的程序的 Main 模块。

module Main ( lazyNormalize, main ) where

    import Data.ByteString.Lazy as BL ( fromChunks, interact )
    import Data.Text.Encoding as E (encodeUtf8)
    import Data.Text.Lazy as TL (toChunks)
    import Data.Text.Lazy.Encoding as LE (decodeUtf8)
    import Data.Text.ICU ( NormalizationMode (NFC) )
    import TextLazyICUNormalize (lazyNormalize)
    
    main :: IO ()
    main = BL.interact (
             BL.fromChunks .
             map E.encodeUtf8 .
             TL.toChunks . -- Workaround for buffer not always flushing on newline.
             lazyNormalize NFC .
             LE.decodeUtf8 )

这是 Data.Bytestring.Lazy.interact 函数 Data.Text.Lazy.Text -> Data.Text.Lazy.Text 的包装器,lazyNormalizeNormalizationMode 常量 NFC 作为其柯里化的第一个参数。其他一切只是从我用来做 I/O 的惰性 ByteString 字符串转换为 ICU 库理解的惰性 Text 字符串,然后返回。您可能会看到更多使用 & 运算符编写的程序,而不是这种无点风格的程序。

这里有多个问题:

  1. If the input string is some mathematical expression concatenated with data, and the output string is (non-Haskell) code, then you can Get Things Done. In other words, why not always treat Haskell programs as translators? ([... because] as a translator you can blend genuine I/O into the final executable.)

  2. Regardless of the wisdom of this as a general strategy [...] if this is indeed the approach, can we avoid the IO a type?

  3. Do we need a wrapper in some other language?

  4. Does anyone actually do this?


  1. 使用您的非正式描述,main 的类型签名类似于:

    main :: (InputExpr, InputData) -> OutputCode

    如果我们删除 InputData 组件:

    main :: InputExpr -> OutputCode

    然后(如您所说)main 确实看起来像一个翻译器...但我们已经有了执行此操作的程序 - 编译器!

    虽然特定任务的翻译器有其用武之地,但在我们已经有了通用翻译器的情况下无处不在使用它们似乎有些多余...

  2. ...所以它被用作一般策略充其量只是推测性的。这可能就是为什么这种方法从未正式“内置”到 Haskell 中(而是使用 Haskell 的 I/O 设施实现,例如 interact 用于简单的字符串到字符串互动)。

    至于没有 monadic IO 类型 - 对于像 Dhall, it could be possible...but can that technique also be used to build webservers or operating systems 这样的单一用途语言? (那个留给勇敢的读者作为练习:-)

  3. 理论上:不 - 各种 Lisp Machines 是典型的例子。

    实际上:是的——随着微处理器和汇编语言的种类越来越多,如今在现有的编程语言中可移植地定义基本的运行时服务(并行、GC、I/O 等)是一个必要性。

  4. 不完全是 - 也许最接近您所描述的是(再次)来自 Chalmers 的古老的 Lazy ML 系统或 Miranda 的原始版本支持的字符串到字符串交互性(R).