generated at
ReaderT パターン(翻訳)
(訳者:@Lugendre, @hsjoihs)


Haskellの "デザインパターン"についての質問をオンラインで受け取るか読むことがよくある.一般的な回答は,Haskellにはそれらはない,である.多くの言語はデザインパターンを介して問題に対処するものだ.Haskellでは,それらの問題は言語機能(組み込みの不変性,ラムダ,遅延性など)を介して対処する.しかしながら,Haskellのデザインパターンと大まかに言えるだろう,構造化プログラムに関するいくつかのハイレベルな指針を与える余地がまだあると私は考える.

私が今日説明しようとしているデザインパターンは,少なくとも数年の間,非公式の議論で「ReaderTパターン」として言及してきたものである.私はそれをYesodの Handler 型の設計の基礎として使っている.それはStackコードベースの大部分を表現している.そして私は友人,同僚,顧客に定期的にこれを勧めている.

とは言っても,このパターンはHaskellの世界では共通の認識があるわけではなく,多くの人々が自分たちのコードを異なった方法で設計している.だから,私が書いた他の記事のように,これは非常に独断的だが,あくまで私の個人的な,そしてFP Completeの推奨するベストプラクティスであることを忘れないよう.

このデザインパターンを要約してから,詳細と例外を調べてみよう.

アプリケーションはコアのデータ型を定義する必要がある(必要に応じてそれは Env と呼ばれる).
このデータ型は,(ロギング機能やデータベースアクセスのような)モック可能なすべてのランタイム設定とグローバル機能を含む.
いくつかの可変状態を持たなければならない場合は,可変参照としての Env にそれをいれよう( IORef TVar など).
一般に,アプリケーションコードは ReaderT Env IO に内在する.望むなら, type App = ReaderT Env IO と定義するか,さもなくば, ReaderT を直接使うのではなく, newtype ラッパを使うこと.
場合によっては,追加のモナド変換子を使うことができるが,それはアプリケーションの小さなサブセットに限られる.これらのサブセットが純粋なコードである場合はそれが最善である.
オプション: App データ型を直接使用する代わりに, MonadReader MonadIO のようなmtlスタイルの型クラスで関数を作成する.これにより,IOと可変参照によって投げ捨てるように言ったように聞こえる純粋さの一部を回復できる.

これらは一気に理解するには長い.(mtl型クラスのように)それのうちのいくつかは不明瞭かもしれない.そして他の部分(特にその可変参照の部分)はおそらく完全に間違っていると感じられるだろう.これらの主張をありだと思ってもらえるように,ニュアンスを説明するとしよう.

いい感じのグローバル

簡単な部分をざっと説明しよう.グローバル変数は悪く,可変グローバルはもっと悪い.ロギングレベルを設定したいアプリケーションがあるとしよう(たとえば,DEBUGレベルのメッセージを表示すべきか,握りつぶすべきか?).Haskellでは,以下の3つの方法が一般的である.

コンパイル時フラグを使用してどのロギングコードを実行時ファイルに含めるのか制御する
unsafePerformIO を使用して,設定ファイル(または環境変数)を読み込み,グローバルな値を定義する.
main 関数で設定ファイルを読み,それからコードの main 以外の部分に値を(明示的に,または ReaderT を経由して暗黙的に)渡す.

(1)は魅力的だが,経験からするとそれはひどい解決策である.コードベースで条件付きコンパイルを行うたびに,ビルド失敗の可能性があるフラクタルが追加される.5つの条件がある場合は,32(2 ^ 5)種類の可能なビルド設定がある.これら32種類の設定すべてに対して,正しいimport宣言のセットがあることを確認しているか?これをやるのはただただ苦痛である.さらに,デバッグ時にデバッグ情報が不要になることを本当にコンパイル時に決定したいだろうか?私はむしろ,デバッグ中に多くの情報を得るために,コンフィグファイルの false true に反転してアプリを再起動することができたほうが遥かに嬉しい.

(ちなみに,これよりも優れているのは,デバッグレベルを変更するために実行中にプロセスに信号を送る機能だが,ここでは言及しない. ReaderT +可変変数パターンは,これを達成するためのベストな方法の1つである.)

