やさしい Haskell 入門 ( バージョン 98 )
back
next
top
Haskell の I/O システムは純粋に関数的で、なおかつ、伝統的なプログラミン グ言語がもつ表現力をすべて兼備えています。命令型の言語では、プログラムは 世界の現在の状態を確認し変更するアクションを通じて進行します。 典型的なアクションには、グローバル変数を読むこと、設定すること、ファイル への書き込み、入力の読み込み、ウィンドウのオープンなどが含まれています。 このようなアクションは Haskell の一部ですが、言語のコアの部分からは、すっ きりと切離されています。
Haskell の I/O システムはちょっとひるんでしまうような数学の基盤、モ ナド ( monad ) を基礎として築かれています。しかし、I/O システム を使う上で底流にあるモナドの理論を理解する必要はありません。むしろ、モナ ドの理論は、I/O にたまたま適合した概念上の構造です。単純な算術演算を実行 するのに群論を理解する必要がないのと同じで、Haskell の I/O を実行するの にモナド理論を理解する必要ないということです。モナドに関する詳細な説明は、 9 節を参照してください。
I/O システムを構築している、モナド演算子は別の目的にも使用されます。モナ ドのよりつっこんだ観察はのちほどおこないます。ここでは、モナドという用語 を使うのは避けて、I/O システムの使い方に集中しましょう。I/O モナドを単に 抽象データ型だと考えておくのがいいでしょう。
アクションは式言語のHaskellでは呼出すのではなく定義するものです。アクショ ンの定義を評価することでは、実際のアクションは起こりません。むしろ、アク ションは、ここまで考えてきた式の評価というものの外側で起こることです。
アクションはシステムプリミティブとして定義されるようにアトミックなもので もあり、また、一連のアクションの合成でもあります。I/O モナドは複合アクショ ンを構成するためのプリミティブをふくみ、他の言語では ; を使って、 文を一列にならべるのと同じように構成します。このモナドの機構はプログラム のなかのアクションをくっつける糊のような役目をします。
各 I/O のアクションはそれぞれ値を返します。型システムにおいては、返り値
は、IO という「タグ」がつけられています。これはアクションを他の
値と区別するためです。たとえば、関数 getChar の型は、
getChar :: IO Char
です。この IO Char は getChar が呼出されたときに、
文字を返すなんらかの動作をする、ということを示しています。意味のある値を
返さないアクションにたいしてはユニット型 () を用います。たとえ
ば、関数 putChar の型は、
putChar :: Char -> IO ()
で、この関数は引数として文字をとりますが、特に意味のある値は返しません。
アクションは、すこし謎めいた名前の演算子 >>= (あるいは `bind' )を使って、順序付けをします。この演算子を直接使わず、 do 記法という糖衣構文を使うこともできます。この記法では、順序制 御の演算子は構文の後に隠れますので、伝統的な言語に近くなります。 do 記法はレポートの §3.14 にあるように簡 単に >>= に展開することができます。
キーワード do により順に実行される文のならびというのが導入され
ます。文はアクションであるとともに、<- を使ってアクションの
結果に束縛されたパターンであり、let を使った局所的な定義の集合
でもあります。do 記法は let や where と同じく、
レイアウトを使います。ですので、適正なインデントをつかえば、波括弧とセミ
コロンを省略することができます。次のは一文字よみこみそれを印字する簡単は
プログラムです。
main :: IO ()
main = do c <- getChar
putChar c
main の使い方は重要です。main は (C の main
関数と同様に) Haskell のプログラムのエントリポイントとして定義されていま
す。main は IO 型でなければなりません。ふつうは
IO () です。main は Main モジュールのな
かだけで特別な意味をもちます。モジュールに関しては後ほど詳しくふれます。)
上のプログラムは順に 2 つのアクションを実行します。最初に、一文字に読み
込み、その結果を変数 c へ束縛します。それからその文字を印字しま
す。変数のスコープが定義全体にわたる let 式とはちがい、
<- で定義した変数は、そのあとに続く文のなかでのみ有効です。
まだ、説明していないポイントがあります。to を用いて、アクション
を起動し、その結果を知ることができますが、一連のアクションから結果を返す
のはどうすればいいのでしょうか。たとえば、一文字読み込んでその文字が
'y' であれば、True を返す ready という関数を
考えてみましょう。
ready :: IO Bool
ready = do c <- getChar
c == 'y' -- ダメ!!!
この関数は、do のなかの 2 つめの文が、アクションではなく、単な
る真理値ですので、動作しません。この真理値をとって、なにもしないが、結果
としてこの真理値を返すアクションを生成しなければなりません。
return 関数はまさにこれを行います。
return :: a -> IO a
この return 関数はプリミティブの順序実行を完成させます。
ready の最後の行は、return (c == 'y')
でなくてはなりません。
では、もっと複雑な I/O 関数を見てみましょう。まず、getLine 関数
です。
getLine :: IO String
getLine = do c <- getChar
if c == '\n'
then return ""
else do l <- getLine
return (c:l)
else 節にある、ふたつめの do に注目してください。それぞれの
do は一本の文の列を構成しています。if のように介入的な
言語要素はその先のアクション列を導入するのに新たな do を使う必
要があります。
return 関数は真理値などの一般の値を I/O アクションの領域に入れ
ます。その逆はどうでしょう。いくつかの I/O アクションをふつうの式のなか
で起動できるでしょうか。たとえば、x + print y を
式のなかに入れて、 がその式の評価の結果として印字されるようにす
ることはできるでしょうか。答は、「できない」です。純粋関数型のコードのな
かで、こっそりと命令型の世界にしのび込むことはできません。命令型の世界に
「影響される」値にはそれとわかるタグがついている必要があります。次のよう
な関数
f :: Int -> Int -> Int
は絶対に I/O を行うことはできません。それは IO が返り値にあらわ
れていないからです。このことは、デバッグのときに文字通りコード全体にわたっ
て print 文を埋めこむことの多いプログラマにとっては悩みの種でしょう。実
際のところは、この問題に対処するために、いくつかの安全ではない関数が用意
されています。しかし、これらは、上級プログラマにとっておいた方がよいも
のです。デバッグ用のパッケージ ( Trace など ) は往々にして、完
全に安全な手法では「禁じ手」となっている関数を気前よく使っています。
I/O アクションはふつうの Haskell の値です。関数にわたされることもあるで
しょうし、構造のなかに入れることも、Haskell のほかの値とおなじようにでき
ます。次にアクションのリストを考えてみましょう。
todoList :: [IO ()]
todoList = [putChar 'a',
do putChar 'b'
putChar 'c',
do c <- getChar
putChar c]
このリストは実際にはアクションを起動することはありません。アクションを保
持しているだけです。これらのアクションを一つのアクションにまとめるのは、
sequence_ のような関数が必要です。
sequence_ :: [IO ()] -> IO ()
sequence_ [] = return ()
sequence_ (a:as) = do a
sequence as
この関数は、なにもせずに、do x;y を
x >> y へ展開します。( 9.1 節を参照のこと。)
このパターンの再帰は、foldr 関数でとらえることができます。
(foldr) の定義はプレリュードを参照のこと。) さらによい
sequence_ の定義は以下のようなものです。
sequence_ :: [IO ()] -> IO ()
sequence_ = foldr (>>) (return ())
この do 記法は便利なツールですが、この場合には、その底流にある
モナド演算子 >> を使うほうが適当です。do の構築
の基になる演算子を理解することは、Haskell プログラマにとって役にたつこと
です。
sequence_ 関数は、putStr を putChar をつかっ
て構築するときにも使えます。
putStr :: String -> IO ()
putStr s = sequence_ (map putChar s)
Haskell と伝統的な命令型の言語との違いのひとつがこの putStr に
みてとれます。命令型の言語では、命令型の putChar を文字列上でマッ
ピングするだけで印字が可能です。しかし、Haskell では map 関数は
アクションを実行しません。そのかわり、それぞれ、文字列の一文字に対応する
アクションのリストを生成します。sequence_ のなかの畳み込み演算は
>> 関数をつかって、それぞれの個別の演算をひとつの演算にま
とめます。ここでも return () は必要です。foldr は
一連のアクションの最後になにもしないアクションを必要とします。(とくに文
字列が空の場合。)
プレリュードやライブラリには I/O アクションの順序付けに便利な関数がたく さんあります。これらの関数はだいたいのものが、任意のモナド用に汎用化され ています。Monad m => という文脈を含む関数はどれも IO 型で使えます。
ここまでは、I/O 演算中の例外の問題を避けてきました。もし、 getChar がファイルの最後にあたったら、どうなるでしょう。 ( _|_ に対しては error という用語をつかいます。これは、 停止しない、あるいは、パターンマッチが失敗した、というような回復不可能な 条件の場合につかいます。一方、例外は、I/O モナド内で捕捉可能および処理可 能なものです。) I/O モナド中での、「file not found」などの例外的状況をあ つかうには、standard ML の入出力にある機能にあるような、ハンドリング機構 を使います。特別な構文や意味論を用いることはありません。例外処理は、I/O の順序付け演算子の定義の一部になっています。
エラーは特別なデータ型 IOError としてコード化されています。この
型は I/O モナド中でおこりうるすべての例外を表わしています。これは一種の
抽象型で、ユーザが利用可能な IOError の構築子はありません。述語
がいくつか IOError 型の値にたいして使うことができます。たとえば、
isEOFError :: IOError -> Bool
という関数はエラーが、ファイル終端条件でひきおこされたものかどうかを判定
します。IOError を抽象型とすることで、明示的なデータ型の変更な
しで、新しい種類のエラーをシステムに導入することができます。この
isEOFError 関数はプレリュードとは別の IO ライブラリの
なかで定義されています、それゆえ、プログラム中に明示的にインポートする必
要があります。
例外ハンドラ は IOError -> IO a とい
う型になります。catch 関数は例外ハンドラをアクションあるいはア
クションの集まりと関連づけます。
catch :: IO a -> (IOError -> IO a) -> IO a
catch の引数はアクションとハンドラです。アクションが成功すれば、
ハンドラを起動せずにその結果だけを返します。エラーが起これば、
IOError 型の値をハンドラにわたし、そのハンドラに関連づけられて
いるアクションを起動します。例として、エラーにであうと改行を返す
getChar をあげておきましょう。
getChar' :: IO Char
getChar' = getChar `catch` (\e -> return '\n')
これは、すべてのエラーについて同じ処理をしていますので、あまり洗練された
実装ではありません。もし、ファイル終端になった場合だけをわけたい場合には、
エラーの種類を確かめなければなりません。
getChar' :: IO Char
getChar' = getChar `catch` eofHandler where
eofHandler e = if isEofError e then return '\n' else ioError e
ここで使われている ioError 関数は次の例外ハンドラに例外を投げま
す。ioError の型は、
ioError :: IOError -> IO a
です。これは、つぎの I/O アクションを実行するかわりに、制御を例外ハンド
ラに移すという点をのぞけば、return と類似しています。
catch の入れ子になった呼び出しも可能です。入れ子になった
catch の呼び出しは、入れ子になった例外ハンドラを生成します。こ
の例を、getChar' を使って getLine を再定義することで示
しましょう。
getLine' :: IO String
getLine' = catch getLine'' (\err -> return ("Error: " ++ show err))
where
getLine'' = do c <- getChar'
if c == '\n' then return ""
else do l <- getLine'
return (c:l)
入れ子になったエラーハンドラは getChar' がファイル終端を捕捉す ることを可能にしています。一方で、ほかのエラーがおこると "Error: " ではじまる文字列が、getLine' からもどり ます。
Haskell では利便性確保のために、プログラムの最上位レベルにプログラムを停 止し、例外を印字する例外ハンドラが用意されています。
I/O モナドや例外処理機構が提供するもののほか、Haskell の I/O は、他の言 語にあるものとほとんど同じ機構が備わっています。これらの関数の多くは、プ レリュードではなくて、IO ライブラリにあります。それゆえ、スコー プのなかへ明示的にインポートする必要があります。(モジュールとインポート にいついては 11 節で議論します。) さらに、これらの関数の多くはメインのレポートではなく、ライブラリレポート の方で議論されています。
ファイルをオープンするとハンドル (型は Handle ) が生成
されて、これを使って I/O のやりとりをします。このハンドルをクローズする
と、それに関連付けられているファイルがクローズします。
type FilePath = String -- path names in the file system
openFile :: FilePath -> IOMode -> IO Handle
hClose :: Handle -> IO ()
data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode
ハンドルはチャネルにも関連付けられます。チャネルはファイルとは
直接結びつかないコミュニケーションポートです。いくつかのチャネルハンドル
は定義済になっています。たとえば、stdin (標準入力)、
stdout (標準出力)、stderr (標準エラー) です。
hGetChar や hPutChar の文字単位の I/O 演算は、引数のひ
とつとして、ハンドルをとります。さきほどの getChar 関数は次のよ
うに定義されています。
getChar = hGetChar stdin
Haskell では内容全体をひとつの文字列として返すファイルやチャネルが使えま
す。
getContents :: Handle -> IO String
実行上、getContents はチャネルやファイルの全内容を一度にすべて
読みこまなければならず、メモリや実行時間が足りなくなるということが起こり
そうに見えます。しかし、それは違います。鍵となる要点は、
getContents は文字の「遅延」(すなわち非正格)リストを返すという
ことです。(Haskell では文字列は単なる文字のリストであるということを思い
出してください。) このリストの要素は、他のリストと同様に、「必要になって
はじめて」読み込まれるのです。この要求駆動の振舞いの実装は、計算側からの
要求があるたびに、一度に一文字づつファイルから読みこむことで実現すること
が期待されています。
次の例はファイルをコピーする Haskell のプログラムです。
main = do fromHandle <- getAndOpenFile "Copy from: " ReadMode
toHandle <- getAndOpenFile "Copy to: " WriteMode
contents <- hGetContents fromHandle
hPutStr toHandle contents
hClose toHandle
putStr "Done."
getAndOpenFile :: String -> IOMode -> IO Handle
getAndOpenFile prompt mode =
do putStr prompt
name <- getLine
catch (openFile name mode)
(\_ -> do putStrLn ("Cannot open "++ name ++ "\n")
getAndOpenFile prompt mode)
遅延性のある getContents 関数を使うと、ファイルの内容全部を
一度にメモリへ読み込む必要がなくなります。 もし、hPutStr がバッ
ファとして固定長の文字列を採用するなら、一度にメモリへ読み込むには、入力
ファイル 1 ブロック分だけが必要です。入力ファイルは最後の文字が読み出さ
れれば、暗黙のうちにクローズされます。
最後に、I/O プログラミングは重大な問題を表面化したことについて考えましょ
う。このスタイルは従来の命令型のプログラミングとさしてかわらないではない
かということです。たとえば、getLine 関数ですが、
getLine = do c <- getChar
if c == '\n'
then return ""
else do l <- getLine
return (c:l)
これは、つぎの命令型のコード(実際の言語のコードではありません)と酷似して
います。
function getLine() {
c := getChar();
if c == `\n` then return ""
else {l := getLine();
return c:l}}
ということは、結局、Haskell は単に命令型の車輪を再発明しただけなのでしょ
うか。
あるいみでは、そのとおりです。I/O モナドは Haskell のなかに小さな命令型 のサブ言語を構成しています。それゆえ、プログラムの I/O の構成要素は従来 の命令型のコードにそっくりになるのです。しかし、ひとつ大きな違いがありま す。これを扱うのに特別な意味論を必要としないということです。特に、 Haskell における等式論証の仕組はなんら損われていません。プログラム中のモ ナドのコードの命令型のフィーリングは Haskell の関数的な側面をそこなうも のではありません。経験を積んだ関数プログラマはプログラム中の命令型の構成 要素を最小限にし、トップレベルで順序付けを最小にし、それについてだけ、 I/O モナドを使用することができるにちがいありません。モナドはプログラムの 関数的な構成要素と命令的な構成要素を綺麗にわけます。一方で、関数的な サブセットをもつ命令型の言語では純粋に関数的な世界と命令的な世界をへだて るものがハッキリとは定義されていません。