🏹

Swift Bow ArchのLens/Prismによる状態操作

2021/02/21に公開

はじめに

iOSDC Japan 2020の@inamiyさんの発表では、すごく雑に言って関数型なエッセンス(副作用の抽象化やLens/Prismやモナドなど)を含むようなライブラリーとして3つが挙げられた。

今回この記事で言及するBow Archはその3つのライブラリーの中の1つであり、著者が3つともを少し使ってみた限りでは現時点で一番使い勝手が気にいったので入門記事を書くこととした。

@inamiyさんの発表ではBow Archが採用しているComonadic UIという概念や、圏論といったこれらのUIライブラリーの背景にある性質にまで踏み込んで解説しており、筆者のようなSwiftはそこまで書かないが関数型プログラミングに多少興味がある人が強く引き付けられた。一方でこの圏論のような抽象的構造は(筆者のように多少は関数型プログラミングに教養があっても)非常に難しいというか、抽象的なのでそれを理解しているからといって具体的なところ(UIを実装するとか)ですぐ役に立つのかどうか?が分かりづらいと思う。したがってこの記事では圏論上の対応などは可能な限り言及を避けて、まずはLens/Prismを利用したUIの状態操作が実際上の役に立つのか?という点をなるべく強調して説明していきたいと思う。
この記事を読んで分からないことや改善点、誤りなどを見つけたら気軽にコメントなどで教えてほしい。

今回つくるもの

今回は下記の画像のように2つのスライダーを動かすと表示が変化するという極めてシンプルなUIを作成する。


今回つくるアプリ

2つのスライダーはそれぞれコーヒー豆の重量と水の重さを表現している[1]。この程度のアプリなら単にSwift UIでそのまま書いてOKと思うが、簡単のために小さい例でやってみることにする。なお全体のソースコードは下記のGitHubリポジトリーにある。

UIと状態変更

そもそも実用上でBow Archやこれが採用しているLens/Prismの利用することによって何が嬉しいのか?ということを明らかにするためには、Swift UIを素直に利用した場合について少し理解しておく必要があると感じたので、このあたりから説明したい。

ナイーブなSwift UI

Swift UIでは次のようなObservableObjectに準拠し、ミュータブル(var)を持つようなクラス(ViewModel)をまず作っておく。

ContentViewModel.swift
final class ContentViewModel: ObservableObject {
    @Published var coffeeBeansWeight: Double = 0.0
}

そして、Viewにこのミュータブルを渡して、たとえばスライダーをタップしてときに数値が更新されるようにする。

ContentView.swift
struct ContentView: View {
    @ObservedObject var viewModel: ContentViewModel
    var body: some View {
        VStack {
            Text("Coffee Beans Weight: \(String(format: "%.1f", viewModel.coffeeBeansWeight))g")
            
            HStack(alignment: .top) {
                Image(systemName: "minus")
                Slider(value: $viewModel.coffeeBeansWeight, in: 0...50, step: 0.5)
                Image(systemName: "plus")
            }
	}
    }
}

こうしてViewModelとView(ContentView)でデータ(この例ではcoffeeBeansWeight)を操作できるようになった。例では単にViewからしかデータを変更できないので、たとえばViewModel側に次のようなdidSetを入れることで、スライダーが変更されたときに他の処理を実行することもできる。

ContentViewModel.swift
@Published var coffeeBeansWeight: Double = 0.0 {
    didSet {
        calculate() // 何かすごい処理がおきる!
    }
}

さて、このようにすれば下記の(1)〜(3)のループによってユーザーの入力へのレスポンスとなる適切なUIが次々と生成されていくはずである。

  1. ViewModelに定義された状態をViewに渡すことで、UIからのユーザーの入力を受け取る
  2. ViewModel側にはdidSetのような処理を定義されており、ユーザー入力と現在の状態から適切な次の状態へと遷移させる
  3. ViewModelに定義された状態の変更をViewが検知してユーザーに適切なUIをアウトプットする