それでは,条件付きでコンパイルすべきではないということに関してあなたは私と同意した.とはいえ、もうアプリケーション全体を作成したので、なんらかの設定値をあちこちに引き回すために書き換えなきゃいけないことを躊躇するだろう.わかる(わかる).実際辛い.だから,ファイルは一回しか読まないし,ランタイム全体で純粋っぽい値だし,全体的に安全そうだし,もう unsafePerformIO を使っちゃえと思うわけだ.しかしながら:

これで,例外が発生する場所についてわずかなレベルの不確定性がある.設定ファイルが見つからないか,無効な場合,どこから例外がスローされるのだろうか?アプリの起動時にすぐに発生してくれる方がはるかに嬉しい.
(残りのコードよりも失敗する可能性が高いと分かっているので)より多いデバッグ情報を使用してアプリケーションの小さな一部分を実行したいとしよう.基本的には,これは全然できない.
何らかの理由で設定ファイルの構文解析中にSTMを使用すると,なんとよくわからんけど,設定値は最初に別の STMブロック内で評価される.(そんなこと起こるわけないんだよなぁ......)
あなたが unsafePerformIO を使うたびに,子猫が一匹死にます.

苦痛をこらえ, Env データ型を定義し,設定値を入れて,あなたのアプリケーションのあちこちに引き回すときがきた.最初からそのようにアプリケーションをデザインしていたなら,問題なくいい感じになる.アプリケーションの開発の後半でそれを行うのは確かに辛いが,以下に述べるいくつかの事項はこのつらみを軽減できる.そして, unsafePerformIO 周りでのデータの競合状態に直面するよりも,機械的なコードの書き換えという少しの苦痛を背負ったほうが絶対に良い.これはHaskellであることを覚えておこう.つまり,私達はランタイムよりもコンパイル時に苦しみたいと思うということだ.

悲運を受け入れ,(3)にオールインしたら,これからは何不自由ない贅沢な暮らしが待っている:

いくつかの新しい設定値を渡したい?簡単,ただ Env 型のフィールドを増やすだけだ.
ログレベルを一時的に上げたい? local を使えば人生バラ色だ.

CPPコード(1)やグローバル変数(2)のような醜いハックに頼る可能性ははるかに低くなる.「「「正しい方法」」」に潜む苦痛を取り除いたからだ.

リソースの初期化

設定値を読む場合は良かったが,もっと良いパターンとしていくつかのリソースの初期化がある.乱数ジェネレータを初期化したり,ログメッセージの送信のために Handle を獲得したり,データーベースプールを設定したり,一時ディレクトリを作りファイルにストアしたりしたいとしよう.これらすべて,なんらかのグローバルの位置からより main の中でするほうがはるかに論理的である.


これらの種類の初期化におけるグローバル変数によるアプローチの利点の一つは,値を使う最初の一回まで初期化を遅らせられることである.これは,いくつかのリソースを必要としないことがあると考えられる場合には嬉しい.しかし,それらがほしいなら, runOnce のようなアプローチを使っても良い.

WriterTとStateTを避ける

一体どうして最後の手段以外のものとして可変参照を推奨するのだろうか.私たちは皆,Haskellの純粋性が最優先事項であり,可変性が悪魔であることを知っている.それに,アプリケーションで時間とともに変える必要があるいくつかの値を持ちたいときには,我々は WriterT StateT と呼ばれる素晴らしいものを知っている.なぜそれらを使わないのか?

実際,初期のバージョンのYesodはまさにそうだった.Yesodはあなたがユーザセッション値を変更して,レスポンスヘッダを設定すできるように, Handler 内で StateT 的なアプローチを使っていた.しかし,ずいぶん前に可変参照に切り替えた.以下が理由だ.

例外下での生存
ランタイム例外があると, WriterT StateT にある状態を失うことになる.可変参照ではそうではない.ランタイム例外がスローされる前の最後の有効な状態を読み取ることができる.これは,レスポンスが notFound のように失敗した場合でも,レスポンスヘッダを設定できるようにするために,Yesodでとても便利に使用されている.

偽の純粋性
私たちは WriterT StateT 純粋であるといい,厳密にはそうだ.しかし,正直に言おう.アプリケーションが完全に StateT に内在しているならば,純粋なコードに期待する,ミュータブルな変数の使用を制限するという性質は持たない.ごまかさずに,可変変数を持っていると言おう!

