Closed9

JavaScriptのSet/MapのforEach内で新しい値を追加すると無限ループが発生する問題を調査する

ピン留めされたアイテム
uttkuttk

以前以下のようなツイートをしました。

https://twitter.com/uttk_dev/status/1417435416756580356

最近になって気になってきたので、なぜ上記のような挙動になるのか調査したいと思います。
何か知っている方が居ましたら、情報提供してくれると嬉しいです。

ピン留めされたアイテム
uttkuttk

まとめ

あんまり参考にならないと思うけど、一応分かったことをまとめておく。

分かったこと

  • Set/MapのforEach()内で新しい要素を追加すると無限ループするのは仕様によるモノ
  • 配列( Array )のforEach()内で新しい要素を追加しても無限ループが発生しないのは、forEach()を実行した時にループする範囲が決まるため。
    • ただし、要素を削除して範囲が変化した場合はループ回数に影響する
  • Set/Mapと配列( Array )のforEach()の内部処理は全然違う。( 後述 )
uttkuttk

無限ループが発生する条件について

無限ループが発生する条件は以下の通り👇

  • Set/MapのforEach内で一意な値を追加する
  • 既に追加されている値の場合はループは発生しない
    • Mapの場合はKeyの値が一意の場合に無限ループが発生する
  • 配列( Array ) では、似たような処理をしても無限ループは発生しない
uttkuttk

具体的なソースコード

Mapの場合

Mapの場合
const mapList = new Map();

// 要素が無いとforEach()に渡すコールバックが実行されないので、ここで要素を一つ追加する
mapList.set(0, "hoge"); 
 
// 無限ループが発生する処理
mapList.forEach(() => {
  mapList.set(Math.random(), "hoge"); // 一意な値をKeyに設定している
})

// 無限ループが発生しない処理
mapList.forEach(() => {
  mapList.set(0, "hoge"); // 既に追加されているKeyの値を設定している
})

Setの場合

Setの場合
const setList = new Set();

// 要素が無いとforEach()に渡すコールバックが実行されないので、ここで要素を一つ追加する
setList.add(0); 
 
// 無限ループが発生する処理
setList.forEach(() => {
  setList.add(Math.random()); // 一意な値を追加している
})

// 無限ループが発生しない処理
setList.forEach(() => {
  setList.add(0); // 既に追加されているKeyの値を設定している
})
uttkuttk

配列( Array )の場合は、無限ループが発生しない

JSにはSet/Mapの他に配列( Array )があるが、こちらは似たような処理をしても無限ループが発生しない。

配列(Array)の場合
const arrayList = [];

// 要素が無いとforEach()に渡すコールバックが実行されないので、ここで要素を一つ追加する
arrayList.push(0); 
 
// 無限ループが発生しない
arrayList.forEach(() => {
  arrayList.push(Math.random()); // 一意な値を追加している
})
uttkuttk

Set/Map.prototype.forEach()のRFCを読む

Set

https://tc39.es/ecma262/#sec-set.prototype.foreach

Map

https://tc39.es/ecma262/#sec-map.prototype.foreach

分かったこと

上記のサイトより以下を引用👇

New keys added after the call to forEach begins are visited. A key will be revisited if it is deleted after it has been visited and then re-added before the forEach call completes. Keys that are deleted after the call to forEach begins and before being visited are not visited unless the key is added again before the forEach call completes.

翻訳

forEach を呼び出した後に追加された新しい値は参照(visited)されます。Keyが参照(visited)された後に削除され、forEach の呼び出しが完了する前に再び追加された場合は、再度参照(visited)されます。forEach の呼び出しが開始された後、参照(visited)される前に削除されたKeyは、forEachの呼び出しが完了する前にKeyが再び追加されない限り、参照(visited)されません。

上記より、どうやら無限ループが発生するのは仕様らしい。なるほど。
ちゃんとまとめると以下のようになる。

  • forEachを呼び出した後に新しい値を追加するとcallbackが実行される
  • forEach内で値を参照(visited)して削除しても、forEachの呼び出しが完了する前に再び追加するとcallbackが再度実行される。
  • forEach内で参照(visited)される前に削除されたKeyは、forEachの呼び出しが完了する前にKeyが再び追加されない限り、callbackは実行されない。

