Chapter 02無料公開

Harvest(副作用, Optics, データ構造)

Yasuhiro Inami
Yasuhiro Inami
2020.09.22に更新

それではまず拙作の Harvest からご紹介します。


Harvest の基本設計は、4年前の発表内容からそれほど大きく変わっていません。
ただ、その後の大きな改善として、 SwiftUI と Combine 対応をしたこと、また 副作用のキュー管理 という工夫と、Optics と呼ばれる関数型プログラミングのテクニックを使って、各コンポーネント間の疎結合化を図っています。


ここでまず、 副作用のキュー管理 とはどういうことでしょうか。
簡単に言うなら、それは PublisherPublisher ということです。
(NOTE: Publisher は Rx でいう Observable のこと)
つまり、Publisher がネストした型です。

普段、リアクティブなコードを書く際には、なかなかネストした型を見かけることは少ないですが、
例えば flatMap を使ってフラット化する場合、その背景にあるのはこのネストした状態です。
そしてFRPでは、この フラット化には様々な方法が挙げられます。
例えば、merge 戦略 ですとか, concat, switchToLatest, race といったものがあります。


この辺りは、皆さんおなじみの Rx Marble diagram 風に書くと、各々のフラット処理の違いがこのような図となって現れます。
後続の Publisher の処理をpendingしたり、キャンセルしたり、などが内部的に行われます。


Harvest では、この構造をキューに見立てて(青色)、Reducerの計算結果の 副作用を各々のフラット化戦略を持つキューに分配していきます。
そして、フラット化したものをさらにマージして、それをフィードバックループに戻します。
こうすることで、Elm Architecture をベースとしつつ、 引き続き FRP の便利な恩恵に授かれる というわけです。


この Elm Architecture に FRP を組み合わせる利点としては、やはり FRP は副作用の扱いがお手軽 というメリットが挙げられます。
例えば、Rx throttle のような遅延処理を行いたい場合、丁寧に状態管理をしようとすると、前回入力時刻という状態を保存したり、割と面倒くさい作業が発生しますが、そのような 「つまらない状態管理」を省けるのがFRPの良さ だと思います。
また、先ほどのキュー管理についても、FRP は裏側でキューの状態管理をしているとみなすこともできるので、やはり自分の手で一から実装したくないですね。
というわけで、以上の点が、Harvest が FRP を Elm Architecture に織り交ぜて使う背景です。


それでは続いて、 Harvest のもう一つの特徴である Optics について見ていきます。
先ほどのキュー管理の話とは異なり、こちらは(次に話す)Composable Architecture や他のライブラリでも使うことのできる汎用的なテクニックなので、ぜひ覚えていってもらえればと思います。


Optics は日本語で言うところの、ヒカリの光学です。
ざっくり言うと、LensPrism という2つの型があります。
Lens は主に struct のメンバ変数に関する getset の2つの関数
Prism は enum の associated values の取得を試みる tryGet と enum case関数である build の2つの関数、と考えることができます。

(例:スライド中の Command.rm(rf:) : Bool -> Commandbuild 関数の候補になります)


それを Swiftのコードで書くとこのようになります。
Lensgetset の2つ
PrismtryGetbuild の2つから成ります。


では、この LensPrism は一体何の役に立つのでしょうか?
例えば、ここに アプリの巨大な状態ツリーが定義 されていて、
個々のコンポーネントの状態が細分化された構造をしているとします。
そして、 状態の深い階層の一箇所を変更したい 場合を考えます。


その場合、流れとしては、まず 深い階層に get を繰り返してリーチし、その値を変更します。


その後、 1つ上の階層の状態を更新します。
そして、さらに上の階層を更新・・・を延々と行って、全体の変更がようやく完了です。


このとき、各階層には getset の2本の矢印のペア がありますが、
これらをまとめて Lens と呼びます。
この場合、2つの階層の異なるレンズが存在します。


実は Lens の面白い特徴として、これら2つを合成することができます
すると、この図のように、一気に深い階層をたどる deep な1つの Lens にまとめる ことができます。
この 合成可能 という点が、Lens を始めとする Optics 全般の重要な性質です。


それでは反対に、アプリの Action についてはどうでしょうか?

Action は通常、 enum で表され、やはり状態のときと同じように、個々のコンポーネントごとに細分化してツリーで管理することができます。
ここで注目してもらいたいのは、先ほどの状態の場合とは異なり、矢印の向きが逆 ということです。
これは struct が掛け算の型であるのに対して、 enum は足し算の型 であるためです。
この辺りの話は、より深堀りしていくと、やがてお約束の 圏論 の話に行き着きます。
実は、去年の iOSDC ですでに話していたりします。