並行性
put 4 >> concurrently (modify (+ 1)) (modify (+ 2)) >> get の結果は何ですか?あなたはそれが7になるだろうと言うことを望むかもしれないが,それは絶対にない.

考えられる選択肢は, StateT が提供する状態に関して concurrently がどのように実装されているかに依り,4,5,6である.信じられない?次を試してみて.

fpc01.hs
#!/usr/bin/env stack -- stack --resolver lts-8.12 script import Control.Concurrent.Async.Lifted import Control.Monad.State.Strict main :: IO () main = execStateT (concurrently (modify (+ 1)) (modify (+ 2))) 4 >>= print

問題は,状態を親スレッドから両方の子スレッドに複製する必要があり,その後どの子状態が生き残るかを任意に選ぶ必要があることである.または,必要に応じて,両方の子状態を破棄して元の親状態を続けることもできる(ちなみに,このコードがコンパイルされることが悪いことだと言うなら,私もそのとおりだと思うし, Control.Concurrent.Async.Lifted.Safe を使うことを提案する).

異なるスレッド間での可変状態の処理は難しい問題だが, StateT はそれを隠すことはできても,問題を解決することはできない.可変変数を使用すると,これについて考えることを余儀なくされる.「どんな意味論が欲しい? IORef を使用し, atomicModifyIORef に固執する必要があるか? TVar を使うべきか?」これらは妥当な疑問であり,そして私達が考察することを余儀なくされるものである. TVar のようなアプローチの場合,


fpc02.hs
#!/usr/bin/env stack -- stack --resolver lts-8.12 script {-# LANGUAGE FlexibleContexts #-} import Control.Concurrent.Async.Lifted.Safe import Control.Monad.Reader import Control.Concurrent.STM modify :: (MonadReader (TVar Int) m, MonadIO m) => (Int -> Int) -> m () modify f = do ref <- ask liftIO $ atomically $ modifyTVar' ref f main :: IO () main = do ref <- newTVarIO 4 runReaderT (concurrently (modify (+ 1)) (modify (+ 2))) ref readTVarIO ref >>= print

そして,prebaked transformersでもう少し凝ったことさえできる.

WriterTは壊れている
Gabriel Gonzalezが示しているように,正格な WriterT でさえスペースリークがあることを忘れてはいけない.

但し書き
私は時々未だに StateT WriterT を使用する.その代表的な例がYesod の WidgetT だ.これは基本的には HandlerT の上に WriterT が居座っている.そういった文脈では意味がある.なぜなら,

可変状態は,アプリケーションの小さなサブセットに対して変更されることが予想される.
ウィジェットを構築しながら副作用を実行することはできるが,ウィジェットの構築自体は道徳的に純粋なアクティビティである.
例外時に状態を生き残らせる必要はない.何かがうまくいかない場合は,代わりにエラーページを送り返す.
ウィジェットを作成するときに並行性を使用する適切な理由はない.
私のスペースリークの懸念にもかかわらず,私は徹底的に WriterT とその代替案に対してベンチマークを行い,そして WriterT がこのユースケースのための最速であることがわかった(数字は推論を破った).

この規則の他の大きな例外は純粋なコードだ.アプリケーションのサブセットに IO を実行できないがある種の可変状態を必要とするなら,絶対に,100%, StateT を使おう.

ExceptTを避ける

私はすでに, IO 上の ExceptT が悪い考えであることを断言している.手短に言うと, IO は例外がいつでもスローされる可能性があるという契約であり,実際には ExceptT は考えられる例外を文書化していないため,誤解を招く.詳細についてはそのブログ記事に書いてある.

私は, ExceptT を適用した StateT WriterT のいくつかの欠点も同じなので,この話をここに書き直している.例えば,どのようにして ExceptT で並行処理を処理するか?実行時例外では,動作は明らかである. concurrently の使用時に,いずれかの子スレッドが例外をスローすると,もう一方のスレッドが強制終了され,親で例外がスローされる.どのような振る舞いを ExceptT に望む?

