Haskell 输入和输出

时间:2021-03-16 15:47:31

我们已经说明了 Haskell 是一个纯粹函数式语言。虽说在命令式语言中我们习惯给电脑执行一连串指令,在函数式语言中我们是用定义东西的方式进行。在 Haskell 中,一个函数不能改变状态,像是改变一个变量的内容。(当一个函数会改变状态,我们说这函数是有副作用的。)在 Haskell 中函数唯一可以做的事是根据我们给定的参数来算出结果。如果我们用同样的参数调用两次同一个函数,它会回传相同的结果。尽管这从命令式语言的角度来看是蛮大的限制,我们已经看过它可以达成多么酷的效果。在一个命令式语言中,编程语言没办法给你任何保证在一个简单如印出几个数字的函数不会同时烧掉你的房子,绑架你的狗并刮伤你车子的烤漆。例如,当我们要建立一棵二元树的时候,我们并不插入一个节点来改变原有的树。由于我们无法改变状态,我们的函数实际上回传了一棵新的二元树。

函数无法改变状态的好处是它让我们促进了我们理解程序的容易度,但同时也造成了一个问题。假如说一个函数无法改变现实世界的状态,那它要如何印出它所计算的结果?毕竟要告诉我们结果的话,它必须要改变输出设备的状态(譬如说屏幕),然后从屏幕传达到我们的脑,并改变我们心智的状态。

不要太早下结论,Haskell 实际上设计了一个非常聪明的系统来处理有副作用的函数,它漂亮地将我们的程序区分成纯粹跟非纯粹两部分。非纯粹的部分负责跟键盘还有屏幕沟通。有了这区分的机制,在跟外界沟通的同时,我们还是能够有效运用纯粹所带来的好处,像是惰性求值、容错性跟模块性。

到目前为止我们都是将函数加载 GHCi 中来测试,像是标准函式库中的一些函式。但现在我们要做些不一样的,写一个真实跟世界交互的 Haskell 程序。当然不例外,我们会来写个 "hello world"。

现在,我们把下一行打到你熟悉的编辑器中

main = putStrLn "hello, world"

我们定义了一个 main,并在里面以 "hello, world" 为参数调用了putStrLn。看起来没什么大不了,但不久你就会发现它的奥妙。把这程序存成 helloworld.hs

现在我们将做一件之前没做过的事:编译你的程序。打开你的终端并切换到包含 helloworld.hs 的目录,并输入下列指令。

$ ghc --make helloworld 
[1 of 1] Compiling Main                 ( helloworld.hs, hellowowlrd.o ) 
Linking helloworld ...

顺利的话你就会得到如上的消息,接着你便可以执行你的程序 ./helloworld

$ ./helloworld 
hello, world

这就是我们第一个编译成功并印出字串到屏幕的程序。很简单吧。

让我们来看一下我们究竟做了些什么,首先来看一下 putStrLn 函数的型态:

ghci> :t putStrLn 
putStrLn :: String -> IO () 
ghci> :t putStrLn "hello, world" 
putStrLn "hello, world" :: IO ()

我们可以这么解读 putStrLn 的型态:putStrLn 接受一个字串并回传一个 I/O action,这 I/O action 包含了 () 的型态。(即空的 tuple,或者是 unit 型态)。一个 I/O action 是一个会造成副作用的动作,常是指读取输入或输出到屏幕,同时也代表会回传某些值。在屏幕印出几个字串并没有什么有意义的回传值可言,所以这边用一个 () 来代表。

那究竟 I/O action 会在什么时候被触发呢?这就是 main 的功用所在。一个 I/O action 会在我们把它绑定到 main 这个名字并且执行程序的时候触发。

把整个程序限制在只能有一个 I/O action 看似是个极大的限制。这就是为什么我们需要 do 表示法来将所有 I/O action 绑成一个。来看看下面这个例子。

main = do 
    putStrLn "Hello, what's your name?" 
    name <- getLine 
    putStrLn ("Hey " ++ name ++ ", you rock!")

