nested Alternative (or MonadPlus)

最近仕事でattoparsecを使ってパーサを書いたりしている。パーサを書く際はAlternativeクラスの(<|>)が便利だ。

(<|>) :: f a -> f a -> f a

(a <|> b)とはざっくり言って、「aを実行して失敗したらbを実行する」ということ。Alternativeをアクションだととらえれば、これは「アクションaが例外を投げたらそれを全てキャッチして無視し、bを実行する」という(やや乱暴な)例外処理として考えることもできる。

さて、Alternativeインスタンスであるモナドはいろいろある。例えばMaybeがそうである。また一方で、Alternativeインスタンスを作るモナドトランスフォーマーもある。例えばExceptTである。

そこで先日ふと気になったのだが、例えばモナド(ExceptT e Maybe)は(<|>)についてどのような挙動を取るのだろうか?この場合、outer monad(ExceptT)とinner monad(Maybe)の2種類の例外が存在する。普段何気なく(<|>)を使っているとこういう基本的なところが分からなくなる。

ということで、実験してみた。

import Control.Applicative ((<|>))
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Except (ExceptT, throwE)

type NestedM = ExceptT String Maybe

outerFail :: String -> NestedM String
outerFail = throwE

innerFail :: NestedM String
innerFail = lift Nothing

ret :: String -> NestedM String
ret = return

で、ghciで試す。

*Main> outerFail "outer_fail" <|> ret "fallback"
ExceptT (Just (Right "fallback"))
*Main> innerFail <|> ret "fallback"
ExceptT Nothing
*Main> outerFail "outer_fail1" <|> outerFail "outer_fail2"
ExceptT (Just (Left "outer_fail1outer_fail2"))
*Main> outerFail "outer_fail1" <|> innerFail <|> outerFail "outer_fail2" <|> ret "fallback"
ExceptT Nothing

これより、以下のことが分かる。

  • NestedMの(<|>)は、outer monad(ExceptT)の例外をキャッチして後続のアクションを実行する。
  • NestedMの(<|>)は、inner monad(Maybe)の例外はキャッチできず、inner monadは即座に例外状態(Nothing)に落ちる。

もちろん、このへんの挙動はモナドトランスフォーマーの仕様次第だが、少なくともExceptTはこうなっているらしい。

ExceptTの実装を見れば分かるが、結局outer monad(ExceptT)のアクションはinner monad(Maybe)のコンテクストで実行される。よってinner monadが例外状態に落ちれば、outer monadに実装された例外処理はそもそも実行されない。よってouter monadで定義された(<|>)はinner monadの例外をキャッチできない。当たり前といえば当たり前のことなのだ。

(<|>)は便利なメソッドだが、モナドスタック中に複数のinstanceが存在する場合は混乱を招くかもしれない。それに対し、MonadThrowとMonadCatchを用いた例外処理(いわゆるJavaっぽい例外処理)は投げられた例外の型によって脱出スコープを制御できる。そのため、一つの例外処理モナド(例えばCatchT)でいろいろな脱出スコープを実現できる。その拡張性・柔軟性が、MonadThrow/MonadCatchの巧みな点かなと思った。