SwiftでElmを作る
by LINE Engineer on 2016.12.9
この記事は、LINE Advent Calendar 2016の 6日目の記事です
こんにちは、開発1センター・開発2室の 稲見 (@inamiy) です。 普段はiOSエンジニアとしてSwiftを書いていますが、最近はもっぱら関数型プログラミング全般に興味があります。
今日は、「SwiftでElmを作る」というテーマで、お話しさせていただきます。
Elmって何?
Web向けの静的型付け・関数型プログラミング言語です。詳しくは http://elm-lang.org をご参照ください。
簡単に言うと、「Haskell + React.js + Redux」です。コンパイル時に、JavaScriptに変換されます。
さっそく、簡単なボタンカウンターの例を見てみましょう。
import Html exposing (beginnerProgram, div, button, text) import Html.Events exposing (onClick) -- `main`関数 = プログラムの始まり。 -- 初期状態(model)に`0`をセット + 以下にあるview関数、update関数をセット。 main = beginnerProgram { model = 0, view = view, update = update } -- `view`関数 = モデル(状態)からビュー(Virtual DOM)を生成。 -- プログラムがメッセージを受け取る度に呼ばれる。 -- ユーザー入力(onClick)等の度に、プログラムにメッセージが送られる。 view model = div [] [ button [ onClick Decrement ] [ text "-" ] , div [] [ text (toString model) ] , button [ onClick Increment ] [ text "+" ] ] -- `Msg` = メッセージ型。今回は2つのパターンだけ定義。 type Msg = Increment | Decrement -- `update`関数 = 状態遷移関数。 -- 「メッセージ」と「現在の状態」を引数に、「新しい状態」を返す。 -- プログラムがメッセージを受け取る度に呼ばれる。 update msg model = case msg of Increment -> model + 1 Decrement -> model - 1
美しいですね。コードが何をしているのか、一目瞭然です。
ちなみに、iOS (UIKit)の世界には、Target-ActionやDelegate、Key-Value-Observation、Notification、Callback、Promise、Observable/Signal (リアクティブプログラミング)など、様々なイベント処理がありますが、Elmほど簡単で分かりやすい実装はないと思います。 Elmの内部では、Effect Managerと呼ばれるcore機能が言語内に実装されているため、上記の仕組みが可能となっています。
さて、iOSでもこんなオシャレなコードを書いてみたいと思いませんか? 実は、UIKitの壁を越えて頑張れば、なんとか作れます。 必要となる知識は、次の2つです。
- Virtual DOM
- ステートマシン(オートマトン)
Virtual DOM
Virtual DOMは、可変(mutable)で変更コストの高いHTMLの各要素(DOM)を仮想化して、不変性(immutability)を担保しつつ、画面更新の際に効率的な差分更新アルゴリズムを用いて、最小限のパッチを計算してDOMに適用する技術です。 2013年のReact.jsの登場で一躍脚光を浴び、その概要はコアコミッターのChristopher Chedeau氏のブログに取り上げられています。
ただ、React.jsには、Virtual DOM以外にもiOSのUIViewController
と同様のビューのライフサイクル管理などがあり、コードの全体像が複雑なので、今回はVirtual DOMの機能だけに特化したMatt-Esch/virtual-domを用います。
Virtual DOMのdiffアルゴリズム
Matt-Esch/virtual-domの動作手順をざっくり追ってみましょう。
1. HTMLタグ名がまったく異なる場合
oldTree = <div> newTree = <section>
この場合、古い<div>
を破棄して新しい<section>
に、子要素ごと入れ替えます。
2. HTMLタグは同じで、属性等が異なる場合
oldTree = <div style="text-align: center; height: 20px;"> newTree = <div style="text-align: left; width: 100px;">
この場合、<div>
を再利用して、属性の差分(remove/update/insert)を計算し、パッチを作ります。 その後、子要素配列について、下記のdiffChildren
を計算します。
3. 子要素配列の diffChildren 計算
oldChildren = [<div>, <div>, <section>] newChildren = [<div>, <span>, <div>, <div>, <h1>]
oldTree
とnewTree
の子要素配列がこのような場合、配置が変わっても、DOMを適切に再利用したいところです。 しかし、「HTMLタグが同じなら再利用する」という単純なルールでは、意図しない再利用が起きてしまうことがあるので、「unique keyを割り振る」ことで、再利用できる箇所を明確にします。
oldChildren = [key1, key2, <section>] newChildren = [<div>, <span>, key2, key3, <h1>]
こうすることで、「key1
の<div>
は削除」、「key2
の<div>
は配置換え」、「key3
の<div>
は新規追加」ということが分かります。 (Virtual DOMでは、階層をまたいだ配置換えの計算を省略することで、O(n)
のパフォーマンスを実現しています)
次に、newChildren
をoldChildren
の順番に合うように再配置した中間状態midChildren
を作ります。 (この処理の説明は割愛)
oldChildren = [key1, key2, <section>] midChildren = [null, key2, <div>, <span>, key3, <h1>] // `newChildren`を再配置した中間状態(削除用のnullあり) newChildren = [<div>, <span>, key2, key3, <h1>]
そこから、midChildren
→newChildren
への再配置(remove/insert)パッチを生成することは難しくありません。
そして、oldChildren
→midChildren
については、それぞれの要素を左から順に比較します。 まず、(old: key1, new: null)
については「key1
の削除パッチ」を作り、(old: key2, new: key2)
と(old: <section>, new: <div>)
は「再帰diff
計算後のパッチ」、 残りの余った要素については「要素の追加パッチ」(<span>
, key3
, <h1>
)を作ります。
こうして、すべてのパッチが出来上がりました。 個々のパッチには、Virtual DOMの階層に応じたdepth-firstなindex番号を割り振ることで、生DOMの各ノードに適切にパッチが当たるようになっています。
iOS版 Virtual DOM
前節のフローを再現した、Swift + iOS portのライブラリを作りましたので、合わせてご覧ください。 (iOSではDOMを扱わないので、VTree
と呼ぶことにします)
VTree の擬似コード
基本的な流れは、本家のコードとほぼ同じです。
protocol VTree { ... var children: [VTree] { get } } struct VView: VTree { ... } // 仮想 UIView (内部で`var`は使わない) struct VLabel: VTree { ... } // 仮想 UILabel (内部で`var`は使わない) ... func render(state: State) -> VTree { return VView(children: [ VLabel(text: "(state)") ]) } var state = 0 var tree = render(state) var view = createView(tree) // 例:タイマーで状態変更しつつ、定期的に再描画 timer(1) { state += 1 let newTree = render(state) let patch = diff(old: tree, new: newTree) view = apply(patch: patch, to: view) }
ここで登場する重要な型は、次の通りです。
state: State
… ユーザーが定義した状態render: (State) -> VTree
… ユーザーが定義した関数。状態からVTree
を生成する(描画の度に呼ばれる)createView: (VTree) -> UIView
…VTree
からUIView
を生成する(高コスト)diff: (old: VTree, new: VTree) -> Patch
… 2つのVTree
を比較して、UIView
更新用のPatch
を作るapply: (patch: Patch, to: UIView) -> UIView?
…Patch
を既存のUIView
に適用(場合によっては、新しく生成されたUIView
が返る)
上の例では、再レンダリングを担当するtimer
内のブロックで、高コストなcreateView
を毎回使う代わりに、diff(old:new:)
とapply(patch:to:)
が使われていることが分かります。
なお、属性(プロパティ)の差分計算結果は、基本的にDictionary<String, Any>
で保存されます(例:["text" : "123"]
)。 ここで、JavaScriptは非常に動的な言語なので、この「Dictionary」と「DOMのプロパティ」を1:1で直接マッピングすることが簡単ですが、Swiftの場合、型が一致しないので扱いに困ります。 しかし幸いなことに、Swiftにはリフレクション(Mirror
)があり、iOSの裏側はObjective-Cで動的性質(Key-Value-Coding)を兼ね備えているので、これらを使ってマッピングすることが可能です。
// リフレクション (propertyの読み込み) var properties = [String : Any]() for case let (key?, value) in Mirror(reflecting: vtree).children { properties[key] = value } // property diff を計算... // Key-Value-Coding (propertyの書き込み) for (key, value) in diffProperties { view.setValue(value, forKey: key) }
黒魔法は最高ですね!
VTree から AnyVTree へ
上記の例は、protocol VTree
が単純なインターフェースの場合のみ成立します。 もし、もっと型安全な設計を考えると、内部にassociatedtype
が必要になり、 protocol自身が具体型として振る舞えなくなる問題が生じてきます。 つまり、[VTree]
のようなヘテロ(異型)な配列が気軽に作れなくなります。
その場合、「型消去 (type erasure)」というテクニックを使います。 具体型AnyVTree
を定義し、init
にbase
を受け取って、内部に隠蔽することで解決できます。
protocol Message { ... } enum MyMsg: String, Message { case ... } protocol VTree { associatedtype MsgType: Message // より型安全に ... var children: [AnyVTree<MsgType>] { get } // `[VTree]`ではない } /// 型消去された`VTree`. struct AnyVTree<Msg: Message>: VTree { ... init<T: VTree>(_ base: T) { ... } } let child1 = VView<MyMsg>(...) let child2 = VLabel<MyMsg>(...) let child3 = VImageView<MyMsg>(...) // 配列要素の型が異なるので、コンパイルエラー //let tree = VView(children:[child1, child2, child3]) // AnyVTree配列にすると、コンパイルOK let tree = VView(children:[*child1, *child2, *child3]) // NOTE: prefix func * = AnyVTree.init
また、VTree
がボタン経由などでイベントメッセージを発行できる点も考慮すると、ジェネリックな<Msg>
型パラメータが付与されているのが望ましいです。 そうすることで、AnyVTree<Msg>
型は elm-lang/virtual-dom のNode msg
型と同じ形になります。 そして、もし既存のAnyVTree<Msg>
を再利用したい(けれどMsg
型は変えたい)場合は、AnyVTree.map
を使って変換することで、新しいMsg
型にも対応できます。
ステートマシン(オートマトン)
次に、Elmがステートマシンとしてどのように動くのかを見てみましょう。 Elmでは、イベントメッセージ(Msg
)がプログラムに送られる度に、内部のイベントループがそれを処理します。 フローは次の通りです。
update(msg, model)
が呼ばれ、新しい状態(+追加の副作用Cmd msg
)が作られるupdate : msg -> model -> model
またはupdate : msg -> model -> (model, Cmd msg)
view(newModel)
が実行され、新しいVirtual DOMが生成される- 新旧のVirtual DOMを比較し、最小限のパッチが既存DOMに当てられる
- 追加の副作用があれば、実行する
前章のtimer
を使って「状態を直接変更した」例とは異なり、1.のupdate
(純粋関数)を通して「model
が間接的に更新」されます。 そして、4.で追加の副作用(出力)を実行します。 実は、この1.と4.の組み合わせは、「ミーリ・マシン」と呼ばれる有限オートマトンに相当していて、JavaScriptの世界でもReduxと呼ばれる状態コンテナが、それに近い実装になっています。
Swiftでは、Reduxにインスパイアされたフレームワークが幾つかありますが、私が今年の8月にiOSDC Japan 2016で「Reactive State Machine」というものを発表しましたので、今回はそれを使います。
なお、ReactiveAutomaton単体では、2.と3.に相当するレンダリング機能がないので、ラッパーを別途作ります(後述)
ReactiveAutomatonの使い方
ReactiveAutomatonを簡単に紹介すると、「FRP (関数型リアクティブプログラミング) + Redux」です。Elmのprogram
関数と、型の形が似ています。
実際の使用例を見てみましょう(FRPライブラリにReactiveCocoaを使用)
typealias State = Int enum Input { case increment case decrement } /// 状態遷移関数 func mapping(state: State, input: Input) -> State? { switch input { case .increment: return state + 1 case .decrement: return state - 1 } } /// 入力ストリーム+入力を送る関数を作成 let (inputSignal, observer) = Signal<Input, NoError>.pipe() /// オートマトンを作成 let automaton = Automaton<State, Input>(state: 0, input: inputSignal, mapping: mapping) expect(automaton?.state.value) == 0 observer.send(value: .increment) expect(automaton?.state.value) == 1 observer.send(value: .increment) expect(automaton?.state.value) == 2 observer.send(value: .decrement) expect(automaton?.state.value) == 1
RxSwiftやRxJavaに詳しい方は、Signal
= Observable
、Signal.pipe
= PublishSubject
と読み替えて下さい。 Automaton
を初期化するためには、初期状態、mapping関数のほかに、入力ストリームinputSignal
が必要になります。
また、状態遷移関数に追加の副作用を加える際には、下記のようなMarkdown Table風のシンタックスシュガーもサポートしています。
let mappings: [Automaton<State, Input>.NextMapping] = [ /* Input | State | Effect */ /* -----------------------------------------*/ .increment | { $0 + 1 } | loggingEffect, .decrement | { $0 - 1 } | .empty, ] let reducedMapping = reduce(mappings)
その他、詳細については、README.mdをご参照ください。
Swift + Elm = SwiftElm
必要な道具はだいたい揃いました。 残りのレンダリング機能についても、Automaton
の状態遷移成功時のSignal
をobserve
して、diff(old:new:)
を別スレッドで、apply(patch:to:)
をメインスレッドで実行して、ビューの更新を行えば良いです。 Automaton
とレンダリング機能をラップした、Program<Model, Msg>
型を作ります。
最終的なコードは、下記のレポジトリにあります。 リアクティブプログラミングを駆使して、スレッドセーフな実装がたったの100行程度で完成です。
では最後に、冒頭のElmのデモアプリをSwiftで書いてみましょう!
import UIKit import VTree import SwiftElm @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? var program: Program<Model, Msg>? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { self.program = Program(model: 0, update: update, view: view) self.window = UIWindow() self.window?.rootViewController = self.program?.rootViewController self.window?.makeKeyAndVisible() return true } } enum Msg: String, Message { case increment case decrement } typealias Model = Int func update(state: Model, input: Msg) -> Model? { switch input { case .increment: return state + 1 case .decrement: return state - 1 } } func view(_ model: Model) -> VView<Msg> { return VView(children: [ *VLabel(text: "(model)"), *VButton(title: "+", handlers: [.touchUpInside : .increment]), *VButton(title: "-", handlers: [.touchUpInside : .decrement]), ]) }
(長い・・・)
main関数として、@UIApplicationMain
からclass AppDelegate
を書かないといけない点が辛いですが、それ以外のコードは大分シンプルにまとまったかと思います。
まとめ
ということで、駆け足になりましたが、SwiftでElmの世界観を味わってみたい方は、ぜひ下記のレポジトリをチェックしてみてください。
- https://github.com/inamiy/VTree
- https://github.com/inamiy/ReactiveAutomaton
- https://github.com/inamiy/SwiftElm
ちなみに現状作れるのは、上記のデモアプリまでのクオリティとなっています。 UIKitのラッパークラスが圧倒的に足りていないので、もし興味のある方は、ぜひPull Requestをお願いします!
来週月曜はNeilさんによる「Comprehensive Security for Hadoop」です。お楽しみに!