さて今度は、 子の Action を親に送って、親がその associated value を変更して、子に流す場合 を考えます。


すると今度は、structの状態のときは逆に、まず 子階層の値から親の enum を build 関数で作る ことができます。
それから、親がその値を修正して・・・


子階層が再び受け取る場合は、 tryGet 関数を繰り返してリーチする ことができます。
もちろんこの場合、全く別のenumの値に変わってしまって、
正しい associated value が抜き出せない場合もあるので、
この tryGet という関数は、 失敗可能な Optional を返り値としてもちます


すると、やはりここでも、各階層ごとに tryGetbuild の2本の矢印のペア ができます。
このペアを Prism といいます。


そして Prism もやはり 合成が可能 です。


ちなみに余談ですが、これらの矢印や合成といった話は、まさに 圏論 そのものです!
簡単に図で表すとこのようになります。

今回、細かい話は省略しますが、レンズの世界では getset のペアが、左側の圏にあるようなグニャッとした双方向の矢印の形で存在 しています。
(NOTE: 前述の雑な双方向性の図をもう少し丁寧に描画したものになっています)

このままでは圏論的に大変扱いにくい(矢印とは呼べない)のですが、実はこれは見方を変えると・・・


この図の中央のように、1本の綺麗な矢印にシュッと置き換えることが可能です。
証明としては、2年前の iOSDC の発表で出てきた 「米田の補題」 というものを2回使うと導けます。

さて、このシンプルになった一本のレンズの矢印ですが、実際のiOSアプリ設計でどのように使うのでしょうか?
そもそもこの図がiOS開発と何の関係があるのか、よく分からないですよね?


でも 実は、メチャクチャ関係があります!
というのも、この中央の レンズの矢印から、このように Reducer の変換関数を作ることができる からです。
この図の意味するところは、異なる状態やアクションの型を持つ Reducer について、子から親への Reducer に型変換 することができます。
そして、同じ型に揃えられれば、複数の Reducer の合成が可能 です。


つまり、LensPrism は、Reducer の型変換と合成に役立つ というわけです。


そしてこの「Reducerの合成」というのはとても重要な性質で、
私たちはアプリを開発するにあたって、ビジネスロジックの本質である Reducer を分解できる
すなわち 巨大な状態とアクションの型について分解して管理できる ことを意味します。
つまり モジュールの疎結合化 につながります。


実際にその例をSwiftコードで書くと、このようになります。
まず LensPrism を引数に Reducer 変換を返す関数 contramap を定義できます。
ちなみに先ほどの図では、レンズは子(状態)から親(状態)への矢印でしたが、
このコードの Lens は親(状態)から子(状態)への矢印として定義されているので、
矢印の向きが逆となり、共変関手ではなく反変関手 を使う必要があります。
なので、関数名としては、 map ではなく contramap になります。
また、状態とアクションの2パターンがあるので、いわゆる bicontravariant functor (双反変関手) になります。


そして、この contramap を使うと、 Reducer の合成がこのように書けます。
異なる子コンポーネントの childReducer 2つに対して、合計4回 contramap 処理を施すことで、
同じ親の Reducer 型に合わせ、そして合成することが可能
になります。
なお、このロジックは、Harvest でも (次に紹介する)Composable Architecture でも使われています。


というわけで、 Optics についてざっくりまとめるとこの通りです。
何よりも、コンポーネントを疎結合に保てる というのが最大の魅力ですね。
そして、その背景にあるのが、関数型プログラミングと圏論 ということで、
その理論を知るとフロントエンド開発が捗るという一例になれば幸いです。


最後に、Harvest のビューと Store (状態管理コンテナ)まわりの階層構造 についても簡単に説明します。
特に真新しい話ではないですが、このように、SwiftUI のビューがツリー構造になっていたとして・・・


Harvest では頂点の Root ビューに Store をもたせ、Single Source of Truth を保ちつつ、
下のビュー階層では StoreProxy という参照が代理となって、 Store のツリー構造を形成します。
(NOTE: StoreProxy では Lens を用いて、必要最小限の状態の参照を持ちます)


そして、子階層のビューがイベントを受け取ると、一番最上位の親に伝搬していき、 Reducer 計算と差分レンダリングが行われます。
この辺りは、4年前の話や Redux・Elm Architecture とほとんど同じ動作ですね。


以上が、Harvest の簡単な紹介と、そこで使われているテクニックになります。
より詳しくは、Harvest のサンプル集をまとめたレポジトリがありますので、そちらをご覧ください。