Setも同じ挙動になっているという事は、おそらく同じ仕様になっているのだろう。
しかし、仕様だというのは分かったが、「 なぜ配列( Array )と違ったモノになっているのか? 」という事が分からないので調査がまだまだ必要だ。

uttkuttk

Array.prototype.forEach()のRFCを読む

https://tc39.es/ecma262/#sec-array.prototype.foreach

分かったこと

上記のサイトより以下を引用👇

The range of elements processed by forEach is set before the first call to callbackfn. Elements which are appended to the array after the call to forEach begins will not be visited by callbackfn. If existing elements of the array are changed, their value as passed to callbackfn will be the value at the time forEach visits them; elements that are deleted after the call to forEach begins and before being visited are not visited.

翻訳

forEachが処理する要素の範囲は、callbackfnの最初の呼び出しの前に設定されます。forEachの呼び出しが始まった後に配列に追加された要素は、callbackfnによって参照(visited)されることはありません。配列の既存の要素が変更された場合、callbackfnに渡されたその値は、forEachがそれらの要素を参照(visited)したときの値になります。

まとめると以下のようになる。

  • forEachが処理する要素の範囲は、callbackの最初の呼び出しの前に設定される。
  • forEachの呼び出しが始まった後に配列に追加された要素は、callbackが参照(visited)されることはありません。

つまり、forEach内で値を追加しても範囲が既に決まっているので参照(visited)されない。
上記より、仕様の時点でSet/Mapとの違いが明確に定義されているようだ。

uttkuttk

Set/MapのforEach()と配列( Array )のforEach()の違いについて

仕様書を見るに、どうもSet/Mapと配列( Array )のforEach()は内部処理が違うようだ。

Set/Mapの場合

※ Set/Mapは基本的な部分は同じなので、Mapの処理のみ記載

以下は、Map.prototype.forEachの仕様書より引用。

  1. Let M be the this value.
  2. Perform ? RequireInternalSlot(M, [[MapData]]).
  3. If IsCallable(callbackfn) is false, throw a TypeError exception.
  4. Let entries be the List that is M.[[MapData]].
  5. For each Record { [[Key]], [[Value]] } e of entries, do
    a. If e.[[Key]] is not empty, then
    i. Perform ? Call(callbackfn, thisArg, « e.[[Value]], e.[[Key]], M »).

処理内容を見ると、Iterableを使ってループしている感じ。なので、要素の追加や削除についてループ中でも処理することができるッポイ。

配列( Array )の場合

以下は、Array.prototype.forEachの仕様書より引用。

  1. Let O be ? ToObject(this value).
  2. Let len be ? LengthOfArrayLike(O).
  3. If IsCallable(callbackfn) is false, throw a TypeError exception.
  4. Let k be 0.
  5. Repeat, while k < len,
    a. Let Pk be ! ToString(𝔽(k)).
    b. Let kPresent be ? HasProperty(O, Pk).
    c. If kPresent is true, then
    i. Let kValue be ? Get(O, Pk).
    ii. Perform ? Call(callbackfn, thisArg, « kValue, 𝔽(k), O »).
    d. Set k to k + 1.
  6. Return undefined.

処理内容を見ると、indexをインクリメントしながらループしているみたい。
仕様にある通り、2番目( Let len be ? LengthOfArrayLike(O).の所 )、つまりループする前に範囲を決めているので、例えループ中に範囲が増えても、それが反映されない。しかし、範囲が狭まる場合は予め定義した範囲内に収まるので、処理に反映される。

まとめ

上記より、

  • Array.prototype.forEach()はインデックスによるループ
  • Set/Map.prototype.forEach()はIterableによるループ

と言う違いがある。

... (´・ω・`) < うん。知ってた。

uttkuttk

発展形

ここからは、上記の使用を踏まえて考察していく🔍

for...ofについて

イテレーターを使ったループに、for...ofがある。これも上記の仕様と同じく、値を追加するとループが発生する。

for...ofでも無限ループが発生する
const list = new Set([1,2,3,4,5])

for(const value of list) {
  list.add( Math.random() ) // 一意な値を追加する
}
このスクラップは2021/08/18にクローズされました