几乎所有程序都是用来从外部世界收集数据,处理数据,并把处理结果返回给外部世界的。也就是说,输入和输出对于程序设计来说相当关键。
Haskell的I/O系统很强大,表达能力很强也很容易使用,理解它的原理对于学习Haskell来说非常重要。Haskell把纯函数式代码和那些会对外部世界产生影响的代码严格区分了开来。也就是说它把副作用完全隔离在了纯函数式的代码之外。这样不仅可以帮助程序员更容易验证程序的正确性,也能让编译器自动进行优化和并行化。
本章先从Haskell简单的标准I/O开始。然后我们再来讨论一些其他更强大的做法,以及更详细地探讨I/O是如何与纯粹、惰性、函数式的Haskell世界相融合的。
Haskell中的经典I/O
我们先来看一个程序,它和其他语言如 C 或 Perl中操作I/O的方式非常像。
-- file: ch07/basicio.hs
main = do
putStrLn "Greetings! What is your name?"
inpStr <- getLine
putStrLn $ "Welcome to Haskell, " ++ inpStr ++ "!"
可以把这个程序编译成独立可执行文件,或者用runghc执行它,也可以在 ghci中调用它的main函数。这里是一个用 runghc 的例子:
$ runghc basicio.hs
Greetings! What is your name?
John
Welcome to Haskell, John!
输出的结果还是相当明了的,可以看到 putStrLn 输出了一个 String 和一个换行符。getLine从标准输入中读入一行。你可能还不太了解 <- 的语法。简单地说,它就是把执行I/O动作的结果绑定到变量名上。然后我们用列表连接操作符 ++ 来把输入的字符串和程序自己的文本连接起来。
我们来看一下 putStrLn 和 getLine 的类型,你可以从库参考文档中找到这个信息,或者直接问ghci:
ghci> :type putStrLn
putStrLn :: String -> IO ()
ghci> :type getLine
getLine :: IO String
注意这两个类型的返回值里都有IO类型。这样我们就可以判断出他们要么是具有副作用,要么是用相同参数调用时返回结果可能不同,也可能是二者皆有。putStrLn 的类型看上去像个函数。接受一个String类型的参数并返回一个 IO () 类型的值。那么到底什么是 IO () 呢?
类型为 IO 某某 的就是一个I/O动作。你可以保存它们,但是不会产生任何影响。你可以写一句 writefoo = putStrLn "foo",但这句话不会做任何有用的事。但是如果之后在另一个 I/O 动作中使用到了 writefoo,那么当父动作被调用的时候,writefoo就会被执行 -- I/O动作可以通过更大的 I/O动作结合在一起。()是一个空的元组(发音为 "unit"),表示从 putStrLn中没有返回值。与 Java或C中的void类似。
[Tip] Tip
动作可以在任何地方进行创建,赋值,和传递。但是,他们只有从另一个I/O动作中才能被执行。
让我们在ghci中看一下:
ghci> let writefoo = putStrLn "foo"
ghci> writefoo
foo
在这个例子里,foo被输出并不是因为putStrLn返回了它。而是putStrLn的副作用导致foo被写到终端。
还有一件事需要注意:ghci实际“执行”了writefoo。这意味着,给ghci一个I/O动作,它就会立即执行它。
[Note] I/O动作是什么?
I/O动作 (Action):
* 类型为 IO t
* 是 Haskell 中的一等公民,并与Haskell的类型系统无缝的结合
* 执行时产生副作用,但求值时不会。也就是说,只有在某个 I/O环境下被调用时才会产生副作用。
* 任何表达式都可以返回I/O动作,但是这个I/O动作只有在另一个 I/O动作(或者main)内才会被执行。
* 执行一个类型为 IO t 的动作会执行相应的 I/O动作,并返回一个 t 类型的值。
getLine 的类型看上去可能有些奇怪。看起来它更像一个值而不是函数。实际上,可以这么看它:getLine存储了一个I/O动作。当该动作被执行时,返回一个String。<-操作符用来把结果从被执行的动作中“拉出来”,并存储到一个变量里。
main本身是一个类型为 IO () 的动作。你只能从另一个 I/O动作中执行I/O动作。所以Haskell程序中所有的 I/O动作最终都是由main驱动的,那是每个Haskell程序开始执行的地方。这就是Haskell提供的隔离副作用的机制:在I/O动作中执行I/O动作,并从那里调用纯函数式的(非I/O)函数。大部分Haskell代码是纯的;I/O动作执行I/O的动作同时调用这些纯的代码。
要执行一组动作, do是很方便的一个方法。后面会看到,还有其他方法。当使用do时,缩进变得很重要;你需要要保证所有的动作代码都对齐了。
只有当你需要执行多于一个动作的时候才需要使用 do,整个do程序块的返回值就是最后一个被执行的动作的返回值。在“剖析do程序块”一节有对do语法的完整解释。
我们来看一个在I/O动作中调用纯函数式代码的例子:
-- file: ch07/callingpure.hs
name2reply :: String -> String
name2reply name =
"Pleased to meet you, " ++ name ++ ".\n" ++
"Your name contains " ++ charcount ++ " characters."
where charcount = show (length name)
main :: IO ()
main = do
putStrLn "Greetings once again. What is your name?"
inpStr <- getLine
let outStr = name2reply inpStr
putStrLn outStr
注意这个例子中的 name2reply 函数。它是一个普通的 Haskell 函数,遵从我们已经说过的所有规则:当给出相同输入时总是返回相同的结果,没有副作用,惰性求值。它用到了一些其他的Haskell函数: (++), show, 和 length。
到后面的main中,我们把 name2reply inpStr 的值绑定到 outStr 变量。在do程序块中时,用 <- 获得IO动作的返回值,用 let 获得纯函数式代码的返回值。在do程序块中,你不能在let语句后面使用 in 。
在这段代码中你可以看到如何从键盘上读取一个人的名字。然后这个名字被传递给一个纯函数,然后其结果被输出。实际上,main的最后两行也可以用 putStrLn (name2reply inpStr) 来代替的。这样尽管main 确实具有副作用(它让终端上出现了一些字符),而name2reply 没有并且不可能有副作用。因为name2reply是纯函数,不是一个动作。
我们可以ghci中验证一下:
ghci> :load callingpure.hs
[1 of 1] Compiling Main ( callingpure.hs, interpreted )
Ok, modules loaded: Main.
ghci> name2reply "John"
"Pleased to meet you, John.\nYour name contains 4 characters."
ghci> putStrLn (name2reply "John")
Pleased to meet you, John.
Your name contains 4 characters.
字符串中的\n 是换行符,让终端在输出时开始一个新行。在ghci中直接调用 name2reply "John"的话会在字面上显示 \n ,因为它是用show来显示函数返回值的。但是使用putStrLn 的话,会把字符串发送给终端,终端则会把 \n 翻译成换行符。
你觉得要是直接在ghci中输入main 会发生什么呢?你可以自己试一下。
看过了这些例子程序之后,你可能会想Haskell其实是命令式的而不是纯粹的、惰性的,函数式的。这些例子有些看上去好像就是一系列动作按顺序执行。但是还里面确实还有更深刻的含义。我们将在本章后面部分的"Haskell真的是命令式的么?"和"惰性I/O"两节中继续探讨这个问题。
Pure vs. I/O
为了帮助理解纯函数式代码和I/O之间究竟有何不同,这里给出一个对照表。当我们说纯函数式代码时,我们说的是那些对相同输入总是返回相同结果,并且没有副作用的Haskell函数。在Haskell里,只有I/O动作的执行不适用于这些规则。
Table 7.1. Pure vs. Impure
Pure Impure
纯 非纯
给定相同参数总是返回相同值 对相同参数可能返回不通值
永远没有副作用 可以有副作用
用于不改变状态 可以改变程序,系统或外界的全局状态
为什么纯粹性如此重要
这一节中我们已经探讨了Haskell是如何将纯函数式代码与 I/O动作清楚的分离开来的。大多数语言并不会这样区分。像 C 或 Java这样的语言里,编译器不能保证某一个函数对相同的参数总是返回相同的值,或者保证一个函数永远没有副作用。要想知道一个函数是否有副作用,唯一的办法就是去读它的文档,而这文档还不一定准确。
程序中很多bug都是由一些出乎意料的副作用导致的。还有一些就是因为被一个函数对相同的输入返回不同的结果给搞糊涂了。随着多线程和其他形式的并行变得越来越平常,要管理全局的副作用就变得愈加困难了。
Haskell这种把副作用隔离进I/O动作的方法提供了一个清楚的边界。你总是可以清楚地知道系统的哪一部分可能会修改状态,哪些不会。你总是可以确信程序中纯函数式的那部分代码不会产生出人意料的结果。这可以帮助你编写程序。同样也可以帮助编译器来理解你的程序。例如最近一些版本的ghc就可以对代码中纯函数式的部分——这部分代码可说是计算中的圣杯——提供一定程度的自动的并行处理。
关于这个主题的更多讨论,见 “惰性I/O的副作用”一节。
操作文件和句柄
现在你已经看过如何通过计算机终端与用户进行交互了。当然,你经常会需要操作一些特定的文件。这个,同样很容易做到。
Haskell为I/O定义了很多基本函数,他们中的许多都与其他编程语言类似。System.IO 的库参考文档提供了所有基本I/O函数的概述,如果你需要某个在本文中没有涉及到的函数,可以到参考哪里。
操作文件,一般从 openFile 开始,它会返回给你一个文件句柄。然后你就可以用这个句柄对那个文件进行操作。Haskell提供了诸如hPutStrLn 这样的函数,它类似putStrLn,不过需要多传一个文件句柄参数,指定要操作的文件。操作完用 hClose来关闭句柄。这些函数都定义在 System.IO 中,因此在操作文件之前要先导入这个模块。差不多所有非"h"开头的函数都有与之相对的 "h"开头的函数;例如有一个 print 用来向屏幕输出,就有一个hPrint用来向文件输出。
我们先来用命令式的方式来对文件进行读写,应该和其他语言里面的while循环有些类似。不过这并不是Haskell里最好的写法;后面你还会看到更多更Haskell的做法。
-- file: ch07/toupper-imp.hs
import System.IO
import Data.Char(toUpper)
main :: IO ()
main = do
inh <- openFile "input.txt" ReadMode
outh <- openFile "output.txt" WriteMode
mainloop inh outh
hClose inh
hClose outh
mainloop :: Handle -> Handle -> IO ()
mainloop inh outh =
do ineof <- hIsEOF inh
if ineof
then return ()
else do inpStr <- hGetLine inh
hPutStrLn outh (map toUpper inpStr)
mainloop inh outh
所有Haskell程序都是从main开始执行。首先打开两个文件:input.txt 以读模式打开,output.txt 以写模式打开。之后调用mainloop对文件进行处理。
mainloop首先检查是否已经到达文件的末尾(EOF)。如果不是就从输入中读入一行。把它转换成大写后写入到输出文件。之后递归的调用mainloop继续处理文件。
注意这里对return的调用。这与C或Python中的return不一样。在那些语言里,return用来立即中止当前函数的执行,并把值返回给调用者。在Haskell里,return与 <- 恰好相反。也就是说return把一个纯的值包装成一个IO类型。因为每一个I/O动作必须返回IO类型,如果你的结果来自纯的计算,必须把它包装成IO类型再返回。比如说,对于7这个Int,return 7就会创建一个类型为 IO Int的动作。当该动作被执行的时候,这个动作会返回7.关于return更详细探讨,请看“return的本质”一节。
让我们尝试运行一下这个程序。假设我们已经有了一个 input.txt 文件,内容如下:
This is ch08/input.txt
Test Input
I like Haskell
Haskell is great
I/O is fun
123456789
执行 runghc toupper-imp.hs ,之后会在目录中找到 output.txt 文件。其内容如下:
THIS IS CH08/INPUT.TXT
TEST INPUT
I LIKE HASKELL
HASKELL IS GREAT
I/O IS FUN
123456789
openFile详解
让我们用ghci来检查下 openFile的类型:
ghci> :module System.IO
ghci> :type openFile
openFile :: FilePath -> IOMode -> IO Handle
FilePath 只是String的一个别名。在I/O函数中使用它而非String是为了指明该参数是特别用来作文件名用的,而不是一个常规的数据。
IOMode指定文件如何管理。IOMode可能的取值列在表 7-2 中。
IOMode的取值
IOMode Can read? Can write? Starting position Notes
IOMode 可读? 可写? 开始位置 附注
ReadMode Yes No 文件开头 文件必须已经存在
WriteMode No Yes 文件开头 文件如果已经存在将会完全清空
ReadWriteMode Yes Yes 文件开头 如果文件不存在将会创建;否则已经存在的数据不会动
AppendMode No Yes 文件结尾 文件如果不存在将会创建;否则已经存在的数据不会动
虽然本章大部分例子处理文本文件,但是Haskell也是可以处理二进制文件的。如果要处理二进制文件,就要用 openBinaryFile 代替 openFile。把文件当作二进制打开与作为文本打开,在Windows上处理时会有所不同。在Linux一类的操作系统上, openFile 和 openBinaryFile执行的是完全相同的操作。不管怎样,即使出于移植性的考虑,处理二进制文件时也应该总是使用openBinaryFile。
关闭句柄
你已经看到hClose是用来关闭文件句柄的。让我们花点时间来探讨下为什么关闭句柄很重要。
在“缓冲”一节你将会看到,Haskell为文件维护了内部的缓冲区。这带来了很关键的性能提升。但是,这样一来的话,以写入模式打开的文件,可能要到调用hClose的时候,有些数据才会真正被写到操作系统上去。
要确保对打开的文件调用hClose的另一个原因是它会占用系统资源。如果你的程序执行很长时间,并且打开了很多文件但是没有关闭他们,你的程序很可能因为资源耗尽而崩溃。这一点上Haskell与其他语言没什么区别。
当程序退出时,Haskell一般会把仍然打开着的文件关闭。然而在某些情况下却不一定,因此再次提醒大家,作为一个负责人的程序员,永远不能忘记调用hClose。
Haskell还提供了一些工具,可以帮助你不论是否有错误发生都能轻松确保打开的文件被关闭。你可以在“扩展实例:函数式I/O和临时文件”一节了解关于finally,在“获取使用释放循环”一节了解bracket。
Seek 和 Tell
当通过句柄从磁盘读写文件的时候,操作系统内部会记录文件当前操作所在的位置。每次读取,操作系统会返回从当前位置开始的一块数据,并根据读取的数据将位置相应地递增。
可以用hTell获得文件当前的位置。当文件刚刚创建时,它是空的,位置为0。写入了5个字节后,它的位置变为5,等等。hTell取一个句柄做参数,返回一个 IO Integer 表示位置。
与hTell相伴的是hSeek。hSeek可以让你修改文件的位置。它接受三个参数:文件句柄,偏移模式(SeekMode)和偏移量。
偏移模式(SeekMode)有三种,用来表示如何对给出的偏移量进行解释。AbsoluteSeek 意思是给定的偏移是文件中的精确位置,这与hTell给出的信息是一致的。RelativeSeek 意思是以当前位置为原点进行偏移,一个正数的偏移量表示向前偏移,而负数表示向后偏移。最后SeekFromEnd将从文件末尾向前偏移指定数量的字节。 hSeek handle SeekFromEnd 0 将把你带到文件的末尾。“扩展实例:函数式I/O和临时文件”一节有一个hSeek的例子。
并不是所有的句柄都是可以进行偏移的。一般来说句柄都是指向文件,但是它也能够指向其他一些不能进行偏移操作的东西,例如网络连接,磁带驱动器,或者终端。可以用hIsSeekable 来检查一个给定的句柄是否支持偏移。
标准输入,标准输出,标准错误
之前我们提到过每一个非"h"的函数,一般都有一个 "h"函数与之对应,可以用来处理任何句柄。实际上,非"h"的函数只不过是他们的"h"函数的一种快捷方式而已。
在System.IO中有三个预定义的句柄。这些句柄总是可用的。它们就是标准输入 stdin ;标准输出 stdout; 和标准错误 stderr。标准输入一般指键盘,标准输出指显示屏,标准错误一般也指向显示屏。
我们可以这样来定义getLine一类的函数:
getLine = hGetLine stdin
putStrLn = hPutStrLn stdout
print = hPrint stdout
[Tip] Tip
这里使用了部分函数。如果不清楚,回顾下“部分函数应用和柯里化”一节。
刚才我们对三个标准文件句柄的解释的只是它们“通常”都指向什么,有的操作系统还允许你在启动的时候把这些文件句柄重定向到其他的地方——文件,设备,甚至是其他程序。这个特性在POSIX系统(Linux,BSD, Mac)上的shell脚本中被广泛应用,在Windows上也可以使用。
一般来说使用标准输入输出而非显示指定文件是有好处的,这样你可以通过终端和用户进行交互。同时也允许你操作输入输出文件,如果需要的话甚至还可以和其他程序组合在一起。
举个例子来说,你可以这种方式给 callingpure.hs 提供输入:
$ echo John|runghc callingpure.hs
Greetings once again. What is your name?
Pleased to meet you, John.
Your name contains 4 characters.
当执行 callingpure.hs 时,它不需要等待键盘输入,而是从 echo 程序接收到 John。同时注意到和用键盘输入时不同,输出中没有John的那一行。终端把你键入的内容回显给你,但这是通过另一个程序进行输入,不会把输入包含在输出流里。
删除和重命名文件
本章前面部分探讨了如何操作文件的内容。现在让我们来关心下如何操作文件本身。
System.Directory 模块里有两个函数还是挺有用的。一个是removeFile,它只接受一个文件名作为参数,执行的操作就是删除这个文件。renameFile取两个文件名作参数:第一个是旧的文件名,第二个是新文件名。如果两个文件名处于不同的目录中,你也把他当成是移动操作。调用renameFile前旧文件名必须已经存在。如果新的文件名已经存在了,会先把它删了,然后再进行改名操作。
像其他取文件名做参数的函数一样,renameFile在旧文件名不存在的情况下会抛出异常。第19章《错误处理》将会介绍更多异常处理的信息。
System.Directory模块中还有很多函数用来进行目录的创建和删除,获取目录中的文件列表,检测文件是否存在。在“目录和文件信息”一节将会对这些话题进行探讨。
临时文件
程序员经常需要创建临时文件。这些文件可以用来存储计算需要的大量数据,或者是供给其他程序或其他用户使用的数据。
你可以手动为创建的文件取一个独一无二的文件名,但是在不同的平台上安全地做到这一点还是需要处理一些细节上的不同。Haskell提供了一个方便的函数叫 openTempFile (和对应的openBinaryTempFile),可以帮你处理这个问题。
openTempFile 需要两个参数:要创建文件的目录和文件名命名的“模板”。目录可以直接用 "."表示当前工作目录。或者使用System.Directory.getTemporaryDirectory得到机器上的临时目录。文件名模板作为创建文件名的基础,然后再添加一些随机字符上去,以确保产生的文件名是真正独一无二的。
openTempFile 的返回类型是 IO (FilePath, Handle)。元组的第一部分是创建文件的文件名,第二部分是以 ReadWriteMode 模式打开的文件句柄。当操作完文件句柄后,要用 hClose 将它关闭,并调用 removeFile 删除该临时文件。下面举个例子:
扩展实例:函数式I/O和临时文件
这里有一个比较庞大的例子,它融合了本章以及之前章节,甚至一些还没见过的概念。尝试阅读本程序,看看能否看出它是做什么的,以及是如何去做的。
-- file: ch07/tempfile.hs
import System.IO
import System.Directory(getTemporaryDirectory, removeFile)
import System.IO.Error(catch)
import Control.Exception(finally)
-- 主程序入口。在myAction中使用临时文件
main :: IO ()
main = withTempFile "mytemp.txt" myAction
{-
程序核心部分。传递一个文件路径和一个临时文件句柄进行调用。
myAction 是从 withTempFile 中调用的,所以当 myAction 函数退出时,临时文件将会被自动关闭并删除,。
-}
myAction :: FilePath -> Handle -> IO ()
myAction tempname temph =
do -- 在终端上显示欢迎词
putStrLn "Welcome to tempfile.hs"
putStrLn $ "I have a temporary file at " ++ tempname
-- 查看下初始位置
pos <- hTell temph
putStrLn $ "My initial position is " ++ show pos
-- 向临时文件中写入一些数据
let tempdata = show [1..10]
putStrLn $ "Writing one line containing " ++
show (length tempdata) ++ " bytes: " ++
tempdata
hPutStrLn temph tempdata
-- 查看新的位置,实际上这并不改变pos在内存中的值,
-- 但是它让 "pos" 变量在 "do" 程序块后面的部分中指向了另一个值。
pos <- hTell temph
putStrLn $ "After writing, my new position is " ++ show pos
-- 转移到文件起始位置并开始显示
putStrLn $ "The file content is: "
hSeek temph AbsoluteSeek 0
-- hGetContents 惰性读取整个文件
c <- hGetContents temph
-- 把文件一个字节一个字节输出,后跟 \n
putStrLn c
-- 以 Haskell 字面量形式显示
putStrLn $ "Which could be expressed as this Haskell literal:"
print c
{-
本函数接收两个参数:临时文件名模式和一个函数。它会创建一个临时文件,并将文件名和文件句柄传递给那个函数。
临时文件用通过 openTempFile 创建的。目录是 getTemporaryDirectory 所指定的,如果系统没有临时目录概念,则用 "." 。
给定的文件名模式被传递给了 openTempFile。
当给定函数中止,即使是异常中止,文件句柄也会被关闭,同时临时文件被删除。
-}
withTempFile :: String -> (FilePath -> Handle -> IO a) -> IO a
withTempFile pattern func =
do -- The library ref says that getTemporaryDirectory may raise on
-- exception on systems that have no notion of a temporary directory.
-- So, we run getTemporaryDirectory under catch. catch takes
-- two functions: one to run, and a different one to run if the
-- first raised an exception. If getTemporaryDirectory raised an
-- exception, just use "." (the current working directory).
库参考手册里说如果系统不支持临时目录概念的话,getTemporaryDirectory 将会抛出异常。
因此我们在 catch 之下来执行 getTemporaryDirectory。catch 接受两个函数参数:一个会直接运行,另一个会在第一个函数抛出异常时运行。如果getTemporaryDirectory抛出了异常,就使用"."
tempdir <- catch (getTemporaryDirectory) (\_ -> return ".")
(tempfile, temph) <- openTempFile tempdir pattern
-- 调用 (func tempfile temph) 对临时文件进行操作。 finally 需要两个操作作为参数。第一个会被直接执行。
-- 第一个操作执行之后,不论是否抛出异常,第二个操作都会被执行。
-- 这样,我们就能确保临时文件总是会被删除。finally 返回第一个操作的返回值。
finally (func tempfile temph)
(do hClose temph
removeFile tempfile)
我们来看下程序的结尾。从withTempFile这个函数可以看出Haskell在引入I/O的时候并没有忘记它自己函数式的本质。这个函数取一个String和另一个函数做参数。withTempFile使用临时文件的文件名和句柄传入的函数进行调用。当传入的函数退出时,临时文件被关闭和删除。因此即使在处理I/O时,我们依然能够看到传递函数作为参数的用法。Lisp程序员大概会发现这个 withTempFile 函数跟Lisp里的 with-open-file 函数很类似。
适当的异常处理可以让你的程序在遇到错误时更加健壮。通常情况下对临时处理完成后都需要把临时文件删除,即使发生了错误也不例外。我们需要保证这一点。异常处理更多信息见第19章《错误处理》。
回到程序的开始,main的定义只是简单的 withTempFile "mytemp.txt" myAction 。 之后使用临时文件的文件名和文件句柄调用 myAction。
然后myAction在终端上显示一些信息,向文件中写入一些数据,再移动到文件开头用 hGetContents 读取数据,再把文件内容一个字节一个字节的显示出来。之后又通过 print c 来按Haskell的字面量格式输出出来,最后这个操作等价于 putStrLn (show c) 相同。
让我们来看看输出:
$ runhaskell tempfile.hs
Welcome to tempfile.hs
I have a temporary file at /tmp/mytemp8572.txt
My initial position is 0
Writing one line containing 22 bytes: [1,2,3,4,5,6,7,8,9,10]
After writing, my new position is 23
The file content is:
[1,2,3,4,5,6,7,8,9,10]
Which could be expressed as this Haskell literal:
"[1,2,3,4,5,6,7,8,9,10]\n"
每次运行此程序,临时文件名都会稍有不同,因为它包含有随机生成的部分。观察这些输出,你可能会有如下一些疑问:
1、为什么写入了22个字节后,位置是23?
2、为什么文件内容最后显示有一个空行?
3、为什么在Haskell字面格式显示的末尾有一个 \n
你可能已经猜到这三个问题的互相之间都是有关联的。你可以先自己想一会,看能否找出答案。如果需要帮助,这里也为你提供一些解释:
1、因为我们是用的 hPutStrLn 而不是 hPutStr 来写入数据。hPutStrLn 总是会在行末尾添加 \n,这个 \n 在 tempdate 中并不存在。
2、这里是用 putStrLn c 来显示文件内容 c 。因为文件内容本来就是通过 hPutStrLn 写入的,所以 c 的末尾是个换行符,而 putStrLn 在输出的时候又在末尾添加了一个换行符,结果就显示出了一个空行。
3、\n 就是一开始hPutStrLn输出的换行符。
最后说明下,在不同的操作系统上字节数计算方式会有所不同。例如在Windows上使用\r\n的双子节序列作为行尾标记,因此在不同的平台上可能看上去会不太一样。
惰性I/O
本章到目前为止,你看到的例子都是比较传统的 I/O 处理方式。每一行或每一块数据都是单独读取的,并且也需要单独进行处理。
Haskell还提供另一种方式。因为Haskell是惰性语言,也就是说只有到最后关头才会对数据的值进行求值,所以这里会一些新颖的处理I/O的方法。
hGetContents
新方法之一就是hGetContents函数。hGetContents 的类型是 Handle -> IO String。返回的 String 的值就是指定文件句柄中的全部内容。
在严格求值的语言里,使用这样的函数往往是不太好的。因为读取一个2KB的文件还可以,要是读取一个500GB文件的全部内容,将很可能会因为内存不够而崩溃。在这些语言中,需要用传统的机制如循环来处理文件的全部内容。
但是hGetContents是不一样的。它返回的String是惰性求值的。也就是说在调用hGetContents 时并不会进行实际的读取操作。只有当列表的元素(字符)被处理时才会从文件句柄中读取数据。当String中的元素不被使用时,Haskell的垃圾收集器会自动释放它的内存。所有这些都是透明的。它看上去就像是——其实实际上也是——一个纯的String,因此你可以把它传递给纯函数式的代码(没有IO)。
我们来看一个例子。前一节“处理文件和句柄”中有一个命令式的程序,把文件的全部内容转换成大写。它的命令式算法与其他很多语言中见到的差不多。这里展示一个利用惰性求值的简单地多的算法:
-- file: ch07/toupper-lazy1.hs
import System.IO
import Data.Char(toUpper)
main :: IO ()
main = do
inh <- openFile "input.txt" ReadMode
outh <- openFile "output.txt" WriteMode
inpStr <- hGetContents inh
let result = processData inpStr
hPutStr outh result
hClose inh
hClose outh
processData :: String -> String
processData = map toUpper
注意hGetContents为我们处理了所有的读取逻辑。还有 processData 这个函数,它是一个纯函数,因为它没有副作用,并且使用相同的参数调用总是返回相同的结果。它不需要——也没办法——知道它的输入是从文件中惰性读取的。不管是20个字符的字符串还是磁盘上存的500GB的数据,它都可以完美地进行处理。
可以使用ghci来确认一下:
ghci> :load toupper-lazy1.hs
[1 of 1] Compiling Main ( toupper-lazy1.hs, interpreted )
Ok, modules loaded: Main.
ghci> processData "Hello, there! How are you?"
"HELLO, THERE! HOW ARE YOU?"
ghci> :type processData
processData :: String -> String
ghci> :type processData "Hello!"
processData "Hello!" :: String
[Warning] Warning
在上面的例子中,如果你想在处理完inpStr变量之后保留着继续使用的话,程序的内存利用率会有所降低。这是因为编译器必须把inpStr 的值保留在内存中,后面才可以继续使用。上面这个程序里,编译器知道 inpStr 永远不会再用了,因此会立刻把它释放掉。只要记住:内存只有在最后一次使用后才会被释放。
为了清楚得展示其中纯函数式的代码的使用,使得这个程序写得有点冗长,这里有个更简洁的版本,接下来将以这个程序作为基础。
-- file: ch07/toupper-lazy2.hs
import System.IO
import Data.Char(toUpper)
main = do
inh <- openFile "input.txt" ReadMode
outh <- openFile "output.txt" WriteMode
inpStr <- hGetContents inh
hPutStr outh (map toUpper inpStr)
hClose inh
hClose outh
使用hGetContents时并不一定要处理输入文件的全部内容。当Haskell系统断定hGetContents返回的整个字符串可以被垃圾回收时——意味着它再也不会被用到——它会自动关闭相应文件。从文件读出来的数据也是一样的处理。当一段数据不再需要的时候,Haskell环境将会释放占有的内存。严格地说在这个例子里根本不需要调用hClose。不过加上它仍然是一个好习惯,因为将来对程序的修改可能会使得调用hClose变得必要。
[警告]警告
当使用hGetContents时,要注意一点:就算后面的程序中并不直接引用这个文件句柄,你也必须等到 hGetContents 的结果被处理完后才能关闭这个句柄。如果提前关闭可能会导致文件数据的丢失。因为Haskell是惰性的,你通常可以做出这样的假设,你输出了多少计算结果,就有多少计算涉及的输入被读取。
readFile和writeFile
Haskell程序员经常把hGetContents用作过滤器。他们从一个文件读取数据,对数据进行一些处理,把结果写出到其他地方。因为这个模式如此常见,所以就产生了一些更快捷的方式。readFile 和 writeFile 就是这样的快捷方式,他们可以把文件直接当作字符串进行处理。它们内部会处理好打开文件,关闭文件,读写数据的所有细节问题。readFile内部就是使用hGetContents的。
你能猜出这些函数的类型么?我们用ghci来看看:
ghci> :type readFile
readFile :: FilePath -> IO String
ghci> :type writeFile
writeFile :: FilePath -> String -> IO ()
这里是一个使用readFile和writeFile的例子程序:
-- file: ch07/toupper-lazy3.hs
import Data.Char(toUpper)
main = do
inpStr <- readFile "input.txt"
writeFile "output.txt" (map toUpper inpStr)
看,程序的核心部分只有两行!readFile 返回一个惰性String,我们把它存储在 inpStr里。然后处理它,并传给 writeFile 写到输出文件。
readFile和writeFile都没有向你暴露文件句柄,因此也就不需要手动调用 hClose。readFile内部使用hGetContents,当返回的String被垃圾回收,或者输入文件被全部读取完毕之后,隐藏文件句柄将会自动被关闭。writeFile也会在输入的String全部写完后自动关闭文件句柄。
关于惰性输出
现在你应该了解如何在Haskell中进行惰性输入了。但是惰性输出又是怎么回事呢?
我们已经知道,在Haskell中,只有到真正被使用的时候才会对表达式进行求值。像writeFile 和putStr这样的函数把传入的整个字符串写到输出文件,因此整个字符串都必须被求值。因此可以保证putStr的参数将会被完全求值。
但是这对惰性输入又意味着什么呢?在上面的例子里,对putStr或writeFile的调用是否会立刻将整个字符串强制载入到内存中?
答案是否定的。 putStr(和所有类似的输出函数)负责在数据可用的时候把他们写入到输出文件,但它们没有必要保留那些已经被写出的数据,只要程序中没有其他部分需要这些数据,它们的内存就会被立刻释放。某种意义上来说,你可以把在readFile和writeFile 间传递的字符串想象成连接两者的管道。数据从一端,经过一些变形,流向另一端。
你可以给 toupper-lazy3.hs 生成一个大的 input.txt,来自己验证这一点。这会需要一点时间来处理,但是在它处理过程中你会看到一个很低且稳定的内存占用。
interact
我们已经学到readFile 和 writeFile 是如何处理读取文件,进行转换,再写到另一个文件的这种常见情况。还有比这更常见的一种情况:从标准输入读入,进行转换,再将结果写到标准输出。要处理这种情况,有一个叫 interact 的函数。interact的类型是 (String -> String) -> IO ()。即取一个类型为 String -> String 的函数作为参数。它会通过 getContents 惰性的读入标准输入,并将结果传入这个函数,再把这个函数的返回发送到标准输出。
我们可以把我们上面的例程转换成通过interact来操作标准输入输出,像下面这样:
-- file: ch07/toupper-lazy4.hs
import Data.Char(toUpper)
main = interact (map toUpper)
看,我们只用一行代码就完成了转换!要得到和之前例子相同的效果,需要像下面这样来执行:
$ runghc toupper-lazy4.hs < input.txt > output.txt
或者,如果想在屏幕上看到输出,可以这样:
$ runghc toupper-lazy4.hs < input.txt
如果你希望程序交互性地处理输入输出的话,执行 runghc toupper-lazy4.hs 即可,不要带其他命令行参数。可以看到每输入一个字符,它都用大写形式回显出来。不过这种情况下不同缓冲模式可能会对程序的具体行为有所影响,关于缓冲的更多信息参见本章后面的“缓冲”一节。如果你遇到输入一行结束时才回显,或者暂时没有进行回显,那就是缓冲惹得祸了。
我们也可以用 interact 来编写一些简单的交互程序。我们先上一个简单的例子:把输入转换成大写并在前面添加一行文本。
-- file: ch07/toupper-lazy5.hs
import Data.Char(toUpper)
main = interact (map toUpper . (++) "Your data, in uppercase, is:\n\n")
[Tip] Tip
如果对 . 操作符的使用感到迷惑,不妨参考下“通过函数组合重用代码”一节。
这里我们在输出的开头添加了一个字符串。但是你可以看出这里面有什么问题么?
因为我们对 (++) 的结果调用 map,使得我们添加的这一串头部字符串也变成大写的了。修改一下:
-- file: ch07/toupper-lazy6.hs
import Data.Char(toUpper)
main = interact ((++) "Your data, in uppercase, is:\n\n" .
map toUpper)
它把头部移到了map 之外。
用 interact 做过滤器
interact也常常用作过滤器。假设你要读入一个文件,并将所有包含字母"a"的每一行打印出来。这里是一个使用 interact 的方法:
-- file: ch07/filter.hs
main = interact (unlines . filter (elem 'a') . lines)
这里引出了三个还不熟悉的函数。我们在ghci中看下它们的类型:
ghci> :type lines
lines :: String -> [String]
ghci> :type unlines
unlines :: [String] -> String
ghci> :type elem
elem :: (Eq a) => a -> [a] -> Bool
你可以通过它们的类型猜出这些函数是做什么的么?猜不到的话,也可以从“热身:可移植的文本行切分”一节以及“特殊的字符串处理函数”一节中找到解释。你会经常看到在I/O动作中使用 lines 和 unlines 函数。最后,elem 函数取一个元素和一个列表,如果这个元素出现在列表中的话就返回True。
用我们的标准输入数据来运行它:
$ runghc filter.hs < input.txt
I like Haskell
Haskell is great
果然输出了包含 "a" 字符的两行。惰性过滤器是Haskell中强大的处理方式。可以想想看,一个过滤器——如标准Unix程序 grep——听上去就像一个函数。它获取一些输入,进行一些计算,产生一些可预测的输出。
IO Monad
前面我们已经看了很多使用Haskell语言进行I/O处理的例子,下面让我们退一步,来考虑下I/O是如何与更广泛的Haskell语言进行融合的。
因为Haskell是一种纯函数的语言,每次用相同的参数调用一个函数都会返回相同的结果。而且,函数也不会改变程序的全局状态。
那您可能会好奇I/O是如何融合进这样一种情况的呢,因为很显然,如果从键盘上读入一行输入,读取输入的函数是不可能每次都返回相同的结果的。而且,I/O本来就是要改变状态的,它可以点亮终端上的像素,也可以让打印机进行输出,甚至可以让一个包裹从仓库发往另一块陆地。I/O不光是改变程序的状态,它甚至改变了世界的状态。
动作
大多数语言并不对函数进行纯粹还是不纯粹之分,但是Haskell的函数是数学意义上的函数:它们就是纯粹的计算,不能改变任何外部的事物。此外,计算可以在任何时间进行执行,如果其结果永远没有地方用到,那就不用进行计算。
所以,我们需要些其他的工具来操作I/O。这些工具在Haskell中被称做动作。动作和函数类似,定义的时候不做任何事情,只有被调用时才会执行一些任务。I/O动作通过 IO monad 进行定义。Monad是一种把函数串接起来的强大机制,会在第14章Monad 中进行介绍。不过理解monad并不是理解I/O的必要条件,只要把I/O想象成打上了"IO"标签的动作就可以了。我们来看几个例子:
ghci> :type putStrLn
putStrLn :: String -> IO ()
ghci> :type getLine
getLine :: IO String
putStrLn 的类型和一般的函数没有什么两样,它接受一个参数并返回一个 IO () 。 这个 IO () 就是一个动作。你还可以在纯函数式代码中存储或传递动作,不过这种情况并不常见。动作在被调用之前什么都不做。再来看一个例子:
-- file: ch07/actions.hs
str2action :: String -> IO ()
str2action input = putStrLn ("Data: " ++ input)
list2actions :: [String] -> [IO ()]
list2actions = map str2action
numbers :: [Int]
numbers = [1..10]
strings :: [String]
strings = map show numbers
actions :: [IO ()]
actions = list2actions strings
printitall :: IO ()
printitall = runall actions
-- Take a list of actions, and execute each of them in turn.
runall :: [IO ()] -> IO ()
runall [] = return ()
runall (firstelem:remainingelems) =
do firstelem
runall remainingelems
main = do str2action "Start of the program"
printitall
str2action "Done!"
str2action 函数取一个参数,并返回一个 IO ()。在main 函数的末尾可以看出,可以直接在另一个动作中对它进行调用,它会立刻输出一行。你也可以在纯函数式代码中存储它但不能执行。list2actions 函数就是这样的例子,在 str2action 上使用 map ,返回一个动作的列表,就和对待其他纯的数据一样。可以看到 printitall 函数完全是通过纯函数实现的。
虽然定义了 printitall,但在它被求值并不会被执行。注意在main中我们把 str2action当作 I/O动作来执行,之前我们在I/O monad外面也使用过它来将结果组装进一个列表中。
你可以这样来理解:do 程序块中的每一个语句(除了 let)都必须产生一个I/O 动作来执行。
调用printitall将最终执行所有的动作。实际上,因为Haskell是惰性的,那些动作也是到这个时候才被生成出来。
当运行这个程序时,输出如下:
Data: Start of the program
Data: 1
Data: 2
Data: 3
Data: 4
Data: 5
Data: 6
Data: 7
Data: 8
Data: 9
Data: 10
Data: Done!
这个例程还可以用更简洁的方式来写。看下面这个:
-- file: ch07/actions2.hs
str2message :: String -> String
str2message input = "Data: " ++ input
str2action :: String -> IO ()
str2action = putStrLn . str2message
numbers :: [Int]
numbers = [1..10]
main = do str2action "Start of the program"
mapM_ (str2action . show) numbers
str2action "Done!"
注意 str2action 中函数组合操作符的使用。在 main 中有一个对 mapM_ 的调用。这个函数类似 map。它取一个函数和一个列表作为输入。提供给mapM_的函数是一个I/O动作,它在列表中的每一个项目上进行执行。 mapM_ 抛弃动作执行的结果,当然你也可以使用 mapM 来获取IO动作返回的结果。我们看一下它们的类型:
ghci> :type mapM
mapM :: (Monad m) => (a -> m b) -> [a] -> m [b]
ghci> :type mapM_
mapM_ :: (Monad m) => (a -> m b) -> [a] -> m ()
[Tip] Tip
这些函数不止可以应用在I/O上;它们也可以应用在任何Monad上。现在,每当看到"M",都要想到"IO"。而且,以下划线结尾的函数一般都会将动作的结果丢弃。
为什么有了map了还要有mapM呢?因为map是纯函数,它返回一个列表,它不能直接执行动作。而mapM是IO monad中的工具,它可以直接执行动作。
回到main函数,mapM_ 在numbers的每一个元素上调用 (str2action . show) 。show把一个数字转换成字符串,str2action把一个字符串转换成一个动作。mapM_把这些单独的动作打包成一个系列的动作,然后执行他们,把数据打印出来。
顺序
do语句块是把动作连接起来的缩写方式。有两个操作符可以用来代替 do 语句块: >> 和 >>=。在ghci中看下它们的类型:
ghci> :type (>>)
(>>) :: (Monad m) => m a -> m b -> m b
ghci> :type (>>=)
(>>=) :: (Monad m) => m a -> (a -> m b) -> m b
>> 操作符把两个动作顺序连接在一起:先执行第一个动作,然后执行第二个。最终返回第二个动作执行的结果。第一个动作执行的结果就被抛弃掉了。这类似于在do语句块中包含简单的一行。可以用 putStrLn "line 1" >> putStrLn "line 2" 来试验下。它会打印出两行,抛弃掉第一个 putStrLn的结果,并给出第二个的结果。
>>= 操作符执行一个动作,然后把它的结果传递给一个函数,这个函数会返回一个动作,然后这个动作也被执行,整个表达式的结果是第二个动作的结果。可以用 getLine >>= putStrLn 作为一个例子,它从键盘读入一行,然后显示出来。
我们来重写一个例子,让它不包含do语句块。还记得本章开始的这个例子么?
-- file: ch07/basicio.hs
main = do
putStrLn "Greetings! What is your name?"
inpStr <- getLine
putStrLn $ "Welcome to Haskell, " ++ inpStr ++ "!"
我们不使用do语句块来写:
-- file: ch07/basicio-nodo.hs
main =
putStrLn "Greetings! What is your name?" >>
getLine >>=
(\inpStr -> putStrLn $ "Welcome to Haskell, " ++ inpStr ++ "!")
Haskell编译器内部会将do语句编译成上面这种形式。
[Tip] Tip
忘记如何用 \ (lambda表达式)了?参考“匿名函数(lambda)”一节。
return的本质
本章前面的时候有提到return在它的表象下面还有更深层的含义。很多语言中都有一个叫return的关键字,它们将会中断当前函数的执行,并把结果返回给调用者。
Haskell的return函数跟他们很不一样。在Haskell里,return是用来把数据包装到monad中的。具体到I/O的话,return就是用来把纯的数据包装到IO monad 中的。
那我们为什么要进行这样的操作呢?记住任何依赖于 I/O的函数必须存在于 IO monad中。因此如果编写一个函数,它要执行一些 I/O动作,然后又要进行一些纯的计算操作,那就需要通过 return 把纯计算操作的返回结果转换成适当的返回类型了。否则要产生类型错误的。比如:
-- file: ch07/return1.hs
import Data.Char(toUpper)
isGreen :: IO Bool
isGreen =
do putStrLn "Is green your favorite color?"
inpStr <- getLine
return ((toUpper . head $ inpStr) == 'Y')
我们有一个纯的计算,它产生一个布尔值。这个计算被传递给return,return把它放到 IO monad 中。因为它是 do语句块的最后一个值,所以它成了 isGreen 的返回值,而不是因为我们使用了return函数。
这是上面这个程序的另一个版本,它把纯的计算提取到一个独立的函数中。这有助于保持纯函数式代码的分离,并且使程序的意图更加明确。
-- file: ch07/return2.hs
import Data.Char(toUpper)
isYes :: String -> Bool
isYes inpStr = (toUpper . head $ inpStr) == 'Y'
isGreen :: IO Bool
isGreen =
do putStrLn "Is green your favorite color?"
inpStr <- getLine
return (isYes inpStr)
最后,下面这个的例子展示了return并不一定要出现在do语句块的末尾。在实践中,它常常在末尾,但并非必需。
-- file: ch07/return3.hs
returnTest :: IO ()
returnTest =
do one <- return 1
let two = 2
putStrLn $ show (one + two)
注意我们把 <- 和 return进行组合,而 let 和字面量进行组合。这是因为要进行相加操作,需要使用纯的值。所以需要用 <- 把纯的值从 monad 中“拉”出来,它是return的逆运算。在ghci中执行它,可以看到显示出了3。
Haskell真的是命令式的么?
这些do语句块看上去很像命令式语言。毕竟,大多数时候你都是给出一些顺序执行的命令。
但是Haskell的核心依然是一种惰性语言。虽然经常需要顺序的执行I/O动作,但它是通过Haskell中已有的工具来进行的。而且Haskell通过 IO monad 将I/O和语言的其他部分很好地分离开来。
惰性I/O 的副作用
本章前面讲到了 hGetContents。我们解释说它返回的 String 可以在纯函数式代码中使用。
我们需要对究竟什么是副作用做出更明确的解释。我们说的Haskell没有副作用,它究竟是什么意思呢?
在从某种意义上来说,“副作用”不可能完全避免的。一个有问题的循环,即使完全使用纯函数式代码进行书写,也有可能导致系统内存耗尽并令机器崩溃,或者导致内存数据被交换到磁盘。
当我们说没有副作用时,我们指的是Haskell中纯的代码不能执行引发副作用的命令。纯函数不能修改全局变量,不能进行I/O请求,也不能执行命令来破坏系统。
当我们把hGetContents返回的String传递给纯函数,函数并不知道这个字符串是来自磁盘的文件。它还是表现得跟平常一样,当然处理那个String会导致I/O命令的执行。但它们不是由纯函数执行的;它们是纯函数进行处理的过程所导致的一个结果,就和导致内存和磁盘进行交换的那个情况一样。
在某些情况下,您可能需要对I/O何时发生进行更精确的控制。也许你正在交互式地读取用户输入的数据,或者是通过管道从其他程序读取输入,这时需要直接与用户通信。在这些情况下,hGetContents可能就不那么合适了。
缓冲
I/O子系统是现代计算机中最慢的部分。写磁盘花费的时间比写内存高出上千倍。通过网络写入还要再慢上成百上千倍。即使你的操作不直接导致磁盘操作——比如说因为数据被缓存了——但I/O依然会进行一下系统调用,还是会把速度拖慢了。
为此,现代的操作系统和编程语言都提供一些工具,帮助程序更高效的处理I/O。操作系统通常会进行缓存,把常用的数据块存放在内存以便进行快速存取。
编程语言通常也会进行缓冲。这意味着即使代码一次只处理一个字节,它们也可能会向操作系统请求一大块数据。这样可以获得很大的性能提升,因为每次对操作系统进行I/O请求都会带来很多开销。缓冲可以让我们读取相同数量数据时产生的I/O请求少的多。
Haskell也在它的I/O系统里提供了缓冲。很多情况下,缓冲是默认的。只是在前面的章节中,我们一直没有提到它的存在。Haskell一般情况都能够很好地选取合适的默认模式。但是这个默认选择很少是速度最快的。如果对你的代码来说I/O的速度很重要的话,手动修改缓冲模式可以给程序的性能带来很大提高。
缓冲模式
Haskell中有三种缓冲模式:NoBuffering , LineBuffering ,和BlockBuffering,他们通过 BufferMode 类型进行定义。
NoBuffering,正如它的名字所说:不进行缓冲。使用 hGetLine 这样的函数读取数据的话,一次只会从操作系统读取一个字符。写数据的时候也会立刻数据把数据写出去,并且经常是一次只写一个字符。因此NoBuffering通常性能很差,不适合一般用途使用。
LineBuffering 在出现换行符或者数据太多时,才将缓冲数据写出,在输入时,它尝试读取数据块的所有数据直到遇到换行符。当从终端读取时,每当按下回车时它就会立刻返回数据。它往往是合理的默认设置。
BlockBuffering让Haskell读写固定大小的数据块。在处理大批量数据时,它的性能是最好的,即使数据是面向行的。但是它不能用在交互程序上,因为它在块读取满之前是阻塞的。BlockBuffering接收一个Maybe类型参数,如果是 Nothing,它使用Haskell实现预定义的缓冲区大小。或者,你可以用 Just 4096 这样的设置来把缓冲区设置成4096个字节。
默认的缓冲区模式取决于操作系统和Haskell的实现。可以调用 hGetBuffering 来询问系统当前的缓冲模式。可以用hSetBuffering 设置当前的缓冲模式,它接收一个句柄和一个 BufferMode。例如,可以写 hSetBuffering stdin (BlockBuffering Nothing)。
冲刷缓冲区
不管是哪种类型的缓冲,都会常常需要强制把缓冲区的内容写出去。有时这会自动进行:比如调用 hClose的时候。但还是会有时候需要你手动来调用 hFlush,这会强制把在缓冲区中等待的数据立刻写出。如果句柄是网络socket的话,这个操作会很有用,因为你常常会需要立刻把数据写出去。或者希望把数据写到磁盘上,使得其他并发读取它的程序可以立刻使用。
读取命令行参数
很多命令行程序需要处理传给它的参数。System.Environment.getArgs 返回 IO [String] ,列出了每个参数。它类似于 C 语言argv在索引1之后的部分。程序名(C里的 argv[0])可以用 System.Environment.getProgName 获得。
System.Console.GetOpt 模块提供了一些解析命令行选项的工具。如果你的程序拥有复杂的选项,它就很有用了。在“命令行解析”一节中有使用它的例子。
环境变量
如果需要读取环境变量,可以用System.Environment里面的两个函数:getEnv和getEnvironment。getEnv查看特定的变量,如果不存在就抛出异常。getEnvironment 把所有环境变量返回成 [(String, String)] 的值,之后就可以用lookup这类的函数来查找到你需要的环境变量了。
在Haskell中能够跨平台地设置环境变量的方法。如果在POSIX平台上如Linux,你可以用System.Posix.Env模块中的 putEnv 或者 setEnv。Windows上设置环境变量的方法没有进行定义。
[15] 后面你会看到它还具有更广泛的应用,但现在考虑这几条就够了。
[16] 值() 的类型也是()。
[17] 命令式语言的程序员可能会担心这样的递归调用会消耗大量的桟空间。在Haskell里,递归是常见的用法,编译器足够聪明可以通过尾递归优化来避免消耗太多桟空间。
[18] 例如在混合程序的C语言部分存在bug。
[19] 与其他程序通过管道进行互操作的更详细信息,请看“扩展程序:管道”一节。
[20] POSIX 程序员会有兴趣知道它与C中的unlink()相对应。
[21] hGetContents 将在“惰性I/O”一节讨论
[22] 也有一个操作标准输入的快捷函数 getContents
[23] 更精确的说,它是从文件当前位置到文件末尾的全部内容
[24] I/O错误如磁盘空间满了
[25] 技术上讲,mapM把一组单独的I/O动作组合成一个大的动作。大的动作执行时单独的动作被分别执行。