ナイーブなSwift UIの課題

上記で説明したようにSwift UIをそのまま使うだけであっても十分にUIを作れると思うが、一方でプログラムが複雑になると、次のような理由で保守性が低下する可能性があると考えている。

  • ミュータブルな状態をViewModelとViewの両方から変更することになり、十分に状態の量が多いアプリケーションであれば、プログラマーが予期しない状態が生じてしまい、かつそれがどこでどうやって発生したのかを特定するのが困難となりそうである
  • ViewModelで起動される関数はdidSetの中で呼ばれることから、返り値が利用されないので事実上返り値の型はVoidに固定されることになる。返り値がVoidである以上はこの中でミュータブルな状態の書き換えを生じさせるしかなく、型やインターフェースによって何をしているのか?ということを追跡できるような性質が失なわれる
  • 複数ある状態を書き換えていくような仕組みをテストするよりは、引数によって確定した結果が返ってくるような関数のほうが一般的に単体テストが書きやすいと考えられる
    • グローバル変数があちこちに定義されていて、そのグローバル変数によって特定の挙動をしたうえでグローバル変数を書き換えてVoidを返すような関数f: Void -> Voidよりも、g: String -> Int?のような引数だけを使って結果が確定する関数gの方が単体テストしやすそうだというのはある程度一般的だと考えている
  • 筆者の予想になってしまうが、async/awaitといった平行・並列の強化によってミュータブル状態の変更がよりシビアになるのではないかと思う。具体的にはデッドロックやレースコンディションといった他のスレッド実行による問題を意識する必要が生じたときに、このようなミュータブル状態の変更は見つけにくいバグへと発展する可能性がある

これらの課題は、ほとんどがReduxの三原則(Three Principles)で禁止されていることに該当していると個人的に思っており、著者はReact + Reduxの経験はほとんどないが過去にjQueryなどでDOMを状態としたプログラムを書いていた経験やサーバーサイドプログラミングの経験から言っても、このようなミュータブルの利用は長期間メンテナンスするということに向いていないと思っている。

Reactのアプローチ

MVVMとは違ったアプローチとしてReactを紹介する。下記のコードは@kazuma1989さんの記事からReactのコードを引用したものである。

App.tsx
export function App() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>今のカウント: {count}</p>

      <button
        onClick={() => {
          setCount((v) => v + 1)
        }}
      >
        カウントアップ
      </button>
    </div>
  )
}

これはカウントアップボタンを押すと表示された今のカウントが1つずつ増加していくプログラムとなっている。上のコードをSwift UI風に書くと次のようになる。

let (count, setCount) = useState(0)

VStack {
    Text("今のカウント: \(count())")
    Button(action: {
        setCount { (s: Int) in
            s + 1
        }
    }, label: {
        Text("カウントアップ")
            .font(Font.system(size: 14).bold())
    })
}

今、useState(0)により初期値として0となるような状態countと、それをアップデートするための関数setCountが与えられた。したがってuseStateはSwiftで次のような型が付くはずである。

func useState<A>(_ init: A) -> (() -> A, ((A) -> A) -> Void)

ジェネリクスで抽象化したが、引数initとして0を与えたため、上記の例は型を明示するとuseState<Int>(0)ということになる。
setCountの引数の型はA -> Aという関数になっていることがポイントである。setCountは現在の値を引数として次の状態を得るような関数を引数に取る高階関数となっている。前節で述べたようにナイーブなSwift UIでは状態のアップデートは何か型Aを引数に取ることはできても、変更は状態に代入するという返り値がない操作で行うため、最終的な返り値はVoidとなるようなA -> Voidであった。一方でReactのコードではアップデートがこのように古い値から新しい値を新たに生成する方法となった。これによって次のような良いことがある。

  • 新しい方法を生成するため、setCountにユーザーが渡す関数内ではミュータブルを伴う代入が発生しない
  • たとえばこのsetCountに渡す関数である(v: Int) in { v + 1 }にテストを与えて適切なアップデートとなっているか?といったことを検査するのも状態を代入で更新しているコードよりは容易となりそうである
  • 更新の際にアクセスする変数が限定されているため他の実装によって挙動が変わるといったこともない

