HaskellのファイルIO

(2009.5.12) ページを分割し、加筆。

(2017.3) 全体的に書き直し。今どきの内容に更新。

(2017.6) 例外まわりを書き直し, withFileの説明を追加。

Haskellでファイルを読み書きしたりする方法について。例外についても、少し解説。

テキストモード、バイナリモード

(2017.3 追加)

Haskell のファイルIOでは、ファイルを開くときに、テキストモードとバイナリモードがある。

テキストモード

テキストモードでは, Windows環境では, 読み込み時に CRLF -> LF変換、書き込み時に逆の変換が行われる。UNIX 環境では、テキストファイルの改行コードが CRLFであっても、特に何も変換されない。

テキストモードでは, ファイルの文字コードが設定される。Haskell の文字列は Unicodeだった。ファイルを読み込むときに, ファイルの文字コードに沿って Unicode テキストに変換され, String などに格納される。

ファイルを開いた時点では, 文字コードは, 実行時のロケールのそれと仮定される. ハンドルごとに, hSetEncoding 関数で変更できる。

テキストファイルを軽く扱うような場合はテキストモードを使う。

バイナリモード

一方、バイナリモードでは、改行コードの変換は行われず、ファイルはただの8bitのバイト列として取り扱われる。

テキストだけれども文字コードが不明など難しい状況を扱うときにも, バイナリモードを用いる。

バイナリモードのファイルを読み込むときは, ByteString を使うこと。型としては String で読み込むこともできてしまうが、1バイトずつ Char に格納されてしまう。

readFile, writeFile

簡単にテキストファイルを読み込むには、Prelude モジュールの readFile 関数を呼び出します。型はこう;

Haskell
[POPUP]
  1. readFile :: FilePath -> IO String
  2. type FilePath = String

readFileの第一引数はファイル名で、ファイルを開くところからしてくれます。実際の読み込みは遅延評価され、後工程で必要でない部分は読み込まれません。

ただ, String型は廃れているので, 新しいプログラムでは Text型のほうがいいでしょう。Data.Text.IOモジュールの同名関数を使います。

バイナリモードでバイト列として読み込むには, Data.ByteStringモジュールの同名関数を使います。

ファイルに書き込むには, writeFile 関数を呼び出します。宣言;

Haskell
[POPUP]
  1. writeFile :: FilePath -> String -> IO ()

次の例は単にファイルの内容を表示します。

Haskell
[POPUP]
  1. main :: IO ()
  2. main = do
  3. cs <- readFile "03readfile.hs"
  4. putStr cs

次の例はファイルの先頭5行のみを表示します。readFile で全部読んでいるように見えますが、遅延評価のため、実際には必要でない部分は読み込まれません。

Haskell
[POPUP]
  1. import Data.Text as DT
  2. import Data.Text.IO as DTI
  3. -- 文字列の先頭 n 行を得る
  4. headNLines :: Int -> Text -> Text
  5. headNLines n text =
  6. DT.unlines $ Prelude.take n $ DT.lines text
  7. main :: IO ()
  8. main = do
  9. cs <- DTI.readFile "first_5_lines.hs"
  10. DTI.putStr $ headNLines 5 cs

ファイルハンドル

readFile, writeFile は大雑把すぎます。

ファイルを開くときに読み書きモードにしたり, 1行ずつ自分で制御したいときなどは System.IO モジュールの関数を用います。C++での <cstdio> 相当です。

ちなみに、より細かく制御したいときは, unix パッケージの System.Posix.IO モジュールを使います。UNIX (POSIX) の open(2) 相当です。ここでは割愛します。

話を戻して, 名前からだいたい何をするものか見当がつきます。

Haskell
[POPUP]
  1. openFile :: FilePath -> IOMode -> IO Handle
  2. data IOMode = ReadMode
  3. | WriteMode
  4. | AppendMode
  5. | ReadWriteMode
  6. hClose :: Handle -> IO ()
  7. hGetChar :: Handle -> IO Char
  8. hGetLine :: Handle -> IO String
  9. hPutChar :: Handle -> Char -> IO ()
  10. hPutStr :: Handle -> String -> IO ()
  11. hPutStrLn :: Handle -> String -> IO ()

ファイルを開く

ファイルを開くのに, openFile 関数と openBinaryFile があります。前者がテキストモード, 後者がバイナリモードになります。

型は同じです。

このほか, 一時ファイルを扱うために, openTempFile 関数, openBinaryTempFile 関数があります。

ファイルの読み込み

テキストモードのときは, System.IO モジュールにある String を返す関数 (古い、非推奨) か、Text 型を返す Data.Text.IO モジュールの同名関数を使います。

バイナリモードのときは, Data.ByteStringモジュールの, ByteString を返す関数を使うこと。

1行読み込むのは hGetLine関数です。改行コードの '\n' は、読み込んだ文字列から削除され, 戻り値には含まれません。そのため, ファイル末尾の '\n' の有無を区別できません。

