メインコンテンツへ移動

遅延 IO から始める_Haskell ノート 6

無料2018-05-26#Functional_Programming#Haskell随机数#Haskell异常处理#Haskell获取命令行参数#Haskell ByteString#Haskell Buffer

buffer、chunk と thunk の物語

一.遅延 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 で、一度に 1 chunk ずつ読み込みます。その具体的なサイズはオペレーティングシステムに依存します

line-buffering と block-buffering はBufferMode値で表されます:

data BufferMode
  = NoBuffering | LineBuffering | BlockBuffering (Maybe Int)
    -- Defined in 'GHC.IO.Handle.Types'

BufferMode型には 3 つの値があります。NoBufferingLineBufferingBlockBuffering (Maybe Int)はそれぞれ buffer を使わない、line-buffering を使う、block-buffering を使うことを表します。其中Maybe Intは各 chunk が何バイト(byte)かを示します。Nothingの場合はシステムデフォルトの chunk サイズを使用します。NoBuffering1 文字ずつ読み込むことを意味し(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 があれば、buffer の flush が必要です。そのためhFlushもあります:

hFlush :: Handle -> IO ()

buffer をクリアするために使用します。buffer が満杯になるのを待ったり、他の自動 flush メカニズム(例:line-bufferingは改行文字に遭遇すると flush する)を待ったりする必要はありません

P.S.これはイメージしやすいが優雅ではない比喩です:

あなたのトイレはタンクに 1 ガロンの水が溜まると自動的に流れます。だからあなたは 1 ガロンになるまで絶えず水を注ぎ続け、トイレは自動的に流れます。水の中のデータも見えます。しかし手動でレバーを回して流すこともできます。既存の水を流してしまいます。流すという動作が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 の各要素は 1 バイト(8 ビット)です。遅延と厳密(非遅延)の 2 種類があります:

  • 遅延:Data.ByteString.Lazy。これも同様に遅延性を持ちますが、List よりも少し勤勉で、要素ごとの thunk ではなく、chunk ごと(64K ごとに 1 chunk)です。ある程度 thunk の数を減らしています

  • 厳密:Data.ByteStringモジュールに位置し、thunk を全く生成しません。一連のバイトを表し、無限長の strict bytestring は存在せず、遅延 List のメモリ上の利点もありません

lazy bytestring は chunk List(List 中の各要素が 64K サイズの strict bytestring)のようであり、遅延性による効率への影響を軽減しつつ、遅延性のメモリ上の利点も持っています。したがってほとんどの場合は lazy 版を使用します

P.S.64K というサイズには理由があります:

64K はあなたの CPU の L2 キャッシュに収まる可能性が非常に高いです

常用関数

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 にします。先に拼接してから 1 つ 1 つの 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 ()

実際には、ByteStringString型は大多数のシナリオで容易に相互変換できるため、まずStringで実装し、性能が良くないシナリオでByteStringに変更することができます

P.S.より多くのByteString関連関数はData.ByteString を参照してください

三.コマンドライン引数

対話式入力やファイル読み込みの他に、コマンドライン引数はユーザー入力を取得するもう 1 つの重要な方法です:

-- 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コンパイル実行の他に、ソースコードを直接実行する方法もあります:

$ runhaskell testArgs.hs -b -c
/absolute/path/to/ghc-8.0.1/bin/ghc
NODE_ENV production
testArgs.hs -b -c

この場合、getExecutablePathghc(実行ファイル)の絶対パスを返します

四.乱数

I/O の他に、もう 1 つ確実に純粋でないシナリオは乱数です。では、純粋関数は乱数を生成できるでしょうか?

擬似乱数を生成することは可能です。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)

確かに純粋関数なので、2 回呼び出した結果は全く同じです(連続して呼び出したからではなく、10 日半月後に呼び出してもこの結果です)。型宣言を通じて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]

注意:GHCI 環境で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 を 2 つの random generator に分割し、その 1 つを global generator として設定し、もう 1 つを返します。したがって:

> getStdGen
1661435170 1655838864
> getStdGen
1661435170 1655838864
> newStdGen
1018232589 1655838863
> getStdGen
1661435171 2103410263

上記の例のように、newStdGenは新しい random generator を返すだけでなく、global generator をリセットします

五.例外処理

これまで多くの例外を見てきました(パターンマッチングの漏れ、型宣言の欠如、空配列の先頭要素取得、ゼロ除算例外など)。例外が発生すると、プログラムはすぐにエラーを報告して終了しますが、例外をキャッチす���ことは試していませんでした

実際には、他の主流言語と同様に、Haskell にも完全な例外処理メカニズムがあります

I/O 例外

I/O 関連のシナリオでは、より厳密な例外処理が必要です。内部ロジックと比較して、外部環境ははるかに制御不能で、信頼できないからです:

ファイルを開く際、ファイルがロックされている可能性や、ファイルが削除されている可能性、あるいはハードディスク全体が抜かれている可能性もあります

この時点で例外を投げ、プログラムに何らかの問題が発生したことを通知し、期待通りに正常に実行されていないことを知らせます

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

其中isUserErroruserError :: 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に渡すために、もう 1 層包みましたが、見た目が愚かで、保持できる現場情報も非常に限られています

したがって、他の言語と同様に、例外オブジェクトからいくつかのエラー情報を取り出すことができます。例えば:

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
    )

其中ioeGetFileNameIOErrorからファイルパスを取り出すために使用されます(これらのツール関数はすべて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

純粋関数も例外を発生させる可能性があります。上記のゼロ除算例外と空配列の先頭要素取得例外には、2 つの処理方法があります:

  • 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 を参照してください

参考資料

コメント

コメントはまだありません

コメントを書く