この記事ははてなデベロッパーアドベントカレンダー2015の16日目の記事です.昨日は id:motemen の エンジニア寿司を支える技術 - Hatena Developer Blog でした.
こんにちは.id:yashigani_w です.
はてなでは定期的に開発合宿があり,好きな開発言語を試したり,普段仕事では一緒にならないメンバーとサービス開発をすることができます.
今年の合宿で私が所属したチームでは,node.jsとtypescriptを使い,Webサービスを開発しました.
私は普段iOSアプリ開発を担当しているので,あまりサーバサイドの実装をすることはありませんし,JavaScriptもあまり得意ではないのですが,開発合宿の機会を使って新たな技術に挑戦してみました.
合宿を前に技術的に不安を抱えていた私は,あるチームメンバーに「事前になにを学んでおけばいいか」と訪ねたところ,「Promiseをよく使うから理解しておいてくれ」と助言されました. Promiseについて調べはじめると「これはSwiftで実装するとおもしろそうだぞ」と感じ,学習がてらSwiftで実装してみることにしました.
Promiseとは
実装するためには,まずPromiseについて理解を深める必要があります. そもそもPromiseとはなんなのでしょうか? Promiseは,非同期処理を内包したオブジェクトで,それに対して処理を追加していくことができるのが特徴です. 今回の元ネタとなるJavaScriptでは,Promiseを使ってこのようなコードを書くことができます.
// http://azu.github.io/promises-book/#how-to-write-promiseより抜粋 function getURL(URL) { return new Promise(function (resolve, reject) { var req = new XMLHttpRequest(); req.open('GET', URL, true); req.onload = function () { if (req.status === 200) { resolve(req.responseText); } else { reject(new Error(req.statusText)); } }; req.onerror = function () { reject(new Error(req.statusText)); }; req.send(); }); } // 実行例 var URL = "http://httpbin.org/get"; getURL(URL).then(function onFulfilled(value){ console.log(value); }).catch(function onRejected(error){ console.error(error); });
非同期処理がうまく隠蔽されているうえに,結果に対する操作を逐次実行のように加える事ができます.
このPromiseですが,Promise - JavaScript | MDN を参照すると,意外とそれ自体のAPIはさほど多くありません.
非同期処理を隠蔽すること,状態と値を持つこと,then
とcatch
で別のPromiseを作って返すことの3つを抑えれば実装できそうです.
今回はこのドキュメントを参照しつつ実装してみました.
非同期処理を隠蔽する
Swiftで非同期処理するならGCDを使います.
(SwiftのOSS化に伴い,コアライブラリとして公開されるのもあって,安心して使えます)
といっても,そのままinit
に与えられたclosureを非同期実行するだけです.
public init(_ executor: (T -> Void, ErrorType -> Void) -> Void) { dispatch_async(queue, { executor(self.onFulfilled, self.onRejected) }) }
onFulfilled
とonRejected
は後述するPromiseの状態を変更するためのメソッドです.
状態と値
Promiseには Pending/fulfilled/rejected の3つの状態があります. 加えて,fulfilled か rejected の状態では非同期処理の結果によって得られた値かエラーを持っています. それぞれSwiftではenumを使って表現するのがよいでしょう. 値については任意の型を使えるようにしたいので,ジェネリクスとenumを組み合わせてこのように表現することにしました.
// 状態 enum State { case Pending case Fulfilled case Rejected } // 結果の値かエラー enum Result<T> { case Undefined case Value(T) case Error(ErrorType) }
そしてこれらを持つPromiseを定義します.
public final class Promise<T> { internal private(set) var state: State = .Pending internal private(set) var result: Result<T> = .Undefined public init(_ executor: (T -> Void, ErrorType -> Void) -> Void) { dispatch_async(queue, { executor(self.onFulfilled, self.onRejected) }) } private func onFulfilled(value: T) { if case .Pending = state { result = .Value(value) state = .Fulfilled } } private func onRejected(error: ErrorType) { if case .Pending = state { result = .Error(error) state = .Rejected } } }
onFulfilled
やonRejected
では複数回実行されないように.Pending
のときのみ状態を変更するようにしています.
また,外部から状態を変更されると困る部分に関してはprivate
でアクセス制御をしておきます.
これで,T
型の値を持つPromise<T>
が定義できました.
then
とcatch
then
とcatch
はPromiseに処理を追加し,新しいPromiseを返します.
catch
はthen(null, onRejcted)
のショートハンドとみなすことができますので,実装自体はthen
のみを考えれば事足ります.
元のPromiseの非同期処理が終了している場合(fulfilled または rejected であるとき)は,即時で与えられた関数を実行して新しいPromiseを返すことができますが,そうでない場合(pending の状態)では,非同期処理の完了を待つ必要があります. そこで,元のPromiseの処理の完了を待つために,プロパティに非同期処理の完了とともに実行されるclosureを追加します.
public final class Promise<T> { internal private(set) var state: State = .Pending { didSet { if case .Pending = oldValue { switch (state, result) { case (.Fulfilled, .Value(let value)): resolve?(value) case (.Rejected, .Error(let error)): reject?(error) default: () } } } } private var resolve: (T -> Void)? private var reject: (ErrorType -> Void)? // 省略... private func onFulfilled(value: T) { if case .Pending = state { result = .Value(value) state = .Fulfilled } } private func onRejected(error: ErrorType) { if case .Pending = state { result = .Error(error) state = .Rejected } } }
state
が.Pending
から変化したときに実行します.
then
でこれらのプロパティにclosureをセットすれば非同期実行を待つことができます.
また,今回はPromiseをPromise<T>
としましたが,then
の操作はPromise<T>
からPromise<U>
に写す操作だととらえることができますので,then
はこのように定義します.
func then<U>(onFulfilled: T -> U, _ onRejected: (ErrorType -> Void)?) -> Promise<U> { return Promise<U> { _resolve, _reject in switch (self.state, self.result) { case (.Pending, _): let resolve = self.resolve self.resolve = { resolve?($0) _resolve(onFulfilled($0)) } let reject = self.reject self.reject = { reject?($0) _reject($0) onRejected?($0) } case (.Fulfilled, .Value(let value)): _resolve(onFulfilled(value)) case (.Rejected, .Error(let error)): _reject(error) onRejected?(error) default: assertionFailure() } } }
元のPromiseの状態によって処理を分岐しています.
then
ができたので,これを使ってcatch
が実装できます.
public func `catch`(onRejected: ErrorType -> Void) -> Promise<T> { return then({ $0 }, onRejected) }
ここではthen
の第一引数に引数をそのまま返すclosureを渡すのがポイントです.
これで基本的な機能は実装できました.
その他のAPIを実装する
ES6のPromiseには他にもいくつかのAPIがありますが,基本的にいままで実装したものを組み合わせれば実装することができます.
例えば,Promise.resolve
ではこのように非同期処理の実行を待ちます.
public static func resolve(value: T) -> Promise<T> { let semaphore = dispatch_semaphore_create(0) let promise = Promise { resolve, _ in resolve(value) dispatch_semaphore_signal(semaphore) } dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) return promise }
Swiftっぽく味付けをする
ここまではなるべくES6のPromiseに則った実装をしましたが,せっかくSwiftで実装しているので少しSwiftっぽさを効かせたいと思います.
then
をオーバーロードしてtrailing closureを使えるようにする
ES6ではthen
メソッドはp.then(onFulfilled, onRejected)
のように定義されていますが,Swiftではオーバーロードし1引数のものと2引数のものを分けたほうがよいでしょう.
// 2引数then public func then<U>(onFulfilled: T -> U, _ onRejected: ErrorType -> Void) -> Promise<U> { } // 1引数then public func then<U>(onFulfilled: T -> U) -> Promise<U> { }
Swiftではデフォルト引数を与えることでメソッド呼び出し時の引数を省略できます.
しかし,2引数のonRejected
をOptionalにしデフォルト引数にnil
を与えるスタイルだと,trailing closure(引数の最後のclosureはメソッド呼び出しの外に書くことができる記法)が使えません.
// デフォルト引数の場合trailing cosureが使えない Promise.resolve(1) .then { $0 * 2 } .then { $0 + 1 }
このようなパターンではデフォルト引数ではなく,オーバーロードを使うほうが良いといえます.
autoclosureを使って便利イニシャライザを追加する
JSONのパース結果など,エラーを発生させる関数の結果をそのままPromiseに包みたくなることがあるかもしれません. その都度,
let p = Promise { onFulfilled, onRejected in do { let obj = try NSJSONSerialization.JSONObjectWithData(data, options: []) onFulfilled(obj) } catch { onRejected(error) } }
のようにエラーハンドリングを書くのは面倒です.
そこで,autoclosure
を使ってこのようなイニシャライザを追加し,エラーハンドリングを隠蔽すると便利です.
public convenience init(@autoclosure(escaping) _ executor: () throws -> T) { self.init { resolve, reject in do { let v = try executor() resolve(v) } catch { reject(error) } } }
autoclosure
な引数は,自動でclosureに包まれます.
escaping
はこのclosureは即時に実行されるという意味で,この関数が外の値をキャプチャしないことを示します.
この便利イニシャライザによって,元のコードはこのような形でPromiseに包むことができるようになります.
let p = Promise(try NSJSONSerialization.JSONObjectWithData(data, options: []))
autoclosure
便利ですね.
まとめ
新しい概念を学習するために実際に実装してみるのは学習効果がとても高く,なにより楽しむことができました. 当初APIばかりを読んで実装していたのですが,型が無いとその本質を捉えるのが難しく,インターフェースをすばやく理解するために型は有益な情報なんだと再認識する機会にもなりました.
最後に申し訳程度にSwiftっぽく味付けをしてみましたが,catch
は予約語なので使うときにエスケープが必要なのであまり好ましくありません.
APIについては一考の余地がありそうです.
今回紹介したPromiseは以下のリポジトリで公開しています. テストコード以上の利用例はありませんので常用に耐えうるかまではわかりません. また,Promiseの仕様は promises-aplus/promises-spec · GitHub で策定されていますが,今回の実装はこちらの全てを網羅しているわけではないことに注意してください. CocoaPodsやCarthageなどのパッケージマネージャでもインストールできますので,興味があれば使ってみてください.
後日談
合宿では主にasync/awaitを使ったので,Promiseを直接使うことはほぼありませんでした.
参考資料
明日のアドベントカレンダーは id:wtatsuru です.
お楽しみに!