「Learn Haskell」#4 输入输出与文件

Input/Output

运行Haskell程序

不在GHCi中运行一个Haskell程序有两种方式:

  1. 编译运行:
    1
    2
    $ ghc --make code
    $ ./code
  2. 通过runhaskell命令直接运行:
    1
    $ runhaskell code.hs

输出文本

在一个Haskell程序中输出文字需要定义一个main函数:

1
main = putStrLn "Hello World"

其中putStrLn的类型是:

putStrLn :: String -> IO ()

putStrLn接收一个String类型,并返回一个结果为()类型的IO动作(I/O action)。所以main函数的类型为IO ()。(IO的Kind是* -> *)

除此之外,还有其他默认提供的输出文本的函数:

  • putStr:输出文本,结尾不换行
  • putChar:输出单个字符,结尾不换行。接收的参数为单个Char,不是String(用单引号不是双引号)
  • print:可以接收任何Show的成员,先用show转化为字符串然后输出。等同于putStrLn . show

do block

在main函数中使用多个putStrLn需要使用do语句:

1
2
3
main = do
putStrLn "Line1"
putStrLn "Line2"

其中最后一行一定要返回IO ()类型的值

输入文本

输入文字需要在do块中使用getLine:

1
2
3
main = do
line <- getLine
putStrLn line

getLine的类型是:

getLine :: IO String

而<-操作符将getLine中的String提取了出来给到了line,使line变成了String类型的一个字符串。

而且使用输入的字符串必须要经过一次<-,不能直接使用getLine作为字符串,因为getLine不是String类型,而是IO String类型。

除此之外,还可以使用getChar来获取单个字符,但仍然需要使用<-操作符来提取Char

其他IO相关函数用法

return

Haskell中的return和其他命令式语言中的return完全不同,它不会使函数直接结束并返回一个值。

main函数必须定义为类型为IO ()的函数,所以在main函数中使用if语句,如果不输出的话也不可以直接放下什么都不干,因为这时候main函数的类型不是IO ()。所以这时需要使用return ()来为main函数指定为IO ()类型,例如:

1
2
3
4
5
6
main = do 
line <- getLine
if null line
then return () -- <-这里
else do
...

使用<-操作符也可以直接将return语句中的内容提取出来,比如a <- return ‘A’,执行后a就是’A’。

when

when包含在Control.Monad模块中,它表示在满足第一个参数的条件下会执行第二个函数,否则会return ()。比如:

1
2
3
4
5
6
7
import Control.Monad   

main = do
c <- getChar
when (c /= ' ') $ do
putChar c
main

等同于:

1
2
3
4
5
6
7
main = do     
c <- getChar
if c /= ' '
then do
putChar c
main
else return ()

sequence

sequence在IO中使用时可以达成[IO a] -> IO [a]的效果,所以可以用作:

1
[a, b, c] <- sequence [getLine, getLine, getLine]

mapM & mapM_

在IO相关的地方使用map,可以使用mapM和mapM_,其中mapM有返回值而mapM_直接扔掉了返回值:

1
2
3
4
5
6
7
8
9
ghci> mapM print [1,2,3]  
1
2
3
[(),(),()]
ghci> mapM_ print [1,2,3]
1
2
3

forever

forever函数包含在Control.Monad模块中。在main函数开头加上forever函数可以使后面的do块一直重复执行直到程序被迫终止,如:

1
2
3
4
import Control.Monad

main = forever $ do
...

forM

forM函数包含在Control.Monad模块中,它的功能和mapM类似,从第一个参数中逐个取出元素传入第二个参数(一个接收一个参数的函数)中,并且第二个参数可以返回IO a类型。比如:

1
2
3
4
5
6
7
8
9
import Control.Monad

main = do
colors <- forM [1, 2, 3, 4] (\a -> do
putStrLn $ "Which color do you associate with the number " ++ show a ++ "?"
color <- getLine
return color)
putStrLn "The colors that you associate with 1, 2, 3 and 4 are: "
mapM putStrLn colors

