M.Hiroi's Home Page

お気楽 Haskell プログラミング入門

中級編 : ファイル入出力

Copyright (C) 2013-2021 Makoto Hiroi
All rights reserved.

はじめに

今回はテキストファイルの入出力処理について説明します。

●標準入出力

Haskell では、ハンドル (Handle) を介してファイルにアクセスします。ハンドルはファイルと 1 対 1 に対応していて、ファイルからデータを入力するときは、ハンドルを経由してデータが渡されます。逆に、ファイルへデータを出力するときもハンドルを経由します。

通常のファイルはハンドルを生成しないとアクセスすることができません。ただし、標準入出力は Haskell の起動時にハンドルが自動的に生成されるので、簡単に利用することができます。

一般に、キーボードからの入力を「標準入力」、画面への出力を「標準出力」といいます。標準入出力に対応するハンドルはモジュール System.IO に定義されています。下表に変数名を示します。

表 : 標準入出力
変数名ファイル
stdin Handle標準入力
stdout Handle標準出力
stderr Handle標準エラー出力

Handle はハンドルを表すデータ型です。「簡単な入出力」で説明した関数 getLine, readLn, putStr, putStrLn, print は stdin, stdout 専用の関数ですが、ハンドルを指定して入出力を行う関数も System.IO には用意されています。主な関数を下表に示します。

表 : 主な入出力関数
関数名機能
hGetChar Handle -> IO Charハンドルから 1 文字読み込む
hGetLine Handle -> IO Stringハンドルから 1 行読み込む
hGetContents Handle -> IO Stringハンドルに含まれる内容を文字列にして返す
hPutChar Handle -> Char -> IO ()ハンドルに 1 文字書き込む
hPutStr Handle -> String -> IO ()ハンドルに 1 行書き込む
hPutStrLn Handle -> String -> IO ()ハンドルに 1 行書き込む (改行付き)
hPrint Handle -> a -> IO ()ハンドルにデータ型 a を表す文字列を書き込む

hGetContents は遅延評価により、必要になったときにファイルからデータを読み込みます。つまり、返り値の文字列 (リスト) は遅延ストリームとして利用することができます。

簡単な実行例を示します。

ghci> :m + System.IO
ghci> hGetChar stdin
h'h'
ghci> hGetLine stdin
hello, world
"hello, world"
ghci> hPutChar stdout 'h'
hghci>
ghci> hPutStr stdout "hello, world"
hello, worldghci>
ghci> hPutStrLn stdout "hello, world"
hello, world
ghci> hPrint stdout 10
10
ghci> hPrint stdout "hello, world"
"hello, world"

●ファイルのオープンとクローズ

ファイルにアクセスする場合、次の 3 つの操作が基本になります。

  1. アクセスするファイルをオープンする
  2. 入出力関数を使ってファイルを読み書きする。
  3. ファイルをクローズする。

「ファイルをオープンする」とは、アクセスするファイルを指定して、それと 1 対 1に対応するハンドルを生成することです。入出力関数はオープンしたハンドルを経由してファイルにアクセスします。

Haskell の場合、ファイルをオープンするにはモジュール System.IO に用意されている関数 openFile を使います。オープンしたファイルは必ずクローズしてください。この操作を行う関数が hClose です。openFile と hClose の型を示します。

openFile :: FilePath -> IOMode -> IO Handle
hClose :: Handle -> IO ()
data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode
type FilePath = String

openFile は引数にファイル名 (FilePath) とアクセスモード (IOMode) を指定して、ファイル名で指定されたファイルに対応する Handle を生成し、それを IO に格納して返します。アクセスモードは IOMode 型で指定します。下表にアクセスモードを示します。

表 : アクセスモード
モード動作
ReadMode 読み込み (read) モード
WriteMode 書き出し (write) モード
AppendMode 追加 (append) モード
ReadWriteMode 更新モード (読み書き両方が可能)

読み込みモードの場合、ファイルが存在しないとエラーになります。書き出しモードの場合、ファイルが存在すれば、そのファイルを大きさ 0 に切り詰めてからオープンします。追加モードの場合、ファイルの最後尾にデータを追加します。

ファイル名は文字列で指定し、ファイル名のパス区切り記号にはスラッシュ ( / ) を使います。\ は文字列のエスケープコードに割り当てられているため、そのままではパス区切り記号に使うことはできません。ご注意ください。

●ファイルの表示

それでは簡単な例題として、ファイルの内容を画面へ出力する関数 cat を作ってみましょう。プログラムは次のようになります。

リスト : ファイルの表示 (1)

import System.IO

cat :: FilePath -> IO ()
cat filename = do
  handle   <- openFile filename ReadMode
  contents <- hGetContents handle
  putStr contents
  hClose handle

