問題と適用コンテキスト
ソリューション内でネイティブの Promise.all を呼び出さずに、promiseAll(iterable) を実装してください。入力は、Promise、thenable、または通常の値(plain values)を含む有限の同期的 iterable です。出力は新しい Promise です。
すべての入力が fulfill(成功解決)した際、完了順序に関係なく、結果はイテレーション順の配列でなければなりません。いずれかの入力が reject(拒否)された場合、外側の Promise は最初に発生した rejection の理由で即座に reject されなければなりません。空の入力が与えられた場合は [] を返す必要があります。イテレータ自体の読み取り時に例外がスローされた場合も、外側の Promise は reject されなければなりません。
これは、フロントエンドまたは JavaScript ロールのライブコーディング面接に適した問題です。このソリューションでは、ネイティブの Promise を使用して thenable を取り込み、非同期リアクションをスケジュールして構いません。サブクラスのコンストラクタ、内部スロット、イテレータのクローズ処理に関する仕様の細部まで完全に再現する必要はありません。コーディング前にその境界を伝えておきましょう。「コア動作」と「仕様に完全準拠した実装」は異なる課題です。
面接官が評価している点
優れた回答は、ループを書く前にセマンティクスを列挙します。評価される 5 つの重要なポイントは、配列だけでなく iterable を受け入れること、Promise.resolve を介して通常の値と thenable を正規化すること、順序を維持するためにインデックスを保持すること、空の入力を個別に処理すること、そしてイテレーションエラーと Promise の拒否の双方を外側の rejection にルーティングすることです。
一般的な不十分な回答では、配列に対して map を呼び出し、完了した値を順次 push してしまいます。これでは、すべてが順番通りに fulfill する 1 つの例はパスするかもしれませんが、Set、ジェネレータ、空の入力、または完了順序が前後する場合に失敗します。より優れた回答は不変条件(invariants)から始めます。results[i] は常にイテレーションインデックス i の値に対応すること、pending はまだ fulfill していない入力の数であること、pending がゼロに達したときにのみ全体の fulfillment が許可されること、の 3 点です。
面接官は、「フェイルファスト(即時拒否)」と「残りのキャンセル」が混同されていないかも確認します。外側の Promise が一度 reject されると、その後の状態変更は無効になりますが、すでに開始された処理自体は継続して実行されます。キャンセルを実現するには入力側の操作でサポートされる追加のプロトコルが必要であり、集約用の Promise がその機能を勝手に作り出すことはできません。
回答前に明確にすべき質問
- 入力は常に配列ですか、それとも任意の同期的 iterable ですか? 配列のみのサポートであればインデックスループが可能です。提示された iterable 仕様をサポートするには、
for...ofを使用し、次の値を取得する際にスローされるエラーを処理する必要があります。 - 通常の値(plain values)や thenable も有効な入力ですか? そうである場合、各要素を
Promise.resolveに通す必要があります。数値、文字列、通常のオブジェクトに対して直接item.thenを呼び出すと失敗します。 - これは仕様レベルのポリフィルですか、それともコア動作の面接向け実装ですか? 仕様レベルのバージョンでは、
thisコンストラクタ、Promise サブクラス、重複呼び出しに対する内部ガード、厳密なイテレータクローズ規則も処理します。このソリューションはネイティブの Promise を返し、完全な仕様準拠を主張するものではありません。 - 失敗後に残りの操作をキャンセルする必要がありますか? 必要な場合、設問で AbortSignal やタスクキャンセルインターフェースを定義する必要があります。現在の仕様では、外側の Promise を早期に reject するだけであり、他の操作は実行され続けます。
- 入力は有限ですか?
Promise.allは入力を同期的に消費します。無限イテレータの走査は決して終了しません。このソリューションでは、空間計算量をnで記述できるように、有限入力を前提とします。
これらの回答によってループ、エラー処理、API の契約が変わるため、実装前に確認する価値があります。
30秒の回答フレームワーク
「新しい Promise を返し、有限の iterable を同期的に走査します。各要素についてイテレーションインデックスを保持し、未解決カウントをインクリメントし、Promise.resolve を使用して通常の値、thenable、または Promise を正規化します。fulfillment ハンドラは保持されたインデックスに値を書き込み、カウントがゼロになったときに結果の配列を resolve します。rejection ハンドラは外側の Promise を即座に reject します。イテレータのエラーでも reject されるよう、走査全体を try...catch でラップします。空の入力には fulfillment ハンドラが存在しないため、走査後に明示的に [] で resolve します。これにより、入力順序が維持され、フェイルファストが実現されますが、すでに開始された操作はキャンセルされません。」
ステップごとの詳細解説
まずは同等ではない安易なベースラインから考えます。各要素を順次 await して結果を収集する方法です。これは順序を維持しますが、本来なら重複して実行できる待機処理を直列化してしまいます。前の要素が完了するまで、次のリアクションは登録すらされません。求められている操作は、すでに取得された入力を集約することであり、直列のタスクキューではありません。
推奨される実装には、1 つの結果配列、2 つのカウンタ、および 1 回の走査が必要です。
function promiseAll(iterable) {
return new Promise((resolve, reject) => {
const results = [];
let pending = 0;
let index = 0;
try {
for (const item of iterable) {
const currentIndex = index;
index += 1;
pending += 1;
Promise.resolve(item).then(
(value) => {
results[currentIndex] = value;
pending -= 1;
if (pending === 0) {
resolve(results);
}
},
reject,
);
}
} catch (error) {
reject(error);
return;
}
if (index === 0) {
resolve([]);
}
});
}currentIndex はそのイテレーション中に固定されます。たとえば、3 つの入力が 30ミリ秒、10ミリ秒、20ミリ秒後に完了したとします。それらの fulfillment ハンドラは 1, 2, 0 の順序で実行されますが、書き込む位置は常に 1, 2, 0 であるため、最終的な配列は 0, 1, 2 の順序で返されます。インデックス指定の代入を results.push(value) に置き換えると、完了順序で誤って返されてしまいます。
Promise.resolve(item) は 2 つの境界ケースを同時にカバーします。通常の値はすでに fulfill された Promise になります。thenable はその then メソッドが取り込まれます。壊れた thenable がコールバックを複数回呼び出したとしても、ネイティブ Promise の一方向の状態遷移により、重複した完了通知がこのハンドラに届くのを防ぎます。item instanceof Promise によるチェックでは、別レルム(realm)の Promise や正当な thenable の両方を見落としてしまいます。
空の入力には明示的な分岐が必要です。カウンタはゼロから始まり、resolve を呼び出す fulfillment ハンドラが存在しません。走査後に index === 0 をチェックすることで、返される Promise を空の配列で fulfill します。呼び出し側によって登録された .then は、通常の Promise ルールに基づいて非同期に実行されます。
try...catch は同期的なイテレーション失敗を処理します。たとえば、ジェネレータが最初の値にハンドラをアタッチした後の 2 回目の next() 呼び出しで例外をスローする場合があります。catch ブロックは外側の Promise を reject します。それより前の値が後から fulfill しても、そのハンドラがすでに reject された状態を変更することはできません。個々の要素からの rejection は、同じ reject にルーティングされます。複数の入力が reject された場合、最初に実行された rejection ハンドラが外側の理由を決定します。
走査と解決には全体で O(n) の計算量がかかります。結果配列と要素ごとの fulfillment リアクションには O(n) の空間が必要です。1 つの fulfillment を処理するには、インデックスへの書き込みとデクリメントのために O(1) の作業が追加されます。もしすべての fulfillment と rejection の結果が必要な場合は all-settled の仕様を使用します。同時実行数の制限が必要な場合は、タスクファクトリを受け取ってスケジューラを追加します。すでに開始されている Promise のコレクションをこの関数に渡しても、後から同時実行数を制限することはできません。
検証はハッピーパスだけに留めるべきではありません。空の iterable、Set、通常の値・Promise・thenable の混在、入力と逆の順序での完了、最も早い rejection、そして走査の途中で例外をスローするイテレータをカバーします。それぞれのケースが特定の不変条件をテストするため、単一のサンプル配列を比較するよりも説得力があります。
質の高い模範解答
「Promise サブクラスへの完全な準拠は主張せず、有限の同期的 iterable とネイティブ Promise の結果を対象として実装します。満たすべきセマンティクスは 3 つあります。結果がイテレーション順に従うこと、入力が 1 つでも reject されたら外側の Promise が即座に reject されること、空の入力は空の配列で fulfill されることです。
走査中、すべての要素に固定のインデックスを割り当て、pending をインクリメントします。各要素は Promise.resolve を通過するため、数値、既存の Promise、thenable が同一のパスで処理されます。fulfillment 時にその固定位置へ書き込み、カウントをデクリメントします。最後の fulfillment で配列全体を resolve します。rejection ハンドラには外側の reject を指定します。iterable から次の値を取得する際に同期的に例外がスローされる可能性があるため、for...of を try...catch の中に配置します。走査で要素が見つからなかった場合は、直接 [] で resolve します。
走査と解決には O(n) の時間計算量と O(n) の空間計算量がかかります。フェイルファストの動作は集約された結果のみを変更し、他の非同期処理を停止させるわけではありません。キャンセルが必要な場合は、入力のタスク仕様に AbortSignal を追加します。すべてのエラーを収集する必要がある場合は、この関数の rejection ルールを変更するのではなく、all-settled のセマンティクスを使用します。」
よくある間違い
- fulfill された値を
pushで収集する → 配列の順序が完了速度に依存するため、時間のかかる最初の要素が末尾に来てしまう → 走査中にcurrentIndexをキャプチャし、results[currentIndex]を代入する。 item.then(...)のみを呼び出す → 通常の値にはthenがなく、ネイティブ以外の thenable を安全に取り込めない → すべての要素をPromise.resolve(item)で正規化する。- iterable サポートを謳いながら入力の length から未解決カウントを開始する → Set やジェネレータには信頼できる
lengthがなく、仕様を勝手に狭めてしまう → 値を走査しながらpendingをインクリメントする。 - 空の入力を忘れる → fulfillment をトリガーするハンドラが存在せず、返された Promise が永遠に pending のままになる → 走査後に
index === 0の場合に空配列を resolve する。 forEach(async ...)を使用してそれを await する →forEachは非同期コールバックを await しないため、制御フローとエラー伝播が誤った動作になる →.thenハンドラを直接登録し、カウンタで集約する。- 入力の rejection のみを処理する → iterable の
next()から同期的にスローされたエラーが考慮から漏れる → 走査全体をtry...catchで囲み、外側の Promise を reject する。 - フェイルファスト動作によってリクエストがキャンセルされると主張する → Promise の不可逆な状態変更は基盤となる操作を停止させない → キャンセルには AbortSignal やその他のタスクレベルのキャンセルプロトコルが必要であると明記する。
フォローアップの質問と回答
フォローアップ 1: 結果の順序が正しいことをどのように証明しますか?
次の不変条件を使用します。イテレーションインデックス i の値はインデックス i のみを所有し、その fulfillment ハンドラは results[i] にのみ書き込みます。ハンドラの実行順序は書き込みが「いつ」行われるかを変えるだけであり、「どこに」書き込まれるかは決して変わりません。したがって、n 個すべての入力が fulfill した後、インデックス 0 から n - 1 には対応する入力の値が格納されます。同じ遅延時間に頼るのではなく、入力順序と逆の遅延を持つ 3 つの Promise を使ってこの主張をテストします。
フォローアップ 2: rejection の発生後に残りのネットワークリクエストをキャンセルするにはどうすればよいですか?
Promise は基礎となる操作のキャンセル用エントリポイントを公開していないため、現在の関数では不可能です。入力を AbortSignal を受け取るタスクファクトリに変更し、1 つの共有 AbortController を作成します。タスクが失敗したときは、外側の Promise を reject する前に controller.abort() を呼び出します。一部のタスクはすでに完了しているか、シグナルを無視するか、キャンセル中に失敗する可能性があるため、リクエストのキャンセルは標準の Promise.all セマンティクスに隠された機能ではなく、拡張された契約となります。
フォローアップ 3: 同時に実行できるタスクを最大 3 つまでに制限するにはどうすればよいですか?
入力をすでに開始された Promise から、まだ開始されていない関数に変更します。次のタスクのインデックス、現在実行中のタスク数、および結果配列を追跡します。1 つのタスクが完了するたびに別のタスクを開始し、実行中のカウントを 3 以下に維持します。API が依然として Promise の配列を受け取る場合、通常はその配列が構築される時点で処理が開始されてしまうため、スケジューラを適用するには遅すぎます。重要な問題は完了カウンタの名前変更ではなく、処理の開始タイミングです。
フォローアップ 4: 仕様レベルの Promise.all との間に残る差異は何ですか?
この実装では常にネイティブの Promise を使用します。this からコンストラクタを取得したり、Promise サブクラスの動作、内部スロット、仕様に定められたイテレータクローズ手順、およびすべての急激な完了(abrupt completion)の細部を再現したりはしていません。面接では、これをコア動作の実装と呼びます。公開可能なポリフィルには、ECMAScript アルゴリズムへの段階的なマッピングと互換性テストが必要です。一般的な使用例をパスするだけでは完全な仕様準拠とは言えません。