转载于:https://www.cnblogs.com/IBBC/archive/2011/07/25/2116321.html

Real World Haskell 第七章 I/O相关推荐

  1. 数字图像处理——第七章 小波和多分辨处理

    数字图像处理--第七章 小波和多分辨率处理 文章目录 数字图像处理--第七章 小波和多分辨率处理 写在前面 1 多分辨率处理 1.1 图像金字塔 1.2 多尺度和多分辨率的区别 2 小波 2.1 连续 ...

  2. 现实迷途 第七章 特殊客户

    第七章 特殊客户 注:原创作品,请尊重原作者,未经同意,请勿转载,否则追究责任. 江北一般都是上午待在办公室里,搜集信息或整理以前做过的系统,下午才出去站街招客. 站街站了一段时间后,江北有点不想去了 ...

  3. stm32 工业按键检测_「正点原子STM32Mini板资料连载」第七章 按键输入实验

    1)实验平台:正点原子STM32mini开发板 2)摘自<正点原子STM32 不完全手册(HAL 库版)>关注官方微信号公众号,获取更多资料:正点原子 第七章 按键输入实验 上一章,我们介 ...

  4. 第七章——DMVs和DMFs(2)——用DMV和DMF监控索引性能

    原文: 第七章--DMVs和DMFs(2)--用DMV和DMF监控索引性能 本文继续介绍使用DMO来监控,这次讲述的是监控索引性能.索引是提高查询性能的关键性手段.即使你的表上有合适的索引,你也要时时 ...

  5. 2017上半年软考 第七章 重要知识点

    第七章项目范围管理 []项目范围管理概念 [][]项目范围管理的含义和作用 项目范围管理内容p289 项目范围对项目管理的重要性?p289 [][]项目范围管理的主要过程 项目范围管理的6个过程是? ...

  6. 服务器架构之性能扩展-第七章(8)

    第七章Cacti系统监控邮件报警和压力测试 7.1 Cacti工作原理 原理简单来说,Cacti就是rrdtool的一个forefront,它内置了快速的获数据取工具.优秀的绘图模板以及许多设计精良的 ...

  7. 鸟哥Linux私房菜_基础篇(第二版)_第七章学习笔记

    第七章 Linux文件和目录管理 绝对路径:以"/"开始 相对路径:以非"/"开始 其中,"."代表当前目录,".."代 ...

  8. 计算机组成原理 输入输出系统,计算机组成原理(第七章输入输出系统

    计算机组成原理(第七章输入输出系统 (6页) 本资源提供全文预览,点击全文预览即可全文预览,如果喜欢文档就下载吧,查找使用更方便哦! 9.9 积分 第七章输入输出系统第一节基本的输入输出方式一. 外围 ...

  9. 课本学习笔记5:第七章 20135115臧文君

    第七章 链接 注:作者:臧文君,原创作品转载请注明出处. 一.概述 1.链接(linking):是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可被加载或被拷贝到存储器并执行. 2 ...

最新文章

  1. Python Qt GUI设计:QScrollBar类实现窗口水平或垂直滑动条效果(拓展篇—4)
  2. Android: Custom View和include标签的区别
  3. Android系统默认Home应用程序(Launcher)的启动过程源码分析
  4. [BZOJ2707]走迷宫
  5. 阿里云OSS 服务端签名后直传之分片上传(结合element-ui的upload组件)
  6. servlet核心API的UML图
  7. ROC曲线(Receiver Operating Characteristic Curve)
  8. 案例:实现用户登录功能
  9. Discuz X2 模块模板代码详解,DIY更容易!
  10. 阿里架构师墙裂推荐Java岗实战文档:Spring全家桶+Docker+Redis
  11. chrome 插件开发中的热更新问题
  12. 2021年全球及中国区块链投融资及技术专利情况:中国区块链相关注册企业达到9.36万余家,新增专利15985项 [图]
  13. 渠道分销管理系统解决方案
  14. 用Mac安装homebrew的时候报错解决方法
  15. 怎么用ping命令测试网速
  16. 全新型App开放框架—Clouda
  17. Python实用案例,Python脚本实现快速卡通化人物头像,让我想起了QQ秀时光!
  18. 酷酷资源社网站同款xiuno模板/知乎蓝魔改版源码/附多个插件
  19. pc投屏android软件,Scrcpy安卓电脑投屏软件下载
  20. android8.0 对于外置SDCARD的访问(MTK 平台)

热门文章

  1. 端口映射问题:Bad Request This combination of host and port requires TLS.
  2. 用伪代码模拟洗衣机的运转流程
  3. 2022-2028年中国BOPET薄膜行业市场全景调查及投资前景预测报告
  4. ionic4中使用Swiper触屏滑动--技术
  5. LeetCod中等题之复数乘法
  6. 预测汽车级Linux专业技术的需求
  7. 【网站汇总】安装教程系列
  8. No service of type Factory available in ProjectScopeServices
  9. 「Django」rest_framework学习系列-用户认证
  10. module.exports 和 export default