新的语法,有趣吧!它看起来就像一个命令式的程序。如果你编译并执行它,它便会照你预期的方式执行。我们写了一个 do 并且接着一连串指令,就像写个命令式程序一般,每一步都是一个 I/O action。将所有 I/O action 用 do 绑在一起变成了一个大的 I/O action。这个大的 I/O action 的型态是 IO (),这完全是由最后一个 I/O action 所决定的。

这就是为什么 main 的型态永远都是 main :: IO something,其中 something 是某个具体的型态。按照惯例,我们通常不会把 main 的型态在程序中写出来。

另一个有趣的事情是第三行 name <- getLine。它看起来像是从输入读取一行并存到一个变量 name 之中。真的是这样吗?我们来看看 getLine 的型态吧

ghci> :t getLine 
getLine :: IO String

Haskell 输入和输出

我们可以看到 getLine 是一个回传 String 的 I/O action。因为它会等用户输入某些字串,这很合理。那 name <- getLine 又是如何?你能这样解读它:执行一个 I/O action getLine 并将它的结果绑定到 name 这个名字。getLine 的型态是IO String,所以 name 的型态会是 String。你能把 I/O action 想成是一个长了脚的盒子,它会跑到真实世界中替你做某些事,像是在墙壁上涂鸦,然后带回来某些数据。一旦它带了某些数据给你,打开盒子的唯一办法就是用 <-。而且如果我们要从 I/O action 拿出某些数据,就一定同时要在另一个 I/O action 中。这就是 Haskell 如何漂亮地分开纯粹跟不纯粹的程序的方法。getLine 在这样的意义下是不纯粹的,因为执行两次的时候它没办法保证会回传一样的值。这也是为什么它需要在一个 IO 的型态建构子中,那样我们才能在 I/O action 中取出数据。而且任何一段程序一旦依赖着 I/O 数据的话,那段程序也会被视为 I/O code

但这不表示我们不能在纯粹的代码中使用 I/O action 回传的数据。只要我们绑定它到一个名字,我们便可以暂时地使用它。像在name <- getLine 中 name 不过是一个普通字串,代表在盒子中的内容。我们能将这个普通的字串传给一个极度复杂的函数,并回传你一生会有多少财富。像是这样:

ain = do 
    putStrLn "Hello, what's your name?" 
    name <- getLine 
    putStrLn $ "Read this carefully, because this is your future: " ++ tellFortune name

tellFortune 并不知道任何 I/O 有关的事,它的型态只不过是 String -> String

再来看看这段代码吧,他是合法的吗?

nameTag = "Hello, my name is " ++ getLine

如果你回答不是,恭喜你。如果你说是,你答错了。这么做不对的理由是 ++ 要求两个参数都必须是串列。他左边的参数是String,也就是 [Char]。然而 getLine 的型态是 IO String。你不能串接一个字串跟 I/O action。我们必须先把 String 的值从 I/O action 中取出,而唯一可行的方法就是在 I/O action 中使用 name <- getLine。如果我们需要处理一些非纯粹的数据,那我们就要在非纯粹的环境中做。所以我们最好把 I/O 的部分缩减到最小的比例。

每个 I/O action 都有一个值封装在里面。这也是为什么我们之前的程序可以这么写:

main = do 
    foo <- putStrLn "Hello, what's your name?" 
    name <- getLine 
    putStrLn ("Hey " ++ name ++ ", you rock!")

然而,foo 只会有一个 () 的值,所以绑定到 foo 这个名字似乎是多余的。另外注意到我们并没有绑定最后一行的putStrLn 给任何名字。那是因为在一个 do block 中,最后一个 action 不能绑定任何名字。我们在之后讲解 Monad 的时候会说明为什么。现在你可以先想成 do block 会自动从最后一个 action 取出值并绑定给他的结果。

除了最后一行之外,其他在 do 中没有绑定名字的其实也可以写成绑定的形式。所以 putStrLn "BLAH" 可以写成_ <- putStrLn "BLAH"。但这没什么实际的意义,所以我们宁愿写成 putStrLn something

初学者有时候会想错

name = getLine

以为这行会读取输入并给他绑定一个名字叫 name 但其实只是把 getLine 这个 I/O action 指定一个名字叫 name 罢了。记住,要从一个 I/O action 中取出值,你必须要在另一个 I/O action 中将他用 <- 绑定给一个名字。

