Haskell I/O 教程

本文译自https://www.vex.net/~trebla/haskell/IO.xhtml
Author: Albert Y.C.Lai trebla@vex.net
Translator: ZuoXinyu iamzuoxinyu@gmail.com
本文经原作者授权翻译,转载需注明来源。

这篇Haskell I/O 教程始自于我为Haskell Wiki 写的Haskell Wiki - A Brief Introduction to Haskell - I/O.之所以将这篇文章发布在我的个人网站而不是在Haskell Wiki更新:

  • Haskell Wiki的格式缺乏可拓展性。
  • Haskell Wiki的渲染出了问题,很难修复。并不是针对它,但它的确有所限制。我也需要更自由的表达方式。
  • 我们知道如何避免失效的渲染:不用 <hask> 标记Haskell代码,只需要旧的<code>代替。但是仍然有太多需要修复的问题。
  • 我尝试过去修复我在Haskell Wiki页面上写过的别的文章。但是总有那么些顽固的Do-gooders会将标签改回<hask>,理由是代码高亮对他们来说是oh-so-important。我已经厌倦于修复这些损坏和教育那些家伙了————事实上如果他们再不校对并且注意那些他们自己造成的失败的渲染,如果他们只着眼于他们聪明的代码高亮,我将永远离开那里。

大纲

预备知识

本文要求读者掌握以下知识:

  • Lambda 表达式
  • 模式匹配
  • 能够阅读类型(Types)的能力。本文将会需要你翻译 IO X -> (X -> IO Y) -> IO Y。我仅仅是说“翻译(parse)”。你不必理解 IO X代表什么(我会告诉你的),你只需要知道它如何是一个合法的类型。
  • 类型检测。重申一次,你不必总是去理解类型的意义,只需明白这只是一个匹配类型的文字游戏即可。

I/O动作类型

Haskell I/O动作有着自己的类型,形式为IO X。这个类型的意义和使用方法本文将逐步展开阐述。(它不可能靠一个外行人的三言两语就能总结出来;你现在所想的都是错误的。)但是我可以明确地告诉你为什么我们不使用别的语言,而仅用简单的X() -> X

在Haskell中,如果有 g :: String,那么g永远都只是同一个字符串。如果有f :: () -> String,那么f () :: String也永远是同一个字符串。(译者注:注意f :: () -> String是一个函数,而f ()是将f应用于()后柯里化的结果,其类型为String) String() -> String不完全可能是同一个“读取一行”的动作,因为在不同时间里,一个读取动作可能会给你不同的字符串。此处翻译有不当之处

当你输入“读取一行”的动作:getLine :: IO String,我们并不要求它每次返回的字符串是相同的。我们所要求的是,它永远都是同一个I/O动作。准确地说:每一次当你使用getLine时,它都是同一个“读取一行”的动作。

IO a类型享受着一种微妙的地位:一个程序的入口点,也就是main,必须是IO a类型的。这自然因为,整个程序都只是一些输入输出,以及,你同这个世界之间的相互作用。

初步示例

putStrLn :: String -> IO ()是一个函数,而不是一个I/O动作,因为它的类型就是一个函数类型。然而,一旦你给它提供了一个字符串参数,你就得到了一个I/O动作:putStrLn "hello":: IO (). 这的确是一个I/O动作的类型。这个动作输出了hello。你可以将它用在main中:

1
2
3
-- put this in file r0.hs, compile and run, don't walk
main :: IO ()
main = putStrLn "hello"
1
2
3
4
$ ghc r0.hs
...
$./r0
hello

getLine:: IO String已经是一个正确的类型,你可以将它用在别的程序中的main之中:

1
2
3
-- put this in file r1.hs, compile and run, don't walk
main :: IO ()
main = putStrLn "Good morning"
1
2
3
4
$ ghc r1.hs
...
$./r1
Good morning

如何拓展r1.hs以处理输入的行,更广泛地说,如何使用IO a中的a,如何构建复合动作,则是下一节将要介绍的主题。

数据传递和复合动作

