Google Chromeに入ったジェネレータとPromiseで非同期処理に革命が起きた
Google Chrome Canary(正確にはV8)に、ついにGenerators(yield)が入った。これを上手に使うと、エラー処理を含む非同期コードを同期的に書くことができるようになり、見通しが極めて良くなるので、ここで紹介する。
ここで紹介するものはいずれNode.jsでも使用できるようになるので、Webとの互換性を気にする必要のないNode.jsでは近いうちに活用できるようになると思う。
下のコードを動かすためには、最新のGoogle Chrome Canaryで、chrome://flagsからexperimental javascriptを有効にしておく必要がある。
ES6 HarmonyのGenerator構文について
functionではなくfunction*というキーワードを使うと、yieldキーワードが使えるようになる。
function* range(begin, end) { for (var i = begin; i < end; i++) { yield i; } }
こうして作成したジェネレータは、for of構文を使って、
for (var n of range(0, 5)) { console.log(n); }
として使うことができるが、今のところfor of構文は実装されていないようなので、まだ使えない。 手動でジェネレータを動かすこともでき、next/send/throwメソッドを使って、
var g = range(0, 2); console.log(g.next()); console.log(g.next()); console.log(g.next()); console.log(g.next()); // Error: Generator has already finished
このように動かすこともできる。これは実装済み。send/throwは引数を一つ受け付け、それぞれ、yieldが返す値を指定するとき、yieldに例外を発生させたいときに使う。 詳しい仕様は http://wiki.ecmascript.org/doku.php?id=harmony:generators に載っているが、まあPythonのジェネレータとほとんど同じと思うと良いだろう。
非同期処理/Promiseとの組み合わせ
ジェネレータのすごいところは、関数を途中で止められるところである。この特性と、Promiseをを組み合わせると、非同期処理を同期的に書くことができる。 Promiseそのものの詳細については http://javascripter.hatenablog.com/entry/2012/12/30/232842 に書いた。
ここではPromiseの実装としてjQuery.Deferredを使うが、好きなものを使うと良いだろう。 はてブで指摘されたのでPromiseを修正 id:teppeis ++
// new Promise(function (resolve, reject) { resolve('hi'); }) -> promise function Promise(setup) { var d = jQuery.Deferred(); var p = d.promise(); setup(d.resolve, d.reject); return p; }
まず、後に定義するasync関数を使うと何ができるかを先に示す。 一番簡単な例だと、
function wait(ms) { return new Promise(function (resolve, reject) { setTimeout(resolve, ms); }); } async(function* () { console.log('hi'); yield wait(1000); console.log('this will appear after 1 sec'); });
まず、waitを同期的に書けたのがわかる。これはジェネレータを使わないと
console.log('hi'); wait(1000).then(function () { console.log('this will appear after 1 sec'); });
である。
Promiseの結果を取得することもでき、非同期XMLHttpRequestは下記のように書ける。
function get(url) { return new Promise(function (resolve, reject) { var req = new XMLHttpRequest(); req.open('GET', url, false); req.onload = function () { resolve(req); }; req.onerror = function () { reject(req); }; req.send(null); }); } async(function* () { var res = yield get(location.href); var len = res.responseText.length; console.log('length is', len); });
これはジェネレータ抜きだと
get(location.href).then(function (res) { var len = res.responseText.length; console.log('length is', len); });
に対応する。 then(resolve, reject)のreject部はどう書けばいいのだろうか?同期処理と同じように
async(function* () { try { yield get(location.href); } catch (ex) { alert('could not fetch ' + location.href); } });
と書けばよい。asyncはプロミスを返すので
async(function* () { yield get(location.href); }).then(function () {}, function (ex) { // エラー処理はここで });
とすることもできるし、実際のところ、yield promiseとするだけで完全に同期処理のように扱える。
var len = async(function* () { return (yield get(location.href)).responseText.length; }); async(function* () { return (yield len) + 1; }).then(function (val) { console.log(val); }, function () { console.log('たぶんgetでエラーが発生した感じがする'); });
などの組み合わせも、期待通り動く。 さて、肝心のasync関数についてだが、
function isPromise(maybePromise) { return maybePromise && typeof maybePromise.then === 'function'; } function isStopIteration(maybeStopIteration) { // ugly, but works well return maybeStopIteration && ( typeof StopIteration === 'function' ? maybeStopIteration instanceof StopIteration : maybeStopIteration.message === 'Generator has already finished'); } function async(thunk) { return new Promise(function (resolve, reject) { var thread = thunk(); function proceed(method, result) { var returnValue; try { returnValue = thread[method](result); } catch (ex) { if (isStopIteration(ex)) { resolve(result); } else { reject(ex); } return; } if (isPromise(returnValue)) { returnValue.then(function (result) { proceed('send', result); }, function (result) { proceed('throw', result); }); } else { // silently proceed proceed('send', returnValue); } } proceed('send', null); }); }
たったこれだけである。 ジェネレータを使い同期的に書いたコードもasyncが返す時にはPromiseになり、Promise自体を拡張する必要がないこと、同期/非同期をまぜてほぼ同じように書けること、NodeのDomainのようなこともasyncで囲うだけでできることを考えると、非常に筋がいいと思う。
参考
https://github.com/mozilla/task.js 少しoverkillな気がするが、同じコンセプトのもの。一年前から更新されてないし、どうなったのかな?という感じは多少する。
https://github.com/kriskowal/q Q.asyncが同じことをやろうとしているが、StopIterationがないのでGoogle Chromeでは動いていない。いずれ対応すると思われる。
コードをうごくように修正した。ちなみに、Firefoxはずいぶん前からGeneratorを実装していて、ES6の構文とは多少異なり独自仕様だが、task.jsはFirefoxで動くように作られているので、興味があったら動かしてみると良いだろう。