IO 示例
在本节课中,我们介绍 Haskell 中的输入/输出操作。
输入输出简介
到目前为止,我们编写的代码都是纯函数式代码,不包含任何副作用。当然,这并不适用于输入输出(IO)操作,因为它们的核心目的就是执行副作用并和“外部世界”进行交互。
因此,我们需要 Haskell 语言的一个特性来执行非纯操作。乍一看,这似乎令人担忧。如果我们允许在任意 Haskell 函数中使用非纯操作,就会失去引用透明性以及代码易于推理的优势。
Haskell 的解决方案是引入一种特殊类型 IO a。一个类型为 IO a 的值的意义是:“一个执行 IO 操作并产生类型为 a 的值的行动”。所有处理 IO 的函数都必须返回 IO 类型。反过来,不返回 IO 的函数则不能与外部世界交互。因此,我们可以通过类型声明轻松判断一个函数是纯的还是非纯的。
Hello, World
首先,我们来看一个“Hello, World!”程序。
1main :: IO () 2main = putStrLn "Hello, World!"
函数 main 的类型是 IO (),即它执行 IO 操作并返回一个空元组。注意,() 既用于表示空元组的类型,也表示该类型的唯一值。空元组类型常用于那些不需要返回任何值的函数,也就是说,main 只是为了其副作用而执行,并不会产生后续可使用的值。
putStrLn 的类型是:
1putStrLn :: String -> IO ()
它接收一个字符串,并生成一个 IO 行动,该行动不产生任何值。在此情况下,这个 IO 行动的作用是将输入的字符串打印到屏幕上。
我们在课程中已经多次看到 main 函数的使用——通常与 putStrLn 或 print 函数一起出现。在所有这些例子中,我们实际上一直在执行 IO 操作!
现在我们开始处理 IO,就不再局限于在交互环境 ghci 中测试函数了。此外,我们还可以编译并运行程序。函数名 main 是特殊的:每个 Haskell 程序都必须恰好包含一个函数 main :: IO ()。当程序被编译并运行时,这个函数会被执行。
让我们把“Hello, World!”程序保存到文件 hello.hs 中。通过调用 ghc hello.hs 来编译程序,会生成一个名为 hello 的可执行文件。然后我们可以运行 hello,它将调用 main 函数,并在屏幕上输出 Hello, World!。
1ghc hello.hs 2./hello 3Hello, World!
不过,即使代码包含 IO 操作,我们仍然可以使用 ghci 来调试代码。
1Prelude> sayHello = putStrLn "hello" 2Prelude> sayHello 3hello
链接 IO 操作
>> 运算符可用于将两个 IO 操作组合成一个。结果是一个 IO 操作,它按顺序执行两个输入操作。如果操作产生值,则返回第二个操作的值。
1>> :: IO a -> IO b -> IO b
我们可以使用 >> 来打印多行输出。
1printTwoLines :: String -> String -> IO () 2printTwoLines s t = putStrLn s >> putStrLn t 3 4main = printTwoLines "first" "second"
让我们尝试将其扩展为一个递归函数,接收一个字符串列表,并将每个字符串单独打印在一行上。
1printLines :: [String] -> IO () 2printLines [] = pure () 3printLines (x:xs) = putStrLn x >> printLines xs 4 5main = printLines ["one", "two", "three"]
这里有趣的是递归的基例:当列表为空时,我们希望什么也不打印。因此需要一个类型为 IO () 的动作,它什么都不做。这可以通过特殊的 IO 动作 pure :: a -> IO a 实现。它接收一个类型为 a 的值,返回一个不执行任何操作但产生该输入值的 IO 动作。因此 pure () 是一个不执行任何操作且返回空元组的 IO () 动作。
处理输入
现在该谈谈 IO 中的 “I” 了,也就是读取输入。与 putStrLn 对应的输入操作是 getLine :: IO String。这是一个 IO 动作,用于读取一行输入,并将其作为字符串返回。
让我们写一个函数,读取一行输入并原样回显给用户。我们需要结合 getLine 和 putStrLn,但不能使用 >>(因为 >> 会忽略第一个 IO 操作产生的值)。相反,我们需要使用 绑定 操作符 >>=。
1>>= :: IO a -> (a -> IO b) -> IO b
绑定操作符将一个产生 a 的第一个 IO 操作与一个函数组合,该函数接收 a 并在其内部使用第二个 IO 操作。最终产生一个 b。在我们的例子中,第一个操作是读取一行,得到一个 String。函数接收这个字符串,并将其打印到屏幕上,最终返回空元组 ()。
1echo :: IO () 2echo = getLine >>= \s -> putStrLn s 3 4*IO> echo 5hello 6hello
这是一个终端界面,其中定义了 echo 函数。要使用它,先输入 echo,然后输入另一行内容,这行内容将被 getLine 读取并回显给你。
>> 操作符可以被实现为 >>= 的一种特殊情况,忽略第一个 IO 操作的返回值。
1>> :: IO a -> IO b -> IO b 2ioa >> iob = ioa >>= \_ -> iob
这意味着 >> 不适合用于组合读取输入的 IO 操作。一般来说,当一个 IO 操作产生有用的结果(而非空元组 ())时,我们应该使用 >>=,并将产生的值用于后续的 IO 操作。