初めに
もともと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に続きます。