繰り返すが,純粋なコードで StateT を使用すべきなのと同様に,実行時例外が契約の一部ではない場合は,純粋なコードで ExceptT 使用しても良い.しかし,一旦主なアプリケーションの変換子から StateT , WriterT , ExceptT を排除したなら,残されるのは…

ReaderTだけ

そして今,あなたは私がこれを「ReaderTデザインパターン」と呼ぶ理由を知っている. ReaderT は前述の他の3つの変換子よりも大きな利点をもつ.変更可能な状態はない.これは単にすべての関数に追加のパラメータを渡すのに便利な方法に過ぎない.そしてそのパラメータに可変参照が含まれていても,そのパラメータ自体は完全に不変である.とすれば,

並行性について述べた状態上書きの問題をすべて無視できる.上記の例で .Safe モジュールがどのように使用できたかに注目しよう.これは, ReaderT によって同時実行するのが実際に安全であるためである.
同様に、 monad-unlift ライブラリパッケージを使うことができる.
モナド変換子の深い積み重なりは紛らわしい.たった1つの変換子にまとめれば,複雑さが大幅に軽減される.
それはあなたにとってのみ単純なのではない.GHCにとっても簡単で,変換子の深さが5のコードよりも ReaderT 1層のコードを最適化する方がはるかにうまい傾向がある.

ちなみに,一度 ReaderT を無批判に受け入れたら,それを完全に捨てて,手動で自分の Env を渡して回ることができる.私たちのほとんどはそれをしていない.なぜならそれはマゾっぽい感じがするからだ( logDebug の呼び出しのたびにロギング関数を得られる場所を教えなければならないと想像してみよう).しかし,変換子の理解を必要としない,より単純なコードベースを作成することも、今や手の届く範囲内だ.

Has型クラスパターン

ロギング関数を含むように,上記の可変関数の例を拡張するとしてみよう.これは次のようになるかもしれない.

fpc03.hs
#!/usr/bin/env stack -- stack --resolver lts-8.12 script {-# LANGUAGE FlexibleContexts #-} import Control.Concurrent.Async.Lifted.Safe import Control.Monad.Reader import Control.Concurrent.STM import Say data Env = Env { envLog :: !(String -> IO ()) , envBalance :: !(TVar Int) } modify :: (MonadReader Env m, MonadIO m) => (Int -> Int) -> m () modify f = do env <- ask liftIO $ atomically $ modifyTVar' (envBalance env) f logSomething :: (MonadReader Env m, MonadIO m) => String -> m () logSomething msg = do env <- ask liftIO $ envLog env msg main :: IO () main = do ref <- newTVarIO 4 let env = Env { envLog = sayString , envBalance = ref } runReaderT (concurrently (modify (+ 1)) (logSomething "Increasing account balance")) env balance <- readTVarIO ref sayString $ "Final balance: " ++ show balance

この例に対するあなたの最初の反応はおそらく,アプリケーションのために Env データ型を定義するのはオーバーヘッドやボイラープレートのようにみえるというのだろう.そのとおりである.とはいえ,私が上で言ったように,より良い長期のアプリケーション開発のプラクティス用意するために最初の段階でつらみを受け入れたほうが良い.さぁ,このパターンに倍プッシュだ......

このコードにはもっと大きな問題がある.密結合すぎる modify 関数は Env の値を全部取り入れているが,ロギング関数はまったく使っていない.同様に, logSomething Env が提供する可変変数を全く使っていない.関数にあまりにも多くの状態を公開するのは悪いことである.

型シグネチャから,コードが何をしているかについての情報を得ることができない.
テストするのはもっと難しい. modify が正しく動いているかを確認するために,我々はあるダミーのロギング関数を提供する必要がある.

ということでこのボイラープレートに倍プッシュし,Has型クラスのテクを使おう.
これは MonadReader やその他の MonadThrow MonadIO と言った他のmtl型クラスと相性が良く,関数が何を要求するのかを厳密に言及することができるようになる.ただし,前もって,大量の型クラスを定義するという犠牲を払う必要がある.これがどのようになるか見てみよう.

