この記事は、LINE Advent Calendar 2016の 7日目の記事です
こんにちは、開発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>
この場合、古いを破棄して新しいに、子要素ごと入れ替えます。
2. HTMLタグは同じで、属性等が異なる場合
oldTree = <div style="text-align: center; height: 20px;">
newTree = <div style="text-align: left; width: 100px;">
この場合、を再利用して、属性の差分(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
のは削除」、「key2
のは配置換え」、「key3
のは新規追加」ということが分かります。 (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: , new: )
は「再帰diff
計算後のパッチ」、 残りの余った要素については「要素の追加パッチ」(, key3
, )を作ります。
こうして、すべてのパッチが出来上がりました。 個々のパッチには、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
で保存されます(例:["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
がボタン経由などでイベントメッセージを発行できる点も考慮すると、ジェネリックな型パラメータが付与されているのが望ましいです。 そうすることで、AnyVTree
型は elm-lang/virtual-dom のNode msg
型と同じ形になります。 そして、もし既存のAnyVTree
を再利用したい(けれど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
型を作ります。
最終的なコードは、下記のレポジトリにあります。 リアクティブプログラミングを駆使して、スレッドセーフな実装がたったの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」です。お楽しみに!