trifle

技術メモ

Haskell の I/O 処理 覚え書き

初学者のメモです.

概要

Haskell では標準入出力, ファイル入出力, データベースへのアクセスなど, あらゆる外部とのやり取りを, IO型という"読み書きするコンテクスト"を付与された型で管理する. これにより, プログラムの中のファイルの読み書きに関連する部分とそうでない部分は型を見るだけで判別することができる.
IO a -> a という形式の関数が用意されていないため, 読み書きするコンテクストからそうでない状態へ型を変換することができない. したがって, ファイルの読み書きによって内部処理が書き換わることがなく, IOの付いていない関数は純粋であることが保証される.
IO型はモナド則を満たしており, do式を使うことで, 命令を手続き式に分かりやすく書くことができる.


基本

IO a型はa型に読み書きするコンテクストを付与させる. 例えば, getLineという関数はIO String型を持っているが, これは, ただのString型ではなく, 読み書きするコンテクストを持ったString型のことである. 今回のgetLineの場合で言えば, ただの文字列ではなく標準入力から取得された文字列だ, ということを表している.
最終的にはプログラムは何らかの出力(の可能性)とともに停止するが, この最終的な状態を表す型はIO ()型である. ただの()型(値が無いという意味をを持つUnit型)ではなく, 読み書きするコンテクストが付与された()型ということである. Haskell においてmain関数が

module Main(main) where

main :: IO ()
main = do
 (略)

という風にIO ()型を持っているのはそういうことである.


I/O アクションにまつわる関数①(標準入出力)

標準出力

print :: Show a => a -> IO () -- Show 型クラスのインスタンスならどんな型でも出力 
putChar :: Char -> IO ()  -- Char 型(一文字)を出力
putStr :: String -> IO () -- String 型(文字列)を出力
putStrLn :: String -> IO () -- String 型(文字列)を出力して改行

標準入力

getChar :: IO Char -- 標準入力から取得された Char 型の一文字
getLine :: IO String -- 標準入力から取得された String 型の一行
readLn :: Read a => IO a -- 標準入力を Read 型クラスのインスタンスならどんな型にも変換


I/O アクションの組み立て

return, >>=, >>を使って組み立てる.

return :: Monad m => a -> m a
(>>=) :: Monad m => m a -> (a -> m b) -> m b
(>>) :: Monad m => m a -> m b -> m b

return は, 値に読み書きするコンテクストを与える.
>>= は, 何らかの読み書きするコンテクストを, 読み書きするコンテクストを与える(しかも型を変化させる可能性のある)関数に与え、別の読み書きするコンテクストを発生させる.
>>は, 読み書きするコンテクストを捨て, 別の読み書きするコンテクストを発生させる.

例1: 2つの文字列を一行ずつ読み取り, 合体させて出力する.

Prelude> func1 = getLine >>= \x -> getLine >>= \y -> return (x ++ y) >>= putStrLn
Prelude> func1
yamano -- 入力
susume -- 入力
yamanosusume -- 出力

これは以下のように書き換えられる.

Prelude> func1 = getLine >>= \x -> getLine >>= \y -> let z = x ++ y in return z >>= putStrLn


例2: 2つの整数値を一行ずつ読み取り, 足し算と掛け算の結果を一行ずつ出力する.

Prelude> func2 = (readLn :: IO Int) >>= \a -> (readLn :: IO Int) >>= \b -> return (a + b) >>= print >> return (a * b) >>= print
Prelude> func2
4 -- 入力
5 -- 入力
9 -- 出力
20 -- 出力

これは以下のように書き換えられる.

Prelude> func2 = (readLn :: IO Int) >>= \a -> (readLn :: IO Int) >>= \b -> let c = a + b in return c >>= print >> let d = a * b in return d >>= print


do 記法

先の例2の場合を一つのプログラムにしてみると,

module Main(main) where

main :: IO()
main = 
    (readLn :: IO Int) >>= \a ->
    (readLn :: IO Int) >>= \b ->
    let c = a + b in
    return c >>= 
    print >>
    let d = a * b in
    return d >>=
    print

あるいは,

module Main(main) where

main :: IO()
main = 
    (readLn :: IO Int) >>= \a ->
    (readLn :: IO Int) >>= \b ->
    let c = a + b in
    print c >>
    let d = a * b in
    print d

となるが, 式の末尾に -> やら >> やらあり, 見づらい.
そこで, 糖衣構文(Syntactic Sugar)として do 記法が存在する. do 記法を使うとプログラムは以下のように書き換えられる.

