ES6 Generatorを使ってasync/awaitを実装するメモ

こんにちは丸山@です。

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を使って処理を再開する側を制御側」と呼ぶことにする。

仕様についてはこの辺り

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についても知識が必要になるので@さんの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
*/

http://goo.gl/MI0dO1

function *という構文により生成された関数generatorFunctionはGenerator Functionとなる。そしてgeneratorFunctionを実行すると処理が 開始されるのではなく Generator Object generatorObjectが返される。

generatorObject.next()を実行することで関数本体の処理を開始することができる。処理が開始され、yield式が実行されるとそこで 処理が中断 される。中断された処理を再開するにはgeneratorObject.next()を実行する。そうするとまた次のyield式で処理が中断される。

これがGeneratorの基本的な使い方。以降ではgeneratorFunctiongenFuncgeneratorObjectgenと記述する。

終了を検出する
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
*/

http://goo.gl/leF0l4

被制御側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
*/

http://goo.gl/kBuonQ

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
*/

http://goo.gl/lPUI5b

制御側から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
*/

http://goo.gl/gtSz0V

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
*/

http://goo.gl/gyARHj

まず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
*/

http://goo.gl/JPuv0S

先ほどの実装では制御側からコールバック関数を登録する必要があり、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
*/

http://goo.gl/gUzY0r

非同期処理の戻り値を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!!!
*/

http://goo.gl/klB194

非同期処理の例外は面倒だが、ここでは同期処理と同じように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
*/

http://goo.gl/WRdTk7

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
*/

http://goo.gl/zcMH5l

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.returnES6 Draftにはあるけど、traceurやChromeに実装されていないのはなぜ?
  • Delegating yield yield *という機能がES wikiにはあるけど、ES6 Draftには無いのはなぜ?
追記(2014/01/04)

はてブやコメントでGenerator.prototype.returnyield*について教えてもらった。id:teramakoさん、id:jserさんありがとうございます。

ES6 Generatorを使ってasync/awaitを実装するメモ - maru source

yield * は 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

*1:C#のasync/awaitより

*2:基本、ハマりどころ、テストなどPromiseについて網羅されていてめちゃ良い