I/O actions 只会在绑定给 main 的时候或是在另一个用 do 串起来的 I/O action 才会执行。你可以用 do 来串接 I/O actions,再用 do 来串接这些串接起来的 I/O actions。不过只有最外面的 I/O action 被指定给 main 才会触发执行。

喔对,其实还有另外一个情况。就是在 GHCi 中输入一个 I/O action 并按下 Enter 键,那也会被执行

ghci> putStrLn "HEEY" 
HEEY

就算我们只是在 GHCi 中打几个数字或是调用一个函数,按下 Enter 就会计算它并调用 show,再用 putStrLn 将字串印出在终端上。

还记得 let binding 吗?如果不记得,回去温习一下这个章节。它们的形式是 let bindings in expression,其中bindings 是 expression 中的名字、expression 则是被运用到这些名字的算式。我们也提到了 list comprehensions 中,in 的部份不是必需的。你能够在 do blocks 中使用 let bindings 如同在 list comprehensions 中使用它们一样,像这样:

import Data.Char 
 
main = do 
    putStrLn "What's your first name?" 
    firstName <- getLine 
    putStrLn "What's your last name?" 
    lastName <- getLine 
    let bigFirstName = map toUpper firstName 
        bigLastName = map toUpper lastName 
    putStrLn $ "hey " ++ bigFirstName ++ " " ++ bigLastName ++ ", how are you?"

注意我们是怎么编排在 do block 中的 I/O actions,也注意到我们是怎么编排 let 跟其中的名字的,由于对齐在 Haskell 中并不会被无视,这么编排才是好的习惯。我们的程序用 map toUpper firstName 将 "John" 转成大写的 "JOHN",并将大写的结果绑定到一个名字上,之后在输出的时候参考到了这个名字。

你也许会问究竟什么时候要用 <-,什么时候用 let bindings?记住,<- 是用来运算 I/O actions 并将他的结果绑定到名称。而 map toUpper firstName 并不是一个 I/O action。他只是一个纯粹的 expression。所以总结来说,当你要绑定 I/O actions 的结果时用 <-,而对于纯粹的 expression 使用 let bindings。对于错误的 let firstName = getLine,我们只不过是把 getLine 这个 I/O actions 给了一个不同的名字罢了。最后还是要用 <- 将结果取出。

现在我们来写一个会一行一行不断地读取输入,并将读进来的字反过来输出到屏幕上的程序。程序会在输入空白行的时候停止。

main = do 
    line <- getLine 
    if null line 
        then return () 
        else do 
            putStrLn $ reverseWords line 
            main 
 
reverseWords :: String -> String 
reverseWords = unwords . map reverse . words

在分析这段程序前,你可以执行看看来感受一下程序的运行。

首先,我们来看一下 reverseWords。他不过是一个普通的函数,假如接受了个字串 "hey there man",他会先调用words 来产生一个字的串列 ["hey", "there", "man"]。然后用 reverse 来 map 整个串列,得到["yeh", "ereht", "nam"],接着用 unwords 来得到最终的结果 "yeh ereht nam"。这些用函数合成来简洁的表达。如果没有用函数合成,那就会写成丑丑的样子 reverseWords st = unwords (map reverse (words st))

那 main 又是怎么一回事呢?首先,我们用 getLine 从终端读取了一行,并把这行输入取名叫 line。然后接着一个条件式 expression。记住,在 Haskell 中 if 永远要伴随一个 else,这样每个 expression 才会有值。当 if 的条件是 true (也就是输入了一个空白行),我们便执行一个 I/O action,如果 if 的条件是 false,那 else 底下的 I/O action 被执行。这也就是说当 if 在一个 I/O do block 中的时候,长的样子是 if condition then I/O action else I/O action

我们首先来看一下在 else 中发生了什么事。由于我们在 else 中只能有一个 I/O action,所以我们用 do 来将两个 I/O actions 绑成一个,你可以写成这样:

else (do 
    putStrLn $ reverseWords line 
    main)

