久々に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 patternやrioはそういうスタンスに見える。
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.BarbieのFunctorB
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.MorphのMFunctor
もよく似ているが、これは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をビルドするときには、全てのm
はAppM
に具体化される。
ResourceTについて
自分はあまり使わないが、外部ライブラリの提供する関数にはMonadResource
をconstraintとして持つものもある。そうすると、上記AppM
モナドにもMonadResourceをつけておくと便利だろうか。
newtype AppM a = AppM (ResourceT (LoggingT (ReaderT Env IO)) a) deriving ( ... MonadResource ...)
しかし、ResourceT
(というかrunResourceT
関数)には、そのモナドコンテクストの終端で未解放リソースを全て解放するという重要な役割がある。Application全体をResourceT
で覆ってしまったら、コンテクスト終端によるリソース解放が機能せず、リソースリークが発生しやすくなるのではないか?
そんなことを考えて少し調べると、以下のブログ記事を見つけた。
要するに、ResourceTが本当に必要になる場面というのは極めて稀なので、そうでなければ普通にbracket patternを使いましょう、というもの。なるほど。
ということで、やはりAppM
にMonadResource
をつけないほうがよさそうだ。外部の関数でMonadResource
を要求する関数がある場合、速攻でrunResourceT
を呼ぶといいのではないか。
doR :: MonadResource m => m () doU :: MonadUnliftIO m => m () doU = runResourceT doR
こうするとMonadResource
の代わりにMonadUnliftIO
constraintが発生する。MonadUnliftIO
は、定義はややこしいが実体は割とお行儀のよい子なので、それほど危険はないはず(その分モナドスタックの自由度は下がるだろうけど)。
参考
- Lessons learned while writing a Haskell application ? gvolpe's blog
- Prefer Typeclasses over Records of Functions - DEV Community
- jaspervdj - Haskell Design Patterns: The Handle Pattern
- The RIO Monad
- mmorph: Monad morphisms
- barbies: Classes for working with types that can change clothes.
- barbies-thで気軽にHKDを堪能しよう [Haskell AdC 14] - モナドとわたしとコモナド
- ResourceT: A necessary evil
- fpco/unliftio: The MonadUnliftIO typeclass for unlifting monads to IO