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ミーティングの議事録をみても、「こんな使い所少なそうな機能のために~.
というわりと使い道ありそうな構文を使っていいのか」というような議論が見られます。
ということで、たいへん前途多難なプロポーザルで今後どのように進むか全然分かりませんが、なかなか面白そうなプロポーザルなので説明してみました。~.
という愉快な構文が使えるようになるのを正座して待ちましょう。