Haskell/理解 Monad/IO
Haskell 的两个主要特征是纯函数和惰性求值。所有 Haskell 函数都是纯函数,这意味着在给定相同的参数的情况下,它们会返回相同的结果。惰性求值意味着,默认情况下,Haskell 值只有在程序的某些部分需要它们时才会被求值——也许永远不会被求值,如果它们从未被使用——并且尽可能避免对同一值的重复求值。
纯函数和惰性求值带来了许多优势。特别是,纯函数可靠且可预测;它们简化了调试和验证。测试用例也可以轻松设置,因为我们可以确保除了参数之外,没有任何其他因素会影响函数的结果。由于完全包含在程序内部,Haskell 编译器可以彻底评估函数以优化编译后的代码。但是,涉及与程序外部世界交互的输入和输出操作无法通过纯函数来表达。此外,在大多数情况下,I/O 无法惰性执行。由于惰性计算仅在它们的返回值变得必要时才执行,因此不受约束的惰性 I/O 将使现实世界效果的执行顺序不可预测。
无法忽略此问题,因为任何有用的程序都需要执行 I/O,即使只是显示结果。既然如此,我们如何管理像打开网络连接、写入文件、从外部世界读取输入或任何其他超出计算值的动作?主要见解是:动作不是函数。IO
类型构造函数提供了一种将动作表示为 Haskell 值的方法,以便我们可以用纯函数来操纵它们。在序言章中,我们预见到了这种解决方案的一些关键特征。现在我们也知道 IO
是一个 Monad,我们可以结束我们在那里开始的讨论。
让我们将函数与 I/O 相结合,创建一个完整的程序,它将
- 要求用户输入一个字符串
- 读取他们的字符串
- 使用
fmap
应用一个函数shout
,该函数将字符串中的所有字母都大写 - 写入结果字符串
module Main where
import Data.Char (toUpper)
import Control.Monad
main = putStrLn "Write your string: " >> fmap shout getLine >>= putStrLn
shout = map toUpper
我们有一个完整的程序,但我们没有包含任何类型定义。哪些部分是函数,哪些是 IO 操作或其他值?我们可以在 GHCi 中加载我们的程序并检查类型
main :: IO ()
putStrLn :: String -> IO ()
"Write your string: " :: [Char]
(>>) :: Monad m => m a -> m b -> m b
fmap :: Functor m => (a -> b) -> m a -> m b
shout :: [Char] -> [Char]
getLine :: IO String
(>>=) :: Monad m => m a -> (a -> m b) -> m b
哇,那里有很多信息。我们之前都见过这些,但让我们回顾一下。
main
是 IO ()
。那不是一个函数。函数的类型是 a -> b
。我们的整个程序都是一个 IO 操作。
putStrLn
是一个函数,但它会生成一个 IO 操作。 "Write your string: " 文本是一个 String
(记住,它只是 [Char]
的同义词)。它被用作 putStrLn
的参数,并被合并到生成的 IO 操作中。所以,putStrLn
是一个函数,但 putStrLn x
会计算为一个 IO 操作。IO 类型中的 ()
部分(称为单元类型)表示没有可传递给任何后续函数或操作的值。
最后一点是关键。我们有时非正式地说一个 IO 操作“返回”某些东西;但是,太字面地理解会导致混淆。当我们谈论函数返回结果时,意义很明确,但 IO 操作不是函数。让我们跳到 getLine
——一个确实提供值的 IO 操作。getLine
不是一个返回 String
的函数,因为getLine
不是一个函数。相反,getLine
是一个 IO 操作,当被求值时,它会具体化一个 String
,然后可以通过例如 fmap
和 (>>=)
传递给后续函数。
当我们使用 getLine
获取 String
时,该值是 Monadic,因为它被包装在 IO
函子中(恰好是一个 Monad)。我们无法将该值直接传递给接受普通(非 Monadic 或非函子)值的函数。fmap
负责接收一个非 Monadic 函数,同时传入和返回 Monadic 值。
正如我们已经看到的,(>>=)
负责将 Monadic 值传递给一个接受非 Monadic 值并返回 Monadic 值的函数。将 fmap
的非 Monadic 结果接收并返回一个 Monadic 值,然后 (>>=)
将底层非 Monadic 值传递给下一个函数,这似乎效率低下。然而,正是这种链式操作,才创建了可靠的排序,使 Monad 在将纯函数与 IO 操作集成方面非常有效。
鉴于对排序的强调,do
语法 在 IO
Monad 中特别有用。我们的程序
putStrLn "Write your string: " >> fmap shout getLine >>= putStrLn
可以写成
do putStrLn "Write your string: "
string <- getLine
putStrLn (shout string)
将 IO
Monad 视为一种计算的一种方式,即在执行输入和输出操作的同时,通过更改世界状态来提供类型为 a
的值。显然,你无法真正设置世界状态;它对你隐藏,因为 IO
函子是抽象的(也就是说,你无法深入研究它以查看底层值,这种情况与我们在Maybe
情况下看到的情况不同)。
请理解,这种将宇宙视为通过 IO
影响和受到 Haskell 值影响的对象的想法只是一个比喻;充其量是松散的解释。更实际的事实是,IO
只是将一些非常底层的操作引入 Haskell 语言。[1] 记住,Haskell 是一个抽象,并且 Haskell 程序必须编译成机器代码才能实际运行。IO 的实际工作原理发生在更低的抽象级别,并且被连接到 Haskell 语言的定义中。[2]
在谈论 Haskell 中的 I/O 时,形容词“纯”和“不纯”经常出现。为了澄清它们的含义,我们将重新讨论序言章节中的引用透明性。考虑以下代码段
speakTo :: (String -> String) -> IO String
speakTo fSentence = fmap fSentence getLine
-- Usage example.
sayHello :: IO String
sayHello = speakTo (\name -> "Hello, " ++ name ++ "!")
在大多数其他编程语言中,没有为 I/O 操作单独定义类型,speakTo
将具有类似于以下类型的类型
speakTo :: (String -> String) -> String
但是,有了这种类型,speakTo
根本就不是函数!函数在给定相同的参数时会产生相同的结果;但是,speakTo
传递的 String
也取决于在终端提示符处键入的内容。在 Haskell 中,我们通过返回 IO String
来避免这个陷阱,IO String
不是 String
,而是一个承诺,即通过执行某些涉及 I/O 的指令(在本例中,I/O 包括从终端获取一行输入)将传递某些 String
。尽管每次评估 speakTo
时,String
可能不同,但 I/O 指令始终相同。
当我们说 Haskell 是一种纯函数式语言时,我们的意思是所有函数都是真正的函数——换句话说,Haskell 表达式始终引用透明。如果 speakTo
具有我们上面提到的问题类型,引用透明性就会被破坏:sayHello
将是一个 String
,但用任何特定字符串替换它都会破坏程序。
尽管 Haskell 是纯函数式的,但 IO
操作可以被称为不纯,因为它们对外部世界的影响是副作用(而不是完全包含在 Haskell 中的正常效果)。缺乏纯度的编程语言在与各种计算相关的许多其他地方可能存在副作用。但是,纯函数式语言保证即使是不纯值的表达式也是引用透明的。这意味着我们可以用纯函数的方式来讨论、推理和处理不纯度,使用纯函数机制,如函子和平行。虽然 IO
操作是不纯的,但所有操纵它们的 Haskell 函数仍然是纯函数。
函数式纯度加上 I/O 在类型中出现的事实,以多种方式使 Haskell 程序员受益。关于引用透明性的保证极大地提高了编译器优化的潜力。通过类型本身可以区分 IO
值,这使得我们能够立即知道我们在哪里使用副作用或不透明值。由于 IO
本身只是一个函子,我们最大程度地保持了与纯函数相关的可预测性和易推理性。
当我们介绍单子时,我们说过单子表达式可以解释为命令式语言的语句。这种解释对于IO
来说是直接有说服力的,因为围绕 IO 动作的语言看起来很像传统的命令式语言。然而,必须明确的是,我们谈论的是一种解释。我们不是说单子或do
符号将 Haskell 变成了一种命令式语言。重点仅仅是你可以用命令式语句来查看和理解单子代码。语义可能是命令式的,但单子和(>>=)
的实现仍然是纯函数式的。为了使这种区别更加清晰,让我们看一个小的说明。
int x;
scanf("%d", &x);
printf("%d\n", x);
这是一段 C 代码片段,C 是一种典型的命令式语言。在其中,我们声明了一个变量x
,用scanf
从用户输入中读取它的值,然后用printf
打印它。我们可以在一个IO
do 块中,写一个执行相同功能并且看起来非常相似的 Haskell 代码片段。
x <- readLn
print x
从语义上来说,这两个代码片段几乎是等价的。[3] 然而,在 C 代码中,这些语句直接对应于程序要执行的指令。另一方面,Haskell 代码片段会被反糖化为
readLn >>= \x -> print x
反糖化后的版本没有语句,只有函数被应用。我们通过数据依赖关系间接地告诉程序操作的顺序:当我们用(>>=)
链接单子计算时,我们通过将函数应用于早期结果来获得后期结果。只是碰巧的是,例如,评估print x
会导致字符串被打印到终端。
当使用单子时,Haskell 允许我们在保持函数式编程优势的同时,编写具有命令式语义的代码。
到目前为止,我们唯一用到的 I/O 原语是putStrLn
和getLine
以及它们的微小变化。然而,标准库提供了许多其他有用的函数和涉及IO
的动作。我们在Haskell 实践中的 IO 章节中介绍了一些最重要的内容,包括从文件读取和写入文件所需的基本功能。
鉴于单子允许我们以完全通用的方式表达动作的顺序执行,我们可以用它们来实现常见的迭代模式,例如循环吗?在本节中,我们将介绍标准库中的一些函数,这些函数允许我们做到这一点。虽然这里展示的例子应用于IO
,但请记住,以下想法适用于所有单子。
记住,单子值没有什么神奇之处;我们可以像操作 Haskell 中的任何其他值一样操作它们。知道这一点,我们可能会想尝试以下函数来获取五行用户输入。
fiveGetLines = replicate 5 getLine
然而,这并不能奏效(在 GHCi 中试一试!)。问题是replicate
在这种情况下会生成一个动作列表,而我们想要一个返回列表的动作(也就是说,IO [String]
而不是[IO String]
)。我们需要的是一个折叠来遍历动作列表,执行它们并将结果组合成一个单一的列表。碰巧的是,Prelude 函数可以做到这一点:sequence
。
sequence :: (Monad m) => [m a] -> m [a]
因此,我们得到所需的动作。
fiveGetLines = sequence $ replicate 5 getLine
replicate
和sequence
形成了一个吸引人的组合,所以Control.Monad提供了一个replicateM
函数,用于重复一个动作任意次数。Control.Monad
以同样的精神提供了许多其他方便的函数——单子压缩、折叠等等。
fiveGetLinesAlt = replicateM 5 getLine
一个特别重要的组合是map
和sequence
。它们共同允许我们从一个值列表中创建动作,按顺序运行它们,并收集结果。mapM
,一个 Prelude 函数,捕捉了这种模式。
mapM :: (Monad m) => (a -> m b) -> [a] -> m [b]
我们还有一些上述函数的变体,它们在名称中带有一个下划线,例如sequence_
、mapM_
和replicateM_
。这些函数会丢弃任何最终值,因此适合你只关心执行动作的情况。与没有下划线的对应项相比,这些函数就像(>>)
和(>>=)
之间的区别。例如,mapM_
具有以下类型。
mapM_ :: (Monad m) => (a -> m b) -> [a] -> m ()
最后,值得一提的是,Control.Monad
还提供了forM
和forM_
,它们是mapM
和mapM_
的翻转版本。forM_
恰好是命令式 for-each 循环的 Haskell 惯用语;并且类型签名巧妙地暗示了这一点。
forM_ :: (Monad m) => [a] -> (a -> m b) -> m ()
练习 |
---|
|
笔记
- ↑ 技术术语是“原始”,就像原始操作一样。
- ↑ 当然,所有高级编程语言都可以这样说。顺便说一句,Haskell 的 IO 操作实际上可以通过外部函数接口 (FFI) 进行扩展,FFI 可以调用 C 库。由于 C 可以使用内联汇编代码,因此 Haskell 可以间接地参与计算机可以执行的任何操作。尽管如此,Haskell 函数只会间接地将这些外部操作作为
IO
函子的值进行操作。 - ↑ 一个区别是
x
在 C 中是一个可变变量,因此可以在一个语句中声明它,并在下一个语句中设置它的值;Haskell 从不允许这种可变性。如果我们想更紧密地模仿 C 代码,我们可以使用IORef
,它是一个包含可以被破坏性更新的值的单元。出于显而易见的原因,IORef
只能在IO
单子中使用。