継続モナドって何に使うんだ問題に対する一つの例。
リソース管理の問題
プログラミングをやっていると必ずまとわり付いてくるのがリソース管理の問題です。ここで指すリソースというのは、ファイルのハンドルだとか、ソケットだとか、排他処理のためのロックだとか、グラフィックのハンドルだとかそういう話で、GCのない言語だとメモリの管理もこれに含まれるでしょうか。
言うまでもなく、リソースを確保した後はしかるべきタイミングで確実に解放してやる必要があります。しかし往々にして、現実のプログラムではリソースの解放漏れが発生してしまいます。単に解放するコードを書き忘れると言うのが一番単純でしょうもない理由ですが、それでも、C言語のようにリソース解放のための特別な仕組みを持たない言語では、これを徹底するのも結構骨の折れることだったりします。それはともかく、もう少し高尚な悩みとしては、例外との組み合わせで発生する解放漏れというのがよく取り沙汰されます。
次のようなコードを考えてみましょう。
copyFile :: FilePath -> FilePath -> IO ()
copyFile from to = do
h1 <- openFile from ReadMode
h2 <- openFile to WriteMode
content <- hGetContents h1
hPutStr h2 content
hClose h1
hClose h2
これはファイルをコピーするといった単純な関数です。二つのファイルをopenFile
で開いて、一方から中身を読み、他方へと書き、それから二つのファイルハンドルを閉じます。
とても短いプログラムですが、思いつく限りでも次のようなリソース周りのバグが考えられます。
- コピー先のファイルのopenに失敗したら(パーミッションがない、ファイルをつくろうとする場所のフォルダが存在しないなど)、コピー元のファイルのハンドルが解放されない。
- コピー元のファイルの読み込みに失敗したら(ファイルが破損している、あるいはこのコードではテキストモードでオープンしているので、無効なUTF8文字列を読むと例外が投げられる)二つのハンドルが解放されない。
- コピー先のファイルへの書き込みに失敗したら(ディスクの容量が足りない場合など)、二つ目ハンドルが解放されない。
- (あまりないはずだが)一つ目のファイルのハンドルの解放に失敗したら、二つ目のファイルのハンドルが解放されない。
こういう類いのバグはとても典型的なもので、長い年月人類が戦い続けてきた問題ですから、今日までに実に様々な解決のアプローチが考えられてきました。
多くの言語が備えるtry-catch-finallyだとか、C#のusingやJava7のtry-with-resource、あるいはC++のブロックスコープのオブジェクトのデストラクタを使う方法、はたまたGoのdeferのようなもの、それから、おそらくまだまだたくさんのよく知られていない方法があると思います。
bracket
関数
Haskellでは、bracket
というプリミティブがこの用途で用いられます。
bracket
:: IO a
-> (a -> IO b)
-> (a -> IO c)
-> IO c
bracket
は三つの引数を取ります。第一引数はリソースa
を確保する関数、第二引数がそのリソースa
を開放する関数で、第三引数が確保したリソースa
を用いて行いたい処理の関数になります。
例えば、ファイルへのアクセス行う処理をbracket
を用いて書いてみると、次のようになります。
fileLength :: FilePath -> IO Int
fileLength path =
bracket (openFile path ReadMode) hClose $ \h -> do
content <- hGetContents h
return $! length content
ここではファイルを扱いますので、リソース確保関数として、openFile path ReadMode
を、リソース解放関数としてhClose
を渡しています。また細かいところですが、hGetContents
は遅延IOを行ってしまうので、length
をとった値を返す際に$!
を使っています。
意図的に例外を発生させてみると、bracket
が正しく動いているかを確かめることが出来ます。
invalidAccess :: FilePath -> IO ()
invalidAccess path =
bracket (openFile path ReadMode) (\h -> putStrLn "close" >> hClose h) $ \h ->
hPutStrLn h "(´・_・`)"
ReadMode
で開いたファイルにhPutStrLn
で書き込みを行おうとしているので、ここで例外が発生するはずです。また、ファイルが閉じられることを確認するために、ファイルを閉じる前にメッセージを表示します。
$ runhaskell.exe test.hs
close
test.hs: test.hs: hPutStr: illegal operation (handle is not open for writing)
このようにbracket
は求める振る舞いであることが確認できます。
with
系関数
ところでリソース確保と解放は、通常は対になっているもので、リソース確保関数を呼んだ場合はそれと対になっている解放関数を呼び出す必要があります。例えば、ファイルを開いたらファイルを閉じる関数。メモリを確保したらメモリを開放する関数。ソケットを開いたら、ソケットを開放する関数。などといった具合です。
bracket
と合わせて使うことを考えると、あるリソース確保関数に対する解放関数が、普通は決まっているということです。そこでHaskellには、あらかじめ対となるリソース確保関数と解放関数をbracket
に部分適用したユーティリティー関数が用意されています。例えば、ファイルを扱うものなら、withFile
という関数があります。
withFile
:: FilePath
-> IOMode
-> (Handle -> IO r)
-> IO r
第一引数と第二引数は、もともとopenFile
に渡していた引数です。第三引数はbracket
に渡していた、リソースを利用する関数の型と同じです。つまりこれは、第三引数以降を考えると、bracketにopenFile
とhClose
をあらかじめ部分適用した型になっています。
そして実際に、withFile
はそのように定義されています。
withFile :: FilePath -> IOMode -> (Handle -> IO r) -> IO r
withFile name mode = bracket (openFile name mode) hClose
このようなAPIは、Haskellではひとつのイディオムになっていて、リソースを確保する必要のある様々なものを扱うために用いられています。
baseパッケージだけでも、次のような関数があるようです(ここから引用)。
withArray :: Storable a => [a] -> (Ptr a -> IO r) -> IO r
withBuffer :: Buffer e -> (Ptr e -> IO r) -> IO r
withCAString :: String -> (CString -> IO r) -> IO r
withForeignPtr :: ForeignPtr a -> (Ptr a -> IO r) -> IO r
withMVar :: Mvar a -> (a -> IO r) -> IO r
withPool :: (Pool -> IO r) -> IO r
また、リソース確保関数と解放関数を別々に提供する代わりに、with系関数のみを提供するケースもあります。これはライブラリのユーザがリソースの解放を怠ることを絶対に許さないという意図でしょう。
さてwithFile
を用いると、それだけで、最初に書いたファイルコピーの関数を正しく例外に対応したコードに書きなおすことができます。
copyFile :: FilePath -> FilePath -> IO ()
copyFile from to = do
withFile from ReadMode $ \h1 ->
withFile to WriteMode $ \h2 -> do
content <- hGetContents h1
hPutStr h2 content
書き直しは至って機械的で、openFileの部分をwithFileに書き換え、ファイルハンドルをクロージャで受け取るようにするだけです。その他に考えることは何もありません。これで最初に挙げたいろいろなバグは全てなくなりました。めでたしめでたしというわけです。
with
の問題点
しかしながら、それでめでたしめでたしということであれば、こんな記事を書いているってことはないわけで。
with
系APIには厄介な点もあって、身も蓋もなく言えばこれを使ったコードはとても書きづらいのです。もしかしたら、JavaScriptのコールバックで苦労した経験のある人ならイメージしやすいかもしれません。コールバックは必要に応じて、どんどんネストが深くなってしまうのです。
先ほどの例を少し拡張して、二つのファイルを連結して別のファイルに書きだすような関数を書いてみましょうか。普通に書けば次のようになると思います。
catFiles :: FilePath -> FilePath -> FilePath -> IO ()
catFiles from1 from2 to = do
withFile from1 ReadMode $ \h1 ->
withFile from2 ReadMode $ \h2 ->
withFile to WriteMode $ \h3 -> do
hPutStr h3 =<< hGetContents h1
hPutStr h3 =<< hGetContents h2
単純にwithFile
が一つ増えて、それにともなって、ネストがひとつ深くなります。ネストというと字面の話になりますが、実際のところ、プログラミングにおいて見栄えというのはとても重要なファクターを占めていますし、コードを追加したりすることでネストが変動するのは、基本的にあまり歓迎されることではありません。また、このようなコードはリファクタリングや部分を切り出しての抽象化がしづらく、自由に編集したり並び替えができるモナドなどと比べると、かなり取り回しの面倒なコードであると言えるでしょう。
それでもただの審美眼的問題なら、自転車置場でやってればいい話なのですが、この組み合わせや抽象化のしづらさというのは、それにとどまらない本質的な問題をはらんでいます。例えば、二つのファイルを連結する代わりに、任意個のファイル名のリストを受け取り、それらの中身を連結して書き出すような関数を書こうとすれば、どうすればよいでしょうか?ちょっとした頭の体操だと思って、考えてみてください。
catFiles :: [FilePath] -> FilePath -> IO ()
catFiles froms to = do
???
すぐに思いついた人は、かなりプログラミングセンスのある人だと思います。二個、三個といった定数個と、任意個との間には、かなり大きな違いがあります。というのも、今回の場合、withFile
がリソース受け取りのためにネストしたクロージャを要求するので、これを任意個に対応させるには、普通に考えれば明示的な再帰が必要になるからです。
なので、ひとまず問題を切り分けて、withFiles
というwithFileの複数ファイルのバージョンがあることにすれば、catFiles
は次のように書けるはずです。
catFiles :: [FilePath] -> FilePath -> IO ()
catFiles froms to =
withFiles froms ReadMode $ \fromhs ->
withFile to WriteMode $ \toh ->
forM_ fromhs $ \fromh -> do
content <- hGetContents fromh
hPutStr toh content
しかる後に、withFiles
を何とかして定義してやります。
withFiles :: [FilePath] -> IOMode -> ([Handle] -> IO a) -> IO a
withFiles [] _mode k = k []
withFiles (x:xs) mode k =
withFile x mode $ \h ->
withFiles xs mode (\hs -> k $ h:hs)
withFiles
自体もwith系のAPIのように定義するのがキモで、そこがちょっとトリッキーですが、そこを決めればあとは自然と埋まるでしょう。
しかしここで重要なのは、この実装がちょっとややこしいとかそういうことではなく、with系関数の組み合わせが上手く抽象化できないということです。どういうことかというと、今回の場合、リストに対するwithFiles
のコードが必要になりました。では、二つのwith系関数を組み合わせて、リソースのタプルに対するwith関数を作るにはどうすればいいでしょうか。タプルのためにそれ用の組み合わせ関数を用意するべきでしょうか。ではMaybe
の場合は?Either
の場合は?
Foreign.Marshal.Utils
にwithMany
という関数があります。この関数はwith系関数と、それに適用する値のリスト、それから確保したリソースのリストを受け取る関数を受け取ります。
withMany :: (a -> (b -> res) -> res) -> [a] -> ([b] -> res) -> res
これを使うとwithFiiles
はこのように書けます。
withFiles fs mode =
withMany (`withFile` mode) fs
その他に、Maybe
関係のいくつかのユーティリティ関数が用意されてはいるものの、これだけで快適にコードが書けるとは言いがたいでしょう。
継続渡しスタイル(CPS)
ところで賢明な読者の皆さんの中にはすでにお気づきの方もいらっしゃるかもしれませんが、with系の関数は、いわゆる継続渡しスタイルと見ることができます。
継続渡しスタイル(Continuation Passing Style, CPS)と呼ばれるものは、関数が値を返す代わりに、返り値を受け取る別の関数を渡すというものです。
foo :: Int -> Int
foo n = n * 2
こういう単純な関数があったとして、これをCPSに書き換えると、
fooCont :: Int -> (Int -> a) -> a
fooCont n k = k (n * 2)
このようになります。呼び出し方としては、
> fooCont 123 $ \i -> print i
246
このようになります。こんなまどろっこしいことをして何が嬉しいのかというと、実際のところこの例では嬉しい事はなにもないのですが、コンパイラの最適化などでは嬉しいケースも有ります。例えば、CPSにおいては、関数呼び出しは必ず末尾呼び出し、つまりただのジャンプとして扱うことができるのです。
よくある例ですが、階乗を求める例をCPSで書いてみましょう。まずは普通のコードです。
fact :: Int -> Int
fact 0 = 1
fact n = n * fact (n - 1)
これをCPSにするには、返り値を受け取る関数を追加してやります。
factCont :: Int -> (Int -> a) -> a
factCont 0 k = k 1
factCont n k = factCont (n - 1) $ \t -> k (n * t)
factCont
を再帰呼び出しするときに、そこの継続に何を渡すのかというのが少し考えるところですが、\t -> k (n * t)
のように新たにクロージャを作って渡してやればよいです。t
にはn-1
の階乗が計算されたものが入ってくるはずなので、それにn
を掛けたものを自分の引数の継続k
に渡して完成です。
毎回クロージャを作るなんて、このほうが効率悪いんじゃないの?と思うかもしれませんが、コンパイラが十分に賢ければ、このクロージャを作るのは回避されることが期待されて、なおかつ再帰呼び出しがジャンプに書き換えられて、このコードは理想的には極めて効率よく実行される「可能性がある」と言えます。「可能性がある」というのは、Haskellのコンパイラはこれをたまによくうまくやってくれないことがあって、でも気分が良ければ調子よくやってくれます。コンパイラの気持ちは複雑で人間は常に弄ばれます。
また、このCPSへの変換は通常機械的に行うことができます。機械的にCPSに変換した後に、アグレッシブな最適化をかけることによって、正しい末尾再帰の実装を特別な枠組みなしに実現したり、関数呼び出しがの最適化がやりやすくなったりしてコンパイラ自体の単純化にも寄与したりします。
継続モナド
話が長くなりましたが、いよいよ継続モナドの出番です。
継続モナドというものが、mtlのControl.Monad.Cont
に定義されています。
class Monad m => MonadCont m where
callCC :: ((a -> m b) -> m a) -> m a
MonadCont
が継続モナドのクラスで、callCC
というメソッドを一つだけ持ちます。callCC
が何かというのは、きっと読者の皆さんはLispやSchemeでcall-with-current-continuation
という関数に慣れ親しんでおられるはずなので、詳しい説明はここでは割愛させていただきますが、ともかくこれがどういう関数かというと、呼ばれた時点の継続を引数の関数に渡してくれるというものです。
callCC
は、CPSではない、普通のスタイルのコンテキストにおける継続を取り出して、プログラマに見せるという機能を提供します。これはどういうことかというと、継続モナドは内部的にはそのようなことが可能な実装になっていなければならないということです。実装はいろいろ考えられますし、普通のスタイルで実行をして、命令ポインタとスタックフレームをコピーしてうんぬんと言ったものももちろんあり得るでしょう。しかし、もっとも直接的な実装は、CPSの関数をモナドのインスタンスにしてしまうというものです。
MonadCont
のデフォルトの実装、ContT
の定義を見てみましょう。
newtype ContT r m a =
ContT { runContT :: (a -> m r) -> m r }
モナド変換子版を提供するためにContT
はベースのモナドの型m
を引数に取ります。簡単のために、m
にIdentity
を代入した非変換子版のCont
を考えてみましょう。
newtype Cont r a =
Cont { runCont :: (a -> r) -> r }
Cont
は(a -> r) -> r
のnewtypeです。これは直前の計算の結果a
を受け取って、残りの計算を行った結果r
を返す関数を受け取る、まさにCPSスタイルの関数を表しています。
同様にContT
は、その結果をモナドm r
に限定したものと考えることが出来ます。型に従って考えると、単にそういうことになります。
ここで最初の方に出てきた、ファイルを開く関数を振り返ってみましょう。withFile
という関数でした。
withFile :: FilePath -> IOMode -> (Handle -> IO r) -> IO r
ここにContT
の型を並べてみます。
ContT { runContT :: (a -> m r) -> m r }
型パラメータa
にHandle
を、m
にIO
を代入して、ついでに列を揃えてみましょう。
withFile :: FilePath -> IOMode -> (Handle -> IO r) -> IO r
ContT { runContT :: (Handle -> IO r) -> IO r }
なんと!!!withFile
の前二つの引数を除いた型が見事一致しました。withFile
はモナド変換子版の継続モナドそのものだったというわけです。
そういうことであればあとは簡単です。ContT
をつけるだけで自動的にモナドのインスタンスになります。具体的に見てみましょう。最初のファイルコピーのコードです。比較のために元のコードを再掲しておきます。
copyFile :: FilePath -> FilePath -> IO ()
copyFile from to = do
withFile from ReadMode $ \h1 ->
withFile to WriteMode $ \h2 -> do
content <- hGetContents h1
hPutStr h2 content
これを継続モナドを使って書き直します。
copyFile :: FilePath -> FilePath -> IO ()
copyFile from to = (`runContT` return) $ do
h1 <- ContT $ withFile from ReadMode
h2 <- ContT $ withFile to WriteMode
content <- liftIO $ hGetContents h1
liftIO $ hPutStr h2 content
withFile
にContT
をつけることによって、継続渡しの関数があたかもopenFile
のような通常の値を返す関数であるかのように使えています。しかもエラー時を含めて、ブロックを抜けた時に、きちんとリソースの解放が行われます。
ContT
モナドのままだと実行できないので、これを実行できるようにContT
を外してやる必要がります。これはnewtypeのコンストラクタであるContT
を外して中の値を取り出してやるだけです。(`runContT` return)
の部分がそれに相当します。runContT
の型を書き出してみると、次のようになっています。
runContT :: ContT r m a -> (a -> m r) -> m r
runContT
はnewtypeのフィールド名ですが、これを普通の関数だと思えば、継続モナドを受け取ってCPSの関数を返す関数と見ることが出来ます。要するに、ContT
をつけるとCPSから普通のスタイルに変わって、runContT
で普通のスタイルからCPSに戻るというイメージです。しかし理屈ではわかっていても、実際に起こっていることはかなり不思議です。でも型が合っているんだからしょうがない。
次はcatFiles
関数を継続モナドで書き直してみましょう。複数のリソースの確保が結構悩ましい問題でした。
catFiles :: [FilePath] -> FilePath -> IO ()
catFiles froms to =
withFiles froms ReadMode $ \fromhs ->
withFile to WriteMode $ \toh ->
forM_ fromhs $ \fromh -> do
content <- hGetContents fromh
hPutStr toh content
しかし、通常のスタイルになってしまえば、何のことはありません。
catFiles :: [FilePath] -> FilePath -> IO ()
catFiles froms to = (`runContT` return) $ do
fromhs <- forM froms $ \from -> ContT $ withFile from ReadMode
toh <- ContT $ withFile to WriteMode
forM_ fromhs $ \fromh -> do
content <- liftIO $ hGetContents fromh
liftIO $ hPutStr toh content
forM
で繰り返してやるだけです。目的のために、今や普通のモナドを扱う関数を利用することができます。リスト向けにwithMany
なんてものを使ったり、あるいは、あれこれ痒いところに届かない合成関数を揃えていく必要もありません。なにせモナドですから、何も考えずにモナドに身を委ねればよいのです。
速度が気になる方がおられるかもしれませんが、これもCPS変換での最適化と同様に(同じではありませんが)、コンパイラが理想的な振る舞いをしてくれれば、最適化されて、完全に元のコードと同じ性能が出ることが期待されます。そして、GHCの新しいバージョンなら、おおよその場合そういう振る舞いをしてくれるはずです。
他のリソース管理に特化したモナド
継続モナドをリソース管理に使えるという話をしてきましたが、Hackageには幾つかリソース管理を目的としたモナドを実装しているパッケージがあります。
Managed
モナド
managedパッケージで定義されているManaged
モナドは、ContT
と全く同じ型で、振る舞いも同じものですが、リソース管理という目的におけるよりユーザーフレンドリーな名前が付いているという点と、リソース管理のために用いるために便利な関数やインスタンスが追加されているというのがモチベーションのようです。
例えば、Managed
型に対して、Monoid
のインスタンスが定義されていたり、Num
を始めとする直接数値として扱うためのインスタンスが定義されています。
ResourceT
モナド
当然ながらリソース管理のための抽象化が一通りということはありませんし、一通りであるという必要もないので、別のアプローチも考えられます。
resourcetパッケージで定義されているResourceT
モナドはずいぶんと趣向が変わって、リソース解放のための関数を集めた配列を持ち回りながら、最後にその配列の関数を実行するモナドです。リソース確保の際に、リリースするためのキーを返すような機能があって、必要であれば計算の途中でもリソースを開放できるような仕組みになっています。
プログラマが望めば、必要なくなったソケットをすぐさま解放するなどのような処理を簡単に記述できますが、もちろんそういう仕組みを使うときは、プログラマ自身が解放済みリソースに触れないように気をつけなければいけません。
また、そのようなリソースの解放を明示的に行える機能を提供する以上、分離されたリソース確保関数と解放関数が必要になるので、with系の関数とは同時に使用することができません。
この辺りはアプリケーションによってトレードオフになるでしょう。ResourceT
モナドはYesodで使うために作られました。YesodはWebフレームワークですので、できるだけ早いタイミングでソケットなどを解放することが重要なようで、逆にそういうことが必ずしも重要でないならば、安全性と利便性で継続モナドを使うことが勝るケースも考えられます。
まとめ
Haskellではリソース管理のためのプリミティブがしっかりと用意されているものの、これらを用いてどうやってうまく、バグがなくてかつ見通しの良いコードを書けばよいのかというのが、一見すると良くわかりません。bracket
を使えだとか、with系APIのようなものが用意されてるとか、自分でライブラリを作る場合はwith系を用意しておくべきだろうかだとか、そういうのは分かるのですが、実際に書いてみるとかなりめんどくさいものです。
ところが型をよく見てみれば、これはCPSと同じ形をしていて、しかもそれが標準的な枠組みであるモナドとして扱えるということがわかってきます。モナドとして扱えるなら、Haskellにとってはこれ以上ない強力なツールであって、いくらでも快適に扱うことができます。
こういう一見するとつながりが見えない継続とリソース管理が型というツールでつながって、モナドというフレームワークで便利に扱えるようになるというところに、未だにプログラミングの奥深さを感じたり、底知れぬ怖さを感じたりします。