Promise.try:
シンプルで強力な同期/非同期統合の未来
- 実装の深層とPromiseの進化

JSConf JP 2024
saku🌸 / @sakupi01

saku

Web Frontend Engineer
@Cybozu

🍏/ ☕/ 🌏 > ❤️
𝕏 @sakupi01

Promise.try:
シンプルで強力な同期/非同期統合の未来
- 実装の深層とPromiseの進化

Available in EN

Congrats to Promise.try🎉

先月東京で開催されたTC39 104thのMeetingで8年の時を経てStage4に!

Commit c03068b
Normative: add Promise.try (#3327)

1. JavaScriptにおける同期処理と非同期処理の
統一化の難しさ

e.g. syncOrAsyncという、同期・非同期的どちらの結果ももたらす可能性がある関数

function syncOrAsync(id) {
    if (typeof id !== "number") {
        throw new Error("id must be a number"); // 同期的なエラー
    }
    return db.getUserById(id); // 非同期的な処理
}

そのままsync/asyncな関数を扱おうとする

// Uncaught TypeError: syncOrAsync(...).then is not a function
// Property 'then' does not exist on type '"sync"'.ts(2339)
syncOrAsync()
  .then(console.log)
    .catch(console.error);
  • 実行エラー、TypeScriptの場合はコンパイルエラー
  • 同期関数である場合、thenプロパティが存在しないため

新しくPromiseを生成して、そのなかでsync/asyncな関数を解決する

new Promise((resolve) => resolve(syncOrAsync()))
    .then(console.log)
      .catch(console.error);
  • 不必要な非同期処理は発生せず、動作としては問題ない
  • 書きぶりが冗長

Promise.try()が標準化される前にも、Promise.try()のような機能を提供するライブラリは存在していた

「新しくPromiseを生成して、そのなかでsync/asyncな関数を解決する方法」を採用

2. Promise.tryが解決すること

  • 不必要な非同期処理を起こさず、同期・非同期処理を統一的に扱えるように
    • 同期処理の場合も非同期処理の場合もどちらの処理でも:
      • 同じようにデータを扱え
      • 同じようにエラーキャッチできる
    • 直感的でシンプルな構文で済む
try {
  const result = await Promise.try(syncOrAsync());
  console.log(result);
} catch (error) {
  console.error(error);
}

Promise.try is like a .then, but without requiring a previous Promise.
http://cryto.net/~joepie91/blog/2016/05/11/what-is-promise-try-and-why-does-it-matter/

Promise.tryは、Promise不要の.thenのようなものです。

便利!

3. Dive into Promise.try in JSC

Promise.tryの仕様を見てみる

わずか7行!

spec

27.2.4.8 Promise.try ( callback, ...args )

実行された時に、以下の手順で処理を行う:

  1. Cをthisとする
  2. Cがオブジェクトでない場合はTypeErrorをスローする
  3. promiseCapabilityに? NewPromiseCapability(C)の返り値を代入する
  4. コールバック関数にargを渡して実行した結果をCompletionとして、statusに代入する
  5. statusがabrupt completionであれば、promiseCapabilityのrejectにstatusのvalueを渡して実行する
  6. そうでない場合(正常にcallbackの実行が完了した場合)は、promiseCapabilityのresolveにstatusのvalueを渡して実行する
  7. promiseCapabilityのpromiseを返す

NewPromiseCapabilityと
それに関連するECMAScriptの概念

NewPromiseCapability

  • PromiseCapabilityRecordを生成するAbstract Operation
  • 最終的には、PromiseCapabilityRecordを含むNormal Completionを返す、またはThrow Completionする。
  • JSCでの実装では@newPromiseCapabilityに相当する

27.2.1.5 NewPromiseCapability ( C )

PromiseCapability Record

  • NewPromiseCapabilityによって生成される
  • Promiseをresolverejectする関数とPromise自体を持ち合わせたレコード
    27.2.1.1 PromiseCapability Records

PromiseCapabilityRecord

Abstract Operations

  • ECMAScript仕様内で共通して使用される操作をまとめたもの
  • 仕様上の関数というイメージで、仕様内の他の場所から呼び出される

5.2.1 Abstract Operations

Completion Records; ? and !

  • ECMAScriptのランタイムセマンティクスの生成物に対して返されるもの
  • TypeがNormal: Normal Completion
    • !(感嘆符): 操作が絶対に失敗しないことを示す場合に使用
  • TypeがNormal以外: Abrupt Completion
    • ?(疑問符): 操作が失敗する可能性があることを示す場合に使用

CompletionRecords

JSCでのPromise.tryの実装

function try(callback /*, ...args */)
{
    "use strict";
    // 2. this(通常はPromise)がオブジェクトでない場合はTypeErrorをスローする
    if (!@isObject(this))
        @throwTypeError("|this| is not an object");

    var args = [];
    for (var i = 1; i < arguments.length; i++)
        @putByValDirect(args, i - 1, arguments[i]);
    
    // 3. promiseCapabilityに? NewPromiseCapability(C)の返り値を代入する
    var promiseCapability = @newPromiseCapability(this);
...

[JSC] Implement Promise.try #24802

...
    try {
        // 4. コールバック関数にargを渡して実行した結果を(Completionとして、)statusに代入する
        var value = callback.@apply(@undefined, args);
      // 5. エラーがスローされなければ(statusがnormal completionであれば、)
      // promiseCapabilityのresolveにstatusのvalueを渡して実行する
        promiseCapability.resolve.@call(@undefined, value);
    } catch (error) {
      // 6. エラーがスローされれば(statusがabrupt completionであれば、)
      // promiseCapabilityのrejectにstatusのvalue(error)を渡して実行する
        promiseCapability.reject.@call(@undefined, error);
    }
    // 7. promiseCapabilityのPromiseを返す
    return promiseCapability.promise;
}

8 Years of Journey to Stage4

Why it took so long to reach Stage 4

Why it took so long to reach Stage 4


jordan-promise

Promise.tryは「この機能を言語仕様に追加する価値があるか」という議論に
時間を要した

Why it took so long to reach Stage 4

  1. async/awaitの議論の進行
    1. ES2017に入るasync/awaitの議論が進行しており、同様の機能を即時実行async関数(AIIFE)で実現できそうだった
    2. その時点では、新しい構文を追加するほどの価値があるかどうか疑問視された
  2. 需要の可視化が困難だった
    1. Bluebird.jsのような包括的な構成のライブラリでは、Promise.try機能の実際の使用状況を分離して測定することが困難だった
    2. p-tryのようなパッケージはまだ存在していなかった
    3. 実際のユースケースや需要を定量的に示すことが難しかった

使用の進捗を左右するサードパーティのライブラリの影響

  • npmのダウンロード数から、Sindre Sorhusが開発したp-tryが多くの開発者に使われていたことがわかった
  • Promise.tryに対する実際の需要が可視化
  • 標準化が急速に進む

昨今のECMAScriptのPromise動向
- Cancellation in Promise

Cancellation in Promise

新たなステートであるcanceledを言語仕様に追加し、
Promiseのキャンセルをサポートする提案

promise-cancel

というのが存在していた

cancelable-promises

およそ8年前...

  • 新たなステートを追加することへのV8チームの反対
  • 詳細が不明なまま、このproposalはpublic archiveとなり、Issueも閉じられる

This proposal experienced significant opposition from within Google and so I am unable to continue working on it.

Why was this proposal withdrawn? #70

2024年現在:Promiseはキャンセルできる(一応)

  • cancelable-promisesのクローズ後、AbortControllerがDOMに実装される
  • これを活用することでPromiseをキャンセルできるようになった
  • AbortControllerはDOM API

AbortControllerの課題

  • ブラウザ環境に依存している
  • サーバーサイドJavaScriptでの利用に制限が

ECMAScript側でキャンセル機構を標準化することで、統一的なキャンセル処理を実現することが期待されている

Cancellation in Promise の課題

  • すでに標準化されたAbortControllerとの互換性を保つ方法でなければ
    提案の進展は厳しい
  • DOMExceptionをJS側で発生させることと等しくなり、議論は難航しているよう

まとめ

  • Promise.tryがStage4に!JavaScriptの同期・非同期処理の統一化へ
  • JSCはPromise.try()をシンプルなJavaScriptで実装している
  • Cancellation in Promise はJSサイドでの実装の夢を見るか?!

ご清聴ありがとうございました!

I hope you catch("key points") well!💡

4つの主要ブラウザのうち、2つが実装済みで、Safariにはラグがあるだけで、今回紹介するJSCでも実装済みです

よくやってしまいそうな方法として、

新しくPromiseを作成 syncOrAsync() の結果をそのまま resolve Promise を成功として完了させる syncOrAsync() が同期的な値または Promise を返す場合、その結果が .then(console.log) に渡されてコンソールに出力される もし syncOrAsync() がエラーをスローしたり、返された Promise が拒否された場合は、.catch(console.error) によってエラーがコンソールに出力される Promiseは即座に解決されるため、不必要な非同期処理は発生しない

このように、これといった書き方がなかったため、 `Promise.try()`が標準化される前にも、`Promise.try()`のような機能を提供するライブラリは存在していた

これらのライブラリもそうなのですが、Promise.tryが解決することは、

Promise.tryを初期に推し進めていた人の記事

Promise.tryの仕様と実装を見ていきます

まずJSエンジンの実装を見る前に、Promise.tryの仕様を見る

仕様自体はわずか7行で書かれていて非常にシンプルです

日本語訳するとこんな感じなのですが、

promiseCapabilityやNewPromiseCapability、Completionなど、使用を読む上で

見慣れない概念があると思うので、それを説明する。特にNewPromiseCapabilityが肝となる概念なのでそれに付随して他の概念も説明していく。

NewPromiseCapabilityはPromiseCapabilityRecordを生成する仕様上の関数と言える

- ※ 仕様上はnormal completionまたはthrow completionのみが関心ごととなる

- Typeがnormalなものはnormal completionと呼ばれる。normal completion以外のCompletion Recordはabrupt completionと呼ばれる。

thorow completion以外のabruptは出てこない

組み込み関数の抽象操作を表現する過程では、関数の内部的である実装のことは気にしないため、 関数の境界を越えられないbreak/continue/returnは動作せず、 仕様レベルではnormal completionまたはthrow completionのみが関心ごととなるという意味

JSCではビルトイン関数をC++で書くこともできるし、JSで書くこともできる。そして Promise.try は JS で書かれている

そんなJSで書かれたPromise.tryの実装を仕様に即して見てみましょう

使用の一番目の手順は実装には出てこないのでスキップするとして、2番目から見ていきます

`arguments`というArrayLike を args 配列に詰め直してるだけ

仕様では、Completionという概念を使ってエラーハンドリングがなされていたが、それは仕様上の概念であって、JSCのコードではJSの普通のtry-catchのエラーハンドリングとして表現されているということを補足しておく

Jordan Harband, Champion of the Promise.try proposal

具体的には後続スライドのような原因で時間がかかったとChampionのJordan Harbandは述べていた

Jordanについでに「何が今後のPromiseの新機能としてアツいの?」というのを聞けたのでそれを話す

という今後の期待を込めて、発表を終わります