Promise.try가 이제 기준으로 새로 제공됩니다. | Articles | web.dev
이제 Promise.try가 모든 주요 브라우저 엔진에 출시되어 기준을 새로 사용할 수 있습니다.
https://web.dev/blog/promise-try-baseline?hl=koPromise.tryが標準に組み込まれたと聞いたとき、調べてはみたものの、何のためのものなのかすぐには理解できなかった。何かしらの目的を持って生まれたはずなのに…むしろ画期的な機能ならともかく、こうした小さな追加にはきっと物語があるはずだと感じた。たとえ大した話ではなくとも。 今日は考古学者となって、その起源と提案過程、そして実装について一緒に見ていこう。
Promise.try()
Promise.try
No description available
https://tc39.es/proposal-promise-try/#sec-promise.tryPromiseの静的メソッドのラインナップに新たに追加されたメソッドである。 簡単に説明すると、Promise.try(fn, …args)はコールバックfnを即座に(同期的に)実行し、その結果をPromiseで包んで返す。 どういうことか。例を見てみよう。
同期かもしれないし非同期かもしれない関数fがあるとする(async awaitはいったん脇に置いておこう)。
function f() {
// 同期かもしれないし非同期かもしれない
if (Math.random() < 0.5) {
throw new Error("sync fail");
}
return Promise.reject(new Error("async fail"));
}
もしfがただの同期関数であれば、エラーはこのように処理していただろう。
try {
f();
} catch (e) {
console.log(e.message);
}
逆にfがPromiseを返す関数であれば、このように処理していただろう。
f().catch(console.log);
では、もしfが同期かもしれないし非同期かもしれない場合はどうすればよいだろうか。
まあそんな状況はそう多くはないだろうが、もしあるとすれば、こうする必要があるだろう。
Promise.resolve().then(f).catch(console.log);
このように使うと何が問題になるだろうか。
fの実行タイミングが変わってしまう。イベントループの動作順序を考えてみよう。
Promise.resolve().then(f).catch(console.log) の実行フローは以下の通りである。
Promise.resolve()はすでにfulfilled状態のPromiseを即座に返す。.then(f)はfをonFulfilledハンドラとして登録し、対応するReaction Jobをmicrotaskキューに入れる。- 現在のコールスタックがすべてクリアされた後、イベントループがそのmicrotaskを処理し、
fを実行する。 fが同期的に例外をスローした場合、その例外は.thenが返した新しいPromiseのreject状態に変換され、続く.catchがそれを受け取る。
結果としてfは次のtickで実行されることになる。
これによりコールスタックが途切れ、デバッグが困難になり、例外の伝播も一拍遅れてしまう。
ここでPromise.tryがこのすべてを正すために登場する。
Promise.try(f).catch(console.log);
このように使うと、Promise.tryの内部でfを即座に実行し、値であればresolve、throwであればrejectとして処理してくれる。もちろん非同期であればそのまま非同期として処理される。
同期の場合を非同期と均一に処理するためのものだと考えればよい。
こうすればfの実行タイミングがずれることもなく、同期的なエラーに遭遇した場合でも現在のコールスタック上で実行され、トレースが途切れない。
しかし疑問に思うかもしれない。
「同期かもしれないし非同期かもしれないケースって何があるのだろう?」「async awaitで処理すればtry catchで対応できるのでは?」
まずPromiseのタイムラインを簡単に振り返ろう。
ES6でPromiseが登場した。その後、ES2017でasync awaitが登場した。
あなたは今、その間の時代、async awaitが登場する以前の開発者だと想像してみてほしい。
ファイルを読み込んでJSONをパースするコードを書く必要があるとしよう。 ファイルがJSONファイルかどうかを確認し、そうであればパースし、そうでなければエラーをスローするコードを書かなければならない。 以下のように書けるだろう。
const fs = require("fs").promises;
const readFile = (file) => {
if (!file.endsWith(".json")) {
throw new Error("not json");
}
return fs.readFile(file, "utf8");
};
この関数はファイル名に応じて同期的に処理されたり非同期的に処理されたりする。 それに応じて上で見た方法で処理すると、以下のようになる。
Promise.resolve()
.then(() => readFile("some.json"))
.then(JSON.parse)
.catch(handleError);
コードの意図を把握するのに不要な要素が介入してしまう。resolveとthenは単にPromiseで包む役割を果たしているだけで、一種のトリックである。
当時もこのような問題を解決するために、現在のPromise.tryと同様の形のライブラリが作られて使われていた。
代表的なものとして、Bluebirdライブラリでは以下のように使うことができた。
Promise.try | bluebird
Bluebird is a fully featured JavaScript promises library with unmatched performance.
http://bluebirdjs.com/docs/api/promise.try.htmlBluebird.try(readJsonFile, "some.json").catch(handleError);
実装を見てみると、throwされた値を再びPromiseで包んで返す形で実装されている(コードは少し簡略化した)。
// https://github.com/petkaantonov/bluebird/blob/master/src/method.js
Promise.attempt = Promise["try"] = function (fn) {
var ret = new Promise(INTERNAL);
ret._captureStackTrace();
ret._pushContext();
// 同期呼び出し
var value = tryCatch(fn)();
// 結果をPromiseで包んで返す
ret.\_resolveFromSyncValue(value);
return ret;
};
こう見るとPromise.tryはなかなか画期的で便利なメソッドに見える。
しかし上記のような不便さを感じたことが多いだろうか? 私はそれほど多くはなかった。
なぜなら、私たちにはasync awaitがあるからである。
上のコードをasync awaitで書き直すと以下のようになる。
const readJsonFile = async (file) => {
if (!file.endsWith('.json')) {
throw new Error('not json');
}
return fs.readFile(file, 'utf8');
}
const processJsonFile = async () => {
try {
const data = await readFile('some.json');
return JSON.parse(data);
} catch (e) {
console.log(e.message);
}
}
こうすれば同期の場合も非同期の場合も同じように処理できる。
別の例としてcopyも挙げられるだろう。
const copy = (text) => {
return Promise.try(() => {
if (navigator.clipboard) return navigator.clipboard.writeText(text);
// ... some code for document.execCommand
document.execCommand("copy");
});
};
clipboardがサポートされている場合はPromiseを返すメソッドであるnavigator.clipboard.writeTextを使い、
サポートされていない場合は同期メソッドであるdocument.execCommandを使う。こういった場面で使えるだろう。
しかしasync awaitを使えばもっとすっきりと処理できる。
const copy = async (text) => {
if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
} else {
// ... some code for document.execCommand
document.execCommand("copy");
}
};
ではなぜ今になってPromise.tryが登場したのだろうか。 上でも触れたが、Promise.tryはPromiseの登場以後、そしてasync awaitが登場する以前のあの間に提案されたものである。 [tc39]Proposal Promise-tryの最初のコミットを見ると、2016年にさかのぼる。 そしてライブラリ形態の実装はそれよりも前から存在していた。
What is Promise.try, and why does it matter? - joepie91's Ramblings
No description available
http://cryto.net/~joepie91/blog/2016/05/11/what-is-promise-try-and-why-does-it-matter/2016年に提案されたのに、なぜ今になって登場したのだろうか。
熾烈な説得
Promise.tryが2016年に提案され、2024年に最終的にStage 4へ昇格しデプロイされるまで、どのような過程があったのかを見ていこう。
Stage 1 - 2016.9~11
上で触れたように、Promise.tryは2016年にJordan Harband(https://github.com/ljharb)によって提案された。\
2016年9月に最初にStage 1として提案されたが、当時は他のアジェンダに押されて議論されず、その年の11月に本格的な議論が行われた。
proposal-promise-try/README.md at f2c8fdca4b09305a0c2dbcf16c1339b9d1ce8a58 · tc39/proposal-promise-try
ECMAScript Proposal, specs, and reference implementation for Promise.try - tc39/proposal-promise-try
https://github.com/tc39/proposal-promise-try/blob/f2c8fdca4b09305a0c2dbcf16c1339b9d1ce8a58/README.md当時の根拠も、先ほど見た例の通り、「色」が分からない関数に対して不要なsyntaxを使うことを防ぐためであった。
以下のミーティング内容は、当時のミーティング記録を読みやすく整理したものである。
2016年11月29日 TC39 ミーティング内容 - Promise.try 議論
https://github.com/tc39/notes/blob/main/meetings/2016-11/nov-29.md#11iib-promisetry
function foo(relativeURL) {
const absoluteURL = new URL(relativeURL, someBaseURL).href;
return fetch(absoluteURL);
}
foo("http:0"); // 例外発生!! しまった
function foo(relativeURL) {
return Promise.try(() => {
const absoluteURL = new URL(relativeURL, someBaseURL).href;
return fetch(absoluteURL);
});
}
function foo(relativeURL) {
return (async () => {
const absoluteURL = new URL(relativeURL, someBaseURL).href;
return fetch(absoluteURL);
})();
}
async function foo(relativeURL) {
const absoluteURL = new URL(relativeURL, someBaseURL).href;
return fetch(absoluteURL);
}
function foo(relativeURL) {
return async do {
// ?!?!?!
var absoluteURL = new URL(relativeURL, someBaseURL).href;
fetch(absoluteURL);
};
}上で見てきたように、このミーティングでもPromise.tryが必要なケースに対する疑問が強く提起された。
そうしてひとまずStage 1にとどまったまま、8年の月日が流れた。
Stage 2 - 2024.2
2024年2月、長い時間を経てPromise.tryのStage 2に関する議論が始まった。
2024年2月6日 TC39 ミーティング内容 - Promise.try Stage 2 議論
https://github.com/tc39/notes/blob/main/meetings/2024-02/feb-6.md#promisetry-for-stage-2
Stage 2に進入するために、Promise.try関連ライブラリの使用量で説得を試みたが、Promise.tryに対する疑問は依然として残っていた。
使用先の大半はjestだったという。Jordan Harband自身もtapeというテスティングライブラリを扱っていたことを考えれば、納得できる部分である。
テスティングライブラリの立場からすれば、テストのコールバックが同期的か非同期的か分からないことがありうるため、こうした方法が必要だったのではないかと推測する。
Stage 2.7 - 2024.04
Stage 2に入ってからは、とんとん拍子で進んでいった。
しかし、以前と同じ疑問は依然として存在していた。
Promiseには既にcatch、finallyがあるのだから、tryもあれば自然だという意見もあった一方、
あれば便利だとは思うが、自分は実際にはあまり使わないだろうという意見が主流であった。
async IIFEと同等ではないかという意見もあった。この部分は、我々が時折使うパターンにも当てはまりそうである。
頻繁ではないが、たまに以下のようにeffect内で非同期処理を行うために以下のようなパターンが使われることがある(あくまで例である)。
筆者もIIFEパターンの方が馴染みがあるが、認知的にコードを読む過程では後者も十分に直感的であると感じる。
この議論では、await構文のトランスパイルコストがより高いという点が根拠として挙げられている。
// ① async IIFE パターン
useEffect(() => {
(async () => {
try {
const res = await fetch("https://api.example.com/data");
const data = await res.json();
console.log(data);
} catch (err) {
console.error(err);
}
})();
}, []);
// ② Promise.try パターン
useEffect(() => {
Promise.try(() => fetch("https://api.example.com/data"))
.then((res) => res.json())
.then(console.log)
.catch(console.error);
}, []);
2024年4月8日 TC39ミーティング議事録 - Promise.try Stage 2.7 議論
https://github.com/tc39/notes/blob/main/meetings/2024-04/april-08.md#promisetry-for-stage-27
Stage 3 - 2024.06
ここからは既に多くのことが合意されており、実装が始まっていたため、大きな異論なく昇格された。 test262にテストがマージされ、ブラウザが実装を開始した。(ECMA-262に対するテストという意味でtest262と呼ばれている。) https://github.com/tc39/test262/tree/main/test/built-ins/Promise/try
2024年6月11日 TC39ミーティング議事録 - Promise.try Stage 3 議論
https://github.com/tc39/notes/blob/main/meetings/2024-06/june-11.md#promisetry-for-stage-3
Stage 4 - 2024.10
2024年10月、ついに最終的な昇格が実現した。
2024年10月9日 TC39ミーティング議事録 - Promise.try Stage 4 議論
https://github.com/tc39/notes/blob/main/meetings/2024-10/october-09.md#promisetry-for-stage-4
どのように実装されたのか?
ミーティングノートをずっと追いかけながら、提案が発議され最終承認されるまでの過程を見てきた。 では、実際の実装はどうなっているのだろうか? まず仕様を見てみよう。
Promise.try
No description available
https://tc39.es/proposal-promise-try/#sec-promise.try日本語に訳すと以下のようになる。
仕様の疑似コードの読み方については、以下の2つのドキュメントを参考にしてほしい。
ECMAScript® 2026 Language Specification
Introduction This Ecma Standard defines the ECMAScript 2026 Language. It is the seventeenth edition of the ECMAScript Language Specification. ECMAScript is based on several originating technologies, the most well-known being JavaScript (Netscape) and JScript (Microsoft). The language was invent
https://tc39.es/ecma262/multipage/notational-conventions.html#sec-algorithm-conventions
How to Read the ECMAScript Specification
No description available
https://timothygu.me/es-howto/簡単に説明すると、コールバックを受け取って実行し、その結果に応じてPromiseとして包んで返すというものである。
ではさっそくV8上の実装に移ろう。まずはコメントに注目してほしい。
https://source.chromium.org/chromium/chromium/src/+/main:v8/src/builtins/promise-try.tq\ https://github.com/v8/v8/blob/main/src/builtins/promise-reaction-job.tq
// Copyright 2024 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
namespace promise {
// https://tc39.es/proposal-promise-try/#sec-promise.try
@incrementUseCounter('v8::Isolate::kPromiseTry')
transitioning javascript builtin PromiseTry(
js-implicit context: Context, receiver: JSAny)(...arguments): JSAny {
// 1. Let C be the this value.
// 2. If C is not an Object, throw a TypeError exception.
const receiver = Cast<JSReceiver>(receiver)
otherwise ThrowTypeError(MessageTemplate::kCalledOnNonObject, 'Promise.try');
// 3. Let promiseCapability be ? NewPromiseCapability(C).
const capability = NewPromiseCapability(receiver, False);
// 4. Let status be Completion(Call(callbackfn, undefined, args)).
const callbackfn = arguments[0];
let result: JSAny;
try {
if (arguments.length <= 1) {
result = Call(context, callbackfn, Undefined);
} else {
const rest = NewRestArgumentsFromArguments(arguments, 1);
result = Call(
context, GetReflectApply(), Undefined, callbackfn, Undefined, rest);
}
} catch (e, _message) {
// 5. If status is an abrupt completion, then
// a. Perform ? Call(promiseCapability.[[Reject]], undefined, «
// status.[[Value]] »).
Call(context, UnsafeCast<Callable>(capability.reject), Undefined, e);
// 7. Return promiseCapability.[[Promise]].
return capability.promise;
}
// 6. Else,
// a. Perform ? Call(promiseCapability.[[Resolve]], undefined, «
// status.[[Value]] »).
Call(context, UnsafeCast<Callable>(capability.resolve), Undefined, result);
// 7. Return promiseCapability.[[Promise]].
return capability.promise;
}
} // namespace promise
V8上のコードをよく見ると、先ほど確認した仕様の疑似コードがそのままコメントとしてコード上に記録されている。 おおまかにコードを眺めてみると、コメントの疑似コードとほぼ同一に実装されていることがわかる。 このコードはTypeScriptに似ているが、拡張子を見ると.tqという拡張子が使われている。この tq は torque の略である。 実装を探してみた時、てっきりC++に出会うものと思っていたのだが、torqueという言語は一体何なのか?
Torque
TorqueはV8でのみ使用されるDSL(Domain Specific Language)である。 Torqueのマニュアルを参照すると、上記の疑問に対する答えが見つかる。(https://v8.dev/docs/torque)
V8 Torque is a language that allows developers contributing to the V8 project to express changes in the VM by focusing on the intent of their changes to the VM, rather than preoccupying themselves with unrelated implementation details. The language was designed to be simple enough to make it easy to directly translate the ECMAScript specification into an implementation in V8, but powerful enough to express the low-level V8 optimization tricks in a robust way, like creating fast-paths based on tests for specific object-shapes.
V8 Torqueは、V8プロジェクトに貢献する開発者が実装の詳細に囚われることなく、「何を変えたいのか」という意図に集中してVMのロジックを記述できるようにする言語である。ECMAScriptの仕様をV8のコードへ容易に変換できるほどシンプルでありながら、特定のオブジェクト形状を判別して高速実行パスを生成するなど、V8の低レベル最適化手法も堅牢に表現できるほど強力に設計されている。
Torqueの目的自体が、既存の実装をよりhuman readableにすることを目標としている。 その手段として、仕様の疑似コードの意図をできる限りそのまま表現できるように作られた。
誕生の背景
初期のV8は、組み込み関数の大半をセルフホスティングJavaScript(V8専用の方言)で記述していた。 このアプローチ自体は悪くなかったが、Arrayのメソッドのように呼び出し頻度が高く仕様が複雑な関数は、ウォームアップ過程を経なければ速くならなかった。 そこで、こうしたコアの組み込み関数をアーキテクチャ別のアセンブリで直接書いて**「初回呼び出しから最高のパフォーマンス」**を出そうと懸命に記述されていた。 ところが問題は、アセンブリで書いていたために各プラットフォームごとに数万行ものコードを複製しなければならず、保守がどんどん困難になっていったことである。
2015年、TurboFan最適化コンパイラの導入により状況が変わることになる。 TurboFanバックエンドが使用する共通の低レベルIRを人間が直接扱えるようにしたC++ DSL **CodeStubAssembler(CSA、2016)**が登場した。 おかげで一度書いたコードがすべてのプラットフォーム向けに最適化されたアセンブリへ変換されるようになり、多くの手書きアセンブリコードを削除できた。
しかしCSAでさえ、人間が逐一書かなければならず依然として難しく、それに伴うリスクも存在した。 そこで2019年、ECMAScriptの疑似コードをほぼそのまま書き写せば、残りのボイラープレートや安全性チェックをコンパイラが自動生成してくれる上位DSL Torqueが登場することになったのである。 Torqueのコードは中間段階であるCSA C++コードに変換され、TurboFanがさらにマシンコードへ変換してくれる。
まとめると、JSセルフホスティング → アセンブリのfast-path → CSA(CodeStubAssembler) → Torque という順に幾重にも積み重なってきたということである。
具体的な内容は、以下のTobias Tebbiの発表を見るとよくわかる。
https://www.jfokus.se/jfokus20-preso/V8-Torque--A-Typed-Language-to-Implement-JavaScript.pdf
No description available
https://www.jfokus.se/jfokus20-preso/V8-Torque--A-Typed-Language-to-Implement-JavaScript.pdfでは、Torqueで実装されたコードはどのように変換されるのだろうか? V8のソース内を見ると、Torqueのコンパイラも存在する。 https://github.com/v8/v8/blob/main/src/torque/torque-compiler.cc
名前の由来
V8関連ツールの名前は、そのほとんどがエンジンに関連する用語から名付けられている。 代表的なものにIgnition、Sparkplug、TurboFanなどがある。Torqueも回転力を意味するためエンジンの力に関係はあるが、 他の命名に比べるとそこまでピンとこない気がする。直接的にエンジンに関連しているというよりは、トルクを加えてCSAへとねじり込むという、そういうメタファーなのではないだろうか?
ふむ
Promise.tryの使い道を探っているうちに、あれこれ多くのことを調べることになった。 提案の過程を追いかける中で何度も疑問を投げかけられているのを見ながら、なぜか自分まで息が詰まる思いがした。 さまざまなメソッドがどのようにして私たちのもとに届くのか、それが気になる人の参考になれば幸いである。
参考文献
- Promise.try - MDN Web Docs
- Promise.try() support now enabled in Chrome (web.dev)
- TC39 Proposal - Promise.try specification
- Original TC39 Promise.try Proposal README
- Bluebird - Promise.try documentation
- What is Promise.try, and why does it matter? (Joepie91’s blog)
- GitHub - TC39 Promise.try issue discussion
- V8 Torque documentation
- V8 Torque Builtins documentation
- V8 CodeStubAssembler blog post
- Chromium Source - Promise.try implementation in Torque
- V8 Torque Compiler source code
- Jfokus 2020 - V8 Torque: A Typed Language to Implement JavaScript (slides)