PromiseはES2015からJavaScriptに導入された機能で、非同期処理をいい感じに記述できるたいへんありがたいオブジェクトです。実は、Promiseの強化版ともいえる新機能、その名もHandledPromiseが提案されています。また、このHandledPromiseのための新構文~.も同時に提案されています。
例えば、~.を用いて次のようなプログラムを書くことができます。
const data = await disk~.openDirectory('foo')~.openFile('bar.txt')~.read();
この記事では、HandledPromiseと~.について概説します。例によって、これらはStage 1プロポーザルです。つまり、「こういうのがあってもいいんじゃない?」と思われている段階であり、具体的な方向性とかは何一つ決まっていないということです。この記事でお伝えするのは現段階での構想であり、当然ながらまだJavaScriptに正式採用されたものではありません。何年後かにこの機能が採用されたときに全然別の見た目になっていたとしても、悪しからずご了承ください。
この記事は以下の2つのプロポーザルを基に書かれています。
また、このPolyfillも参考になるでしょう。
HandledPromiseの意義
ざっくりと言えば、HandledPromiseは効率的なRPCを実装するための標準化された機構です1。RPCというのはRemote Procedure Callのことですね。要するに、通信先に存在する(仮想的な)オブジェクトに対して操作を行うことができるのです。このプロポーザルでは、タイトルにある~.という構文をRPCを行うための統一的なものとして提供します。
HandledPromiseは~.をサポートする特殊なPromiseであり、RPCをサポートするライブラリの作者は、~.をサポートするためにHandledPromiseを用いた実装を行うことになります。
ですので、ライブラリのユーザーは基本的に~.構文を用いればよく、ライブラリを作る側はHandledPromiseを使って裏で頑張ると言う構図になっています。
これは、我々は普段async関数とawaitがあればなんとかなる一方で、ライブラリの中ではnew PromiseによるPromiseの作成のような多少トリッキーなことが行われているのと似ています。
HandledPromiseが何を解決するのか
なぜHandledPromiseが必要なのかという点は上述のプロポーザル文書を読んでもいまいちピンと来ないと思いますが、添付されているスライドは具体例などもあり比較的分かりやすいです。スライドの10ページ目を引用します。
コードで例示するとこういう感じです。画像左の従来型の処理は以下のようなコードに対応します。
const dir = await disk.openDirectory('foo');
const file = await dir.openFile('bar.txt');
const data = await file.read();
これは何かのファイルシステムからデータを読む処理に見えます。見てわかるとおり、awaitが直列に3つ並んでいます。もしファイルシステムがネットワークの向こうにあった場合、この処理は必然的に3RTTの時間がかかることになります。
HandledPromiseを使うことで、この処理はdisk~.openDirectory('foo')~.openFile('bar.txt')~.read()と書けるようになります。
一見すると~.が「awaitしてメソッド呼び出し」という構文に見えなくもありませんが、もっと本質的な違いがあります2。実は、こう書くことでかかる時間がほぼ1RTTで済むのです。その理由は、一つの命令の結果を受け取る(命令のPromiseが解決される)より前に次の命令を発行できるからなのです。今回の場合、openDirectoryの結果を受け取る前にopenFileやreadという命令を送信しています。~.という構文は、この「Promiseが解決されていなくても次の命令を発行する」という処理のための構文です。
重要なのは、たとえばdisk~.openDirectory('foo')自体がひとつの(Handled)Promiseであるという点です。これをawaitすればディレクトリを表すオブジェクトが手に入るでしょう。それにも関わらず、~.を用いて(Promiseとして解決されるより前に)に追加の命令を発行できるのがポイントです。この、「Promiseである」という点と「~.を通じてメソッド呼び出し(など)ができる」という2つの特徴を両立しているのがHandledPromiseの本質的な特徴です。
しかし、勘のいい皆さんはすでにお気づきでしょう。これが非同期処理のレイテンシに対する銀の弾丸などではなく、まとめて送られてくる命令を正しく処理するという複雑さがネットワークの向こう側に追いやられただけであるということに……。
まあ、パフォーマンスのためなら背に腹は代えられません。ネットワークの向こう側は適当に頑張ってもらいつつ、少なくともこちら側をうまくやるための道具を導入するというのがHandledPromiseの趣旨です。ネットワークの向こう側に複雑さを追いやるのはGraphQLとかにも見える流れですし、それが今どきのやり方なのでしょう。HandledPromiseにもいい感じに対応したサーバー側のフレームワークがきっと出るでしょう。
記事タイトルにあるPromise Pipeliningとは、非同期処理の結果が帰って来る前に次々と命令を送ってレイテンシの低減を図ることを指すようです。
以上でHandledPromiseがやりたいことは見えましたね。前述の二面性を持ったオブジェクトを言語標準として提供することで、RPCなどをやるときのための統一されたインターフェース(あとついでに構文も)を提供しようという魂胆です。ここからは、これを実現するためにどのような機構が必要だったのかを見ていきます。実際のところ、本質的な難しさはネットワークの向こうに押しやっているので(というより、様々な種類の非同期/分散処理に対する抽象化を与えるためにはそうするしかないのですが)、HandledPromiseの機構は一度知ってしまえばそこまで複雑怪奇なものではありません。
HandledPromiseとハンドラ
HandledPromiseを理解するにあたって重要な概念がハンドラです。ハンドラと言えばProxyを扱うときに出てくる用語でしたが、ここでいうハンドラもそれと似た雰囲気です。実際、ある意味でHandledPromiseはProxyに近い側面を持っています。
HandledPromiseにおけるハンドラは、3種類の処理に対する処理を記述する関数です。3種類の処理とは、プロパティアクセス、関数として呼び出す、メソッド呼び出しの3種類です。この3種類は、ちょうど~.で記述できる以下の3種類の構文に対応しています。pというのはHandledPromiseです(後述しますが、普通のPromiseに対しても動作はします)。
| 処理 | 構文 |
|---|---|
| プロパティアクセス |
p~.foo / p~.[name]
|
| 関数として呼び出す | p~.(...args) |
| メソッド呼び出し |
p~.method(...args) / p~.[name](...args)
|
余談ですが、これらの構文はoptional chainingの構文に非常に似ていますね。
上記のdisk~.openDirectory('foo')~.openFile('bar.txt')~.read()という例では、メソッド呼び出しの構文が3連続で続いていたことになります。.を~.に変えることで、これはこの場で行われているのではなくて実際はRPCなんですよと言う雰囲気を出しています。
そして、ハンドラとはこの3種類の操作が行われたときの処理を指定できるオブジェクトです。3種類の操作を全部記述するとこのようになります。
{
get(target, name) {
// target~.[name] に対応するハンドラ
// プロパティアクセス結果のPromiseを返す
},
applyFunction(target, args) {
// target~.(...args) に対応するハンドラ
// 関数呼び出しの返り値のPromiseを返す
},
applyMethod(target, name, args) {
// target~.[name](...args) に対応するハンドラ
// メソッド呼び出しの返り値のPromiseを返す
},
}
つまるところHandledPromiseとは、「ハンドラを装備することによって~.に対する挙動をカスタマイズすることができるPromise」です。
では、次はHandledPromiseの作り方を見ましょう。
HandledPromiseコンストラクタ
HandledPromiseはPromiseの上位互換のような存在で、作り方はPromiseに少し似ています。
new HandledPromise((resolve, reject, resolveWithPresence) => {
}, unfulfilledHandler)
普通のPromiseに比べて少しごちゃごちゃしていますね。コンストラクタに関数を渡すのは同じですが、resolveとrejectに比べて新しいresolveWithPresenceという選択肢が加えられています。さらに、関数に加えてunfulfilledHandlerを渡さなければいけません。
分かりやすいのはunfulfilledHandlerなので、こちらから解説します。名前の通り、これはHandledPromiseがまだ成功していないときに使われるハンドラです。とりあえず単純な例を見てみましょう。
(async ()=> {
const p = new HandledPromise(() => {}, {
get(target, name) {
console.log(name);
return "wow!";
}
});
// p2はPromise。
// ここで "foo" がコンソールに表示される。
const p2 = p~.foo;
const p2Value = await p2;
// p2Valueは "wow!"
console.log(p2Value);
})();
ここで作ったpは永遠に解決されないHandledPromiseです。しかし、getに対応したハンドラが与えられています。pに対して~.で行う操作はこのハンドラが対応することになります。
まずp~.fooとしています。こうすることで、pが持つgetハンドラが呼び出されます。引数targetはpそのもの、nameは"foo"です。注意点として、~.による操作の結果は常にPromiseです。プロパティアクセスのような見た目からはちょっと想像しにくい部分もありますが、プロパティの取得もRPCの一貫と考えればまあ納得できるかもしれません。await p~.fooのようにまとめれば、「fooを取得するという処理を待つ」と言う雰囲気が出ます。
今回はgetハンドラが"wow!"を返したので、p2は"wow!"に解決されるPromiseになります。これをawaitすることで無事に結果を取得できました。
お分かりとは思いますが、最初の例のように~.を何個も繋げたい場合は、これらのハンドラが新しいHandledPromiseを返すようにすればよいのです。
HandledPromiseを解決する
さて、ここからがHandledPromiseの本領発揮です。というのも、上の例だけなら普通のProxyでも似たようなことができるからです。重要なのは、HandledPromiseが上記のようなProxy的な側面と、それ自身Promiseであると言う側面を両立できると言う点なのです。
HandledPromise自体もPromiseですから、それ自身も成功したり失敗することができます。そのとき何が起こるのかを見ていきましょう。
まず、普通のPromiseと同様にresolveで解決した場合を考えます。次の例です。
(async ()=> {
const p = new HandledPromise((resolve, reject) => {
setTimeout(()=> {
resolve({
bar: 12345
})
}, 1000);
}, {
get(target, name) {
console.log(name);
return "wow!";
}
});
const p2Value = await p~.foo;
console.log(p2Value); // "wow!" が表示される
await p;
console.log(await p~.bar); // 12345 が表示される
})();
今回、pの定義を少し変えて1秒後に{ bar: 12345 }というオブジェクトに解決されるようにしました。それより前にp~.fooとした場合は従来通り、ハンドラのgetメソッドが呼ばれます。
では、await pとしてpの解決を待った後にp~.barとしたら何が起こるでしょうか。上のサンプルに書いてある通り、なんと12345が表示されました。
ここで起こっていることは2段階に分けて説明できます。まず、pが成功裏に解決された時点で、unfulfilledHandlerはお役御免になります。pが解決された時点で、これはただのPromiseと同じ扱いになります。したがって、p~.barというのはただのPromiseであるpに~.構文を用いていることになります。
実は、ハンドラを持たないただのPromise(あるいはPromiseですらない値)に~.を用いた場合はデフォルトのハンドラが用いられます3。デフォルトのハンドラの挙動では、p~.barというのはp.then(value => value.bar)という挙動になります。要するに「awaitしてプロパティアクセス」ということですね。今回はpの解決先が{ bar: 12345 }だったので、p~.barで12345が取得できたことになります。
このデフォルトのハンドラは望ましい場面もあるでしょうが、いつもそうとは限りません。HandledPromiseが解決した後も~.に対する制御を失いたくないこともあるでしょう。
そのような場合、2つの選択肢があります。一つはresolveで別のHandledPromiseに解決すること、そしてもう一つはresolveWithPresenceを使うことです。
resolveWithPresenceを使う場合は、解決先を引数で指定する代わりに新しいハンドラオブジェクトをresolveWithPresenceに渡します。そうすると、自動的に新しいオブジェクトが作られてそのオブジェクトに解決されます。しかも、そのオブジェクトに対して~.を用いると指定したハンドラオブジェクトが使用されるのです。なお、presenceとはこの時作られる解決先のオブジェクトのことのようです。なお、この時作られるのはObject.create(null)に相当するもので、プロトタイプを持ちません。
(async ()=> {
const p = new HandledPromise((resolve, reject, resolveWithPresence) => {
setTimeout(()=> {
resolveWithPresence({
get(target, name) {
return "mom!";
}
})
}, 1000);
}, {
get(target, name) {
console.log(name);
return "wow!";
}
});
const p2Value = await p~.foo;
console.log(p2Value); // "wow!" が表示される
const result = await p;
console.log(await p~.bar); // "mom!" が表示される
// {} が表示される
console.log(result);
})();
この例ではまたpの定義が変わっており、resolveの代わりにresolveWithPresenceが使われています。今度は"mom!"を返す関数がgetのハンドラとして定義されています。
すると、p~.fooやp~.barの挙動がpが解決される前後で変わっていることが見て取れます。pが解決される前はunfulfilledHandlerとして渡されたものが使われている一方で、解決後はresolveWithPresenceに渡されたものが使われています。
ちなみに、resolveWithPresenceは返り値としてたった今作られた解決先のオブジェクトを返します。これにより、このタイミングでオブジェクトをカスタマイズすることが可能となっています。
const presence = resolveWithPresence({
get(target, name) {
return "mom!";
},
});
presence.hello = "world";
// ...
const result = await p;
console.log(result.hello); // "world"
以上でHandledPromiseの機能の解説は終わりました。まだよく分からないという読者の方が多いと思いますが、あとで例を用意しているのでご安心ください。しかしその前に、いくつか補足事項を説明します。
~.の脱糖
~.はHandledPromiseのための新しい構文ですが、実はこれを使わなくてもHandledPromiseの機能は利用可能です。HandledPromiseコンストラクタの静的メソッドとしていくつかのメソッドが用意されており、具体的には~.の構文は以下のように~.を使わない形に書き換えることができます。
| 脱糖前 | 脱糖後 |
|---|---|
p~.foo |
HandledPromise.get(p, "foo") |
p~.(...args) |
HandledPromise.applyFunction(p, args) |
p~.method(...args) |
HandledPromise.applyMethod(p, "method", args) |
Eヘルパー
上記のような代替手段があるとはいえ、~.がないとパイプライニングが少し不便です。例えば、disk~.openDirectory('foo')~.openFile('bar.txt')は以下のように書かなければいけません。
HandledPromise.applyMethod(HandledPromise.applyMethod(disk, 'openDirectory', ['foo']), 'openFile', ['bar.txt'])
これはちょっと読みにくくて不便ですね。そこで、HandledPromiseのプロポーザルはEと言うヘルパーを定義しています。Eを用いるとこのように書けます。
E(E(disk).openDirectory('foo')).openFile('bar.txt')
要するに、EでHandledPromiseをラップすることで~.と同様のメソッド呼び出し記法がサポートできるというものです。メソッドチェーンをつなぐ時に毎回Eで囲まないといけないのが残念ですが、~.が無ければこれが精一杯というところなのでしょう。
ちなみに、「Eなんて1文字のグローバル変数を新たに追加して大丈夫なの?」と思った方がいるかもしれませんが、それは心配ありません。Eは組み込みモジュールを通じて提供されますから、衝突の心配はありません。
~.とデフォルトハンドラ
上でちらっと説明したように、~.の左がHandledPromiseでなかった場合はデフォルトのハンドラが用いられます。デフォルトのハンドラも、(特にHandledPromiseではない普通のPromiseに対して)有効な場合があります。pが普通のPromise(かつHandledPromiseに解決されていない)だとした場合に、~.は次のように動作します。
| 構文 | 動作 |
|---|---|
p~.foo |
p.then(obj => obj.foo) |
p~.(...args) |
p.then(func => func(...args)) |
p~.method(...args) |
p.then(obj => obj.method(...args)) |
いずれも「awaitしてからプロパティアクセス/関数呼び出し/メソッド呼び出し」という動作で、このように普通のPromiseに対しては~.は「自動await」、あるいは(ファンクタの意味での)「map」のように動作します。
これがデフォルトになっているということは、HandledPromiseについてもこれから大きくかけ離れない挙動が望ましいと思われます。
それはともあれ、~.構文が市民権を得てきたら、HandledPromiseとは関係のない文脈で便利な構文として~.が使われることもあるかもしれません。見てもびっくりしないようにしましょう。
SendOnly系ハンドラ
これまで紹介したハンドラはget、applyFunction、applyMethodという3種類のトラップから成っていました。実は、さらにgetSendOnly、applyFunctionSendOnly、applyMethodSendOnlyという3種類が存在します。これらは「結果が使われない」ことが分かっているときに使われる特殊なもので、無くても動作はします。たとえば、以下の場合はp~.method()の結果がvoidで即座に消されることが分かっているのでp~.method()の処理にはapplyMethodではなくapplyMethodSendOnlyが使われます。
void p~.method();
他に、ただ単にp~.method();と書いた場合も結果が使われないので消せそうですが、この場合どうするかは議論中です(Completion Recordとの兼ね合いがあるため)。
ハンドラを実装する側が、結果が使われないと分かっている場合になんらかの最適化をすることできるかもしれないということでこれらの機能が存在しているようです。
HandledPromiseの実践例: 出力に 時間がかかる コンソール
さて、ここからが後半です。この記事の前半ではHandledPromiseのAPIを紹介しました。これだけで読者の皆さんがHandledPromiseを使いこなせるようになればよいのですが、HandledPromiseは結構ややこしいためそうもいかないでしょう。
そこで、ここからはHandledPromiseの実践例を一つ作ってみましたので紹介します。これらの例を通じてHandledPromiseの理解を自分のものとしましょう。
遅延ログ出力の理想的状況
この例のソースコードは以下のCodeSandboxにまとまっています。コードを自分で触ってみたい方はこちらをご参照ください。
この例では、画面に何かを出力したいものの、なぜか出力が反映されるまでに時間がかかるという状況を想定しています。具体的にはこんな関数でログを出力しなければいけない場合を考えます。
export const randomSleep = () => {
const sleepDuration = 200 + Math.floor(Math.random() * 800);
return new Promise(resolve => {
setTimeout(resolve, sleepDuration);
});
};
/**
* ランダムに遅延した後ログを出力する。
*/
export const log = async (...args) => {
await randomSleep();
console.log(...args);
};
log関数を呼ぶと、ランダムな時間(200ミリ秒〜1000ミリ秒)待ってからconsole.logが呼ばれます。
これを使って3つログを出力しなければならないとすると、最も単純なやり方は次のmain1関数のようにする方法です。
async function main1() {
await log("this is log1");
await log("log2 is ", 12345);
await log("Hello, log3!");
}
しかし、これはあまりやりたくありません。これだと、3つログを出すのに1つの3倍の時間がかかってしまうからです。
とはいっても、次のmain2のようにするのもいけません。ログの順番が変わってしまうかもしれないからです。
async function main2() {
await Promise.all([
log("this is log1"),
log("log2 is ", 12345),
log("Hello, log3!")
]);
}
では理想的なのはどういう書き方かというと、~.を用いて次のように書けると良いですね。この書き方では、それぞれのlogの処理が終わるのを待たずに次の処理を発行しつつ、logの順番は保存されることが期待されます(待ち時間は、前のlogが終わっていなくてもlogを読んだ瞬間に始まるという想定です)。もちろん、log()の結果は(Handled)Promiseなのでawaitすることでそれまでのログ出力が終了するまで待つことができます。
window.main3 = async function main3() {
await createLogger()
~.log("this is log1")
~.log("log2 is ", 12345)
~.log("Hello, log3!");
}
ということで、このような動作を達成できるcreateLoggerを例として作ってみました。もしみなさんが自分で作ることができればおそらくHandledPromiseマスターと言えるでしょう。やる気のある方はぜひ挑戦してみてください。
なお、残念ながら最初に出てきたlog関数はそのままだと使えず、もっとcreateLoggerと密結合したものにせざるを得ません。もっと実践的にやるならRPCプロトコルから実装していかないといけないのですが、今回はサンプルなのでそこはご容赦ください。
createLoggerの実装
ということで、多少長いですが、できたものがこちらです。上述のCodeSandboxを開いている方は、main3ボタンを押すことでこいつの動作を確かめられます。何回か押してみると、時間をかけずに、かつ順番通りにログが出力されることが分かります。
const handler = {
applyMethod(target, name, args) {
if (name !== "log") {
throw new Error(`Undefined method '${name}'`);
}
return requestLog(target, args);
}
};
export const createLogger = () => {
return new HandledPromise((_rs, _rj, resolveWithPresence) => {
resolveWithPresence(handler);
});
};
function requestLog(parent, args) {
const parentPromise =
"function" === typeof parent.then ? parent : Promise.resolve();
return new HandledPromise(async (_rs, _rj, resolveWithPresence) => {
await randomSleep();
await parentPromise;
console.log(...args);
resolveWithPresence(handler);
}, handler);
}
最初に書いてあるhandlerオブジェクトは簡単です。"log"という名前のメソッドが呼ばれていたらrequestLog関数に処理を投げて、他はエラーにします。このtargetに何が渡されるのかというのは、実は場合により異なります。unfulfilledHandlerの場合(HandledPromiseがまだ成功裏に解決されていない場合)はtargetは当該のHandledPromiseになりますが、resolveWithPresenceの場合(すでに成功裏に解決されている場合)は結果のオブジェクト(presenceオブジェクト)になります。このことはrequestLogの中で使われています。
requestLogの結果(つまりはp~.log(...)の結果)は新しいHandledPromiseです。このHandledPromiseは、作られたらまずrandomSleep()で適当な時間待ちます。これが最初のlogであった遅延の再現です。そして、続けてparentPromiseを待ちます。これは、言うなれば「前のlog(...)が終わるまで待つ」ということです。await randomSleep()で待っている最中に前のlog(...)が終わっていた場合は、await parentPromiseは即座に完了します。これらが終わるとやっとconsole.logを呼び出します。自分のconsole.logが完了したらこのHandledPromiseが解決されたと見なせるので、resolveWithPresenceで自身を解決します。
実際のRPCの場合はこの2連続awaitとconsole.logのところが「RPC命令をリモートに送って、返事が来るまで待つ」というような処理になるわけですね。前のlogが終わるまでこのログを出力しないというような制御は向こうの処理に任せるわけです。
createLoggerの特性
createLoggerの実装はこれだけですが、なかなか面白い特性を見せます。まず、HandledPromiseがPromiseとProxyの両方の特徴を併せ持つおかげで、createLogger()やlog()の返り値は、続けてlog()を呼ぶほかにもawaitすることができます。今回の実装の場合はmain3をこのようにすれば、必ずlog1〜log3が出たあとにlog4が出力されます。
async function main3() {
await createLogger()
~.log("this is log1")
~.log("log2 is ", 12345)
~.log("Hello, log3!");
console.log("log4");
}
また、次のようにすればlog1〜log3とlog4〜log6の二つの系統を混ぜることもできます。なかなか自由度が高いですね。
async function main3() {
await Promise.all([
createLogger()
~.log("this is log1")
~.log("log2 is ", 12345)
~.log("Hello, log3!"),
createLogger()
~.log("log4")
~.log("log", 5)
~.log("log6")
]);
}
まとめ
この記事で紹介したHandledPromiseはステージ1のプロポーザルで、Promiseとしての性質を
保ちつつ、~.により追加の操作を受け付けられるという特徴を持つオブジェクトです。特に、まだPromiseとしては解決していなくても、~.により矢継ぎ早に命令を送ることができ、その特徴からRPCを実装するのに有用です。記事タイトルにあるPromise Pipeliningとは、まさに結果を待たずに次々非同期処理を進めることを指す言葉です。
HandledPromiseによって、JavaScriptはこのような非同期処理を表すための新たな語彙を手に入れることになります(プロポーザルが通ればですが)。
ユーザーからすれば~.を用いてメソッド呼び出しの形で命令を記述できるのが直感的です。一方で、それを提供するライブラリ作者の側はHandledPromiseのAPIを用いて結構泥臭い実装をする必要があります。
そもそもこのプロポーザル、「何かいい感じのライブラリができたから標準に提案してみた」という雰囲気があるような気がしないでもありません。ただし、今回説明を省きましたが、PolyfillではPromiseがHandledPromiseに解決されるケースの動作がうまく実装できないという問題があり、そのために標準化を提案しているというストーリーになっています。
~.という構文に関しても、このまま進むかどうかは大変怪しいところです。TC39ミーティングの議事録をみても、「こんな使い所少なそうな機能のために~.というわりと使い道ありそうな構文を使っていいのか」というような議論が見られます。
ということで、たいへん前途多難なプロポーザルで今後どのように進むか全然分かりませんが、なかなか面白そうなプロポーザルなので説明してみました。~.という愉快な構文が使えるようになるのを正座して待ちましょう。