怎样写一个程序,使用getLine读取一行,然后用putStrLn打印出来呢?

失败尝试 #0: putStrLn getLine ————类型不匹配。
失败尝试 #1: 借助于一个“解包器” stripIO :: IO String -> String,然后写putStrLn (stripIO getLine).————类型正确,然而表现是错误的。就如我之前解释的,因为stripIO getLine :: String,每次你用到它,它都是同一个字符串,这跟“读取”这个动作矛盾。解包这个主意是个逻辑错误,无论你多喜欢它,必须放弃,你不该去解包。

真正将如你所愿的,是将由getLine所读取的行传递给下游的处理器,比如一个putStrLn.

我会用一个类比(虽然我一向反对类比)来解释这一切。我有一个收音机,从空中接收读取信号(类比为getLine)。我还有个扬声器,接受信号,然后放大音量(类比为putStrLn)。设想一下,我有个狡猾的计划,我打算用收音机接收信号,然后亲手去解读这些信号。然后我打算亲手将信号输入到扬声器中。这是个狡猾的计划还是个愚蠢的计划呢?显然,这是个愚蠢的计划,正如别的语言曾让你做过的种种,(你的手就好比状态变量),而你又在Haskell中重蹈覆辙。为什么不用一条线将收音机和扬声器连起来呢?做了这些,就能解放我的双手了。而这正是Haskell将让你做的。

这条线就是 (>>=)。用英文说就是”bind”。使用的方法如下:

1
2
3
4
5
-- put this in file b0.hs, compile and run, enter a line
main :: IO ()
main = getLine
>>=
putStrLn
1
2
3
4
5
$ ghc b0.hs
...
$ ./b0
Good morning
Good morning

感受一下,先看一下类型和怎么匹配的。(>>=)的类型是

1
(>>=) :: IO a -> (a -> IO b) -> IO b

这个类型有点长,不要害怕,冷静下来分析一下。

  • (>>=)需要两个参数。
  • 第一个参数的类型是IO a,而我们给了它getLine :: IO String,匹配成功,类型匹配过程为a = String
  • 第二个参数的类型是a -> IO b,而我们给了它putStrLn :: String -> IO (),类型也匹配: b = ().
  • 因此,整个getLine >>= putStrLn的类型匹配成功,类型为 IO b = IO ()

确切地说,(>>=)的类型是个更抽象,更广义的类型,它是个Monad m => m a -> (a -> m b) -> m b。但今天我们仅仅讨论比较狭义的IO,本文着眼于I/O,而不是更抽象更广义的monads。

注意这些类型,现在它的行为是:

  • getLine >>= putStrLn是一个复合动作,由两个子动作组成,它会执行下列顺序的动作。
  • 第一个动作是getLine
  • 第二个动作的执行需要一个字符串。回想一下单独的putStrLn并不是一个动作,而是类型为String -> IO ()的函数,为这个函数提供一个字符串,就得到了一个动作。
  • 但字符串是哪个呢?好吧,第一个动作从外部世界读取了一行,这就是字符串。
  • 有时候我更愿意用这种方式解释:(getLine >>=)是一个独立的单元,你需要给它一个回调函数。在这个例子里,这个回调函数就是putStrLn。执行过程是:读取一行,然后调用回调函数去处理作为参数的行。

回显一个字符串太无聊了。怎样写一个程序,读取一行,然后在这个行的前面添加一句”You have entered:”呢?

有两种方式。这一节展示了一种方法,第二种方法在下一节中展示。两种方法的区别是:你是想在读取阶段还是想在输出阶段添加这个前缀呢?(我不会暗示哪种方式更好。我展示了两种方法是因为两种方法都会教你一些新的东西。你自己决定使用哪种方法合适。)这一节展示的方法是在输出阶段时添加的前缀。

一个技巧是,你也可以将这个回调函数写成一个lambda表达式(或者你写的任何一个函数),只要类型是正确的。这个回调函数会为putStrLn提供一个字符串类型的参数。可以是:

1
\s -> putStrLn ("You have entered: " ++ s)

