从惰性IO说起_Haskell笔记6

一.惰性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类型下有三个值,NoBufferingLineBufferingBlockBuffering (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相当于范围更小的Int0 ~ 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.旧版本GHCshow出类似于上面的差异,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 ()

实际上,ByteStringString类型在大多数场景可以很容易地互相转换,所以可以先用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)

其中RandomRandomGen种子的类型分别为:

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

其中isDoesNotExistErrorioError如下:

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

纯函数也会引发异常,比如上面的除零异常和空数组取首元异常,有两种处理方式:

  • 使用MaybeEither

  • 使用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

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code