EOFに達していてさらに呼び出すと, 例外が発生します。

次の例は, テキストファイルから1行ずつ読み込み、単に表示します。

Haskell
[POPUP]
  1. import System.IO
  2. import Data.Text.IO as DTI
  3. -- EOFに到達し、さらに読もうとすると、例外が発生する
  4. get_and_print :: Handle -> IO ()
  5. get_and_print fp = do
  6. eof <- hIsEOF fp
  7. if eof then return ()
  8. else do
  9. str <- DTI.hGetLine fp
  10. print str
  11. get_and_print fp
  12. main :: IO ()
  13. main = do
  14. fp <- openFile "crlf.txt" ReadMode
  15. get_and_print fp

例外の捕捉

ファイルが見つからないときは実行時エラー (例外) が投げられます。例外はcatch関数で捕まえます。

Control.Exception.Safeモジュールで, catch関数が宣言されている。

Note.

[2017-06] 新しいコードでは, これまでの Control.Exception モジュール (baseパッケージ) ではなく, Control.Exception.Safe モジュール (safe-exceptions パッケージ) を使うようにします。

Control.Exception.Safe.catch関数の型は、

Haskell
[POPUP]
  1. catch :: (MonadCatch m, Exception e) => m a -> (e -> m a) -> m a
  2. -- Defined in ‘Control.Exception.Safe’

ただし, MonadCatch 型クラスは, Control.Monad.Catch.MonadCatch です。

catchの第一引数が例外が発生するかもしれない処理 (本体部分)、第2引数がエラーハンドラ関数です。

例外が発生したときは, 第1引数のなかの処理が中断され, 第2引数の関数が呼び出されて、catchの戻り値はエラーハンドラの戻り値になります。

例外が発生した箇所には復帰しません。

Java などの try 〜 catch 構文と対応します. (finally も含め, 例外そのものについては, 別ページ: Haskell の例外処理)

次の例は、ファイルが見つからなかったらエラーメッセージを表示します。

(2017.3) 最近のGHCでのコンパイルエラーを修正。

Haskell
[POPUP]
  1. import System.Environment -- getArgs
  2. import System.IO -- hPutStrLn, stderr
  3. -- 現代は, Control.Exception ('base' package) ではなく, いつでも .Safe を使う
  4. import Control.Exception.Safe -- catch (from 'safe-exceptions' package)
  5. onError :: String -> IOError -> IO String -- (1)
  6. onError filename error_ = do
  7. hPutStrLn stderr $ filename ++ " :: " ++ (show error_)
  8. return []
  9. main :: IO ()
  10. main = do
  11. args <- getArgs -- (2)
  12. content <- if null args -- null はリストが空なら真を返す
  13. then getContents
  14. else catch (readFile $ head args)
  15. (onError $ head args)
  16. putStr content

(1) catchの第2引数として渡すために, onErrorを定義しています。戻り値の型は、値を利用するため, catchの第1引数に合わせます. readFileが型 IO Stringなので、同じにします。

(2) getArgs関数はコマンドライン引数の配列を返します。

コマンドの引数が与えられていないときは標準入力から読み込み、引数があるときはそのファイルを読み込みます。

この例は作為的で、普通ならputStrも例外が発生するかもしれない処理のなかに書くところです。

withFile関数

ファイルの入出力の際, ファイルハンドルの閉じ忘れに気をつけないといけません。

例外が発生したとしても自動的にハンドルを閉じてくれる System.IO.withFile 関数を使います。内部で bracketを利用しています。

こういう定義です;

withFile :: FilePath -> IOMode -> (Handle -> IO r) -> IO r
withFile name mode = bracket (openFile name mode) hClose

例を書きます; 複数ファイルの場合は、入れ子にしてやればOK.

Haskell
[POPUP]
  1. import System.IO
  2. -- ハンドルバージョン
  3. do_zip :: Handle -> Handle -> Handle -> IO ()
  4. do_zip h1 h2 hout = do
  5. h1eof <- get_and_put h1
  6. h2eof <- get_and_put h2
  7. if h1eof && h2eof then
  8. return ()
  9. else
  10. do_zip h1 h2 hout
  11. where
  12. get_and_put handle = do
  13. iseof <- hIsEOF handle
  14. if iseof == True then
  15. return iseof
  16. else do
  17. line1 <- hGetLine handle
  18. hPutStr hout $ line1 ++ "\n"
  19. return iseof
  20. -- 二つのファイルを1行ずつ交互にまとめる.
  21. zipFile :: String -- ファイル名1
  22. -> String -- ファイル名2
  23. -> Handle -- 出力先
  24. -> IO ()
  25. zipFile infile1 infile2 outhandle = do
  26. withFile infile1 ReadMode $ \h1 ->
  27. withFile infile2 ReadMode $ \h2 ->
  28. do_zip h1 h2 outhandle
  29. main :: IO ()
  30. main = do
  31. zipFile "foo" "bar" System.IO.stdout