Reactではこれをこのまま使うためには他にも考えることがあるということでReduxのようなより進んだ仕組みが導入された。一方でBow ArchはReact/Reduxとは別のアプローチでこのような恩恵を受け取ろうとしている。

Bow Archが状態の変更に利用する技術

Swift UIとは別のUIライブラリーであるReactの例をこの後も(なるべくReactの知識がなくても理解できる範囲で)利用しつつ、ここからはBow Archがどのような抽象化をしているのかについて述べていく。

Lens

ReactではSwift風に下記のような返り値の型が付くような関数useStateを紹介した。

(() -> A, ((A) -> A) -> Void)

この関数はタプルで結果を返すが、よく見るこれは左が() -> AとなるようなGetterであり、右は現在の状態を使って次の状態を設定するSetterと考えて次のようにラベルをつけると分かりやすくなる。

func useState<A>(
    _ init: A
) -> (getter: () -> A, setter: ((A) -> A) -> Void)

このようにタプルのまま利用することもできるが、わかりやすさのためにこのタプルに名前をつけたものがLensである[2]

Lens.swift
public typealias Lens<S, A> = PLens<S, S, A, A>

public class PLens<S, T, A, B> {
    private let getFunc: (S) -> A
    private let setFunc: (S, B) -> T
}

BowのLens.swiftではさらなる抽象化のために元となる実装PLensを使って定義されているが、T = SかつB = Aなため、次のようになる。

public class Lens<S, A> {
    private let getFunc: (S) -> A
    private let setFunc: (S, A) -> S
}

ただし、ここでは型パラメーターが2つ存在している。型パラメーターAは実際に取り扱いたい値の型として、型パラメーターSはいったい何を意味しているかというと、これは型と型の包含関係を意図している。これを説明するために次のような構造体を定義する。

struct CoffeeBeansWeightState {
    let value: Double
}

struct FirstBoiledWaterAmountState {
    let value: Double
}

struct ContentState {
    let coffeeBeansWeightState: CoffeeBeansWeightState
    
    let firstBoiledWaterAmountState: FirstBoiledWaterAmountState
}

このような2つの構造体をフィールドに持つ構造体ContentStateがあるとする。これは図のように依存関係がある。


図1. https://www.overleaf.com/project/6037ac9ac6868c3002e38e09

