SwiftでElmを作る

この記事は、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>]

oldTreenewTreeの子要素配列がこのような場合、配置が変わっても、DOMを適切に再利用したいところです。 しかし、「HTMLタグが同じなら再利用する」という単純なルールでは、意図しない再利用が起きてしまうことがあるので、「unique keyを割り振る」ことで、再利用できる箇所を明確にします。

oldChildren = [key1, key2, <section>]
newChildren = [<div>, <span>, key2, key3, <h1>]

こうすることで、「key1

は削除」、「key2

は配置換え」、「key3

は新規追加」ということが分かります。 (Virtual DOMでは、階層をまたいだ配置換えの計算を省略することで、O(n)のパフォーマンスを実現しています)

次に、newChildrenoldChildrenの順番に合うように再配置した中間状態midChildrenを作ります。 (この処理の説明は割愛)

oldChildren = [key1, key2, <section>]
midChildren = [null, key2, <div>, <span>, key3, <h1>] 
// `newChildren`を再配置した中間状態(削除用のnullあり)
newChildren = [<div>, <span>, key2, key3, <h1>]

そこから、midChildrennewChildrenへの再配置(remove/insert)パッチを生成することは難しくありません。

そして、oldChildrenmidChildrenについては、それぞれの要素を左から順に比較します。 まず、(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) 
} 

ここで登場する重要な型は、次の通りです。

  1. state: State … ユーザーが定義した状態
  2. render: (State) -> VTree … ユーザーが定義した関数。状態からVTreeを生成する(描画の度に呼ばれる)
  3. createView: (VTree) -> UIViewVTreeからUIViewを生成する(高コスト)
  4. diff: (old: VTree, new: VTree) -> Patch … 2つのVTreeを比較して、UIView更新用のPatchを作る
  5. 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を定義し、initbaseを受け取って、内部に隠蔽することで解決できます。

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-domNode msg型と同じ形になります。 そして、もし既存のAnyVTreeを再利用したい(けれどMsg型は変えたい)場合は、AnyVTree.mapを使って変換することで、新しいMsg型にも対応できます。

ステートマシン(オートマトン)

次に、Elmがステートマシンとしてどのように動くのかを見てみましょう。 Elmでは、イベントメッセージ(Msg)がプログラムに送られる度に、内部のイベントループがそれを処理します。 フローは次の通りです。

  1. update(msg, model)が呼ばれ、新しい状態(+追加の副作用 Cmd msg)が作られる
    • update : msg -> model -> model または
    • update : msg -> model -> (model, Cmd msg)
  2. view(newModel)が実行され、新しいVirtual DOMが生成される
  3. 新旧のVirtual DOMを比較し、最小限のパッチが既存DOMに当てられる
  4. 追加の副作用があれば、実行する

前章の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 = ObservableSignal.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の状態遷移成功時のSignalobserveして、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の世界観を味わってみたい方は、ぜひ下記のレポジトリをチェックしてみてください。

ちなみに現状作れるのは、上記のデモアプリまでのクオリティとなっています。 UIKitのラッパークラスが圧倒的に足りていないので、もし興味のある方は、ぜひPull Requestをお願いします!

来週月曜はNeilさんによる「Comprehensive Security for Hadoop」です。お楽しみに!

Related Post