久々にHaskellでプログラム書いて学んだこと (1): 抽象化レイヤとモナドについて

最近、久々にそこそこのボリュームのHaskellプログラムを書いた。ぶっちゃけ簡単なWebアプリだが、OAuth2(というかOpenID Connect)によるログイン機能を盛り込み、バックグラウンドでAWSのサービスをいくつか扱う。

その開発でいろいろと思うところ、学んだところがあったのでまとめておく。

抽象化レイヤの作り方

プログラムコンポーネントの複数の実装を使い分けられるように、抽象化レイヤを作ることはよくある。代表的なのはストレージコンポーネントで、扱うデータベースの種類や性質の違いを吸収するAPIを定義したくなる。

Haskellでこういった抽象化レイヤを作るやり方は複数あるようだが、代表的なものは以下になるだろうか。

Monad typeclassパターン

Monadを想定したデータ型に対するtypeclassとしてAPIを定義するやり方。例えば

class MonadItemStore m where
  putItem :: Item -> m ItemId
  getItem :: ItemId -> m (Maybe Item)

といった感じ。

この場合、APIを実装するために必要なデータ(例えばデータベースハンドルなど)は全てMonad mのコンテクストに入れることになる。

mがtype parameterなので、IOベースの実装もできるし、非IO(pure)実装も表現できる。

Record of functionsパターン

APIをrecord data typeとして定義するやり方。例えば、

data ItemStore m
  = ItemStore
    { putItem :: Item -> m ItemId
    , getItem :: ItemId -> m (Maybe Item)
    }

こちらでもMonad mをtype parameterとすることで様々なMonadによる実装を実現できる。

IOを基本とするやり方

上記は一般のMonad mについてpolymorphicなやり方だが、Monadは基本的にIOを使うという開き直ったアプローチもある。 Handle patternrioはそういうスタンスに見える。

rioはHandle patternとMonadReaderを複合したものとみなせるだろうか。つまり、

class HasItemStore env where
  putItem :: env -> Item -> IO ItemId
  getItem :: env -> ItemId -> IO (Maybe Item)

putItem' :: (MonadReader env m, HasItemStore env, MonadIO m) => Item -> m ItemId
putItem' i = do
  e <- ask
  liftIO $ putItem e i

こんな感じでHandle相当のデータ構造の存在を示すHas系typeclassとMonadReaderを組み合わせて使う。

で、どれを使うか?

それぞれpros/consがあるようなので一概にどれがベストとは言えないようだが、今回はrecord of functionsパターンを使うことにした。

Monad typeclassパターンの場合、API実装のために必要なコンテクスト(例えばデータベースハンドルやバックグラウンドスレッドと通信するためのキューなど)は全てMonadに押し込む必要がある。結果として扱うMonadの構造がかなり複雑になり、type parameter m に対するconstraintも複雑になりすぎる気がする。

一方、record of functionsパターンはrecord data typeにコンテクストを持たせられるため、Monad mに対する条件はかなり緩くなる。ぶっちゃけ大抵の場合IOでいいだろう。そうなるとHandle patternとほぼ同じになるので、扱う側はかなり気が楽になる。ただ、書いてみるとSTMを使える場面にも遭遇したので、Monadをpolymorphicにする意義はあるように思う。

また、Monad typeclassパターンだとMonad mを含まないmethodは原則としてtypeclass内に含められないが、record of functionsパターンなら含めることができる。

data ItemStore m =
  ItemStore
  { putItem :: Item -> m ItemId
  , getItem :: ItemId -> m (Maybe Item)
  , storeName :: String
  }

record of functionsのモナドパラメータ変換

record of functionsパターンの弱点としては、処理の途中で扱うMonadが変わる場合に対応するのが大変、ということがあるかもしれない。

例えば、

hogeItem, fooItem :: Item

other :: OtherData

newItemStore :: MonadIO m => IO (ItemStore m)

doSomeJob :: MonadResource m => ItemStore m -> OtherData -> m ()

main :: IO ()
main = do
  s <- newItemStore :: IO (ItemStore IO)
  hogeId <- putItem s hogeItem
  _ <- getItem s hogeId
  runResourceT $ do
    doSomeJob s other  ---- ERROR!

