初めに
もともとSymbol.iteratorとSymbol.asyncIteratorの勉強メモをまとめながら調べる資料からgeneratorの非同期関数のキーワードが出てきて、それで初めてThunkとcoを触れました。読めば読むほどasync functionの概念をたどり着く前にまずcallback⇒generator⇒Thunk&co⇒async functionの流れでメモをまとめていきたいと思います。
結論として、
-
callbackは非同期操作のための関数内関数。 -
generatorはyieldによりコールバック地獄回避のための手動ジェネレータ。 -
Thunkはコールバック地獄回避のための自動ジェネレータ。 -
coは自動ジェネレータでありながら返り値がPromiseの保証を取り入れた関数。 -
async functionは非同期操作や可読性をさらに向上させるためasyncとawaitが導入され、返り値がPromiseの保証をする関数です。
(ブラウザEvent Loopでは非同期関数への処理様々です。例えばChromiumではMacrotaskとMicrotaskに分けられて優先度づけられている。ここの非同期関数はあくまでもMicrotaskで処理されるPromiseに指し、Macrotaskでの非同期APIsetTimeout/setInterval、ajaxなどとは無関係です。)
coの段階からPromiseの使用が強調されるというのは、ES6Promiseの出現とブラウザ側の処理と関わっているのですが。そしてこのような分け方では大事な中身の書き方が決して決まっていません。callbackではとうぜんPromiseを取り入れても問題ないです。
Thunkとcoは外部モジュールとして長い間使われてきましたが、generator、async functionはJavaScript自体のものとして実装されています。callbackは技法です。
...というわけでこれらの勉強メモをまとめるのが今回のメインです。そこから学んだ概念や知識を自分の言葉でまとめることによって、正しく理解したのを検証したいと思います。
generator function vs. async function
ここのgenerator関数は普通のgeneratorではなく、Promiseを返す非同期generatorを指しています。
以下はfsを例にして非同期generatorとasync function両者の書き方を比べたのですが、
function* ()⇒async function ()、yield⇒awaitと置き換えても何もおかしくなく、同じく非同期に動作してくれるのです。
ただasyncとawaitのほうが可読性が高くなり、awaitはyieldより受け入れられるデータタイプが幅広く、Promise以外のString、Number、Booleanは自動的にresolved状態のPromiseとして返すおかげで、async関数は最終的にPromiseを返すのが保証される。
const fs = require('fs');
// note: require() is synchronous
const readFile = function (fileName) {
return new Promise((resolve, reject) => {
fs.readFile(fileName, (error, data) => {
if (error) return reject(error);
resolve(data);
});
});
};
// generator function
const gen = function* () {
const f1 = yield readFile('/directory/file1');
const f2 = yield readFile('/directory/file2');
console.log(f1.toString());
console.log(f2.toString());
};
// note: generator's pointer always comes one after another,
// so it works synchronously
// async function
const asyncReadFile = async function () {
const f1 = await readFile('/directory/file1');
const f2 = await readFile('/directory/file2');
console.log(f1.toString());
console.log(f2.toString());
};
(requireはモジュール導入の同期操作の一つで、後ではawaitでモジュール導入の非同期操作ではまとめて説明します。)
callback ⇒ generator
なぜgenerator関数で非同期操作が求められるのか、というのが今回のトピックに触れるとき一番の素朴な疑問です。
それまでにgeneratorはイテレータによりyieldから値を生成させるコルーチン(coroutine)だと認識して、ほかの勉強メモでは何度も練習してきましたが。
JavaScriptの非同期操作や処理だとcallbackとPromiseに基づいたasync functionに認識していたので、同期だと思ったジェネレータでは非同期に操作できること、そしてasyncとawaitキーワードが生まれるまで一連の発展がわかりませんでした。
なのでまずコールバック地獄を改善するためのgenerator関数までまとめたいです。
// callback ⇒ generator
// callback hell
const request = require('request');
const url = 'https://www.google.com';
request(url, function (error, response) {
if (!error && response.statusCode === 200) {
console.log('got it 1');
request(url, function (error, response) {
if (!error && response.statusCode === 200) {
console.log('got it 2');
request(url, function (error, response) {
if (!error && response.statusCode === 200) {
console.log('got it 3');
}
})
}
})
}
});
// improve callback hell => generator
const request = require('request');
function* requestGen() {
function sendRequest(url) {
request(url, function (error, response) {
if (!error && response.statusCode === 200) {
console.log(response.body);
iterator.next(response.body); // always for the next time
}
})
}
const url = 'https://www.google.com';
const r1 = yield sendRequest(url);
console.log('r1', r1);
const r2 = yield sendRequest(url);
console.log('r2', r2);
const r3 = yield sendRequest(url);
console.log('r3', r3);
}
const iterator = requestGen();
iterator.next(); // for the first time
// note: tight coupling and low reusability
関数内のcallbackは非同期関数を実現する技法のひとつですが、長くなるとコールバック地獄になりがちです。
generatorのように一番の外側にはgenerator関数、urlのラッパー関数sendRequest(url)でrequest(url, callback)を包んで、generatorのnext()を通してresponse.bodyを今指しているyieldへ値をアサインする。読み方としては行ったり来たりして理解しづらいかもしれませんが、
function* ()⇒async function ()、yield⇒awaitに置き換えれば読みやすくなると思います。
callback vs. Promise
そしてここで一つの疑問が生まれた。generator関数でのcallbackと、async関数でPromiseとはどこか違いますか?
まず一番の違いはgeneratorのcallbackは非同期操作だけど、実質上同期操作です。ちょっと言い方が変かもしれないが。。
例えば上の例ではrequest(url, callback(error, response))自体はurlのリスポンスをゲットしてから第二引数のcallbackが動作する。そしてcallbackの中身ではiterator.next(response.body)、自分の再帰呼び出ししてからゲットしたresponse.bodyをr1へ戻す。それからconsole.log('r1', r1);でr1を出力、const r2 = yield sendRequest(url);で再びrequest()を行う。
このように順番に動作するというのは、同期関数とはどこが違う?(以前からcallbackを非同期操作と呼ばれる違和感を感じたのはこれです。)あえて同期関数のように書き換えても同じことができるのでは?
// callback
function demo(str, callback) {
console.log(str);
callback();
}
demo('I am str', () => {
console.log('I am callback');
});
// I am str
// I am callback
Promiseは違いますね。前にも少し触れたがEvent Loopでは解決されたPromiseを別のキューMicrotaskに送り、Call stackがクリアしてから順番通りに結果を実行していくんです。(new Promise(function(resolve, reject))が解決状態になるまで同期に動いています。)
// Promise
function PromiseDemo() {
return new Promise((resolve, reject) => {
console.log('number in Promise')
resolve('I am Promise');
})
}
PromiseDemo().then((result) => {
console.log(result);
});
console.log('1');
PromiseDemo().then((result) => {
console.log(result);
});
console.log('2');
// number in Promise
// 1
// number in Promise
// 2
// I am Promise
// I am Promise
JavaScriptはsingle-threadedなので、細かく言えばcallbackもPromiseも一つのスレッドで優先度が違うだけで同期に動作するしか見えないかもしれないけれど、解決されたPromiseはほかのキューに移すというのはスレッドをブロッキングしない遮断しない非同期操作に近い動きを実現してくれました。
ブラウザ側ではほかのタスクキューまたスレッドも存在する、JavaScriptエンジンはその中の一つのスレッドしか過ぎませんが、JavaScriptエンジンを通してスレッドたちへ指示を出したり結果(タスク)を処理したりするMulti-threadではないけれどMultifunctionalです。
generator ⇒ Thunk
Thunk関数の仕組みを勉強してみました。簡潔に言うと(上の例のような)generatorの結合度を下げ再利用を図るための解決策です。Thunk関数は三つの関数から組み立てています。requestの例にしてみれば、
Thunk:第一階層引数は関数、第二階層はURL、第三階層はコールバック、返り値は第一の関数がcallメソッドを通して第二と第三の引数を利用した結果を返す。
run:ジェネレータ関数を受け入れ、カスタムしたnextメソッドが二回目からの呼び出しresult.value(next)ではThunkのcallback関数(=request(url, callback)のcallback)として作用し、requestのresponseがnext(error, data)のdataになり、gen.next(data)の引数dataとして、ジェネレータ関数のyieldを通じて結果を転送する。
done: trueになるまで何度もカスタムnextの再帰呼び出しと、カスタムnext内のgen.next(data)を経由してrequestのresponseを回送する。
generator:Thunk関数の応用をし結果を受け取ったり、それからの操作をしたりもする。
(以下はデモコードです。最新バージョンではない。)
// improve generator tight coupling problem => Thunk function
function Thunk(fn) { // fn => request
return function (...args) {
return function (callback) {
// fn.call => request.call(), args => url
// callback => (first time)request's callback => (second time)custom next()
return fn.call(this, ...args, callback);
}
}
}
function run(fn) {
let gen = fn(); // run generator
// custom next method === request's callback, data => response
function next(error, data) {
// in the first time, result => gen.next()
// when we call gen.next second time forward, data => request's response
let result = gen.next(data);
if (result.done) return;
// result.value => return function(callback) { return request.call(this, url, callback) }
// result.value(callback) => custom next()
result.value(next);
}
next();
}
const request = require('request');
const requestThunk = Thunk(request);
// generator
function* requestGen() {
const url = 'https://www.google.com';
let r1 = yield requestThunk(url);
console.log(r1.body);
let r2 = yield requestThunk(url);
console.log(r2.body);
let r3 = yield requestThunk(url);
console.log(r3.body);
}
run(requestGen);
/**
* generator.next() always send value back to
* yield first, then execute method.
*/
co
coの引数はジェネレータ、返り値はPromiseオブジェクトです。
内部コードyieldにThunk関数をサポートするけどcallbackをいれるThunkしか受け入れられないので、fnとargsがすでにラッピングしておかねばならない。
function Thunk(fn) {
return function (...args) {
return function (callback) {
return fn.call(this, ...args, callback);
}
}
}
const request = require('request');
const requestThunk = Thunk(request); // fn => request
const googleRequest = requestThunk('https://www.google.com'); // args => url
const co = require('co');
co(function* () {
const r1 = yield googleRequest; // run => googleRequest(callback)
const r2 = yield googleRequest;
const r3 = yield googleRequest;
return {
r1,
r2,
r3
}
}).then((response) => {
console.log(response);
}).catch((error) => {
console.log(error);
})
coではyieldがThunk(callback)かPromiseを受け入れるが、未来サポートしない可能性があるのでPromiseのほうがおすすめです。
//
const co = require('co');
co(function* () {
const url = 'https://www.google.com';
const r1 = yield fetch(url);
const r2 = yield fetch(url);
const r3 = yield fetch(url);
return {
r1,
r2,
r3
}
}).then((response) => {
console.log(response);
}).catch((error) => {
console.log(error);
});
about co module
- 引数は
generator。 -
yieldにはThunkかPromiseが受け入れられる。 - 返り値が
Promiseを保証する。
// co module simplifies generator without calling next(),
// and it returns Promise object which can use callback in Promise chain
let co = require('co');
co(gen).then(function () {
console.log('Generator is completed')
});
下は参考文章から取ったcoの内部を再現するデモコードです。(Promiseに基づいたデモコードです。本番のソースコードは下のco module source codeにあります。)
coは最終的にPromiseを返すのがポイントです。Thunkはcallbackから貰った返り値、Promiseは解決されたPromiseです。yieldにはPromiseともかく、なぜThunkも受け入れられるでしょうか。
このデモコードではfsのcallbackからもらった結果dataをresolve()へ、つまりThunkの返り値でも解決されたPromiseにすれば最終的にPromiseを返すのが保証される。
// internal implementation
const fs = require('fs');
const readFile = function (fileName) {
return new Promise((resolve, reject) => {
fs.readFile(fileName, (error, data) => {
if (error) return reject(error);
resolve(data);
});
});
};
const gen = function* () {
const f1 = yield readFile('/directory/file1');
const f2 = yield readFile('/directory/file2');
console.log(f1.toString());
console.log(f2.toString());
};
// note: yield is a two-way syntax
coにはもうひとつのポイントがあります。それはgeneratorのnext()を自動的に繰り返すことです。今のcoはPromiseを保証するのだからPromise chainが利用できます。
手動なら、コールバック地獄のようにならないけれど長くなると読みづらくなります。Thunkのようにcallbackでdone: trueになるまで自分(custom next)を呼び出しすれば自動的に繰り返してくれます。
// manually run
let g = gen();
// value was Promise object
// first, get file1 and put value into callback
g.next().value.then(function (data) {
// second, send file1 back to f1, then get file2 and put into callback
g.next(data).value.then(function (data) {
// finally, send file2 back to f2
g.next(data);
});
});
// automatically run
function run(gen) {
const g = gen();
function next(data) {
// for the first time we call next(), there doesn't have "data", that means we runs "g.next()"
let result = g.next(data);
// if all the file loading were done, it returned value back finally,
if (result.done) return result.value;
// if not, we use Promise chain to send value back and call next() again until all the files were done
result.value.then(function (data) {
next(data);
});
}
next();
}
// note: we can use next() to point to where yield is,
// and send value back to yield by next() at the same time
run(gen);
(自分の感覚ではcoはThunk関数の改善バージョン、Thunkは手動generatorの改善バージョンです。)
generatorを核心概念として、
Thunkはcallback返り値のためのラッパー、
coはcallback返り値がPromiseを保証するラッパー、
runのcustom nextはgeneratorを自動的に動くための再帰呼び出しメソッドです。
co module source code
勉強のために一部のコードを取って概念を説明したいと思います。
(以下はデモコードです。ネーミング変更あり。)
// co module source code
function co(gen) {
let contextOfCo = this;
return new Promise((resolve, reject) => {
// check gen if generator(function)
// if it is, change generator's context with co function
if (typeof gen === 'function') gen = gen.call(contextOfCo);
// avoiding gen is null/undefined or it didn't have next function(not generator), return and suspend
if (!gen || typeof gen.next !== 'function') return resolve(gen);
onFulfilled();
function onFulfilled(resolve) {
let result;
// let generator run automatically
try {
result = gen.next(resolve);
} catch (e) {
return reject(e);
}
next(result);
}
});
}
function next(result) {
// first: check generator state
if (result.done) return resolve(result.value);
// second: ensure return value is Promise object
// invoke external function: toPromise()
let value = toPromise.call(contextOfCo, result.value);
// third: if value is Promise object, use Promise chain to call onFulfilled function again
// invoke external function: isPromise()
if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
// forth: if value is not a function, Promise, generator, array, or object
return onRejected(
new TypeError(
'You may only yield a function, promise, generator, array, or object, '
+ 'but the following object was passed: "'
+ String(result.value)
+ '"'
)
);
}
Thunk関数の概念が分かったらcoはそれに基づいてPromiseの検証や保証をいれた関数です。coではデータタイプ幅広く受け入れるためには返り値Promiseの保証を細かく施しています。
ソースコードを覗いてみればtoPromise()では、thunkToPromise()、arrayToPromise()、objectToPromise()などで転換作業して、とてもよく考えられている工夫されているモジュールだと分かります。
下は使い方のデモコードです。
// usage
// let actions execute asynchronously
// when all of them are completed, it will continue to next step
// array
co(function* () {
let resolve = yield [
Promise.resolve(1),
Promise.resolve(2)
];
console.log(resolve);
}).catch(onerror);
// object
co(function* () {
let resolve = yield {
1: Promise.resolve(1),
2: Promise.resolve(2)
};
console.log(resolve);
}).catch(onerror);
co(function* () {
let values = [n1, n2, n3];
yield values.map(somethingAsync);
});
function* somethingAsync(x) {
// do something async
return y
}
// note: the argument in map() works simultaneously
ここからはasync functionに入りますが、後から読めば文章が長すぎて読みづらくなるのでasync functionの部分はpart2に続きます。