関数 cat の引数 filename はファイル名を表す文字列です。openFile で filename を ReadMode でオープンしてハンドルを変数 handle にセットします。次に、hGetContents でファイルの内容を読みこみ、それを putStr で画面に表示します。最後に hClose でハンドルをクローズします。

簡単な実行例を示します。test00.txt の内容を表示します。

hello, world
hello, Haskell
foo bar baz
oops! oops! oops!
abcd efgh ijkl

図 : test00.txt
ghci> cat "test00.txt"
hello, world
hello, Haskell
foo bar baz
oops! oops! oops!
abcd efgh ijkl

もう一つ簡単な例題として、ファイルの先頭から n 行表示するように cat を変更してみましょう。関数名は cat' としました。次のリストを見てください。

リスト : ファイルの表示 (2)

cat' :: Int -> FilePath -> IO ()
cat' n filename = do
  handle   <- openFile filename ReadMode
  contents <- hGetContents handle
  mapM_ putStrLn $ take n $ lines contents
  hClose handle

lines は文字列を改行文字で分割する関数です。

lines :: String -> [String]

簡単な使用例を示します。

ghci> lines "foo bar baz"
["foo bar baz"]
ghci> lines "foo\nbar\nbaz"
["foo","bar","baz"]

cat' は hGetContents で読み込んだ文字列を lines で分割し、take で n 行取り出して mapM_ putStrLn で表示するだけです。

簡単な実行例を示します。

ghci> cat' 1 "test00.txt"
hello, world
ghci> cat' 2 "test00.txt"
hello, world
hello, Haskell
ghci> cat' 3 "test00.txt"
hello, world
hello, Haskell
foo bar baz
ghci> cat' 10 "test00.txt"
hello, world
hello, Haskell
foo bar baz
oops! oops! oops!
abcd efgh ijkl

hGetContents を使わない場合は次のようになります。

リスト : ファイルの表示 (3)

takeLines :: Int -> Handle -> IO [String]
takeLines 0 _ = return []
takeLines n h = do
  eof <- hIsEOF h
  if eof 
    then return []
    else do
      x <- hGetLine h
      xs <- takeLines (n - 1) h
      return (x:xs)

cat'' :: Int -> FilePath -> IO ()
cat'' n filename = do
  handle   <- openFile filename ReadMode
  contents <- takeLines n handle
  mapM_ putStrLn contents
  hClose handle

関数 takeLines はハンドル h から n 行読み込み、それをリストに格納して返します。返り値の型は [String] ではなく IO [String] になることに注意してください。

ファイルからデータを読み込む場合、ファイルに格納されているデータには限りがあるので、ハンドルからデータを取り出していくと、いつかはデータがなくなります。この状態を「ファイルの終了 (end of file : EOF)」といいます。

最初に関数 hIsEOF を呼び出してファイルの終了 (EOF) をチェックします。hIsEOF の型を示します。

hIsEOF :: Handle -> IO Bool

EOF の場合は True を、そうでなければ False を返します。ただし、真偽値は IO に格納されて返されることに注意してください。

EOF の場合は return で空リストを IO に格納して返します。そうでなければ、hGetLine で 1 行読み込んで変数 x にセットします。次に takeLines で残りの (n - 1) 行を読み込んで変数 xs にセットします。最後に return で (x:xs) を IO に格納して返します。引数が 0 になったら return で空リストを IO に格納して返します。

cat'' は簡単です。ファイルを openFile でオープンし、そこから n 行を takeLines で読み込みます。あとは mapM_ putStrLn で読み込んだ行を標準出力に表示して、hClose でハンドルをクローズします。

●ファイルの書き込み

データをファイルに書き込むには、ファイルを WriteMode でオープンします。このとき、注意事項が一つあります。既に同じ名前のファイルが存在している場合は、そのファイルの長さを 0 に切り詰めてからデータを書き込みます。既存のファイルは内容が破壊されることに注意してください。

それでは簡単な例題として、[String] の要素を 1 行ずつファイルに書き込む関数 outputStrings を作ってみましょう。次のリストを見てください。

リスト : ファイルの書き込み

outputStrings :: FilePath -> [String] -> IO ()
outputStrings filename xs = do
  handle <- openFile filename WriteMode
  mapM_ (hPutStrLn handle) xs
  hClose handle

最初に openFile でファイル filename を WriteMode でオープンします。あとは、mapM_ を使ってリスト xs から要素を一つずつ取り出し、それを hPutStrLn でファイルに書き込みます。

簡単な実行例を示します。

ghci> outputStrings "test01.txt" ["hello, world", "foo", "bar", "baz", "oops"]
ghci> cat "test01.txt"
hello, world
foo
bar
baz
oops


ファイルをコピーするプログラムも簡単に作ることができます。次のリストを見てください。

リスト : ファイルのコピー

