順を追って理解する Reader モナドの使い方
前提読者
- モナドや bind 記法について基本的なことは理解している。
- Reader モナドの使い方や仕組みを理解したい。
アプリケーション全体から参照したい設定値
アプリケーションを開発する際に、アプリケーション全体から参照したい設定値のようなものがあるケースがあります。
例えば、以下のような設定値を考えてみます。
data Config = Config { verbose :: Bool } -- 冗長なログ出力が有効か
これはアプリケーション起動時に引数として与えられたり、起動時に設定ファイルから読み込まれることを想定したものです。
このような値はアプリケーションの至るところから参照したくなるはずですが、Haskell ではグローバル変数やオブジェクト指向における Singleton パターンが利用できないため、常に関数の引数で引き渡していく必要があります[1]。
以下のコードは Config
を関数の引数で渡していく例です。
main :: IO ()
main = do
let config = Config { verbose = False } -- 実際には設定ファイルなどから読み込む
let result = runApp config -- `config`を引き渡す
print result
-- 1 + (2 + 3)
runApp :: Config -> Int
runApp config = incr config (add config 2 3) -- ✅ さらに`config`を引き渡す
-- (+)
add :: Config -> Int -> Int -> Int
add config x y = x + y -- 実際には`config`を利用している想定
-- (+1)
incr :: Config -> Int -> Int
incr config x = x + 1 -- 〃
これは短いコード例なのでそこまで面倒に感じないかもしれませんが、アプリケーション中で定義される関数が増えるに従って煩わしくなってくるのは想像に難くないと思います。
Reader モナドの基本
Reader モナドを利用すると、先ほどのコードを以下のように書き換えることができます。
import Control.Monad.Trans.Reader -- `transformers`パッケージが必要
main :: IO ()
main = do
let config = Config { verbose = False }
let result = runReader runApp config -- `runReader`関数で Reader モナドを実行する
print result
runApp :: Reader Config Int
runApp = do
config <- ask -- `ask`関数で`Config`を取得できる
x <- add 2 3 -- ✅ 引数で渡す必要が無い
incr x -- 〃
add :: Int -> Int -> Reader Config Int
add x y = return (x + y)
incr :: Int -> Reader Config Int
incr x = return (x + 1)
モナドを利用するようになったためコードの見た目が変わっていますが、関数を呼び出すたびに config を引き渡す処理が無くなっている のが分かるかと思います。
変更点を順に見ていきます。
関数シグネチャの変更点
add
関数に注目すると、Config
を引数で取る代わりに Reader Config Int
が返り値の型として使用されるように変更されているのが分かります。
- add :: Config -> Int -> Int -> Int
+ add :: Int -> Int -> Reader Config Int
他の関数も同様に、Reader Config <結果の型>
が返り値の型として利用されています。
Maybe モナドが Maybe Int
といったように取り扱いたい型を Maybe
で包んだように、Reader モナドでは Reader <参照したい設定値>
で取り扱いたい型(ここでは Int
)を包んで、 Reader <参照したい設定値> Int
などのようにして利用します。
この”参照したい設定値”のことは環境と呼ぶのが一般的なので、本記事でも以降は”環境”と呼んでいきます。今回の例では Config
が環境となっています。
環境の取得
runApp
関数の本体に注目すると、以下のように ask
関数を利用して環境である Config
を取得しています。
runApp :: Reader Config Int
runApp = do
config <- ask -- config :: Config
x <- add 2 3
incr x
bind 記法で書き直すと、Reader Config a
型で統一されていて、モナドとしてきちんと繋がっているのが分かりやすいかと思います。
runApp' :: Reader Config Int
runApp' =
ask >>= \config -> -- ask :: Reader Config Config
add 2 3 >>= \x -> -- add 2 3 :: Reader Config Int
incr x -- incr x :: Reader Config Int
Reader モナドの実行
最後に main
関数に注目すると、runReader
という関数で Reader モナドを実行しているのが分かります。
main = do
let config = readConfig
let result = runReader runApp config -- `runReader`関数で Reader モナドを実行する
...
runReader
は Reader r a -> r -> a
という型になっており、第1引数に Reader モナド、第2引数に Reader モナドから参照したい環境を指定することで、 Reader モナドを実行して結果を返します。
このように Reader <環境>
というモナドで値を包むことで、ask
などの関数から環境を取得できるようにし、 runReader
関数で実際の環境を指定して実行する、というのが Reader モナドの基本的な使い方になります。
Reader モナドで使える関数
先ほどの例では Reader モナドに利用できる ask
という関数を利用しましたが、他にもいくつか便利な関数が用意されています。
asks 関数
asks
関数を利用すると、環境を取得する際に関数で値を加工することができます。
これは具体的なコード例を見ると分かりやすいでしょう。
runApp :: Reader Config Int
runApp = do
config <- ask -- config :: Config
isVerbose <- asks verbose -- isVerbose :: Bool
ask
が環境である Config
をそのまま取得するのに対して、asks
では verbose
関数を引数に与えて、中身の値をそのまま取得しています(レコード構文で定義されたフィールド verbose
は Config -> Bool
な関数であることを思い出しましょう)。
言い換えると、以下のコードはどちらも等価です。
config <- ask
config <- asks id -- id :: Config -> Config
local 関数
local
関数を利用すると、一時的に環境を書き換えた状態で処理をすることができます。
これも具体的なコード例を見たほうが早いでしょう。
runApp :: Reader Config Int
runApp = do
result <- local (\config -> config { verbose = True }) $ add 2 3
... -- 後続の処理は元の Config が利用される
ここでは verbose の値を True
に変更した状態で Reader モナドである add 2 3
を実行しています。local
の名前どおり、一時的に環境を書き換えて実行するだけなので、後続の処理については元の環境が利用されます。
第2引数は、環境の型が同じ Reader モナドを指定できるので do 記法を利用することもできます。
runApp :: Reader Config Int
runApp = do
result <- local (\config -> config { verbose = True }) $ do
x <- add 2 3
incr x
...
IO モナドと一緒に利用する
さて、ここまで Reader モナドを説明するために、Reader モナドを単独で使用したコード例を見てきましたが、実際には IO モナドなどの他のモナドと併用したい ケースがほとんどでしょう。
例えば、今回の場合は「 verbose
が True
の場合にログ出力をする」といった使い方を想定していたわけですが、ファイルにせよ標準出力にせよ、出力するためには IO モナドの利用が必須になってきます。
runApp :: Reader Config Int
runApp = do
putStrLn "add is called." -- ❗IOモナドではないため利用できない
return $ x + y
この問題は Reader モナドに限った話ではなく、他のモナドを利用する際にもついてまわる話でモナド変換子などを用いてモナドを合成する必要がでてきます。
今回のケースにおいては Reader
の代わりに ReaderT
モナドを利用して IO
モナドをくるむ必要が出てきます。
以下に、当初想定していた verbose
が True
の場合だけ冗長なログ出力を行うコードの例を記載します。
import Control.Monad.Trans.Class -- `lift`関数を利用するために必要
main :: IO ()
main = do
let config = readConfig
result <- runReaderT runApp config -- `runReaderT`を利用する
print result
runApp :: ReaderT Config IO Int -- `Reader`の代わりに`ReaderT`を利用する
runApp = do
verboseLog "runApp is called."
...
verboseLog :: String -> ReaderT Config IO ()
verboseLog message = do
isVerbose <- asks verbose
if isVerbose
then lift $ putStrLn message -- IOモナドを利用する場合は`lift`関数で持ち上げる
else return ()
モナド変換子については、この記事の範囲を超えるので詳しい説明は割愛しますが、Maybe モナドを例にした解説記事を書いていますので、よろしければご参考ください。
Reader モナドの仕組み
Reader モナドは以下のようにして実装することができます[2]。
newtype Reader env a = Reader { runReader :: env -> a }
-- Functor / Applicative の実装は省略
instance Functor (Reader env) where
instance Applicative (Reader env) where
instance Monad (Reader env) where
return a = Reader $ \_ -> a
m >>= k = Reader $ \env -> runReader (k (runReader m env)) env
一般的には、環境を表す env
の部分は r
が使用されて Reader r a
と書かれることが多いですが、ここでは”環境”という言葉からイメージしやすい env
を利用しています。
順に見ていきましょう。
Reader の定義
まず、Reader
の定義を見るとレコード構文で runReader :: env -> a
が定義されています。
newtype Reader env a = Reader { runReader :: env -> a }
これは先述のコード例において main
関数で利用していた runReader
関数にあたり、中身は環境 env
を受け取って a
を返す関数であることが分かります。
runReader runApp config
と記述していましたが、runReader runApp
するとモナドが剥がされた env -> a
という関数が返却されるので、それを config
に適用していると読むと対応が分かりやすいと思います。
return の定義
次に Monad
における return
の実装です。
-- return :: a -> Reader env a
return a = Reader $ \_ -> a
これは引数に渡ってくる環境を無視して、指定された値を返すだけの関数を返すようになっています。
>>= の定義
bind 演算子の実装は以下のようになっています。
-- (>>=) :: Reader env a -> (a -> Reader Env b) -> Reader Env b
m >>= k = Reader $ \env -> runReader (k (runReader m env)) env
まず結果は Reader
にする必要があるため Reader $ \env -> ...
としています。
ラムダ式の中では、最初に runReader m env
で bind の左辺の Reader モナドを剥がして実行し、その後で右辺の k
を適用しています。そしてその適用した結果も Reader モナドで包まれているので、 runReader
で剥がして実行しています。
以下のように let-in
に書き換えると処理がイメージしやすいかもしれません。
-- (>>=) :: Reader env a -> (a -> Reader Env b) -> Reader Env b
m >>= f = Reader $ \env ->
let x = runReader m env -- 左辺の Reader モナドを実行(x :: a)
n = f x -- 右辺を適用(n :: Reader Env b)
in runReader n env -- その結果も剥がして実行(:: b)
bind 演算子の左辺と右辺の計算が結合されて、新しい Reader
モナドに合成される、というイメージすると分かりやすいかもしれません。
ask の定義
ask
や asks
の実装は、以下のように非常にシンプルなものです。
ask :: Reader env env
ask = Reader id
asks :: (env -> a) -> Reader env a
asks f = Reader f
Reader
の中身である runReader
は、Reader { runReader :: env -> a }
とあったように、環境を引数にとって任意の値を返す関数です。
ask
の場合は、引数に与えられた環境をそのまま返せばいいので id
関数を Reader
で包めば十分です。asks
の場合も似たようなもので、id
の代わりに引数に渡された関数を Reader
で包むだけです。
並べて見比べてみる
最後に、通常の書き方と bind 演算子を使った書き方、もっとも自明と思われる Reader
を直接使った書き方の3つを並べてみます。
-- 1. 通常の書き方
runApp :: Reader Config Int
runApp = do
config <- ask
x <- add 2 3
incr x
-- 2. bind 記法を使った書き方
runApp' :: Reader Config Int
runApp' =
ask >>= \config -> -- ask :: Reader Config Config
add 2 3 >>= \x -> -- add 2 3 :: Reader Config Int
incr x -- incr x :: Reader Config Int
-- 3. もっとも自明と思われる書き方
runApp'' :: Reader Config Int
runApp'' =
Reader (\config -> id) >>= \config ->
Reader (\_ -> 2 + 3) >>= \x ->
Reader (\_ -> x + 1)
なお最後の例について、Reader
の値コンストラクタはエクスポートされていないため、transformers
パッケージを利用した場合はコンパイルエラーになりますが、同等の関数である reader
を代わりに使用することができます。
runApp'' :: Reader Config Int
runApp'' =
reader (\config -> id) >>= \config ->
reader (\_ -> 2 + 3) >>= \x ->
reader (\_ -> x + 1)
実際の利用例
手前味噌で恐縮ですが、自作の CLI ツールで ReaderT モナドを使用している箇所 があるので、よろしければご参考ください。
まとめ
- Reader モナドを利用すると、環境を Reader モナド内から参照できるようになる。
- その結果、関数で明示的に引き渡していく必要がなくなる。
- 設定値など、アプリケーション全体から参照したい値がある場合に便利。
-
ask
やasks
で参照でき、local
で一時的に環境を書き換えられる。 - IO モナドなどと組み合わせる場合は
ReaderT
モナドが利用できる。
Reader モナドは多くの Haskell 入門書でも説明されるのですが、個人的になかなか理解に時間がかかった部類のトピックなので、個人的に分かりやすいように説明を試みてみました。
Reader モナド(や他のモナド)の理解のきっかけになれば幸いです。
参考
P.S.
最近は Swift プログラマのための Haskell 入門 という連載ブログ記事を書いてます。(そろそろ第6回を公開するつもりです・・・)
Discussion