这样可以明显看到整个 do block 可以看作一个 I/O action,只是比较丑。但总之,在 do block 里面,我们依序调用了getLine 以及 reverseWords,在那之后,我们递归调用了 main。由于 main 也是一个 I/O action,所以这不会造成任何问题。调用 main 也就代表我们回到程序的起点。

那假如 null line 的结果是 true 呢?也就是说 then 的区块被执行。我们看一下区块里面有 then return ()。如果你是从 C、Java 或 Python 过来的,你可能会认为 return 不过是作一样的事情便跳过这一段。但很重要的: return 在 Hakell 里面的意义跟其他语言的 return 完全不同!他们有相同的样貌,造成了许多人搞错,但确实他们是不一样的。在命令式语言中,return 通常结束 method 或 subroutine 的执行,并且回传某个值给调用者。在 Haskell 中,他的意义则是利用某个 pure value 造出 I/O action。用之前盒子的比喻来说,就是将一个 value 装进箱子里面。产生出的 I/O action 并没有作任何事,只不过将 value 包起来而已。所以在 I/O 的情况下来说,return "haha" 的型态是 IO String。将 pure value 包成 I/O action 有什么实质意义呢?为什么要弄成 IO 包起来的值?这是因为我们一定要在 else 中摆上某些 I/O action,所以我们才用 return () 做了一个没作什么事情的 I/O action。

在 I/O do block 中放一个 return 并不会结束执行。像下面这个程序会执行到底。

main = do 
    return () 
    return "HAHAHA" 
    line <- getLine 
    return "BLAH BLAH BLAH" 
    return 4 
    putStrLn line

所有在程序中的 return 都是将 value 包成 I/O actions,而且由于我们没有将他们绑定名称,所以这些结果都被忽略。我们能用 <- 与 return 来达到绑定名称的目的。

main = do 
    a <- return "hell" 
    b <- return "yeah!" 
    putStrLn $ a ++ " " ++ b

可以看到 return 与 <- 作用相反。return 把 value 装进盒子中,而 <- 将 value 从盒子拿出来,并绑定一个名称。不过这么作是有些多余,因为你可以用 let bindings 来绑定

main = do 
    let a = "hell" 
        b = "yeah" 
    putStrLn $ a ++ " " ++ b

在 I/O do block 中需要 return 的原因大致上有两个:一个是我们需要一个什么事都不做的 I/O action,或是我们不希望这个 do block 形成的 I/O action 的结果值是这个 block 中的最后一个 I/O action,我们希望有一个不同的结果值,所以我们用return 来作一个 I/O action 包了我们想要的结果放在 do block 的最后。

在我们接下去讲文件之前,让我们来看看有哪些实用的函数可以处理 I/O。

putStr 跟 putStrLn 几乎一模一样,都是接受一个字串当作参数,并回传一个 I/O action 印出字串到终端上,只差在putStrLn 会换行而 putStr 不会罢了。

main = do putStr "Hey, " 
          putStr "I'm " 
          putStrLn "Andy!"
$ runhaskell putstr_test.hs 
Hey, I'm Andy!

他的 type signature 是 putStr :: String -> IO (),所以是一个包在 I/O action 中的 unit。也就是空值,没有办法绑定他。

putChar 接受一个字符,并回传一个 I/O action 将他印到终端上。

main = do putChar 't'     
          putChar 'e' 
          putChar 'h'
$ runhaskell putchar_test.hs 
teh

putStr 实际上就是 putChar 递归定义出来的。putStr 的边界条件是空字串,所以假设我们印一个空字串,那他只是回传一个什么都不做的 I/O action,像 return ()。如果印的不是空字串,那就先用 putChar 印出字串的第一个字符,然后再用 putStr 印出字串剩下部份。

putStr :: String -> IO () 
putStr [] = return () 
putStr (x:xs) = do 
    putChar x 
    putStr xs

看看我们如何在 I/O 中使用递归,就像我们在 pure code 中所做的一样。先定义一个边界条件,然后再思考剩下如何作。

print 接受任何是 Show typeclass 的 instance 的型态的值,这代表我们知道如何用字串表示他,调用 show 来将值变成字串然后将其输出到终端上。基本上,他就是 putStrLn . show。首先调用 show 然后把结果喂给 putStrLn,回传一个 I/O action 印出我们的值。