copyFile :: FilePath -> FilePath -> IO ()
copyFile fname1 fname2 = do
  hin  <- openFile fname1 ReadMode
  hout <- openFile fname2 WriteMode
  contents <- hGetContents hin
  hPutStr hout contents
  hClose hin
  hClose hout

引数 fname1 が入力ファイル名、fname2 が出力ファイル名を表します。最初に、fnam1 を ReadMode で、fname2 を WriteMode でモードでオープンします。次に、入力ファイルから hGetContents で内容を読み込み、hPutStr で出力ファイルへ書き出します。最後に hClose でファイルをクローズします。

簡単な実行例を示します。

ghci> copyFile "test00.txt" "test02.txt"
ghci> cat "test02.txt"
hello, world
hello, Haskell
foo bar baz
oops! oops! oops!
abcd efgh ijkl


ファイルのコピーは関数 readFile と writeFile を使うともっと簡単になります。関数の型を示します。

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

ファイルのオープンとクローズの処理を readFile と writeFile が行ってくれるので、プログラムはとても簡単になります。次のリストを見てください。

リスト : ファイルのコピー (2)

copyFile' :: FilePath -> FilePath -> IO ()
copyFile' fname1 fname2 = readFile fname1 >>= writeFile fname2

readFile で読み込んだデータを演算子 >>= で取り出して writeFile に渡すだけです。

●withFile

Haskell には、ファイルのオープンとクローズを自動的に行ってくれる便利な関数 withFile が用意されています。withFile の型を示します。

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

withFile は第 1 引数にファイル名、第 2 引数にアクセスモード、第 3 引数に関数を指定します。withFile はファイルを指定されたアクセスモードでオープンし、そのハンドルを関数の引数に渡して呼び出します。withFile の実行が終了すると、ファイルのハンドルは自動的にクローズされます。何かしらのエラーが発生した場合でも withFile はファイルをクローズしてくれるので安心です。

簡単な例として cat'' と outputStrings を withFile を使って書き直してみましょう。プログラムは次のようになります。

リスト : withFile の使用例

cat''' :: Int -> FilePath -> IO ()
cat''' n filename = 
  withFile filename ReadMode $ \handle -> 
     takeLines n handle >>= mapM_ putStrLn

outputStrings' :: FilePath -> [String] -> IO ()
outputStrings' filename xs =
  withFile filename WriteMode $ \handle ->
    mapM_ (hPutStrLn handle) xs

withFile に渡す関数はラムダ式を使うと簡単です。ラムダ式の引数 handle に withFile がオープンしたファイルのハンドルが渡されます。どちらの場合もファイルをクローズする処理を書く必要がないので、プログラムはとても簡単になります。

このほかにも、Haskell にはいろいろな入出力関数が用意されています。詳しい説明は Haskell のリファレンスマニュアルを参照してください。

●コマンドライン引数の取得

Haskell の場合、モジュール System.Environment の変数 getArgs にコマンドラインで与えられた引数が格納されています。getArgs の型を示します。

getArgs :: IO [String]

簡単な実行例を示しましょう。次のリストを見てください。

リスト : コマンド引数の表示 (cmdline.hs)

import System.Environment

main :: IO ()
main = getArgs >>= print

cmdline.hs は変数 getArgs の内容を表示するだけです。3 つの引数を与えて起動すると、次のように表示されます。

$ stack ghc -- cmdline.hs
[1 of 1] Compiling Main             ( cmdline.hs, cmdline.o )
Linking cmdline.exe ...

$ ./cmdline foo bar baz
["foo","bar","baz"]

簡単な例として、コマンドラインからファイル名を指定してファイルの内容を表示するプログラム cat.hs を作ってみましょう。次のリストを見てください。

リスト : ファイルの表示 (cat.hs)

import System.Environment
import System.IO

main :: IO ()
main = do
  args <- getArgs
  case args of
    [] -> getContents >>= putStr
    _  -> mapM_ (\x -> readFile x >>= putStr) args

最初に getArgs でコマンドラインからファイル名を取得します。空リストの場合は標準入力 (stdin) からデータを getContents で読み込みます。そうでなければ、mapM_ で args からファイル名を取り出し、ラムダ式の中でファイルからデータを読み取って表示します。とても簡単ですね。

それでは cat.hs をコンパイルして、実際に実行してみましょう。

$ cat < test00.txt
hello, world
hello, Haskell
foo bar baz
oops! oops! oops!
abcd efgh ijkl

$ cat test00.txt
hello, world
hello, Haskell
foo bar baz
oops! oops! oops!
abcd efgh ijkl

$ cat test00.txt test01.txt
hello, world
hello, Haskell
foo bar baz
oops! oops! oops!
abcd efgh ijkl
hello, world
foo
bar
baz
oops

$ 

正常に動作していますね。


初版 2013 年 4 月 7 日
改訂 2021 年 1 月 31 日