🔖

[PureScript] 解説!Extensible Effects(拡張可能作用) ~なんでお前そんなことできるんだよ?編~

2023/07/23に公開2

はじめに

私はこれまでFreeモナドを自分で作ってみる記事実用されているFreeモナドの処理を解説する記事多相バリアントの記事などを書いてきました。

それはすべて……Extensible Effects(拡張可能作用)の話をするためだったのです!

なぜならば、PureScriptのExtensible Effectsの実装であるpurescript-runRun型は、Freeモナド + 多相バリアント型(Functor版) で構成されているからです!

Extensible Effects (Run)
newtype Run r a = Run (Free (VariantF r) a)

必要な前提知識が多い。だから、先に記事を書いて説明しておく必要があったんですね。

ちなみに書いていたら長くなりすぎたので、前編と後編に分けることにしました。
今回は前編で「Extensible Effects(Run)って、どうしてあんな風に書けるの?」という疑問の解消を試みます。

後編では作ったExtensible Effectsを実行する際、裏側で何が起きているのかを説明していきます。

こんな人に読んでもらいたい

  1. 「Extensible Effectsってなんか小難しくてよくわからんから使わない」と思ってる方
     → Extensible Effectsは決して万能ではありませんが、知ればプログラミングで採用できる手法の手札を増やすことができます。
  2. 俺たちは雰囲気でExtensible Effectsを使っている、という方
     → 雰囲気で使えるような代物じゃない気がしますが、もしいたら。
  3. Extensible Effectsの仕組みに興味がある方
     → 私のように「これなんで、こんなふうに使えるんだ?」というところが気になって仕方がない方。ちょっとはスッキリするかもしれません。

Extensible Effectsとは

副作用(Effect)を扱うにあたって、複数の副作用を合成したくなることがあります。
その場合の手段としては、モナド変換子や、Tagless Final、あるいはFreeモナドをCoproductで合成する等々様々な方法がありますが、Extensible Effectsもその手段の一つとなります。

Extensible Effects自体については、他のページでも紹介されておりますし、私もExtensible Effectsを使った記事を書いています。
https://zenn.dev/funnycat/articles/f012b0429d8304

例えば、次のユースケースを実現する関数を作りたいとします。
『指定したIDのユーザーと同じグループに属するユーザーに紐づくToDoをすべて取得する』
そして、ユーザーに関する副作用と、ToDoに関する副作用があり、それぞれを合成してこのユースケースを実現したいとします。
その場合、Extensible Effectsを利用すると次のようなコードが掛けます。
(色々定義などを端折っていますが、まずは雰囲気だけ感じとってください。定義は後編で書きます。)

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_REPOSITORYTODO_REPOSITORYという副作用の名前らしきものがあります。
関数内で使っているfindUserByIdfindToDoListByUserIdsなどはそれぞれの副作用に紐づく関数です。
副作用は+で結合していくだけで増やすことができます(つまり『拡張可能』)。
モナド変換子と違って、合成の順序は一切気にする必要はありません。
なぜRunの中に副作用を書くだけで、紐づく関数が自由に呼び出せるようになるのか、不思議ではありませんか?

今回はこのExtensible Effects(Run)の動作がどのように実現されているかに主眼をおいて解説していきたいと思います。

解説 Extensible Effects

Extensible Effect(Run)の定義

冒頭でもチラッとお見せしましたが、これがExtensible Effects(Run)の定義です。
(以下、単にRunと言ったりします)

Run
newtype Run r a = Run (Free (VariantF r) a)

最初に書いた通り、RunFree (VariantF r) aをラップしているだけで、実体はFree (VariantF r) aです。
つまり自明なことを敢えて言うようで恐縮ですが、Runを構成する要素は次の3つに大きく分解することができます。

  • Run固有のもの
  • Free(フリーモナド)
  • VariantF (多相バリアント)

このように分解して考えることで、どの機能が何によって実現されているかを理解しやすくなります。

サンプルを読んでも何も知らない状態では様々な要素が渾然一体となっており、初見では脳が「このサンプルよくわかんねぇし面倒くさそうだから使うのやめようぜ?」という気持ちになりかねませんが、分類・整理することで落ち着いて見ることができるようになります。

なので、解説は上記の分類を意識しながら行っていきます。

モナドとしてのRun

手始めに極めて重要なことをお伝えします。それは

Runのモナド関連の型クラス群の処理はFreeに委譲されている

ということです。

これは次のコードを見ていただければ意味がわかると思います。

Run
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に委譲されているというわけです。
定義からしてRunFreeのラッパーですからね。

従って、Runのモナド的な動きの部分はすべてFreeを見れば理解することができます。

Runを返す関数のdo記法の部分の繋がりは、すべてFreebindによって実現されているというわけです。
例えば先程提示したコード例の関数では処理のステップごとにFreebindが呼ばれることになります。

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

