こんにちは丸山@h13i32maruです。
ES6のGeneratorを勉強する題材としてasync/awaitを実装してみたので、そのメモです。
Genratorとは
ES6のGeneratorとは「任意の時点で処理を中断/再開することができる関数」というもの。一般的にはコルーチン(coroutine)と呼ばれるもので、サブルーチン(通常の関数)を一般化したもの。ES6でGeneratorを理解するには3つのキーワードがある。
- Generator Function
- 処理の中断/再開が行われる特殊な関数
function* generatorFunction(){}
のようにfunction*
を使って定義する
- Generator Object
- 中断された処理を再開したり、値を取得し対するオブジェクト
var generatorObject = generatorFunction()
のように取得する
- yield
- Generator Functionの中で使われる処理の中断を指定するキーワード
var result = yield request('http://example.com')
のように使う
以降では「Generator Functionの関数本体側を被制御側」「Generator Objectを使って処理を再開する側を制御側」と呼ぶことにする。
仕様についてはこの辺り
- http://people.mozilla.org/~jorendorff/es6-draft.html#sec-generatorfunction-objects
- http://people.mozilla.org/~jorendorff/es6-draft.html#sec-generator-objects
- http://wiki.ecmascript.org/doku.php?id=harmony:generators
- http://wiki.ecmascript.org/doku.php?id=strawman:async_functions
Generatorは関数内に状態を持つことができるので、無限リストや遅延処理に使うことができる。今回はGeneratorを使って非同期処理を同期処理のように記述できる仕組みを実装する。「非同期処理を同期処理のように記述できる仕組み」をasync/awaitと呼ぶ*1。
目標
最終的にはES6で以下のように記述できるようにしたい。
function sleep(msec) { var promise = new Promise(function(resolve, reject){ if (msec < 0) { return reject(new Error('msec must be greater than 0')); } setTimeout(function(){ resolve('you sleep ' + msec); }, msec); }); return promise; } async(function*(){ console.log('start'); var result = yield sleep(1000); console.log(result); console.log('done'); });
非同期処理sleep(1000)
があたかも同期的な処理のように書ける。これはES6で使えるようになったGeneratorの機能であるfunction*
とyield
を使って実現する。ただし、これだけだとasync/awaitは実現できないのでasync
という関数を実装する必要がある。今回はこのasync
関数を実装できるようになるのが実質的な目標。それとES6のPromiseについても知識が必要になるので@azu_reさんのJavaScript Promiseの本を読んでおくこと*2。
Generatorの基本的な使い方
まずはGeneratorの基本的な使い方から。
処理の中断/再開
function* generatorFunction(){ console.log('generatorFunction: 100'); yield console.log('generatorFunction: 200'); console.log('generatorFunction: 300'); yield console.log('generatorFunction: 400'); console.log('generatorFunction: 500'); } console.log('a'); var generatorObject = generatorFunction(); console.log('b'); generatorObject.next(); console.log('c'); generatorObject.next(); console.log('d'); generatorObject.next(); console.log('e'); function* generatorFunction(){ console.log('generatorFunction: 100'); yield console.log('generatorFunction: 200'); console.log('generatorFunction: 300'); yield console.log('generatorFunction: 400'); console.log('generatorFunction: 500'); } console.log('a'); var generatorObject = generatorFunction(); console.log('b'); generatorObject.next(); console.log('c'); generatorObject.next(); console.log('d'); generatorObject.next(); console.log('e'); /* a b generatorFunction: 100 generatorFunction: 200 c generatorFunction: 300 generatorFunction: 400 d generatorFunction: 500 e */
function *
という構文により生成された関数generatorFunction
はGenerator Functionとなる。そしてgeneratorFunction
を実行すると処理が 開始されるのではなく Generator Object generatorObject
が返される。
generatorObject.next()
を実行することで関数本体の処理を開始することができる。処理が開始され、yield
式が実行されるとそこで 処理が中断 される。中断された処理を再開するにはgeneratorObject.next()
を実行する。そうするとまた次のyield
式で処理が中断される。
これがGeneratorの基本的な使い方。以降ではgeneratorFunction
はgenFunc
、generatorObject
はgen
と記述する。
終了を検出する
function* genFunc(){ yield console.log(1); yield console.log(2); yield console.log(3); } var gen = genFunc(); while(1) { var result = gen.next(); if (result.done) { break; } gen.next(); } console.log('done'); /* 1 2 3 done */
被制御側genFunc
のすべての処理が終了し、本当に関数が終了したことを知るにはgen.next()
の戻り値からresult.done
のように取得する。
yieldの右辺値を受け取る
function func() { return 100; } function* genFunc(){ yield func(); } var gen = genFunc(); var result = gen.next(); console.log('value is ' + result.value); gen.next(); /* value is 100 */
yieldの右辺値を制御側で受け取るにはgen.next()
の戻り値result.value
から取得する。
yieldに戻り値を与える
function func() { return 100; } function* genFunc(){ var x = yield func(); console.log('x is ' + x); } var gen = genFunc(); var result = gen.next(); gen.next(result.value + 200); /* x is 300 */
制御側からyieldに戻り値を設定し、yieldの左辺に代入するにはgen.next(val)
のように引数を与えて実行する。これは上記「yieldの右辺値を受け取る」と対になる動きをする。この2つを組み合わせることでyieldの右辺と左辺をつなぐことができる。一般的にはyieldの右辺値を制御側が受け取り、何かしら加工して、yieldの戻り値として設定してyieldの左辺に代入する。
例外を発生させる
function func() { return -1; } function* genFunc(){ try { var x = yield func(); console.log('x is ' + x); } catch (e) { console.log(e.message); } } var gen = genFunc(); var result = gen.next(); if (result.value < 0) { gen.throw(new Error('value must be greater than 0')); } else { gen.next(result.value); } /* value must be greater than 0 */
gen.next()
は通常の処理再開だが、gen.throw()
を使うことで例外を発生させて処理を再開することができる。このときgen.throw()
に引数として渡した値が例外オブジェクトとして投げられる。
非同期処理と組み合わせる
次は処理を中断/再開できる仕組みと非同期処理を組み合わせて、同期処理のように書けるようにする。以降では非同期処理はPromiseを返すことを前提としている。PromiseについてはJavaScript Promiseの本を参照。
非同期処理と組み合わせる
function sleep(msec) { var promise = new Promise(function(resolve, reject){ setTimeout(resolve, msec); }); return promise; } function* genFunc(cb){ console.log('sleep...'); yield sleep(1000).then(cb); console.log('done'); } var gen = genFunc(function(){ gen.next(); }); gen.next(); /* sleep... done */
まずgen.next()
によりyield sleep(1000).then(cb)
の右辺までが実行され、非同期処理sleep(1000)
の開始とPromiseにコールバックを設定するthen(cb)
までが行われて中断する。そしてsleep(1000)
が完了すると、Promiseに登録したcb
が実行される。ここでcb
は制御側から与えられた関数であり、内部はgen.next()
を実行しており、処理を再開される。これで1秒間停止したあとに、処理が再開される非同期処理を同期処理のようにかけるようになった。
Promiseの扱いを隠蔽する
function sleep(msec) { var promise = new Promise(function(resolve, reject){ setTimeout(resolve, msec); }); return promise; } function* genFunc(){ console.log('sleep...'); yield sleep(1000); console.log('done'); } var gen = genFunc(); var result = gen.next(); var promise = result.value; promise.then(function(){ gen.next(); }) /* sleep... done */
先ほどの実装では制御側からコールバック関数を登録する必要があり、genFunc
の内部でもPromiseの扱いを意識しないといけない。なのでPromiseの扱いをgenFunc
から隠蔽してすべて制御側で行えるようにした。gen.next()
の戻り値にはyield
の右辺値が入っているのでそれを使ってPromiseを取得し、コールバック関数を設定することで実現。
非同期処理の戻り値を返す
function sleep(msec) { var promise = new Promise(function(resolve, reject){ setTimeout(function(){ resolve('You sleep ' + msec); }, msec); }); return promise; } function* genFunc(){ console.log('sleep...'); var msg = yield sleep(1000); console.log(msg); console.log('done'); } var gen = genFunc(); var result = gen.next(); var promise = result.value; promise.then(function(val){ gen.next(val); }) /* sleep... You sleep 1000 done */
非同期処理の戻り値をyield
の右辺に渡すことで、戻り値がある非同期処理にも対応できるようにする。これは単純にPromise#resolve
に渡される値をgen.next()
で渡せばいいだけ。
例外処理
function sleep(msec) { var promise = new Promise(function(resolve, reject){ setTimeout(function(){ reject(new Error('error!!!')); }, msec); }); return promise; } function* genFunc(){ try { yield sleep(1000); console.log(msg); } catch(e) { console.log(e.message); } } var gen = genFunc(); var result = gen.next(); var promise = result.value; promise.then(function(val){ gen.next(val); }).catch(function(e){ gen.throw(e); }); /* error!!! */
非同期処理の例外は面倒だが、ここでは同期処理と同じようにtry-catchで囲むだけで例外処理を実現できるようにする。Promise#catch
を使って非同期処理がrejectedな状態になったのをハンドリングして、gen.throw
で例外を投げればよいだけ。被制御側は普通にtry-catchで囲めばよい。
async関数を実装する
制御側の処理を抽象化してasync関数を実装する。
asnync関数
function sleep(msec) { var promise = new Promise(function(resolve, reject){ if (msec < 0) { return reject(new Error('msec must be greater than 0')); } setTimeout(function(){ resolve('you sleep ' + msec); }, msec); }); return promise; } function* genFunc(){ var x; try { x = yield sleep(-1); console.log(x); } catch (e) { console.log(e.message); } x = yield sleep(2000); console.log(x); } function async(genFunc) { var gen = genFunc(); var result = gen.next(); chain(result); function chain(result) { if (result.done) { return result.value; } var promise = result.value; promise.then(function(val){ var result = gen.next(val); chain(result); }).catch(function(e){ var result = gen.throw(e); return chain(result); }) } } async(genFunc); /* msec must be greater than 0 you sleep 2000 */
async
関数のナイーブな実装としてはこんな感じになる。内部関数chain
によってyield
の中断とPromise#then
による再開をつなげていくイメージ。
Promise化する
function sleep(msec) { var promise = new Promise(function(resolve, reject){ if (msec < 0) { return reject(new Error('msec must be greater than 0')); } setTimeout(function(){ resolve(`you sleep ${msec}`); }, msec); }); return promise; } function* genFunc(){ try { var x = yield sleep(-1); console.log(x); } catch (e) { console.log(e.message); } x = yield sleep(2000); console.log(x); return 'done genFunc'; } function async(genFunc) { var gen = genFunc(); return new Promise(function(resolve, reject){ onFulfilled(); function onFulfilled(val) { try { var result = gen.next(val); } catch (e) { return reject(e); } return chain(result); } function onRejected(e) { try { var result = gen.throw(e); } catch (e) { return reject(e); } return chain(result); } function chain(result) { if (result.done) { return resolve(result.value); } var promise = result.value; if (promise instanceof Promise) { return promise.then(onFulfilled).catch(onRejected); } else { reject(new Error('should be promise')); } } }); } async(genFunc).then(function(val){ console.log(val); }).catch(function(e){ console.log('done: ' + e.message); }); /* you sleep 1000 you sleep 2000 done genFunc */
async
関数自体も非同期関数なのでPromise化する。これでひと通り完成。あとはyieldの右辺値としてPromiseだけではなくPromiseの配列や別のGeneratorを受け取れるように改良するとさらに便利になる。
まとめ
async/awaitを実装したライブラリとしてtj/coというnode.js向けに有名なライブラリがあり、今回Generatorを勉強するのにすごくお世話になった。async
関数の中身はこのcoとほぼ同じである。Javascript ES6 generatorsという資料も良かった。
ES7にはasync/awaitがAsync Functionsとして入るかもしれないので、楽しみにしている。ちなみにこのwikiにもAsync FunctionsをGeneratorで実装する例が載っており、coやasyncとほぼ同じ実装になっている。
GeneratorとPromiseを使うとすごく強力だけど、まだ頭がGenerator脳になっていないので空気を吸うように読み書きできない。JavaScriptを始めたての頃、コールバック関数を読むのに苦労した感じを思い出した。
最後に2点気になることをメモ。だれか知ってる人がいたら教えて欲しい。
Generator.prototype.return
がES6 Draftにはあるけど、traceurやChromeに実装されていないのはなぜ?- Delegating yield
yield *
という機能がES wikiにはあるけど、ES6 Draftには無いのはなぜ?
追記(2014/01/04)
はてブやコメントでGenerator.prototype.return
とyield*
について教えてもらった。id:teramakoさん、id:jserさんありがとうございます。
ES6 Generatorを使ってasync/awaitを実装するメモ - maru sourceyield * は http://people.mozilla.org/~jorendorff/es6-draft.html#sec-generator-function-definitions にあるよ。return() はDraftに載ったのが8月で割りと最近だからでは。Mozillaでも最近バグ登録されたばかりだし https://bugzilla.mozilla.org/show_bug.cgi?id=1115868
2015/01/03 09:47