💥

二種類のリアクティブな値の動的切り替えとモナドの関係

2024/01/16に公開

前提知識

コードは PureScript および Haskell っぽく書いていますが、ReactiveX の対応する概念を書いてあるので Rx を知っていればなんとなく読めると思います。モナドはそういうのがあるんだな~と読み飛ばしておけば OK です。

結論

プロダクトにおいて、例えば React ならコンポーネントを動的に差し替えたりしたり、例えば Unity (with UniRx) なら動的にオブジェクトを作成したりするといった、動的にリアクティブな値を切り替えることがある

リアクティブな値の動的切り替えは次の2つの方法で構成できる

  • Rx の Switch と同様
  • mFRP

どちらも動的な切り替えを可能にするが、前者は外側からのライフサイクル管理、後者は内側からのライフサイクル管理ととれる。

どちらも、次の関数 join を作ることで成り立っているが

Switch

join :: forall a. Signal (Signal a) -> Signal a

mFRP

join :: forall a b. Signal a (Signal a b) -> Signal a b

これはモナドを構成する。

はじめに

ここでは Reactive Programming におけるリアクティブな値(状態的な何か)を扱って、Event は扱わないものとします。

a 型の Reactive な値を Signal a とします

Signal a は値の変更を伝播することができ、例えば ReactiveX なら Select および CombineLatest の実装、Functional Reactive Programming なら map および liftA2 の実装によって達成できます。

map_select :: forall a b. (a -> b) -> Signal a -> Signal b
liftA2_combineLatest :: forall a b c. (a -> b -> c) -> Signal a -> Signal b -> Signal c

これらの関数によって状態の変更を伝播することができます。例えば関数 fSignal Int 型の値 x, y があったとき

x :: Signal Int
y :: Signal Int

f x y = x + y

次のようにすると x または y が変更されたときに同期されるように変更される Signal z が構成できます。

z = liftA2_combineLatest f x y

リアクティブな値の動的な切り替え

ところで、単純な FRP ではアプリケーション初期化時にすべてのリアクティビティ(Signal)のつながりが決定されています。

これは map および liftA2 のみで組み立てているからです。基本的に Applicative は逐次的な実行を行う機能を提供しません(すごくあいまいな話ですが……)

しかし実際のプロダクトでは、それぞれの要素がライフサイクルを持っていて、例えば Web アプリだったら動的にページを切り替えたり、タブを切り替えたりしますし、ゲームだったら動的にオブジェクトを生成します。

そこで、Signal のつながりを動的に切り替えたいという要望が発生します。

ここでは二つの方針で Signal の動的な切り替えを表現したいと思います。

Switch と同様なもの

Reactive X には Switch という Operator があります。これは次のようなことができます。

switch :: Signal (Signal a) -> Signal a

これは Signal a のサブスクライブを外部の Signal に合わせて次々と切り替えていくような動作をします。

ReactiveX のサイトの図がわかりやすいです。

https://reactivex.io/documentation/operators/switch.html

注意点として、同じく ReactiveX にある FlatMap は少し違う動作をします

https://reactivex.io/documentation/operators/flatmap.html

これは、切り替え時に前に Subscribe した Signal を Unsubscribe しないので、Signal の切り替えという点からは不自然です。

この Switch はまんまモナドの join と同じですが、予想通りモナドになります。モナド則はいい感じに絵を描けば成り立つのがわかるので、ここでは省略します。

また、この join から作った liftM2 は先ほどの liftA2 と同様になります。

mFRP

mFRP は次の記事で分かりやすく紹介されています。

https://zenn.dev/yoshikuni_jujo/articles/introduction-to-moffy

mFRP における Signal は先ほどの定義と少し違っていて、型引数が一つ多いです。

Signal a b

この Signal は基本的に a 型の値を発行しますが、最後に b を発行します。

ReactiveX でいうところの、 OnComplete の時のみ OnNext とは違う型を発行する感じです。

mFRP ではこの Signal におけるモナドを提供しています。

join :: forall a b. Signal a (Signal a b) -> Signal a b

これは Rx でいえば OnComplete 時に別の Signal を発行するような Signal をとり、それらを平らに接続するものです。