检查一下,类型依然匹配:String -> IO ()。然后:

1
2
3
4
5
-- put this in file b1.hs, compile and run, enter a line
main :: IO ()
main = getLine
>>=
\s -> putStrLn ("You have entered: " ++ s)
1
2
3
4
5
$ ghc b1.hs
...
$ ./b1
Good morning
You have entered: Good morning

一旦你知道如何用两个子动作构建一个复合动作,你也就知道了怎样构建一个由更多子动作组成的复合动作。技巧是在回调函数中使用更多的的(>>=)。猜想一下下面这个程序的输出,然后检验一下:

1
2
3
4
5
6
7
8
9
10
11
12
-- put this file in b2.hs, compile and run, enter two lines
main :: IO ()
main = putStrLn "Enter a line:"
>>=
\_ -> getLine
>>=
\s0 -> putStrLn "Enter one last line:"
>>=
\_ -> getLine
>>=
\s1 -> putStrLn ("Total length: " ++ show (length (s0 ++ s1)))
-- this code layout is only educational, for showing theoretical structure

上面代码的格式仅用于教学目的。下面的Do-Notation一节将会展示用于实践时的格式。Do-Notation会把你从无尽地重复>>=中解救出来。

是时候说说类型,例如IO String的具体含义了。你已经知道了它代表一个I/O动作。那么这个String在这里是什么呢?凭直觉而言,“I/O动作返回了一个字符串”。这句话有时候正确,有时候错误。我不想模棱两可地趟这趟浑水。我更愿意这么说:

act1 :: IO String的类型意味着:act1是一个I/O动作,至于其中的String这一部分:在act1 >>= callback2中,一个字符串被传递给了callback2。我没有说“返回”了什么,我说的是“传递”字符串给了回调函数。这样说在任何时候都是无懈可击的。这就是我为什么更喜欢用回调函数的说法来解释。

putStrLn s :: IO ()的意思是:在putStrLn s >>= callback3中,一个乏味的()被传递给了回调函数。这是因为这个输出动作实际上并没有给回调函数传递任何有意义的信息,因而,()是正确无误的。你已经在例子中看到我是如何写callback3的。

只是传递而已

怎样写一个程序,读取一行,然后在这个行的前面添加一句”You have entered:”呢?这次我们将在读取阶段添加它。

这就需要借助于另一个库函数了:

1
return :: a -> IO a

看看这个函数的名字,你一定在猜它的意思。好吧,它的名字是 “return”,又趟进了另一片浑水(甚至比上次更糟)。我确信你的猜想又是“有时候对有时候错”,而我就不会趟进这片浑水。我只能告诉你,这不过是一个巧合,这个词跟英语中的 “return” 碰巧押韵而已。

这是个谎言,该死的谎言,是个意义丰富的词语。
对标准而言,好的是有太多标准可以选择。对意义丰富的词语来说,好的是这里有太多的意义可以选择。
实际上,这个类型要更抽象更广义: return :: Monad m => a -> m a

它的行为是:例如 return s,带有一个参数,是个假的I/O动作——不做输入、输出,不改变任何事物——仅仅是将s传递给下一个回调函数(假如这个回调存在的话)。它唯一的作用就是帮你控制传递什么数据。这里有个例子用到了它:

1
getLine >>= \s -> return ("You have entered: " ++ s)

这个例子所有的行为是:读取一行,然后——并非逐字传递——而是将整个行拦截下来并作为字符串参数传递给回调函数。

一个完整的程序是:

1
2
3
4
5
-- put this in file p0.hs, compile and run, enter a line
main :: IO ()
main = (getLine >>= \s -> return ("You have entered: " ++ s))
>>=
putStrLn
1
2
3
4
5
$ ghc p0.hs
...
$ ./p0
Good morning
You have entered: Good morning

不必每次都把代码写成这样。只是想象一下,你将整个“读取一行,然后把它作为字符串参数传递给回调函数”行为封装成一个独立可复用的单元,这样你就会理解地更深一些:

1
2
3
4
5
6
-- put this in file p1.hs, compile and run, enter a line
main :: IO ()
main = getLine_and_prepend >>= putStrLn

getLine_and_prepend :: IO String
getLine_and_prepend = getLine >>= \s -> return ("You have entered: " ++ s)

练习:检查类型匹配。永远都要检查类型是否匹配。

如果你封装一个更复杂一些的复合动作,使传入的数据可选,这会让你感受到更多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- put this in file p2.hs
main :: IO ()
main = get_email >>= putStrLn

get_email :: IO String
get_email = putStrLn "Enter email: " >>= \_ ->
getLine >>= \s0 ->
putStrLn "Enter again: " >>= \_ ->
getLine >>= \s1 ->
if s0 /= s1 then
putStrLn "Different. Re-enter." >>= \_ ->
get_email
else
return s0

Do-Notation

为了让I/O程序看起来更方便工整,Haskell提供了一个特殊的语法糖,我们称之为Do-Notation。下面的例子很好地展示了这个语法,你能从这个例子推出这个语法的形式。

这段代码

1
2
3
4
5
6
act0    >>= \x ->
-- act1, act2 may use x
act1 >>= \_ ->
act2 >>= \z ->
-- act3 may use x,z
act3

可以写成:

1
do { x <- act0; act1; z <- act2; act3  }

可以添加额外的分号。例如:

1
do { ; x <- act0; act1; z <- act2; act3;  }

更方便的,也支持缩进语法:

1
2
3
4
5
6
7
8
9
10
11
12
do x <- act0
act1
z <- act2
act3

-- or

do
x <- act0
act1
z <- act2
act3

当你看到这个漂亮的格式,你就知道我要说什么了。因为Do-Notation仅仅是为了表示(>>=),它能很好地组织I/O语句,当然也适用于所有的Monads实例。

这是用Do-Notation重写的前面的例子:

1
2
3
4
5
6
7
-- put this in file d0.hs
main :: IO ()
main = do putStrLn "Enter a line:"
s0 <- getLine
putStrLn "Enter one last line:"
s1 <- getLine
putStrLn ("Total length: " ++ show (length (s0 ++ s1)))
1
2
3
4
5
-- put this in file d1.hs
main :: IO ()
main = do
s <- get_email
putStrLn s
1
2
3
4
5
6
7
8
9
10
11
12
get_email :: IO String
get_email = do
putStrLn "Enter email: "
s0 <- getLine
putStrLn "Enter again: "
s1 <- getLine
if s0 /= s1 then
-- you need to start a new "do" for this
do putStrLn "Different. Re-enter."
get_email
else
return s0

Do-Notation还有两个特性。其一,你可以在中间书写局部定义:

1
2
3
4
do x <- getLine
let x1 = x ++ x
x2 = take 10 x1
putStrLn x2

如下这种形式:

1
2
getLine >>= \x -> let {x1 = x ++ x; x2 = take 10 x1 }
in putStrLn x2

注意,一般来说,let之后一定要跟着in。但在Do-Notation中,就不能加这个in

这是使用let特性重写的d0.hs

1
2
3
4
5
6
7
8
9
-- put this in file d2.hs
main :: IO ()
main = do putStrLn "Enter a line:"
s0 <- getLine
putStrLn "Enter one last line:"
s1 <- getLine
let s = s0 ++ s1
n = length s
putStrLn ("Total length: " ++ show n)

其二,你不必限制于variable <- action这种形式。也可以使用pattern <- action的形式。

例如在System.IO中有这样一个函数:

1
openTempFile :: FilePath -> String -> IO (FilePath, Handle)

你可以用Do-Notation这样写:

1
2
3
4
do 
(path, handle) <- openTempFile "/tmp" "gory.txt"
hPutStrLn handle ("I am " ++ path)
...

它的形式

1
openTempFile "/tmp" "gory.txt" >>= \(path, handle) -> hPutStrLn handle ("I am " ++ path) >>= \_ -> ...

是简化后的版本。若模式(pattern)是无穷的(non-exhaustive),你就需要了解完整的版本了。这里只是冰山一角。像下面:

1
2
3
do
x:xs <- getLine
putchar x

如果字符串是空的,会发生什么呢?

1
2
3
getLine >>= \y -> case y of
x:xs -> putChar x
_ -> fail "compiler puts an error message here"

fail :: String -> IO a 是另一个库函数。`fail “error message” 抛出一个给定的异常。所以,当字符串是空时,会发生什么呢?答案是:一个异常。你可以捕获这个异常从而使整个程序不至于终止,但那是另外一个教程

尽管使用无穷模式(non-exhaustive patterns)的行为是可预知的,但最好不要依赖它。你要自觉地决定每个case的处理和反馈,小心翼翼地编码实现。你可以写你自己的case分支。这是我自己的:

1
2
3
4
5
6
7
loop = do
s <- getLine
case s of
x:xs -> putChar x
[] -> do
putStrLn "Re-enter"
loop

程序设计、组织和架构

有一次我看到一个学生所写的丑陋的C++代码:

1
2
3
4
5
6
7
8
9
class Complex {
private double real, imag;
public Complex() {
cout << "please enter the real part: ";
cin >> real;
cout << "please enter the imaginary part";
cin >> imag;
}
};

我的观点是:不论你是在使用Haskell或者是别的什么语言,你必须清楚地划分你的程序中哪一部分是用来处理I/O,哪一部分是进行数据处理的。并且,数据处理的部分永远不要混入I/O操作,一丁点也不要有。

在Haskell中,这种划分处理得颇为直接:I/O部分的类型含有IO,内部数据处理则没有。

下面是一个典型的Haskell程序:输入一个字符串,计算串中’x’和’y’出现的次数:

1
2
3
4
5
6
7
8
9
main :: IO ()
main = do
inp <- getLine
let ans :: Int
ans = calculate inp
print ans

calculate :: String -> Int
calculate s = length (filter (\c -> c == 'x' || c == 'y') s)

输入数据和打印结果需要I/O和IO类型;计算过程并不需要。

最后一个操作符

1
putStrLn "hello" >>= \_ -> act2

可以简化为:

1
putStrLn "hello" >> act2

这就是操作符(>>)的作用。简单地说就是:

1
act1 >> act2 = act1 >>= \_ -> act2

按照惯例,(>>)也是个广义的操作符。

惰性还是非惰性

你或许听说过“Haskell是惰性的”。但这只是经验之谈。我今天所讨论的是I/O,在这个维度上,这个说法几乎是完全错误的。

Haskell的I/O不是惰性的。在前面的部分里你也刚刚看到第一手证据。当你使用getLine的时候,计算机会立即让你输入一行数据,无论这一行是否必需。当你使用putStrLn时,计算机会立即将数据打印到屏幕上,无论打印这个行为是否必需。I/O是Haskell中着重强调的非惰性部分,在这里,你重新获得了对于“顺序”的掌控。

有极少的异常情况:在库中,有些I/O函数是惰性的,但我没有提及这些,将来也不会提及。这些函数很难解释,也很难被正确地使用。也正因如此,在实际应用中,它们也极少被使用。所以不要担心,你并没有错过什么。
顺带一提,这些函数是readFile,getContents,hGetContentsinteract。(译者注,这些函数并不少见,当然,对我个人而言。)

如果你的程序需要推迟一个I/O动作,有一个比“惰性”更为通俗的解释。最常见的是标准缓冲区:终端里的行缓冲,文件的块缓冲。这些从Unix就已经成为里标准,我想MSDOS也是如此。如果需要的话,System.IO提供了显示刷新和配置缓冲区的工具。

Monad教程

对I/O编程而言,你并不需要理解广义的Monad。但如果你对Monad抱有郑重其事的兴趣,这当然是极好的。若你有闲暇,可以参考我的另一篇教程Monad教程

结语

这只是一篇教程,关于它的不完整其实是我有意为之。这只是一个开始,标准库里还有许多I/O动作和函数等待你去使用和练习。标准库中还有许多设计抉择、组织方式和实现方法。