fpc04.hs
#!/usr/bin/env stack -- stack --resolver lts-8.12 script {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} import Control.Concurrent.Async.Lifted.Safe import Control.Monad.Reader import Control.Concurrent.STM import Say data Env = Env { envLog :: !(String -> IO ()) , envBalance :: !(TVar Int) } class HasLog a where getLog :: a -> (String -> IO ()) instance HasLog (String -> IO ()) where getLog = id instance HasLog Env where getLog = envLog class HasBalance a where getBalance :: a -> TVar Int instance HasBalance (TVar Int) where getBalance = id instance HasBalance Env where getBalance = envBalance modify :: (MonadReader env m, HasBalance env, MonadIO m) => (Int -> Int) -> m () modify f = do env <- ask liftIO $ atomically $ modifyTVar' (getBalance env) f logSomething :: (MonadReader env m, HasLog env, MonadIO m) => String -> m () logSomething msg = do env <- ask liftIO $ getLog env msg main :: IO () main = do ref <- newTVarIO 4 let env = Env { envLog = sayString , envBalance = ref } runReaderT (concurrently (modify (+ 1)) (logSomething "Increasing account balance")) env balance <- readTVarIO ref sayString $ "Final balance: " ++ show balance

なんてこったボイラープレートだ!そう,型シグネチャは長くなり,まるっきり同じようなインスタンスが書き込まれる.しかし,我々の型シグネチャは非常に有益になり,関数を簡単にテストすることができる.例えば,

fpc05.hs
main :: IO () main = hspec $ do describe "modify" $ do it "works" $ do var <- newTVarIO (1 :: Int) runReaderT (modify (+ 2)) var res <- readTVarIO var res `shouldBe` 3 describe "logSomething" $ do it "works" $ do var <- newTVarIO "" let logFunc msg = atomically $ modifyTVar var (++ msg) msg1 = "Hello " msg2 = "World\n" runReaderT (logSomething msg1 >> logSomething msg2) logFunc res <- readTVarIO var res `shouldBe` (msg1 ++ msg2)

そして,これらすべての型クラスを手動で定義することが悩ましいか,ライブラリの大ファンであれば,Lensを自由に使用することができる.


fpc06.hs
#!/usr/bin/env stack -- stack --resolver lts-8.12 script {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE FunctionalDependencies #-} import Control.Concurrent.Async.Lifted.Safe import Control.Monad.Reader import Control.Concurrent.STM import Say import Control.Lens import Prelude hiding (log) data Env = Env { envLog :: !(String -> IO ()) , envBalance :: !(TVar Int) } makeLensesWith camelCaseFields ''Env modify :: (MonadReader env m, HasBalance env (TVar Int), MonadIO m) => (Int -> Int) -> m () modify f = do env <- ask liftIO $ atomically $ modifyTVar' (env^.balance) f logSomething :: (MonadReader env m, HasLog env (String -> IO ()), MonadIO m) => String -> m () logSomething msg = do env <- ask liftIO $ (env^.log) msg main :: IO () main = do ref <- newTVarIO 4 let env = Env { envLog = sayString , envBalance = ref } runReaderT (concurrently (modify (+ 1)) (logSomething "Increasing account balance")) env balance <- readTVarIO ref sayString $ "Final balance: " ++ show balance

Env に不変のconfigスタイルのデータが一つも含まれていない場合,Lensアプローチの利点はそれほど明白ではない.しかし,深くネストされた設定値があり,特にアプリケーション全体でその中のいくつかの値を local を使って少しだけ変えたい場合は,Lensアプローチはうまくいくだろう.

要約すると,このアプローチは実際に我慢することと,最初のつらみとボイラープレートを甘んじて受け入れることである.私はあなたがアプリケーション開発の間にこのパターンから得る無数の利益が,それらを受け入れる価値が十分にあると言える.覚えておこう.一回前払いすれば,毎日報酬を受け取れるのだ.

純粋性を取り戻せ

我々の modify 関数に MonadIO 制約があるのは残念である.実際の実装では副作用を実行する(具体的に言えば, TVar の読み書き)ために IO が必要だが,その関数のすべての呼び出しに,「ミサイルを打ったり,さらに悪いことに,ランタイム例外を投げることを含む,任意の副作用を実行する権利を持つ」という宣言を汚染させる.ある程度の純粋性を取り戻すことができるのだろうか?答えはイエスである.ただ,それを行うには,もう少しボイラープレートが必要になる.

