一.惰性I/O与buffer
Haskell中,I/O也是惰性的,例如:
readThisFile = withFile "./data/lines.txt" ReadMode (\handle -> do
contents <- hGetContents handle
putStr contents
)
从硬盘读文件时并不会一次性全读入内存,而是一点一点的流式读取。文本文件的话,默认buffer是line-buffering,即一次读一行,二进制文件的话,默认buffer是block-buffering,一次读一个chunk,其具体大小取决于操作系统
line-buffering和block-buffering用BufferMode
值来表示:
data BufferMode
= NoBuffering | LineBuffering | BlockBuffering (Maybe Int)
-- Defined in ‘GHC.IO.Handle.Types’
BufferMode
类型下有三个值,NoBuffering
,LineBuffering
,BlockBuffering (Maybe Int)
分别表示不用buffer,用line-buffering,以及用block-buffering模式。其中Maybe Int
表示每个chunk有几个字节(byte
),给Nothing
的话用系统默认的chunk大小,NoBuffering
表示一次读一个字符(character),会疯狂(高频)访问硬盘,一般不用
可以手动设置BufferMode
,例如:
readThisFileInBlockMode = withFile "./data/lines.txt" ReadMode (\handle -> do
hSetBuffering handle $ BlockBuffering (Just 1024)
contents <- hGetContents handle
putStr contents
)
每次读1024B
(即1KB
),其中hSetBuffering
的类型为:
hSetBuffering :: Handle -> BufferMode -> IO ()
接受一个文件指针和BufferMode
值,返回个空的I/O Action
既然有buffer,就需要flush buffer,所以还有个hFlush
:
hFlush :: Handle -> IO ()
用来清理buffer,不用等buffer塞满或者其它自动flush机制(如line-buffering
遇到换行符就flush)
P.S.有个很形象但不太优雅的比喻:
你的马桶会在水箱有一加仑的水的时候自动冲水。所以你不断灌水进去直到一加仑,马桶就会自动冲水,在水里面的数据也就会被看到。但你也可以手动地按下冲水钮来冲水。他会让现有的水被冲走。冲水这个动作就是hFlush这个名字的含意。
二.Data.ByteString
既然从系统读取文件需要考虑性能采用Buffer,那读入内存之后呢?又该如何存储,如何操作?
ByteString
看着像个新的数据类型,但我们不是已经有String
了吗?
惰性的List
String
是Char List的别名,而List是惰性的,所以:
str = "abc"
charList = ['a', 'b', 'c']
charList' = 'a' : 'b' : 'c' : []
> str == charList && charList == charList'
True
声明字符串"abc"
只是承诺,我们将会拥有一个Char List,那么什么时候才真正拥有(或者创造)这个List呢?
在不得不计算(求值)的时候,比如上例中==
判断的时候:
instance (Eq a) => Eq [a] where
{-# SPECIALISE instance Eq [Char] #-}
[] == [] = True
(x:xs) == (y:ys) = x == y && xs == ys
_xs == _ys = False
(摘自GHC.Classes)
通过模式匹配从左向右遍历对比元素是否相等,每次取List首元,此时才真正需要List,才被“创造”出来
用非惰性的JS来描述就像这样:
function unshift(x, xs) {
return [x].concat(xs);
}
const str = 'abc';
charList = unshift('a', unshift('b', unshift('c', [])));
function eq(s, a) {
if (!s.length && !a.length) return true;
return s[0] == a[0] && eq(s.slice(1), a.slice(1));
}
// test
eq(str, charList);
但与立即求值的JS不同,Haskell是惰性的,所以,实际情况类似于:
const EMPTY_LIST = {
value: Symbol.for('_EMPTY_LIST_'),
tail: () => EMPTY_LIST
};
function unshift(x, xs) {
return { value: x, tail: () => xs };
}
function sugar(str) {
return str.split('')
.reduceRight((a, v) => a.concat([v]), [])
.reduce((a, v) => unshift(v, a), EMPTY_LIST);
}
const str = sugar('abc');
const charList = unshift('a', unshift('b', unshift('c', EMPTY_LIST)));
function eq(s, a) {
if (s === EMPTY_LIST && a === EMPTY_LIST) return true;
return s.value == a.value && eq(s.tail(), a.tail());
}
// test
eq(str, charList);
用“懒”链表来模拟只在真正需要的时候才去创造的List,就像'a' : 'b' : 'c' : []
“承诺”会有一个'a'
开头的List,这个List有多长,占多少空间,在真正需要求值之前都是未知的(也没必要知道,所以允许存在无限长的List,而不用担心如何存储的问题)
但这种惰性并非十全十美,带来的直接问题就是效率不高,尤其是在巨长List的场景(比如读文件),处理一个“承诺”(模拟场景里的tail()
)的成本可能不高,但如果积攒了一大堆的“承诺”,处理这些“承诺”的成本就会凸显出来,实际效率自然会下降。所以,为了解决这个问题,就像引入foldl
的严格版本(非惰性版本)foldl'
一样,我们引入了ByteString
P.S.上面提到的“承诺”,其实在Haskell有个对应的术语叫thunk
ByteString
Bytestring的每个元素都是一个字节(8个bit),分惰性与严格(非惰性)两种:
惰性:
Data.ByteString.Lazy
,同样具有惰性,但比List稍微勤快一些,不是逐元素的thunk,而是逐chunk的(64K一个chunk),一定程度上减少了所产生thunk的数量严格:位于
Data.ByteString
模块,不会产生任何thunk,表示一连串的字节,所以不存在无限长的strict bytestring,也没有惰性List的内存优势
lazy bytestring就像chunk List(List中每个元素都是64K大小的strict bytestring),既减少了惰性带来的效率影响,又具有惰性的内存优势,所以大多数时候用lazy版本
P.S.64K这个大小是有讲究的:
64K有很高的可能性能够装进你CPU的L2 Cache
常用函数
ByteString相当于另一种List,所以List的大多数方法在ByteString都有同名的对应实现,例如:
head, tail, init, null, length, map, reverse, foldl, foldr, concat, takeWhile, filter
所以先要避免命名冲突:
-- 惰性ByteString
import Data.ByteString.Lazy as B
-- 严格ByteString
import Data.ByteString as S
创建一个ByteString:
-- Word8 List转ByteString
B.pack :: [GHC.Word.Word8] -> ByteString
-- 严格ByteString转惰性ByteString
B.fromChunks :: [Data.ByteString.Internal.ByteString] -> ByteString
其中Word8
相当于范围更小的Int
(0 ~ 255
之间,和Int
一样都属于Num
类),例如:
> B.pack [65, 66, 67]
"ABC"
> B.fromChunks [S.pack [65, 66, 67], S.pack [97, 98, 99]]
"ABCabc"
注意,fromChunks
会把给定的一组strict bytestring串起来变成chunk List,而不是先拼接起来再塞进一个个64K空间,如果有一堆碎的strict bytestring
而又不像拼接起来占着内存,可以用这种方式把它们串起来
插入元素:
B.cons :: GHC.Word.Word8 -> B.ByteString -> B.ByteString
B.cons' :: GHC.Word.Word8 -> B.ByteString -> B.ByteString
cons
就是List的:
,用于在左侧插入元素,同样是惰性的(即便第一个chunk足够容纳新元素,也插入一个chunk),而cons'
是其严格版本,会优先填充第一个chunk的剩余空间,区别类似于:
> Prelude.foldr B.cons B.empty [50..60]
Chunk "2" (Chunk "3" (Chunk "4" (Chunk "5" (Chunk "6" (Chunk "7" (Chunk "8" (Chunk "9" (Chunk ":" (Chunk ";" (Chunk "<"
Empty))))))))))
> Prelude.foldr B.cons' B.empty [50..60]
Chunk "23456789:;<" Empty
P.S.旧版本GHC
会show
出类似于上面的差异,0.10.0.1
之后的Show
实现改成了类似于字符串字面量的形式,看不出来差异了,具体见Haskell: Does ghci show “Chunk .. Empty”?
文件读写:
-- 按chunk读
S.readFile :: FilePath -> IO S.ByteString
-- 全读进来
B.readFile :: FilePath -> IO B.ByteString
-- 逐chunk写
S.writeFile :: FilePath -> S.ByteString -> IO ()
-- 一次写完
B.writeFile :: FilePath -> B.ByteString -> IO ()
实际上,ByteString
与String
类型在大多数场景可以很容易地互相转换,所以可以先用String
实现,在性能不好的场景再改成ByteString
P.S.更多ByteString
相关函数,见Data.ByteString
三.命令行参数
除交互输入和读文件外,命令行参数是另一种获取用户输入的重要方式:
-- readWhat.hs
import System.Environment
import System.IO
main = do
args <- getArgs
contents <- readFile (args !! 0)
putStr contents
试玩一下:
$ ghc --make ./readWhat.hs
[1 of 1] Compiling Main ( readWhat.hs, readWhat.o )
Linking readWhat ...
$ ./readWhat ./data/lines.txt
hoho, this is xx.
who's that ?
$ ./readWhat ./data/that.txt
contents in that file
another line
last line
这就有了cat
的基本功能。其中getArgs
的类型是:
getArgs :: IO [String]
位于System.Environment
模块,以为I/O Action形式返回命令行参数组成的String
数组,类似的还有:
-- 获取程序名(可执行文件的名字)
getProgName :: IO String
-- 获取当前绝对路径
getExecutablePath :: IO FilePath
-- 设置环境变量
setEnv :: String -> String -> IO ()
-- 获取环境变量
getEnv :: String -> IO String
P.S.更多环境相关函数,见System.Environment
例如:
import System.IO
import System.Environment
main = do
progName <- getProgName
args <- getArgs
pwd <- getExecutablePath
setEnv "NODE_ENV" "production"
nodeEnv <- getEnv "NODE_ENV"
putStrLn pwd
putStrLn ("NODE_ENV " ++ nodeEnv)
putStrLn (progName ++ (foldl (++) "" $ map (" " ++) args))
试玩:
$ ghc --make ./testArgs
[1 of 1] Compiling Main ( testArgs.hs, testArgs.o )
Linking testArgs ...
$ ./testArgs -a --p path
/absolute/path/to/testArgs
NODE_ENV production
testArgs -a --p path
P.S.除ghc --make sourceFile
编译执行外,还有一种直接run源码的方式:
$ runhaskell testArgs.hs -b -c
/absolute/path/to/ghc-8.0.1/bin/ghc
NODE_ENV production
testArgs.hs -b -c
此时getExecutablePath
返回的是ghc
(可执行文件)的绝对路径
四.随机数
除了I/O,另一个铁定不纯的场景就是随机数了。那么,纯函数能造出来随机数吗?
造伪随机数还是有点可能的。做法类似于C语言,要给个“种子”:
random :: (Random a, RandomGen g) => g -> (a, g)
其中Random
和RandomGen
种子的类型分别为:
instance Random Word -- Defined in ‘System.Random’
instance Random Integer -- Defined in ‘System.Random’
instance Random Int -- Defined in ‘System.Random’
instance Random Float -- Defined in ‘System.Random’
instance Random Double -- Defined in ‘System.Random’
instance Random Char -- Defined in ‘System.Random’
instance Random Bool -- Defined in ‘System.Random’
instance RandomGen StdGen -- Defined in ‘System.Random’
data StdGen
= System.Random.StdGen {-# UNPACK #-}GHC.Int.Int32
{-# UNPACK #-}GHC.Int.Int32
-- Defined in ‘System.Random’
P.S.其中Word
指的是可以指定宽度的无符号整型,具体见Int vs Word in common use?
数值、字符、布尔类型等都可以有随机值,种子则需要通过特殊的mkStdGen :: Int -> StdGen
函数生成,例如:
> random (mkStdGen 7) :: (Int, StdGen)
(5401197224043011423,33684305 2103410263)
> random (mkStdGen 7) :: (Int, StdGen)
(5401197224043011423,33684305 2103410263)
果然是纯函数,所以两次调用结果完全一样(并不是因为连续调用,过十天半个月调用还是这个结果)。通过类型声明来告知random
函数期望返回的随机值类型,不妨换个别的:
> random (mkStdGen 7) :: (Bool, StdGen)
(True,320112 40692)
> random (mkStdGen 7) :: (Float, StdGen)
(0.34564054,2071543753 1655838864)
> random (mkStdGen 7) :: (Char, StdGen)
('\279419',320112 40692)
random
函数每次都会生成下一个种子,所以可以这样做:
import System.Random
random3 i = collectNext $ collectNext $ [random $ mkStdGen i]
where collectNext xs@((i, g):_) = xs ++ [random g]
试玩一下:
> random3 100
[(-3633736515773289454,693699796 2103410263),(-1610541887407225575,136012003 1780294415),(-1610541887407225575,136012003 1780294415)]
> (random3 100) :: [(Bool, StdGen)]
[(True,4041414 40692),(False,651872571 1655838864),(False,651872571 1655838864)]
> [b | (b, g) <- (random3 100) :: [(Bool, StdGen)]]
[True,False,False]
P.S.注意(random3 100) :: [(Bool, StdGen)]
只限定了random3
的返回类型,编译器能够推断出random $ mkStdGen i
所需类型是(Bool, StdGen)
这下有点(伪)随机的意思了,因为random
是个纯函数,所以只能通过换种子参数来得到不同的返回值
实际上有更简单的方式:
random3' i = take 3 $ randoms $ mkStdGen i
> random3' 100 :: [Bool]
[True,False,False]
其中randoms :: (Random a, RandomGen g) => g -> [a]
函数接受一个RandomGen
参数,返回Random
无穷序列
此外,常用的还有:
-- 返回[min, max]范围的随机数
randomR :: (Random a, RandomGen g) => (a, a) -> g -> (a, g)
-- 类似于randomR,返回无限序列
randomRs :: (Random a, RandomGen g) => (a, a) -> g -> [a]
例如:
> randomR ('a', 'z') (mkStdGen 1)
('x',80028 40692)
> take 24 $ randomRs (1, 6) (mkStdGen 1)
[6,5,2,6,5,2,3,2,5,5,4,2,1,2,5,6,3,3,5,5,1,4,3,3]
P.S.更多随机数相关函数,见System.Random
动态种子
写死的种子每次都返回同一串随机数,没什么意义,所以需要一个动态的种子(如系统时间等):
getStdGen :: IO StdGen
getStdGen
在程序运行时会向系统要一个随机数生成器(random generator),并存成全局生成器(global generator)
例如:
main = do
g <- getStdGen
print $ take 10 (randoms g :: [Bool])
试玩一下:
$ ghc --make rand.hs
[1 of 1] Compiling Main ( rand.hs, rand.o )
Linking rand ...
$ ./rand
[False,False,True,False,False,True,False,True,False,False]
$ ./rand
[True,False,False,False,True,False,False,False,True,True]
$ ./rand
[True,True,True,False,False,True,True,False,False,True]
注意,在GHCIi环境调用getStdGen
得到的总是同一个种子,类似于程序连续调用getStdGen
的效果,所以总是返回同一串随机值序列:
> getStdGen
1661435168 1
> getStdGen
1661435168 1
> main
[False,False,False,False,True,False,False,False,True,True]
> main
[False,False,False,False,True,False,False,False,True,True]
可以手动控制取无限序列后面的部分,或者使用newStdGen :: IO StdGen
函数:
> newStdGen
1018152561 2147483398
> newStdGen
1018192575 40691
newStdGen
能够把现有的global generator分成两个random generator,把其中一个设置成global generator,返回另一个。所以:
> getStdGen
1661435170 1655838864
> getStdGen
1661435170 1655838864
> newStdGen
1018232589 1655838863
> getStdGen
1661435171 2103410263
如上面示例,newStdGen
不仅返回新的random generator,还会重置global generator
五.异常处理
直到此刻,我们见过许多异常了(模式匹配遗漏、缺少类型声明、空数组取首元、除零异常等),知道一旦发生异常,程序就会立刻报错退出,但一直没有尝试过捕获异常
实际上,与其它主流语言一样,Haskell也有完整的异常处理机制
I/O异常
I/O相关的场景需要更严谨的异常处理,因为与内部逻辑相比,外部环境显得更加不可控,不可信赖:
像是打开文件,文件有可能被lock起来,也有可能文件被移除了,或是整个硬盘都被拔掉
此时需要抛出异常,告知程序某些事情发生了错误,没有按照预期正常运行
I/O异常可以通过catchIOError
来捕获,例如:
import System.IO.Error
catchIOError :: IO a -> (IOError -> IO a) -> IO a
传入I/O Action和对应的异常处理函数,返回同类型的I/O Action。机制类似于try-catch
,I/O Action抛出异常才执行异常处理函数,并返回其返回值,例如:
import System.IO
import System.IO.Error
import Control.Monad
import System.Environment
main = do
args <- getArgs
when (not . null $ args) (do
contents <- catchIOError (readFile (head args)) (\err -> do return "Failed to read this file!")
putStr contents
)
在找不到文件,或者其他原因导致readFile
异常时,会输出提示信息:
$ runhaskell ioException.hs ./xx
Failed to read this file!
这里只是简单粗暴的吃掉了所有异常,最好区别对待:
main = do
args <- getArgs
when (not . null $ args) (do
contents <- catchIOError (readFile (head args)) errorHandler
putStr contents
)
where errorHandler err
| isDoesNotExistError err = do return "File not found!"
| otherwise = ioError err
其中isDoesNotExistError
和ioError
如下:
isDoesNotExistError :: IOError -> Bool
ioError :: IOError -> IO a
前者是个predicate
,用来判定传入的IOError
是不是目标(文件)不存在引起的,后者相当于JS的throw
,把这个异常再度丢出去
IOError的其它predicate
还有:
isAlreadyExistsError
isAlreadyInUseError
isFullError
isEOFError
isIllegalOperation
isPermissionError
isUserError
其中isUserError
用来判定通过userError :: String -> IOError
函数手动制造的异常
获取错误信息
想要输出引发异常的用户输入的话,可能会这么做:
exists = do
file <- getLine
when (not . null $ file) (do
contents <- catchIOError (readFile file) (\err -> do
return ("File " ++ file ++ " not found!\n")
)
putStr contents
)
试玩一下:
> exists
./xx
File ./xx not found!
> exists
./io.hs
main = print "hoho"
符合预期,这里用了lambda
函数,能够访问外部的file
变量,如果异常处理函数相当庞大,就不太容易了,例如:
exists' = do
file <- getLine
when (not . null $ file) (do
contents <- catchIOError (readFile file) (errorHandler file)
putStr contents
)
where errorHandler file = \err -> do (return ("File " ++ file ++ " not found!\n"))
为了把file
变量传入errorHandler
,我们多包了一层,看起来蠢蠢的,而且能保留的现场信息很有限
所以,像其他语言一样,我们能够从异常对象身上取出一些错误信息,例如:
exists'' = do
file <- getLine
when (not . null $ file) (do
contents <- catchIOError (readFile file) (\err ->
case ioeGetFileName err of Just path -> return ("File at " ++ path ++ " not found!\n")
Nothing -> return ("File at somewhere not found!\n")
)
putStr contents
)
其中ioeGetFileName
用来从IOError
中取出文件路径(这些工具函数都以ioe
开头):
ioeGetFileName :: IOError -> Maybe FilePath
P.S.更多类似函数,见Attributes of I/O errors
纯函数异常
异常并不是I/O场景特有的,例如:
> 1 `div` 0
*** Exception: divide by zero
> head []
*** Exception: Prelude.head: empty list
纯函数也会引发异常,比如上面的除零异常和空数组取首元异常,有两种处理方式:
使用
Maybe
或Either
使用
try :: Exception e => IO a -> IO (Either e a)
(位于Control.Exception
模块)
例如:
import Data.Maybe
> case listToMaybe [] of Nothing -> ""; Just first -> first
""
> case listToMaybe ["a", "b"] of Nothing -> ""; Just first -> first
"a"
其中listToMaybe :: [a] -> Maybe a
用于取List首元,并包装成Maybe
类型(空List就是Nothing
)
除零异常要么手动检查除数不为0
,要么用evaluate
塞进I/O场景,通过try
来捕获:
> import Control.Exception
> first <- try $ evaluate $ 1 `div` 0 :: IO (Either ArithException Integer)
> first
Left divide by zero
实际上,除零异常的具体类型是DivideByZero
,位于Control.Exception
模块:
data ArithException
= Overflow
| Underflow
| LossOfPrecision
| DivideByZero
| Denormal
| RatioZeroDenominator
-- Defined in ‘GHC.Exception’
如果不清楚具体异常类别(这个是确实不清楚异常类型,查源码都猜不出来),或者希望捕获所有类型的异常,可以用SomeException
:
> first <- try $ evaluate $ head [] :: IO (Either SomeException ())
> first
Left Prelude.head: empty list
P.S.关于4种异常处理方案的更多信息,见Handling errors in Haskell