[PureScript] 解説!Extensible Effects(拡張可能作用) ~なんでお前そんなことできるんだよ?編~
はじめに
私はこれまでFreeモナドを自分で作ってみる記事、実用されているFreeモナドの処理を解説する記事、多相バリアントの記事などを書いてきました。
それはすべて……Extensible Effects(拡張可能作用)の話をするためだったのです!
なぜならば、PureScriptのExtensible Effectsの実装であるpurescript-runのRun
型は、Freeモナド + 多相バリアント型(Functor版) で構成されているからです!
newtype Run r a = Run (Free (VariantF r) a)
必要な前提知識が多い。だから、先に記事を書いて説明しておく必要があったんですね。
ちなみに書いていたら長くなりすぎたので、前編と後編に分けることにしました。
今回は前編で「Extensible Effects(Run)って、どうしてあんな風に書けるの?」という疑問の解消を試みます。
後編では作ったExtensible Effectsを実行する際、裏側で何が起きているのかを説明していきます。
こんな人に読んでもらいたい
- 「Extensible Effectsってなんか小難しくてよくわからんから使わない」と思ってる方
→ Extensible Effectsは決して万能ではありませんが、知ればプログラミングで採用できる手法の手札を増やすことができます。 - 俺たちは雰囲気でExtensible Effectsを使っている、という方
→ 雰囲気で使えるような代物じゃない気がしますが、もしいたら。 - Extensible Effectsの仕組みに興味がある方
→ 私のように「これなんで、こんなふうに使えるんだ?」というところが気になって仕方がない方。ちょっとはスッキリするかもしれません。
Extensible Effectsとは
副作用(Effect)を扱うにあたって、複数の副作用を合成したくなることがあります。
その場合の手段としては、モナド変換子や、Tagless Final、あるいはFreeモナドをCoproduct
で合成する等々様々な方法がありますが、Extensible Effectsもその手段の一つとなります。
Extensible Effects自体については、他のページでも紹介されておりますし、私もExtensible Effectsを使った記事を書いています。
例えば、次のユースケースを実現する関数を作りたいとします。
『指定したIDのユーザーと同じグループに属するユーザーに紐づくToDoをすべて取得する』
そして、ユーザーに関する副作用と、ToDoに関する副作用があり、それぞれを合成してこのユースケースを実現したいとします。
その場合、Extensible Effectsを利用すると次のようなコードが掛けます。
(色々定義などを端折っていますが、まずは雰囲気だけ感じとってください。定義は後編で書きます。)
findSameGroupToDoListByUserId
:: forall r
. String
-> Run (USER_REPOSITORY + TODO_REPOSITORY + r) (Array ToDo)
findSameGroupToDoListByUserId userId = do
user <- findUserById userId
group <- findGroupById user.groupId
findToDoListByUserIds group.userIds
Run
の中にUSER_REPOSITORY
とTODO_REPOSITORY
という副作用の名前らしきものがあります。
関数内で使っているfindUserById
やfindToDoListByUserIds
などはそれぞれの副作用に紐づく関数です。
副作用は+
で結合していくだけで増やすことができます(つまり『拡張可能』)。
モナド変換子と違って、合成の順序は一切気にする必要はありません。
なぜRun
の中に副作用を書くだけで、紐づく関数が自由に呼び出せるようになるのか、不思議ではありませんか?
今回はこのExtensible Effects(Run
)の動作がどのように実現されているかに主眼をおいて解説していきたいと思います。
解説 Extensible Effects
Extensible Effect(Run)の定義
冒頭でもチラッとお見せしましたが、これがExtensible Effects(Run
)の定義です。
(以下、単にRun
と言ったりします)
newtype Run r a = Run (Free (VariantF r) a)
最初に書いた通り、Run
はFree (VariantF r) a
をラップしているだけで、実体はFree (VariantF r) a
です。
つまり自明なことを敢えて言うようで恐縮ですが、Run
を構成する要素は次の3つに大きく分解することができます。
Run
固有のものFree
(フリーモナド)VariantF
(多相バリアント)
このように分解して考えることで、どの機能が何によって実現されているかを理解しやすくなります。
サンプルを読んでも何も知らない状態では様々な要素が渾然一体となっており、初見では脳が「このサンプルよくわかんねぇし面倒くさそうだから使うのやめようぜ?」という気持ちになりかねませんが、分類・整理することで落ち着いて見ることができるようになります。
なので、解説は上記の分類を意識しながら行っていきます。
Run
モナドとしての手始めに極めて重要なことをお伝えします。それは
Runのモナド関連の型クラス群の処理はFreeに委譲されている
ということです。
これは次のコードを見ていただければ意味がわかると思います。
derive instance newtypeRun :: Newtype (Run r a) _
derive newtype instance functorRun :: Functor (Run r)
derive newtype instance applyRun :: Apply (Run r)
derive newtype instance applicativeRun :: Applicative (Run r)
derive newtype instance bindRun :: Bind (Run r)
derive newtype instance monadRun :: Monad (Run r)
Functor
やらBind
やらのderive newtype instance
が沢山並んでいますね。
モナドに関わる型クラスの実装は、すべてFree
に委譲されているというわけです。
定義からしてRun
はFree
のラッパーですからね。
従って、Run
のモナド的な動きの部分はすべてFree
を見れば理解することができます。
Run
を返す関数のdo
記法の部分の繋がりは、すべてFree
のbind
によって実現されているというわけです。
例えば先程提示したコード例の関数では処理のステップごとにFree
のbind
が呼ばれることになります。
findSameGroupToDoListByUserId
:: forall r
. String
-> Run (USER_REPOSITORY + TODO_REPOSITORY + r) (Array ToDo)
findSameGroupToDoListByUserId userId = do
user <- findUserById userId
group <- findGroupById user.groupId
findToDoListByUserIds group.userIds
ここまでで、Run
はFree
のラッパーになっており、同じようなことができそうだ、ということがわかりました。
次に、上記の「同じことができそうだ」という部分に着目し、同じような部分と、異なる部分を分離して見てみることによって、Run
についての理解を深めていこうと思います。
Free
とRun
の類似性を通じてRun
を知る
比較のため、Free
とRun
で同じことを実現するコードを書きます。
Free
の例
ではまず、フツーにFree
を使う場合のコードを見てみましょう。
data TeletypeF a = PutStrLn String a | GetLine (String -> a)
-- Freeモナド
type Teletype a = Free TeletypeF a
putStrLn :: String -> Teletype Unit
putStrLn s = liftF (PutStrLn s unit)
getLine :: Teletype String
getLine = liftF (GetLine identity)
-- Freeモナドを使う関数
echo :: Teletype String
echo = do
a <- getLine
putStrLn a
putStrLn "Finished"
pure $ a <> a
Run
の例
続いて上記のRun
版を書いてみます。
Run
は結局Free
のラッパーなので、同じように書けます。
data Teletype a = PutStrLn String a | GetLine (String -> a)
derive instance functorTeletype :: Functor Teletype
type TELETYPE r = (teletype :: Teletype | r)
_teletype = Proxy :: Proxy "teletype"
putStrLn :: forall r. String -> Run (TELETYPE + r) Unit
putStrLn s = lift _teletype (PutStrLn s unit)
getLine :: forall r. Run (TELETYPE + r) String
getLine = lift _teletype (GetLine identity)
echo :: forall r. Run (TELETYPE + r) String
echo = do
a <- getLine
putStrLn a
putStrLn "Finished"
pure $ a <> a
それぞれの類似点
繰り返しますがRun
はFree
のラッパーです。そしてbind
の処理もFree
のものが使われます。
ということを考えるとFree
とRun
は
『(getLine
やputStrLn
で)Freeモナドを作って、(echo
ではFreeモナドを)bind
で繋いでいく』
という意味で同じような構造になっていることがわかると思います。
それぞれの相違点
Free
とRun
では大きな違いが一つあります。
それは
『Free
が型引数として代数的データ型をとっているのに対し、Run
(がラップしているFree
)はRow (Type -> Type)
という型をとっている』
ということです。
もっと踏み込んで書くと、Run
のFree
はFunctor版の多相バリアント型であるVariantF r
をとっており、このr
の型がRow (Type -> Type)
です。
上記におけるTELETYPE
とかProxy
の_teletype
とかの不思議なやつらはこのVarintF
を使う上で必要なものだったのです。
多相バリアントがよくわからなければ、冒頭に記載した多相バリアントの記事を見ていただければ、Row
やProxy
の意味がご理解いただけるでしょう。
(この記事ではRun
が扱う多相バリアントは単にVariantF
と書かせていただくことがあります)
それぞれを比較できるように定義を抜粋してみました。
-- Freeの定義(FreeViewとかCatListとかはこの説明においては気にしないでいい)
data Free f a = Free (FreeView f Val Val) (CatList (ExpF f))
-- Runの定義
newtype Run r a = Run (Free (VariantF r) a)
-- VariantFの定義
data VariantF :: Row (Type -> Type) -> Type -> Type
data VariantF f a
なので定義から自明なのですが、Free
が多相バリアントを持っているということが、Run
の大きな特徴となります。
フリーモナドと多相バリアントが協力し合っているわけですね。
Functor
である必要があるのか
扱う代数的データ型はなぜところでRun
の例では、代数的データ型Teletype
がFunctor
のインスタンスになっていました。
一方Free
の例では、Teletype
はFunctor
のインスタンスになっていません。
実はPureScriptのライブラリpurescript-freeのFree
はFunctor
という制約がないのです。
一般的にフリーモナドはFunctor
を必要としますが、このFree
は違います。
初期の実装では必要としていましたが、色々進化して不要になっています。
Functor
を仮定せずにモナドを形成できるという意味ではFreerモナドといっていいかもしれません。
話を戻しましょう。この疑問に答えるのは簡単です。
Run
がFunctor
版の多相バリアントであるVariantF
を利用しているからです。
利用しているのがVariant
ではなくVariantF
なのは、Run r a
のa
の値を持つ必要があるからでしょう。
Functor
の制約がかけられつつ、Run
をどうやって作っているのか確認できるちょうどいい関数があるので見てみましょう。
それは上記の例のgetLine
関数などでProxy
と代数的データ型の値を使ってRun
を作っているlift
関数です(Run
のモジュールに定義されています)。
-- | proxyとfunctorを受け取って、Runを返す
-- | functorをRunに持ち上げる(liftする)
lift
:: forall symbol tail row f a
. Row.Cons symbol f tail row
=> IsSymbol symbol
=> Functor f
=> Proxy symbol -- ここまでの定義で、↓のRunのrowはこのsymbolを持っていないといけない
-> f a -- `a`は↓のRunの`a`と一致
-> Run row a -- Run
lift p f = (Run <<< liftF <<< inj p) f
inj
はProxy
と値を指定してVariantF
を作る関数です。
それをFree
のliftF
関数でFree
に持ち上げて、更にRun
に持ち上げています。
色々制約がかけられていますが、VariantF
の部分に着目すればシンプルな定義となっています。
Run
の例を再訪する
ここまでわかったことを踏まえてさて、ここまでで次のことがわかりました。
- 代数的データ型を定義しているのは、Freeモナドのため。
-
Row
やProxy
を定義したり、代数的データ型をFunctor
のインスタンスにしているのは、多相バリアントのため。
ではこれらのことを事前知識としながら先ほどのコードをもう一度見てみましょう。
-- Freeモナドで使うため代数的データを定義
data Teletype a = PutStrLn String a | GetLine (String -> a)
-- VariantFで使うため代数的データ型をFunctorのインスタンスにする
derive instance functorTeletype :: Functor Teletype
-- 多相バリアントはレコードが多相であること利用して実現されているのでこれが必要
-- 合成においてはレコードの形ではなくRow (Type -> Type)の方が都合がいいのでこうしている。
type TELETYPE r = (teletype :: Teletype | r)
-- 多相バリアントで使うためラベルを定義(再利用するための定義)
_teletype = Proxy :: Proxy "teletype"
-- Runを作る。中身は多相バリアントなのでラベル(Proxy)が必要
putStrLn :: forall r. String -> Run (TELETYPE + r) Unit
putStrLn s = lift _teletype (PutStrLn s unit)
getLine :: forall r. Run (TELETYPE + r) String
getLine = lift _teletype (GetLine identity)
どうでしょうか。要素分解したことで、見え方が変わったのではないでしょうか。
多相バリアントの恩恵
さて、ここまでみてきた通りRun
は多相バリアントを利用しているわけですが、それにより一体どんな恩恵を受けられているのでしょうか。
そのあたりを説明しましょう。
まず、説明のため、ずーっと上の方で例示したRun
を返す関数displayUserAccountType
のRun
のRowの部分をこんな感じに変えてみます。
findSameGroupToDoListByUserId
:: forall r
. String
-> Run (userRepository :: UserRepository, toDoRepository :: ToDoRepository + r) (Array ToDo)
findSameGroupToDoListByUserId userId = do
user <- findUserById userId
group <- findGroupById user.groupId
findToDoListByUserIds group.userIds
次のようにfindUserById
やfindGroupById
などの関数はRun
型の値を返します(こちらもあえてRowの部分を上と同じように書いています)。
_userRepository = Proxy :: Proxy "userRepository"
findUserById :: forall r. String -> Run (userRepository :: UserRepository | r) User
findUserById userId = lift _userRepository $ FindUserById userId identity
findGroupById :: forall r. String -> Run (userRepository :: UserRepository | r) UserGroup
findGroupById groupId = lift _userRepository $ FindGroupById groupId identity
_toDoRepository = Proxy :: Proxy "toDoRepository"
findToDoListByUserIds :: forall r. Array String -> Run (toDoRepository :: ToDoRepository | r) (Array ToDo)
findToDoListByUserIds userIds = lift _toDoRepository $ FindToDoListByUserIds userIds identity
さて、これまで繰り返してきた通りRun
はRun (Free (VariantF r) a)
のようにVariantF
を内部に持っています。
そしてVariantF
は、レコードを利用しているので、いずれかのラベルと型の組を返せばOKです(同じことだが、どちらを返してもいい)。
この例の(userRepository :: UserRepository, toDoRepository :: ToDoRepository | r)
だと、userRepository :: UserRepository
かtoDoRepository :: ToDoRepository
どちらか返せればOKです。
実際利用している関数はRun (userRepository :: UserRepository | r)
だったり、Run (toDoRepository :: ToDoRepository | r)
を返しているので型に合っています。
いずれか、と言いつつ全部のパターンを返してるじゃねえか!?
と思うかもしれませんが、ここでbind
が使われていることを思い出してください。
bind
で繋がっている処理をすべて辿ればパターンが網羅されるかもしれませんが、返されるのはあくまで処理の流れの最初のやつです(Free
はbind
したとき後続の処理をCatenableList
という構造に追加しておき使うとき取り出される)。
なので、これでOKなのです。
つまりこれで複数の副作用をフラットに合成でき、合成順序を気にせず使えるようになったというわけですが、これが多相バリアントのおかげだったわけですね。
おわりに
今回の解説はここまでとさせていただきます。
これでRun
を作る部分まで説明したので次回は、作ったRun
を使う部分の説明をしたいと思います。
それでは。また。次の記事で会いましょう。
Discussion
そういえば確かに
VariantF
作るのにFunctor
必要でしたね……Coyoneda
使えばFunctor
制約なしでVariantF
作れそうなのでFunctor
制約とることもできそうです確かににうまく
Coyoneda
を組み込めれば、制約とれそうですね。ただ
Run
のコミットログを見ると元々Yoneda
を使っていたのを途中からVariantF
を使うように変えているようなので、何か意図があって敢えてこうしてるのかもです(Run
とVariantF
は同じ方が作られてますし)。