getContents

getLine获取一整行,而getContents从标准输入中获取全部内容直到遇到EOF,并且它是lazy的,在执行了foo <- getContents后,它并不会读取标准输入并且赋值到foo,而是等到需要使用foo的时候再从标准输入读取。

getContents在使用管道传入文字时很常用,可以代替forever+getLine使用,比如一个Haskell程序文件code.hs:

1
2
3
4
5
import Data.Char  

main = do
contents <- getContents
putStr (map toUpper contents)

使用ghc –make code编译后,通过管道传入文字:

1
cat text.txt | ./code

会将text.txt中的所有字母转为大写并输出

interact

上述功能还可以转化为一个String -> String的函数:

1
upperStrings = unlines . map (map toUpper) . lines

而在main中使用这个函数就需要:

1
2
3
main = do
contents <- getContents
putStr (upperStrings contents)

但是String -> String类型的函数在输入输出中的使用太常见了,所以可以使用interact函数来简化。interact的类型是:

interact :: (String -> String) -> IO ()

可以看出它接收一个String -> String的函数,并返回一个IO ()类型,所以可以直接用在main上。

于是整个转换为大写的程序就可以简化为:

1
main = interact $ unlines . map (map toUpper) . lines

文件和流

以下与文件和流相关的函数都包含在System.IO模块中

openFile

openFile函数可以用来打开一个文件,它的类型是:

openFile :: FilePath -> IOMode -> IO Handle

其中FilePath是String的type synonyms,用一个字符串来表示需要打开的文件的路径

IOMode的定义是:

1
data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode

所以它一共只有四个值,用来表示进行IO操作的模式

openFile返回一个IO Handle类型的值,将其用<-操作符提取后会出现一个Handle的值。但不能从Handle中直接使用文字,还需要使用一系列函数:

  • hGetContents :: Handle -> IO String ,从Handle中读取全部内容,返回一个IO String
  • hGetChar :: Handle -> IO Char ,从Handle中读取一个字符
  • hGetLine :: Handle -> IO String ,从Handle中读取一行,返回一个IO String
  • hPutStr :: Handle -> String -> IO () ,向Handle中输出字符串
  • hPutStrLn :: Handle -> String -> IO () ,同上

在使用openFile进行文件操作后,需要使用hClose手动关闭Handle。hClose :: Handle -> IO (),接收一个Handle并返回IO (),可以直接放在main函数末尾

所以使用openFile读取一个文件中的全部内容并输出的全部代码是:

1
2
3
4
5
6
7
import System.IO

main = do
handle <- openFile "text.txt" ReadMode
contents <- hGetContents handle
putStrLn contents
hClose handle

withFile

withFile类似Python中的with open,它在读取文件使用之后不需要手动close文件。它的类型是:

withFile :: FilePath -> IOMode -> (Handle -> IO a) -> IO a

可以看出,它接收三个参数:

  • FilePath:一个表示文件路径的String
  • IOMode:打开文件的模式
  • (Handle -> IO a):一个函数,表示对读取文件后的Handle索要进行的操作,需要返回一个I/O action;而这个返回值也将作为withFile的返回值

现在使用withFile来改写上述代码:

1
2
3
4
5
import System.IO

main = withFile "text.txt" ReadMode (\handle -> do
contents <- hGetContents handle
putStrLn contents)

withFile的功能相当于以下函数:

1
2
3
4
5
6
withFile' :: FilePath -> IOMode -> (Handle -> IO a) -> IO a  
withFile' path mode f = do
handle <- openFile path mode
result <- f handle
hClose handle
return result

readFile

readFile可以更加简化读取文件内容的操作,它的类型:

readFile :: FilePath -> IO String

它只需要输入一个表示文件路径的字符串,返回其中以其中内容为内容的I/O action:

1
2
3
4
5
import System.IO

main = do
contents <- readFile "text.txt"
putStrLn contents

writeFile

writeFile简化了写入文件的操作,它的类型:

writeFile :: FilePath -> String -> IO ()