fpc07.hs
#!/usr/bin/env stack -- stack --resolver lts-8.12 script {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} import Control.Concurrent.Async.Lifted.Safe import Control.Monad.Reader import qualified Control.Monad.State.Strict as State import Control.Concurrent.STM import Say import Test.Hspec data Env = Env { envLog :: !(String -> IO ()) , envBalance :: !(TVar Int) } class HasLog a where getLog :: a -> (String -> IO ()) instance HasLog (String -> IO ()) where getLog = id instance HasLog Env where getLog = envLog class HasBalance a where getBalance :: a -> TVar Int instance HasBalance (TVar Int) where getBalance = id instance HasBalance Env where getBalance = envBalance class Monad m => MonadBalance m where modifyBalance :: (Int -> Int) -> m () instance (HasBalance env, MonadIO m) => MonadBalance (ReaderT env m) where modifyBalance f = do env <- ask liftIO $ atomically $ modifyTVar' (getBalance env) f instance Monad m => MonadBalance (State.StateT Int m) where modifyBalance = State.modify modify :: MonadBalance m => (Int -> Int) -> m () modify f = do -- Now I know there's no way I'm performing IO here modifyBalance f logSomething :: (MonadReader env m, HasLog env, MonadIO m) => String -> m () logSomething msg = do env <- ask liftIO $ getLog env msg main :: IO () main = hspec $ do describe "modify" $ do it "works, IO" $ do var <- newTVarIO (1 :: Int) runReaderT (modify (+ 2)) var res <- readTVarIO var res `shouldBe` 3 it "works, pure" $ do let res = State.execState (modify (+ 2)) (1 :: Int) res `shouldBe` 3 describe "logSomething" $ do it "works" $ do var <- newTVarIO "" let logFunc msg = atomically $ modifyTVar var (++ msg) msg1 = "Hello " msg2 = "World\n" runReaderT (logSomething msg1 >> logSomething msg2) logFunc res <- readTVarIO var res `shouldBe` (msg1 ++ msg2)

今, modify 関数全体が型クラスの中にあるため,この短い例は馬鹿げている.しかし,より大きい例なら, ReaderT パターンを最大限に活用しながら,ロジック全体で任意の副作用が発生しないように指定できることがわかる.

別の言い方をするなら, Monad に内在するので, foo :: Monad m => Int -> m Double 関数を純粋でないように見えるかもしれないが,そうではない.「 Monad の任意のインスタンス」という制約を関数に与えることによって,我々は「これは実際の副作用はもたない」と述べている.結局の所,上記の型は Identity に単一化され,もちろんこれは純粋である.

この例は少し凝っているように感じるかもしれないが, parseInt :: MonadThrow m => Text -> m Int はどうだろうか?あなたは,「それは純粋でなく,ランタイム例外を投げる」と思うかもしれない.しかしながら,この型は parseInt :: Text -> Maybe Int に単一化され,もちろんこれは純粋である.我々は関数について多くの知識を得ており,それを安全に呼べると感じられる.

要約すると,あなたの関数をmtlスタイルの Monad 制約に一般化できるなら,そうすること.あなたは純粋性が持っている多くの利点を取り戻すことができる.

分析

ここでのテクニックは確かに多少手間がかかるが,大規模なアプリケーションやライブラリ開発ではそのコストは償却される.私はこのスタイルで作業することでの利点が,多くの現実のプロジェクトのコストを遥かに上回ることを見出した.

他にも問題がある.例えば,プロジェクトに参加している人にとって,エラーメッセージがもっと複雑になり,認知的なオーバーヘッドが増える.しかし,私の経験では,誰であっても,このアプローチに一旦入信すれば,うまくいく.

上記の具体的な利点に加えて,このアプローチを使用すると,現実世界で人々が経験している多くの一般的なモナド変換子スタックの問題点を回避できる.私は,他の人たちがそれらの現実の例を共有することを勧める.私はこのアプローチに固執しているので,個人的には長い間これらの問題に出会っていない.

公開後の更新

17年6月15日 ImplicitParamsについてのAshleyからの以下のコメントはその拡張に関する問題についてRedditの議論を生み出した .あなた自身で議論を読むこと.しかし私にとってのその議論でのキーポイントはMonadReaderがより良い選択であるということである.