module Main(main) where

main :: IO()
main = do
    a <- readLn :: IO Int
    b <- readLn :: IO Int
    let c = a + b 
    print c
    let d = a * b
    print d

do 記法の詳細なルールについては, Haskell/do notationが詳しい.
IO型の値の中身を変数として設定する際は<-を使い, そうでない普通の変数設定ではletを用いるのがポイントである.
do 記法によって, 「2つの整数値を一行ずつ読み取り, 足し算と掛け算の結果を一行ずつ出力する」という操作を, C の

int main() {
    int a, b;
    scanf("%d", &a);
    scanf("%d", &b);
    int c = a + b;
    printf("%d\n", c);
    int d = a * b;
    printf("%d\n", d);
}

や, python

a = int(input())
b = int(input())
c = a + b
print(c)
d = a * b
print(d)

のように, 命令を手続き式に並べて書くことで表現できる.
このように do 記法を使えるのはモナドの特性である.


(余談)糖衣構文について

このような, 同じ機能のままユーザーにより分かりやすい文法を目指した糖衣構文は, 他にも様々あるようで興味深い.
自分が他に知っているのは Node.js(C# などにもあるそうだが)の await である. これはどういうものかというと, 例えば

async function slow_multiply_1 (x) {
    return new Promise((resolve, reject) => {
        setTimeout(() => resolve(x * 3), 3000);
    });
} // 3秒後に値を3倍する関数

async function slow_multiply_2 (x) {
    return new Promise((resolve, reject) => {
        setTimeout(() => resolve(x * 4), 4000);
    });
} // 4秒後に値を4倍する関数

async function slow_add (p, q) {
    return new Promise((resolve, reject) => {
        setTimeout(() => resolve(p + q), 5000);
    });  
} // 5秒後に与えられた2つの数を足し合わせる関数

という関数をまず用意するとして, 「5を3秒後に3倍して結果を表示し, そのあと6を4秒後に4倍して結果を表示し, そのあと5秒後に合計して結果を表示する」という操作を考える.
await を使わないと

function main () {
    slow_multiply_1(5).then((a) => {
        console.log(a);
        slow_multiply_2(6).then((b) => {
            console.log(b);
            slow_add(a, b).then((c) => {
                console.log(c);
            });
        });
    });
}

main();

と見づらくなってしまう. いわゆるコールバック地獄である. これを, await を使うことで,

async function main() {
    const a = await slow_multiply_1(5);
    console.log(a);
    const b = await slow_multiply_2(6);
    console.log(b);
    const c = await slow_add(a, b);
    console.log(c);
}

main();

とシンプルに書くことができる.


I/O アクションにまつわる関数②(ファイルシステム

ファイル操作

writeFile :: FilePath -> String -> IO () -- ファイルに書き込む
readFile :: FilePath -> IO String -- ファイルを読み取る
appendFile :: FilePath -> String -> IO () -- ファイルの末尾に追加

doesFileExist :: FilePath -> IO Bool -- ファイルの存在確認
renameFile :: FilePath -> FilePath -> IO () -- ファイル名の変更
copyFile :: FilePath -> FilePath -> IO () -- ファイルのコピー
removeFile :: FilePath -> IO () -- ファイルの削除

FilePathString型の型シノニム(同じ型に別名を付けたもの)である.


より細かにファイル操作を制御するためには, System.IO モジュールで提供される Handle を用いる.

stdin :: Handle -- 標準入力
stdout :: Handle -- 標準出力
stderr :: Handle -- 標準エラー出力

ファイルの読み書きのモードは IOMode という型で制御される. これはReadMode, WriteMode, AppendMode, ReadWriteModeという4つのコンストラクタから成るモードである.

data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode

これらを元に, 上記の標準入出力やファイル操作をより一般に拡張した関数が用意されている.

hPutStrLn :: Handle -> String -> IO ()
-- Handle の内容を出力して改行する. hPutStrLn stdout s = putStrLn s である.
hGetLine :: Handle -> IO String
-- Handle の内容を入力する. hGetLine stdin = getLine である.

openFile :: FilePath -> IOMode -> IO Handle -- 指定された IOMode でファイルを開く
hIsEOF :: Handle -> IO Bool -- ファイルの終端であれば True を返す
hClose :: Handle -> IO () -- Handle を閉じる


例えば, hoge.txt の内容を一行ずつ読み取り, 行番号を追加して出力するプログラムは以下の通り.

module Main(main) where

import System.IO

main :: IO ()
main = do
    h <- openFile "hoge.txt" ReadMode
    loop 1 h
    hClose h

loop :: Int -> Handle -> IO ()
loop i h = do
    b <- hIsEOF h
    case b of
        False -> do
            line <- hGetLine h
            putStrLn (show i ++ " " ++ line)
            loop (i + 1) h
        True ->
            return ()

hClose が無いと型が合わず実行できないので, 誤ってファイルを閉じ忘れリソースを消費したままプログラムを実行し続けてしまう, ということが起こり得ないのが静的型付けのメリットである.
なお

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

を使えば, これは

module Main(main) where

import System.IO

main :: IO ()
main = do
    withFile "hoge.txt" ReadMode (loop 1) 

loop :: Int -> Handle -> IO ()
loop i h = do
    b <- hIsEOF h
    case b of
        False -> do
            line <- hGetLine h
            putStrLn (show i ++ " " ++ line)
            loop (i + 1) h
        True ->
            return ()

と書き換えられ, hClose を書く必要もない.
ちょうど, python において

f = open("hoge.txt", 'r')
i = 1
for line in f.read().split("\n"):
    print(str(i) + " " + line)
    i += 1
f.close()

を, with 構文を使って

with open("hoge.txt", 'r') as f:
    i = 1
    for line in f.read().split("\n"):
        print(str(i) + " " + line)
        i += 1

と書き換えられるのに似ている.


ディレクトリ操作

System.Directory モジュールを活用する.

createDirectory :: FilePath -> IO () -- ディレクトリの作成
removeDirectory :: FilePath -> IO () -- ディレクトリの削除
doesDirectoryExist :: FilePath -> IO Bool -- ディレクトリの存在確認
renameDirectory :: FilePath -> FilePath -> IO () -- ディレクトリ名の変更
listDirectory :: FilePath -> IO [FilePath] -- ファイルパス下の項目を表示(. .. が含まれない)
getDirectoryContents :: FilePath -> IO [FilePath] -- ディレクトリ内の全項目を表示(. .. が含まれる)

より高度なものとしては,

createDirectoryIfMissing :: Bool -> FilePath -> IO ()
-- ディレクトリが存在しない場合に限って作成する
-- さらに, 第一引数を True にすると, 親ディレクトリが存在しない場合には親ディレクトリごと作成する
removeDirectoryRecursive :: FilePath -> IO ()
-- ディレクトリ内の子要素もまとめて削除する


I/O アクションにまつわる関数③(例外処理)

純粋にプログラム内で生じうる例外については, Maybe型やEither型で処理すべきだが, 外部とのやり取りに起因する例外, I/O 例外は別個に処理しなければならない. 例えば openFile する対象のファイルが元々存在しないなどの例外である.

例外処理では Control.Exception モジュールを活用する.

catch :: Exception e => IO a -> (e -> IO a) -> IO a
finally :: IO a -> IO b -> IO a

他言語でも見られる catchfinally は関数として用意されている. いずれも中置記法で書くことで, [すべき処理] `catch` [例外処理] とか, [例外を含んだ処理] `finally` [最終的にしたい処理] というような流れで書ける.


例外をまとめて補足する場合

SomeException という型を用いると任意の例外を捕捉できる.
例えば以下は, hoge.txt が存在する場合はその内容が出力され, 存在しない場合は失敗!!: hoge.txt: openFile: does not exist (No such file or directory)と出力され, いずれの場合でも最終的に終わった!!!と表示されるプログラムである.

module Main(main) where

import Control.Exception
import System.IO

main :: IO ()
main =
    (readFile "hoge.txt" >>= putStrLn)
        `catch`
    (\e -> do
        let err = show (e :: SomeException)
        hPutStrLn stderr ("失敗!!: " ++ err))
        `finally`
    (putStrLn "終わった!!!")

ScopedTypeVariables言語拡張を使うと, 引数の型を直接指定できるのでより見やすくなる.

{-# LANGUAGE ScopedTypeVariables #-}

module Main(main) where

import Control.Exception
import System.IO

main :: IO ()
main =
    (readFile "hoge.txt" >>= putStrLn)
        `catch`
    (\(e :: SomeException) -> do
        let err = show e
        hPutStrLn stderr ("失敗!!: " ++ err))
        `finally`
    (putStrLn "終わった!!!")


例外を個別に補足する場合

[-1, 0, 1] というリストを用意し, 入力されたインデックス番号に対応するリストの要素で5を割ることを考えてみる. 1 を入力すると, 1に対応する要素0で5を割ることになるので, DivideByZero エラーになる. DivideByZeroArithException型のコンストラクタで,

data ArithException
  = Overflow
  | Underflow
  | LossOfPrecision
  | DivideByZero
  | Denormal
  | RatioZeroDenominator

という構成になっている. これを意識してプログラムを書くと,

{-# LANGUAGE ScopedTypeVariables #-}

module Main(main) where

import Control.Exception

numbers :: [Int]
numbers = [-1, 0, 1]

main :: IO ()
main =
    (do n <- readLn :: IO Int
        let k = numbers !! n
        print (5 `div` k))
        `catch`
    (\(e :: ArithException) ->
        case e of
            DivideByZero -> putStrLn "ゼロで割るなや"
            _            -> throwIO e >>= putStrLn)
        `catch`
    (\(e :: SomeException) -> do
        let err = show e
        putStrLn ("なんか分からんけどエラー出たわ: " ++ err))

という感じになる.
実際にはDivideByZero以外のArithExceptionは起こり得ないだろうから, ここまで綿密に分岐させる必要はないのだが.
なお, throwIOは例外を投げる関数である.

throwIO :: Exception e => e -> IO a 


ArithException 以外の例外は2つ目のcatchの後で処理される. 例えば,

4 -- 入力
なんか分からんけどエラー出たわ: Prelude.!!: index too large -- 出力
あ -- 入力
なんか分からんけどエラー出たわ: user error (Prelude.readIO: no parse)  -- 出力

である.


個別に捕捉したい例外がたくさんある場合, いちいちcatchを書くのは面倒なので, catchesを使ってまとめる.

catches :: IO a -> [Handler a] -> IO a

ここで登場するHandlerの定義は,

Handler :: Exception e => (e -> IO a) -> Handler a

で, Handler [例外処理] で例外処理を一つのカタマリとして処理することができる.
上記の例は,

{-# LANGUAGE ScopedTypeVariables #-}

module Main(main) where

import Control.Exception

numbers :: [Int]
numbers = [-1, 0, 1]

main :: IO ()
main =
    (do n <- readLn :: IO Int
        let k = numbers !! n
        print (5 `div` k))
        `catches`
    [ Handler $ \(e :: ArithException) ->
        case e of
            DivideByZero -> putStrLn "ゼロで割るなや"
            _            -> throwIO e >>= putStrLn
    , Handler $ \(e :: SomeException) -> do
        let err = show e
        putStrLn ("なんか分からんけどエラー出たわ: " ++ err)]

と書き換えることができる.


特殊な例外操作

onExceptionは, 捕捉されなかった例外が発生した時だけ行いたい操作をfinallyのように書ける.

{-# LANGUAGE ScopedTypeVariables #-}

module Main(main) where

import Control.Exception

numbers :: [Int]
numbers = [-1, 0, 1]

main :: IO ()
main =
    (do n <- readLn :: IO Int
        let k = numbers !! n
        print (5 `div` k))
        `catch`
    (\(e :: ArithException) ->
        case e of
            DivideByZero -> putStrLn "ゼロで割るなや"
            _            -> throwIO e >>= putStrLn)
        `onException`
    (putStrLn "残念!!!")

この場合, 0, 2が入力されれば問題なく計算が行われ, 1が入力されても "ゼロで割るなや" が出力されるが, 4が入力される場合のような捕捉できなかった例外に関して,

4 -- 入力
残念!!! -- 出力
exception2.hs: Prelude.!!: index too large -- 出力

という風になる. 発生した想定外の例外に一旦対応する場合に使える.


bracketは想定外の例外が発生してリソースが解放されなくなってしまうことを防止する.

bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c

bracket [リソース消費] [リソース解放] [すべき処理] という形で, すべき処理が正常に終了しようが(想定外の例外によって)異常に終了しようがリソースが解放されるという仕組みである. 例えば,

module Main(main) where

import System.IO
import Control.Exception

main :: IO ()
main = 
    bracket (openFile "hoge.txt" ReadMode) hClose $ \h ->
        hGetLine h >>= putStrLn


その他今回は触れられなかった(のでいつか追記するかもしれない)トピック

  • コマンドライン引数, 環境変数の処理
  • getContents と遅延 I/O
  • バッファリングモードの設定
  • バイナリ操作
  • ファイルの権限操作
  • 独自の例外の定義