Swift Bow ArchのLens/Prismによる状態操作
はじめに
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)をまず作っておく。
final class ContentViewModel: ObservableObject {
@Published var coffeeBeansWeight: Double = 0.0
}
そして、Viewにこのミュータブルを渡して、たとえばスライダーをタップしてときに数値が更新されるようにする。
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
を入れることで、スライダーが変更されたときに他の処理を実行することもできる。
@Published var coffeeBeansWeight: Double = 0.0 {
didSet {
calculate() // 何かすごい処理がおきる!
}
}
さて、このようにすれば下記の(1)〜(3)のループによってユーザーの入力へのレスポンスとなる適切なUIが次々と生成されていくはずである。
- ViewModelに定義された状態をViewに渡すことで、UIからのユーザーの入力を受け取る
- ViewModel側には
didSet
のような処理を定義されており、ユーザー入力と現在の状態から適切な次の状態へと遷移させる - 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のコードを引用したものである。
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]。
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
とフィールドのCoffeeBeansWeightState
とFirstBoiledWaterAmountState
の間には次のようなことが言える。
-
ContentState
はフィールドcoffeeBeansWeightState
としてCoffeeBeansWeightState
型の値を持つので、ContentState
な値からCoffeeBeansWeightState
とFirstBoiledWaterAmountState
な値が取りだせる(Get) -
coffeeBeansWeightState
またはfirstBoiledWaterAmountState
へ変更があった場合には、ContentState
な値への適切な変更が必要である(Set)
useState
はあくまでも1つの型をSetしたりGetするのみであったが、多くの場合データ構造はこのContentState
のように他のデータ構造をフィールドに持っている。したがってそのような依存するデータ構造との関係も記述できるようにしたのがLens<S, A>
である。
今Lens<ContentState, CoffeeBeansWeightState>
と書いたときには、上記の図(1)の矢印のようにLens<ContentState, CoffeeBeansWeightState>
を具体的に実装すると次のようになる。
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
ではcontentState
のcoffeeBeansWeightState
フィールドへアクセスしそれを返す - そして
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を配置して、それらが下側にあるuseState
やuseReducer
とどう対応づくのかを示している。
Prismに関してはLensのように、Reactにある「機能を単純に型の間にある関係」という点で抽象化したというものではなく、Reactのよく知られた関数でPrismに相当するものはないと思う。直感的な説明をすると「useReducer
からuseState
に相当する機能を取り去った」ような機能となっている。Bow Archではこのように1からuseReducer
に相当する機能を書くのではなくて、さきほど実装した型間のGetter/SetterとなるLensとこれから紹介するPrismの2つからほぼ自動的にuseReducer
を作るというアプローチが採用されている。
この「どういう時に状態を変更するか」というアクションをここでは定義していく。このときLensのときと同様にアクションにも木のような依存関係を定義できる。
enum ContentInput {
case coffeeBeansWeightInput(CoffeeBeansWeightInput)
case firstBoiledWaterAmountInput(FirstBoiledWaterAmountInput)
}
enum CoffeeBeansWeightInput {
case update(Double)
case increase(Double)
case decrease(Double)
}
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>
と同様に画面全体の変更とコーヒー豆の重量状態の関係からいって、次のようになる。
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
は次のようになる。
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]を使って反映させる必要がある。
let widenCoffeeBeansWeightDispatcher: ContentDispatcher =
coffeeBeansWeightDispatcher.widen(
transformState: CoffeeBeansWeightState.contentStateLens,
transformInput: CoffeeBeansWeightInput.contentInputPrism
)
水の重量に対するStateDispatcher
も同様に作れ[5]ば画面全体を制御するContentDispatcher
の完成となる。
typealias ContentDispatcher = StateDispatcher<Any, ContentState, ContentInput>
let combinedDispatcher = ContentDispatcher.empty()
.combine(widenCoffeeBeansWeightDispatcher)
.combine(widenFirstBoiledWaterAmountDispatcher)
StateDispatcher
を使うメリット
Lens/Prismとここまでで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の知識が全くなくてもプログラミングができるということになる。
- 状態とアクションから次にどうなるか振る舞いを定義する
- 状態から適切なUIを生成する
このように2つの部門に分けることができると思われる。たとえば(1)をドメインエキスパートのようなエンジニアがプログラムして、そして(2)をデザイナーやUIエンジニアが担当するといった分業が達成できる可能性がある。
まとめ
本当はComonadを利用したUIについても説明したかったが、Lens/Prismの解説で文章量が多くなってしまったので、いったんこの記事はここまでにすることにした。この記事で例として出したReactが出現したから十分に時間が経った現在であっても、useReducer
などはまだ十分に難解であるから仕方ないとは思う。
もし気合が残っていたら次はこのようにして作った状態をどうやって本物のUIへと反映させるのか?の部分を解説したいと思う。
謝辞
ドラフト版の記事を読んで感想をくれた@7_6_さんと@kyu_uriさんに感謝したい。
参考文献
- Bow Arch: Functional Architecture in Swift(公式ドキュメント)
-
iOSDC Japan 2020: SwiftUI時代の Functional iOS Architecture / 稲見 泰宏(YouTube)
- SwiftUI時代の Functional iOS Architecture(Speaker Deck)
- ぼくのかんがえたさいきょうの useState + useContext よりも Redux のほうが大抵勝っている
Discussion