传入的第一个参数是要写入的文件的路径,第二个参数是要写入的字符串,返回一个IO ()

appendFile

appendFile类似writeFile,但使用它不会覆盖文件中原来内容,而是直接把字符串添加到文件末尾

buffer

文件以流的形式被读取,默认文字文件的缓冲区(buffer)大小是一行,即每次读取一行内容;默认二进制文件的缓冲区大小是以块为单位,如果没有指定则根据系统默认来选择。

也可以通过hSetBuffering函数来手动设置缓冲区大小,这个函数的类型:

hSetBuffering :: Handle -> BufferMode -> IO ()

它接收一个handle,和一个BufferMode,并返回IO ()。其中BufferMode有以下几种:

  • NoBuffering:没有缓冲区,一次读入一个字符
  • LineBuffering:缓冲区大小是一行,即每次读入一行内容
  • BlockBuffering (Maybe Int):缓冲区大小是一块,块的大小由Maybe Int指定:
    • BlockBuffering (Nothing):使用系统默认的块大小
    • BlockBuffering (Just 2048):一块的大小是2048字节,即每次读入2048bytes的内容

缓冲区的刷新是自动的,也可以通过hFlush来手动刷新

hFlush :: Handle -> IO ()

传入一个handle,返回IO (),即刷新对应handle的缓冲区

openTempFile

openTempFile可以新建一个临时文件:

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

FilePath指临时文件要创建的位置路径,String指临时文件名字的前缀,返回一个I/O action,其内容第一个FilePath是创建得到的临时文件的路径,Handle是临时文件的handle

例如:

1
2
3
4
5
6
import System.IO

main = do
(tempFile, tempHandle) <- openTempFile "." "temp"
...
hClose tempHandle

"."指临时文件要在当前目录创建,"temp"指临时文件名字以temp开头。最终得到的tempFile就是./temp…….,temp后为随机数字,如./temp43620-0

路径操作

相关函数都包含在System.Directory模块中,全部内容见System.Directory

getCurrentDirectory

getCurrentDirectory :: IO FilePath

直接返回一个I/O action,其内容是一个字符串表示当前路径的绝对路径

removeFile

removeFile :: FilePath -> IO ()

输入一个文件路径,并删除掉它

renameFile

renameFile :: FilePath -> FilePath -> IO ()

输入一个原路径,一个新路径,为原路径的文件重命名为新路径的名

doesFileExist

doesFileExist :: FilePath -> IO Bool

检查文件是否存在,返回一个包含布尔值的I/O action

Command line arguments

System.Environment模块中提供了两个函数可以用来处理传入命令行的参数

getArgs

getArgs :: IO [String]

不需要输入参数,直接返回一个I/O action,内容为传入命令行的参数(一个由String组成的列表)。相当于C语言中的argv[1:]

getProgName

getProgName :: IO String

返回I/O action,内容为程序的名字,相当于C语言中的argv[0]

Randomness

和随机数有关的函数都包含在System.Random模块中。GHCi启动时可能不会包含System.Random的配置,导致无法找到模块。需要通过stack打开:

1
stack ghci --package random

Haskell要求同样的程序需要运行出同样的结果,除了用到了I/O action,所有会造成不同结果的函数都要交给I/O action来完成

那要使随机数脱离IO存在,就要用到随机生成器(random generator)

System.Random模块提供了几个生成随机数的函数:

random

random :: (Random a, RandomGen g) => g -> (a, g)

其中又有两个新的typeclass,Random表示可以取随机,RandomGen表示随机数生成器。random函数接收一个随机数生成器,返回一个元组,其中第一个元素是生成的随机数,第二个元素是一个新的随机数生成器

获取随机数生成器可以使用mkStdGen函数:

mkStdGen :: Int -> StdGen

其中StdGen是一个RandomGen的实例

运用random生成随机数需要指定类型,不然程序无法确定a是什么类型。例如:

1
2
3
4
5
6
ghci> random (mkStdGen 100) :: (Int, StdGen)
(9216477508314497915,StdGen {unStdGen = SMGen 712633246999323047 2532601429470541125})
ghci> random (mkStdGen 100) :: (Char, StdGen)
('\537310',StdGen {unStdGen = SMGen 712633246999323047 2532601429470541125})
ghci> random (mkStdGen 100) :: (Bool, StdGen)
(True,StdGen {unStdGen = SMGen 712633246999323047 2532601429470541125})

再次运行同样的函数,会得到同样的结果。所以如果需要生成其他的随机数,需要更换生成器,就可以使用上一次调用结果返回的新随机数生成器:

1
2
3
4
5
6
threeCoins :: StdGen -> (Bool, Bool, Bool)  
threeCoins gen =
let (firstCoin, newGen) = random gen
(secondCoin, newGen') = random newGen
(thirdCoin, newGen'') = random newGen'
in (firstCoin, secondCoin, thirdCoin)

randoms

randoms :: (Random a, RandomGen g) => g -> [a]

randoms接收一个RandomGen,返回一个随机的无穷列表。因为它是无穷的,所以不会返回新的随机数生成器

randomR

randomR :: (Random a, RandomGen g) => (a, a) -> g -> (a, g)

可以用来生成有范围的随机数,第一个参数是一个元组,表示生成随机数的范围(闭区间)

randomRs

randomRs :: (Random a, RandomGen g) => (a, a) -> g -> [a]

同上两个,生成有范围的无穷随机数列表

getStdGen

如果想要让程序每次运行得到不同的随机结果,需要使用getStdGen来获取全局随机数生成器,它会在每次运行的时候产生不同的值,也因此,它返回的是一个I/O action,而不是一个直接的StdGen

getStdGen :: Control.Monad.IO.Class.MonadIO m => m StdGen

即可以看成getStdGen :: IO StdGen,需要使用<-操作符将StdGen提取出来

但是在同一个程序中,getStdGen的结果是相同的,全局随机数生成器不会自动更新,所以就需要另一个函数newStdGen

newStdGen

newStdGen :: Control.Monad.IO.Class.MonadIO m => m StdGen

执行newStdGen会进行两个操作:

  • 更新全局随机数生成器,下次执行getStdGen会获得不同的结果
  • 返回一个I/O action,包含一个新的StdGen(但是这个生成器和全局生成器也不同)

Exceptions

程序在运行失败时会抛出异常,可以通过Control.Exception模块中的catch函数来捕获异常:

catch :: Exception e => IO a -> (e -> IO a) -> IO a

第一个参数是要进行的操作,以IO a为返回值的类型,第二个参数是一个函数,它接收异常并进行操作,例如:

1
2
3
4
5
6
7
8
9
10
import Control.Exception

main = main' `catch` handler

main' :: IO ()
main' = do
...

handler :: Exception e => e -> IO ()
handler e = putStrLn "..."

也可以利用守卫(guard)语法和System.IO.Error中的函数来判断IO异常的类型来进行不同操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import System.Environment
import System.IO.Error
import Control.Exception

main = toTry `catch` handler

toTry :: IO ()
toTry = do (fileName:_) <- getArgs
contents <- readFile fileName
putStrLn $ "The file has " ++ show (length (lines contents)) ++ " lines!"

handler :: IOError -> IO ()
handler e
| isDoesNotExistError e = putStrLn "The file doesn't exist!"
| otherwise = ioError e

具体相关全部函数见文档:System.IO.ErrorControl.Exception

Reference


目录

#0 | 总章        
#1 | 基础语法与函数   
#2 | 高阶函数与模块   
#3 | 类型与类型类    
#4 | 输入输出与文件   
#5 | 函子、应用函子与单子
#6 | 半群与幺半群    
#7 | 一些其它类型类   
#A | Haskell与范畴论   

「Learn Haskell」#4 输入输出与文件

https://blog.tonycrane.cc/p/a5bbe48a.html

作者

TonyCrane

发布于

2021-07-07

更新于

2021-07-25

许可协议