ここまでで、RunFreeのラッパーになっており、同じようなことができそうだ、ということがわかりました。

次に、上記の「同じことができそうだ」という部分に着目し、同じような部分と、異なる部分を分離して見てみることによって、Runについての理解を深めていこうと思います。

FreeRunの類似性を通じてRunを知る

比較のため、FreeRunで同じことを実現するコードを書きます。

Freeの例

ではまず、フツーに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

それぞれの類似点

繰り返しますがRunFreeのラッパーです。そしてbindの処理もFreeのものが使われます。
ということを考えるとFreeRun

『(getLineputStrLnで)Freeモナドを作って、(echoではFreeモナドを)bindで繋いでいく

という意味で同じような構造になっていることがわかると思います。

それぞれの相違点

FreeRunでは大きな違いが一つあります。
それは

Freeが型引数として代数的データ型をとっているのに対し、Run(がラップしているFree)はRow (Type -> Type)という型をとっている』

ということです。
もっと踏み込んで書くと、RunFreeはFunctor版の多相バリアント型であるVariantF rをとっており、このrの型がRow (Type -> Type)です。

上記におけるTELETYPEとかProxy_teletypeとかの不思議なやつらはこのVarintFを使う上で必要なものだったのです。
多相バリアントがよくわからなければ、冒頭に記載した多相バリアントの記事を見ていただければ、RowProxyの意味がご理解いただけるでしょう。
(この記事では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の例では、代数的データ型TeletypeFunctorのインスタンスになっていました。
一方Freeの例では、TeletypeFunctorのインスタンスになっていません。
実はPureScriptのライブラリpurescript-freeのFreeFunctorという制約がないのです。
一般的にフリーモナドはFunctorを必要としますが、このFreeは違います。
初期の実装では必要としていましたが、色々進化して不要になっています。
Functorを仮定せずにモナドを形成できるという意味ではFreerモナドといっていいかもしれません。

話を戻しましょう。この疑問に答えるのは簡単です。

RunFunctor版の多相バリアントであるVariantFを利用しているからです。
利用しているのがVariantではなくVariantFなのは、Run r aaの値を持つ必要があるからでしょう。

Functorの制約がかけられつつ、Runをどうやって作っているのか確認できるちょうどいい関数があるので見てみましょう。
それは上記の例のgetLine関数などでProxyと代数的データ型の値を使ってRunを作っているlift関数です(Runのモジュールに定義されています)。

Runのlift関数
-- | 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

injProxyと値を指定してVariantFを作る関数です。
それをFreeliftF関数でFreeに持ち上げて、更にRunに持ち上げています。
色々制約がかけられていますが、VariantFの部分に着目すればシンプルな定義となっています。

ここまでわかったことを踏まえてRunの例を再訪する

さて、ここまでで次のことがわかりました。

  • 代数的データ型を定義しているのは、Freeモナドのため。
  • RowProxyを定義したり、代数的データ型を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を返す関数displayUserAccountTypeRunの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

次のようにfindUserByIdfindGroupByIdなどの関数は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

さて、これまで繰り返してきた通りRunRun (Free (VariantF r) a)のようにVariantFを内部に持っています。
そしてVariantFは、レコードを利用しているので、いずれかのラベルと型の組を返せばOKです(同じことだが、どちらを返してもいい)。
この例の(userRepository :: UserRepository, toDoRepository :: ToDoRepository | r)だと、userRepository :: UserRepositorytoDoRepository :: ToDoRepositoryどちらか返せればOKです。
実際利用している関数はRun (userRepository :: UserRepository | r)だったり、Run (toDoRepository :: ToDoRepository | r)を返しているので型に合っています。
いずれか、と言いつつ全部のパターンを返してるじゃねえか!?
と思うかもしれませんが、ここでbindが使われていることを思い出してください。
bindで繋がっている処理をすべて辿ればパターンが網羅されるかもしれませんが、返されるのはあくまで処理の流れの最初のやつです(Freebindしたとき後続の処理をCatenableListという構造に追加しておき使うとき取り出される)。
なので、これでOKなのです。

つまりこれで複数の副作用をフラットに合成でき、合成順序を気にせず使えるようになったというわけですが、これが多相バリアントのおかげだったわけですね。

おわりに

今回の解説はここまでとさせていただきます。
これでRunを作る部分まで説明したので次回は、作ったRunを使う部分の説明をしたいと思います。

それでは。また。次の記事で会いましょう。
https://zenn.dev/funnycat/articles/230f7fa0d11739

Discussion

ゆきくらげゆきくらげ

そういえば確かに VariantF 作るのに Functor 必要でしたね…… 
Coyoneda 使えば Functor 制約なしで VariantF 作れそうなので Functor 制約とることもできそうです