比較

Switch タイプでは、外側の Signal に合わせて内側の Signal を切り替えるような動作をします。

対して mFRP では外側の Signal の次に内側の Signal を実行するような動作をします。

ここで注目したいのは、mFRP では Signal 自体が完了アクション、OnComplete を持っていることです。

mFRP では Signal を構築するときに終了アクションを組み込み、外側から二つの Signal を連結する、といったことをしている一方、Switch タイプでは内側の Signal は自身のライフサイクルに関する知識は持たず、外側の Signal によって強制的に切り替えられます。

これが結論で述べた Switch は外側からのライフサイクル管理、mFRP は内側からのライフサイクル管理ととれる理由です。

諸概念との対応

ほかのプログラミングにおける諸概念との対応を考えてみます。

React の useState と Hooks

React ではコンポーネントという単位でアプリケーションを構築します。観察すると、コンポーネント自体は自身のライフサイクルを管理せず、親が子コンポーネントのライフサイクルを管理しています。したがって Switch タイプのモナドと対応していると考えられます。

具体的に、コンポーネントは、VDOM が次々に変わる何か、という感じで見れます

component1 :: Signal VDOM 
component2 :: Signal VDOM
...

これらのコンポーネントを動的に切り替えるような Signal を考えてみます。

componentSwitched :: Signal (Signal VDOM)

これを join ないし Switch で平らにすることで親コンポーネントが作成できます。

parentComponent :: Signal VDOM
parentComponent = join componentSwitched

もう少し広く Web アプリの状態ととらえる

先ほどは React と述べましたが基本的に HTML の構造がこれらと相性が良いです

例えばページ切り替えがあったとします

ページ本体は適当に HTML 型として、何ページか用意してみましょう

page1 :: Signal HTML
page2 :: Signal HTML
page3 :: Signal HTML

ページの選択状態を表す Selected 型を用意し、その実際の値を表す selected があったとします

data Selected = One | Two | Three

selected :: Signal Selected
selected = ...

selected に併せてページを切り替えるような Signal を作ります

pageSwitched :: Signal (Signal HTML)
pageSwitched = map_select selectPage selected
  where
    selectPage One = page1
    selectPage Two = page2
    selectPage Three = page3

これを join ないし Switch で平らにすることでサイト全体を表現した Signal が作れます

site :: Signal HTML
site = join pageSwitched

UniRx 使用時の MonoBehaviour のライフサイクル

Unity (with UniRx) では様々なコンポーネントを動的に作成します。例えば HogeComponent を考えます。HogeComponent 内にはいくつかの ReactiveProperty があるでしょう。

その中から特に Int 型の ReactiveProperty を選択するような関数を用意します。

extractIntRP :: HogeComponent -> ReactiveProperty Int

HogeComponent を管理する親コンポーネントがあった場合、その中には次のような ReactiveProperty があるでしょう。

currentHogeComponent :: ReactiveProperty HogeComponent 

すると次のようにすることで、切り替わっていく HogeComponent の中の、Int 型の ReactiveProperty を入手できます

intRP :: ReactiveProperty Int
intRP = switch (map_select extractIntRP currentHogeComponent)

LINQ で書くならこうです

var intRP = currentHogeComponent.Select(extractIntRP).Switch();

また、mFRP の観点から見ると、自身のライフサイクルを自分で管理する場合、すなわち OnDestroy() を自分自身で呼ぶ場合は mFRP っぽくあるのですが、ReactiveX は OnComplete 時に別の値を発行することはないので、mFRP のようなオペレーターはありません (たぶん)

ちなみに

自身で作ったライブラリ purescript-jelly は Switch 方式のモナドを搭載しています。

https://pursuit.purescript.org/packages/purescript-jelly

特に Web アプリなどでは親から子のライフサイクルを管理することがほとんどですので、Switch 方式のほうが自然に状態管理ができます。

まあ時と場合って感じですね……

参考

モナディック関数型リアクティブプログラミング(mFRP)の実装のひとつmoffyの紹介 https://zenn.dev/yoshikuni_jujo/articles/introduction-to-moffy

ReactiveX https://reactivex.io/

GitHubで編集を提案
traP

Discussion