継続モナドで立ち向かうローンパターンとEither地獄
Haskellでファイルなどのリソースの解放を保証するテクニックとして、ローンパターン(Loan Pattern)がある。withFile :: FilePath -> IOMode -> (Handle -> IO r) -> IO r
などがその例だ。
ローンパターンによる関数を複数使ったプログラムは、無名関数のネストが深くなる。
main = do withFile "src.txt" ReadMode \src -> withFile "dst.txt" WriteMode \dst -> ...
この問題には、継続モナドContT
を使ったきれいな解決策が知られている。
main = evalContT do src <- ContT $ withFile "src.txt" ReadMode dst <- ContT $ withFile "dst.txt" WriteMode ...
ミソは、ContTを使うことで、継続渡しスタイルをdo記法に変換できるところにある。
このアイディアを更に深堀りしてみよう。
設定ファイルを読み込みパースする関数parseConfig
と、Config
のあるフィールドを取得する関数getField
があるとする。
設定ファイルを読み込んでフィールドlanguage
を取得し、アプリケーションの言語を変更する処理は次のように書ける。
parseConfig :: MonadIO m => FilePath -> m (Either String Config) getField :: MonadIO m => Config -> m (Either String Value) updateLanguage :: (MonadIO m, MonadState Env) => m () updateLanguage = do ecfg <- parseConfig "app.cfg" case ecfg of Left err -> error err Right cfg -> do elang <- getField cfg "language" case elang of Left err -> error err Right lang -> modify \env -> env { language = lang }
Left
とRight
のパターンマッチが繰り返されている。
こういう地獄に落ちると人は「おお神よ!try-catch構文はどこへ行ってしまったのです!」という気分になり、ExceptT
などの例外モナドを使ってparseConfig
とgetField
を書き直したくなる1。
書き直せるなら万々歳だが、悪しきparseConfig
がすでにコードの深いところまで根を張っていて、そう簡単には修正できないことも多い。
ContT
は、こんなときに助けになる。まずは、継続渡しスタイルを使ってupdateLanguage
を書き換えよう。
updateLanguage :: (MonadIO m, MonadState Env) => m () updateLanguage = do with (parseConfig "app.cfg") \cfg -> with (getField cfg "language") \lang -> modify \env -> env { language = lang } with :: Monad m => m (Either String a) -> (a -> m (Either String b)) -> m (Either String b) with m k = do ea <- m case ea of Left err -> pure $ Left err Right a -> k a
(サラッと書いてしまったが、こういう書き換えは難しい。コツを掴めるまでしばらくかかるが、できるようになると色々便利。)
これでLeft
とRight
のパターンマッチを一つにできた。あとはContT
を使ってdo記法に戻せばいい。
with
をContT
でラップしよう。
updateLanguage :: (MonadIO m, MonadState Env) => m () updateLanguage = evalContT do cfg <- with (parseConfig "app.cfg") lang <- with (getField cfg "language") modify \env -> env { language = lang } with :: Monad m => m (Either String a) -> ContT (Either String b) m a with m = ContT \k -> do ea <- m case ea of Left err -> pure $ Left err Right a -> k a
ややこしいコードを上手く継続渡しスタイルに落としこめれば、ContT
を使ったシンプルなdo記法にリファクタリングできる。
ContT
自体が少々ややこしいので乱用は禁物だが、うまく使えば最小限の変更でプログラムがグッと読みやすくなる。
参考文献
- ローンパターン - haskell-shoen
- Why would you use ContT?
ContT
を使って継続渡しスタイルをdo記法に書き換える例を紹介している。
- Lysxia - The reasonable effectiveness of the continuation monad
- ContT を使ってコードを綺麗にしよう!(元サイトリンク切れのため、Githubで公開されてるMarkdown原稿をリンク)
- 本記事よりもイカした手法を紹介している。
- fallibleというパッケージをリリースしました - Haskell-jp
- ↑の記事をEitherに拡張したもの。
追記というか余談
haskell - Monadic function of `(a -> m (Either e b)) -> Either e a -> m (Either e b)`? - Stack Overflowによると、with
はもっと抽象化できるらしい。
with :: (Monad m, Monad f, Traversable f) => m (f a) -> ContT (f b) m a with m = ContT \k -> do x <- m join <$> traverse k x
このwith
はMaybe
にも対応している。対応しているが、ちょっとやりすぎな気もする。