📗

順を追って理解する Reader モナドの使い方

2021/03/16に公開

前提読者

  • モナドや 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 モナドを実行する
    ...

runReaderReader 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 関数を引数に与えて、中身の値をそのまま取得しています(レコード構文で定義されたフィールド verboseConfig -> 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 モナドなどの他のモナドと併用したい ケースがほとんどでしょう。

例えば、今回の場合は「 verboseTrue の場合にログ出力をする」といった使い方を想定していたわけですが、ファイルにせよ標準出力にせよ、出力するためには IO モナドの利用が必須になってきます。

runApp :: Reader Config Int
runApp = do
    putStrLn "add is called." -- ❗IOモナドではないため利用できない
    return $ x + y

この問題は Reader モナドに限った話ではなく、他のモナドを利用する際にもついてまわる話でモナド変換子などを用いてモナドを合成する必要がでてきます。

今回のケースにおいては Reader の代わりに ReaderT モナドを利用して IO モナドをくるむ必要が出てきます。

以下に、当初想定していた verboseTrue の場合だけ冗長なログ出力を行うコードの例を記載します。

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 モナドを例にした解説記事を書いていますので、よろしければご参考ください。
https://zenn.dev/tobi462/articles/4ae7658d126054

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 の定義

askasks の実装は、以下のように非常にシンプルなものです。

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 モナド内から参照できるようになる。
  • その結果、関数で明示的に引き渡していく必要がなくなる。
  • 設定値など、アプリケーション全体から参照したい値がある場合に便利。
  • askasks で参照でき、local で一時的に環境を書き換えられる。
  • IO モナドなどと組み合わせる場合は ReaderT モナドが利用できる。

Reader モナドは多くの Haskell 入門書でも説明されるのですが、個人的になかなか理解に時間がかかった部類のトピックなので、個人的に分かりやすいように説明を試みてみました。

Reader モナド(や他のモナド)の理解のきっかけになれば幸いです。

参考

http://dev.stephendiehl.com/hask/#reader-monad

P.S.

最近は Swift プログラマのための Haskell 入門 という連載ブログ記事を書いてます。(そろそろ第6回を公開するつもりです・・・)

脚注
  1. Singleton も一種のグローバル変数に変わりありませんが。 ↩︎

  2. 実際の GHC では type Reader r = ReaderT r Identity として型シノニムで 実装 されています。 ↩︎

Discussion