そして、今このContentStateとフィールドのCoffeeBeansWeightStateFirstBoiledWaterAmountStateの間には次のようなことが言える。

  • ContentStateはフィールドcoffeeBeansWeightStateとしてCoffeeBeansWeightState型の値を持つので、ContentStateな値からCoffeeBeansWeightStateFirstBoiledWaterAmountStateな値が取りだせる(Get
  • coffeeBeansWeightStateまたはfirstBoiledWaterAmountStateへ変更があった場合には、ContentStateな値への適切な変更が必要である(Set

useStateはあくまでも1つの型をSetしたりGetするのみであったが、多くの場合データ構造はこのContentStateのように他のデータ構造をフィールドに持っている。したがってそのような依存するデータ構造との関係も記述できるようにしたのがLens<S, A>である。
Lens<ContentState, CoffeeBeansWeightState>と書いたときには、上記の図(1)の矢印のように\texttt{ContentState} \rightarrow \texttt{CoffeeBeansWeightState}の依存を示している。Lens<ContentState, CoffeeBeansWeightState>を具体的に実装すると次のようになる。

CoffeeBeansWeightState.swift
extension CoffeeBeansWeightState {
    static let contentStateLens = Lens<ContentState, CoffeeBeansWeightState>(
        get: { contentState in contentState.coffeeBeansWeightState },
        set: { contentState, newCoffeeBeansWeightState in
            return ContentState(
                coffeeBeansWeightState: newCoffeeBeansWeightState,
                firstBoiledWaterAmountState: contentState.firstBoiledWaterAmountState
            )
        }
    )
}
  • まずgetではcontentStatecoffeeBeansWeightStateフィールドへアクセスしそれを返す
  • そしてsetでは現在のcontentStateとあたらしいCoffeeBeansWeightState型の値からフィールド.coffeeBeansWeightStateを更新する

Lens<ContentState, FirstBoiledWaterAmountState>も同様に書くことができる。
useStateのような1つの型に対する更新処理ではなくて、このような2つ型の間にある関係を記述することによって次のようなメリットがある。

  • UIでは全ての画面を更新せずに狙った部分だけを適切に差分更新することでUXが良くなると考えられる。上記の例のようにある型が持つフィールドに変更があった時に、どの部分を更新する必要がありどの部分は更新しなくてよいのか?という情報を持つことで差分更新がやりやすくなる
    • 筆者の知る限り、現在のBow Archでは差分更新はまだしていないと思われるので、これは今後のBow Archの改造次第では(アプリのコードを一切いじることなく)差分更新になる可能性があるというような話となる
  • 構造体ContentStateをフィールドに持つような型が今後表れた場合に、既存のLensを再利用することができる
    • もし型ごとに更新の木構造を記述しなければならないとすると、同じようなコードが増えてしまう

このような差分更新のテクニックはReactにも存在する。Bow ArchではGetter/Setterに差分更新に将来使えるような情報も組み込んだというふうに考えてよいと思う。

Prism

さてGetter/Setterを得たので、次は「どのような時にSetterを起動するか?」というような処理を記述する必要がある。Lensに近い概念としてReactのuseStateがあったが、ReactにあるuseReducerに近い概念としてPrismがある。useReducerはSwift風に書くと次のようなインターフェースを持つ関数である。

func useReducer<S, A>(
  f: (S, A) -> S,
  initState: S
) -> (getter: () -> S, dispatch: (A) -> Void)

これまでのLensでは取得や変更の方法を与えることはできたが、どういうときにどう変更すればよいかというのはLensの範囲外である。そこを担当する。ただ、ちょっとこのあたりはReactとは別の抽象化となっているので、まずは概念を図で整理する[3]


図2. https://www.overleaf.com/project/603b82ed54755f26ad18edc0

この図は上側にComonadic UIのLens/Prismを配置して、それらが下側にあるuseStateuseReducerとどう対応づくのかを示している。
Prismに関してはLensのように、Reactにある「機能を単純に型の間にある関係」という点で抽象化したというものではなく、Reactのよく知られた関数でPrismに相当するものはないと思う。直感的な説明をすると「useReducerからuseStateに相当する機能を取り去った」ような機能となっている。Bow Archではこのように1からuseReducerに相当する機能を書くのではなくて、さきほど実装した型間のGetter/SetterとなるLensとこれから紹介するPrismの2つからほぼ自動的にuseReducerを作るというアプローチが採用されている。
この「どういう時に状態を変更するか」というアクションをここでは定義していく。このときLensのときと同様にアクションにも木のような依存関係を定義できる。

ContentInput.swift
enum ContentInput {
    case coffeeBeansWeightInput(CoffeeBeansWeightInput)
    
    case firstBoiledWaterAmountInput(FirstBoiledWaterAmountInput)
}
CoffeeBeansWeightInput.swift
enum CoffeeBeansWeightInput {
    case update(Double)
    
    case increase(Double)
    
    case decrease(Double)
}
FirstBoiledWaterAmountInput.swift
enum FirstBoiledWaterAmountInput {
    case update(Double)
    
    case increase(Double)
    
    case decrease(Double)
}

ほぼLensのときと同様ではあるが、たとえばこのように作ることができる。あとはこれらの間の関係をPrismのextensionとして与えるが、ここでPrism.swiftはこのようなコンストラクターを持っている。

public typealias Prism<S, A> = PPrism<S, S, A, A>

public class PPrism<S, T, A, B> { ... }
public extension Prism where S == T, A == B {
    convenience init(extract: @escaping (S) -> A?, embed: @escaping (A) -> S) {
        self.init(
            getOrModify: { s in extract(s).flatMap(Either.right) ?? .left(s) },
            reverseGet: embed)
    }
}

2つの引数はそれぞれ次のような意味となる。

  • extract
    • 大きなアクションSから小さなアクションSを可能ならば取り出す
  • embed
    • 小さなアクションAを大きなアクションSへ埋め込む

したがってLens<ContentState, CoffeeBeansWeightState>と同様に画面全体の変更とコーヒー豆の重量状態の関係からいって、次のようになる。

CoffeeBeansWeightInput.swift
extension CoffeeBeansWeightInput {
    static let contentInputPrism = Prism<ContentInput, CoffeeBeansWeightInput>(
        extract: { contentInput in
            switch contentInput {
            case let .coffeeBeansWeightInput(input):
                return input
            default:
                return nil
            }
        },
        embed: { coffeeBeansWeightInput in
            ContentInput.coffeeBeansWeightInput(coffeeBeansWeightInput)
        }
    )
}

StateDispatcher

ここまでで作ってきたLens/Prismを利用して、さきほどの図のStateDispatcherをつくることができる。いままでのLens/Prismは、たしかにSwiftコードで書いたもののほとんど複数のデータ構造間の性質(上位のデータが変更されたら、下位のデータを変更するなど)を記述していた感が強く、実際に状態をどう変更するか?を直接記述していたというと違うかもしれない。アクションを受けとったときに一体なにをするか?そういった具体的な挙動を記述する場所がStateDispatcherとなり、これがuseReducerに対応している。
具体的にコーヒー豆の重量(CoffeeBeansWeightState)を変更するアクション(enum)であるCoffeeBeansWeightInputを受けとった場合のCoffeeBeansWeightDispatcherは次のようになる。

CoffeeBeansWeightDispatcher.swift
typealias CoffeeBeansWeightDispatcher = StateDispatcher<Any, CoffeeBeansWeightState, CoffeeBeansWeightInput>

let coffeeBeansWeightDispatcher = CoffeeBeansWeightDispatcher.pure { input in
    switch input {
    case let .update(newWeight):
        return .set(
            CoffeeBeansWeightState(value: newWeight)
        )^
        
    case let .increase(weight):
        return .modify { previousState in
            CoffeeBeansWeightState(value: previousState.value + weight)
        }^

    case let .decrease(weight):
        return .modify { previousState in
            CoffeeBeansWeightState(value: previousState.value - weight)
        }^
    }
}

あまりそういう人は少ないかもしれないが、これはインタープリターのように見えるかもしれない。CoffeeBeansWeightInputがプログラム言語のASTであり、coffeeBeansWeightDispatcherにそれぞれのASTに対する振る舞いが定義されていて、最終的にそのプログラムの出力(状態)としてCoffeeBeansWeightStateが出力されるといった感じである。もはやここまで来たらやることは明らかかもしれないが、Lens/Prismと同様にこれをContentState側へ次のようにwiden[4]を使って反映させる必要がある。

CoffeeBeansWeightDispatcher.swift
let widenCoffeeBeansWeightDispatcher: ContentDispatcher =
    coffeeBeansWeightDispatcher.widen(
        transformState: CoffeeBeansWeightState.contentStateLens,
        transformInput: CoffeeBeansWeightInput.contentInputPrism
    )

水の重量に対するStateDispatcherも同様に作れ[5]ば画面全体を制御するContentDispatcherの完成となる。

ContentDispatcher.swift
typealias ContentDispatcher = StateDispatcher<Any, ContentState, ContentInput>

let combinedDispatcher = ContentDispatcher.empty()
    .combine(widenCoffeeBeansWeightDispatcher)
    .combine(widenFirstBoiledWaterAmountDispatcher)

Lens/PrismとStateDispatcherを使うメリット

ここまででBow Archがどのように状態を管理しているのかということが明らかとなったと思う。スライダーが2つあるだけのUIであるにも関わらず、Swift UIであればすぐにできることがBow Archだとここまで大変ということで、利用することのメリットが謎になったかもしれない。ここでは筆者が思うメリットをあげてみる。

状態と振る舞いの分離

StateDispatcherの節で述べたとおり、これらはUIに対する次のようなプログラム言語と処理系となっている。

  • AST: 重量が増えたとか減ったとかのアクション
  • インタープリター: やってきたASTに応じて状態をどう変更するか?という振る舞い[6]
  • 状態: インタープリターが操作した結果

このようになっていると、アクションに対するUIの振る舞いを変更したい場合に修正箇所を局所的にできる。さらに、同じアクションに対して複数のインタープリター(StateDispatcher)を定義できるため、たとえばiOSにエディターのVimを実装したいとなったとき、同じキーイベント(アクション)に対してモードによっては別の振る舞いをしたくなる。もちろん現在の状態をキー入力のたびにチェックして場合わけするという手もあるが、このようにアクションとインタープリターが分離されていれば、モードの変更と同時にインタープリターを切り替えるという方法で対処できる。

状態とUIの分離

この記事ではあくまでもLens/Prismなどを使った「状態操作」についてしか述べていない。したがって、ここからiOSの画面といった本物のUIに反映しなければならない。その部分が@inamiyさんの発表のモナドやコモナドなどを使うところになる。しかしここまで見てきたように、状態とアクションによってどう振る舞うか?という部分を実際のUIとは全く関係なくプログラミングしていることになる。もちろん実際上は本物のUIの都合で状態が追加されるとか、ここはタップできるからこういうアクションが必要だというような相互の関係があるとは思うが、たとえばここまでのLens/PrismやStateDispatcherはUIの知識が全くなくてもプログラミングができるということになる。

  1. 状態とアクションから次にどうなるか振る舞いを定義する
  2. 状態から適切なUIを生成する

このように2つの部門に分けることができると思われる。たとえば(1)をドメインエキスパートのようなエンジニアがプログラムして、そして(2)をデザイナーやUIエンジニアが担当するといった分業が達成できる可能性がある。

まとめ

本当はComonadを利用したUIについても説明したかったが、Lens/Prismの解説で文章量が多くなってしまったので、いったんこの記事はここまでにすることにした。この記事で例として出したReactが出現したから十分に時間が経った現在であっても、useReducerなどはまだ十分に難解であるから仕方ないとは思う。
もし気合が残っていたら次はこのようにして作った状態をどうやって本物のUIへと反映させるのか?の部分を解説したいと思う。

謝辞

ドラフト版の記事を読んで感想をくれた@7_6_さんと@kyu_uriさんに感謝したい。

参考文献

脚注
  1. これはコーヒーを抽出するときのパラメーターであるが、この記事を読むうえではコーヒーに関する知識は「コーヒー豆にお湯を注ぐとコーヒーが出る」くらい分かっていれば十分である。 ↩︎

  2. 型パラメーターがたくさんあることについてはあとで解説する。 ↩︎

  3. StateDispatcherについてはまだ説明していないので、分からなくても大丈夫である。 ↩︎

  4. 余談となるがScalaの関数型ライブラリーにおいてwidenは、型AとそのサブタイプであるBがあるときに、BAとするアップキャストのような操作に対して慣用的に与えられる名前である。 ↩︎

  5. 詳細はこちらを参照するとよい。 ↩︎

  6. このようなASTに対する振る舞いのことを意味論と言うこともある。 ↩︎

Discussion