LoginSignup
187

More than 5 years have passed since last update.

継続モナドによるリソース管理

Last updated at Posted at 2015-06-21

継続モナドって何に使うんだ問題に対する一つの例。

リソース管理の問題

プログラミングをやっていると必ずまとわり付いてくるのがリソース管理の問題です。ここで指すリソースというのは、ファイルのハンドルだとか、ソケットだとか、排他処理のためのロックだとか、グラフィックのハンドルだとかそういう話で、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にopenFilehCloseをあらかじめ部分適用した型になっています。

そして実際に、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.UtilswithManyという関数があります。この関数は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を引数に取ります。簡単のために、mIdentityを代入した非変換子版の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 }

型パラメータaHandleを、mIOを代入して、ついでに列を揃えてみましょう。

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

withFileContTをつけることによって、継続渡しの関数があたかも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にとってはこれ以上ない強力なツールであって、いくらでも快適に扱うことができます。

こういう一見するとつながりが見えない継続とリソース管理が型というツールでつながって、モナドというフレームワークで便利に扱えるようになるというところに、未だにプログラミングの奥深さを感じたり、底知れぬ怖さを感じたりします。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
187