上記のコードではItemStore IOを生成している。このItemStoreを後続のdoSomeJob関数に渡そうとしているが、doSomeJobではモナドとしてm = ResourceT IOを使っているため、m = IOであるItemStoreを渡すことができず、コンパイルエラーになる。

上記の問題を解決するには、ItemStore IO -> ItemStore (ResourceT IO)という変換を実施する必要がある。これには、下記のnatural transformationを利用すればいいだろう。

iToR :: IO a -> ResourceT IO a
iToR = liftIO

あとは、

hoistItemStore :: (forall a .  f a -> g a) -> ItemStore f -> ItemStore g

という関数を書けばhoistItemStore iToRで必要な変換ができる。問題はhoistItemStoreのような関数をrecord of functions型それぞれについて書くのがちょっと面倒という点だ。

なお、上記のhoistItemStoreの一般化バージョンとしては、Data.Functor.BarbieFunctorB typeclassが使えそうだ。

class FunctorB (b :: (k -> Type) -> Type) where
  bmap :: (forall a. f a -> g a) -> b f -> b g

barbiesはもともとHigher-Kinded Data(data D f = D { a :: f Int, b :: f String }みたいなデータ構造)のためのライブラリらしいが、record of functionsもFunctorBのinstanceにすることはできる。ただ、現時点ではautomatic derivingはうまく動かないようだ (#19を参照)。

それにしてもBarbieとはなんともキラキラネームだが、他にいい名前はないのかもしれない。Control.Monad.MorphMFunctorもよく似ているが、これはmonad transformerと同じkindを持つデータ型にしか使えなさそう。その意味ではbarbiesのほうが自由度が高い。

Application全体を覆うモナド

FunctorB instanceを使えばrecord of functionsのモナドパラメータの変換はわりとやりやすくなるが、それでもいちいち変換するのは面倒だ。

そうすると、やはり具体的なモナドデータ型としては、最強のものを一つ作っておいて、Application全体を通じてそのモナドを使うのがいいだろうか。

newtype AppM a = AppM (LoggingT (ReaderT Env IO) a)
        deriving ( Applicative, Functor, Monad, MonadCatch, MonadThrow
                 , MonadIO, MonadLogger, MonadUnliftIO, MonadReader Env
                 )
instance MonadRandom AppM where
  ...

例えば上記のような最強のモナドAppMを定義しておく。

で、モナドを扱う関数は具体的なAppMモナドは使わず、Monad系typeclassで必要なcapabilityを記述する。

doThisThing :: (MonadLogger m, MonadIO m, MonadThrow m) => Int -> m String

doThatThing :: (MonadReader env m, Has env Hoge, MonadLogger m) => m ()

Applicationをビルドするときには、全てのmAppMに具体化される。

ResourceTについて

自分はあまり使わないが、外部ライブラリの提供する関数にはMonadResourceをconstraintとして持つものもある。そうすると、上記AppMモナドにもMonadResourceをつけておくと便利だろうか。

newtype AppM a = AppM (ResourceT (LoggingT (ReaderT Env IO)) a)
                 deriving ( ... MonadResource ...)

しかし、ResourceT(というかrunResourceT関数)には、そのモナドコンテクストの終端で未解放リソースを全て解放するという重要な役割がある。Application全体をResourceTで覆ってしまったら、コンテクスト終端によるリソース解放が機能せず、リソースリークが発生しやすくなるのではないか?

そんなことを考えて少し調べると、以下のブログ記事を見つけた。

要するに、ResourceTが本当に必要になる場面というのは極めて稀なので、そうでなければ普通にbracket patternを使いましょう、というもの。なるほど。

ということで、やはりAppMMonadResourceをつけないほうがよさそうだ。外部の関数でMonadResourceを要求する関数がある場合、速攻でrunResourceTを呼ぶといいのではないか。

doR :: MonadResource m => m ()

doU :: MonadUnliftIO m => m ()
doU = runResourceT doR

こうするとMonadResourceの代わりにMonadUnliftIO constraintが発生する。MonadUnliftIOは、定義はややこしいが実体は割とお行儀のよい子なので、それほど危険はないはず(その分モナドスタックの自由度は下がるだろうけど)。

参考