星にゃーんのブログ

ほとんど無害。

継続モナドで立ち向かうローンパターンと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 }

LeftRightのパターンマッチが繰り返されている。 こういう地獄に落ちると人は「おお神よ!try-catch構文はどこへ行ってしまったのです!」という気分になり、ExceptTなどの例外モナドを使ってparseConfiggetFieldを書き直したくなる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

(サラッと書いてしまったが、こういう書き換えは難しい。コツを掴めるまでしばらくかかるが、できるようになると色々便利。)

これでLeftRightのパターンマッチを一つにできた。あとはContTを使ってdo記法に戻せばいい。 withContTでラップしよう。

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 - 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

このwithMaybeにも対応している。対応しているが、ちょっとやりすぎな気もする。


  1. 例えばparseConfig :: (MonadIO m, MonadError String m) => FilePath -> m Configモナドを使うときはmtlスタイルで書くようにすれば地獄行きをある程度防止できる。Eitherが例外モナドであることを失念しないように気をつけよう。