JavaScript Arrayの奇妙な挙動

に公開

ありのまま、JavaScriptのArrayの挙動を説明する

他のプログラミング言語をやってからJavaScriptに入ると、奇妙に感じるArrayの挙動があります。

奇妙な挙動1: インデックスはArrayのサイズ以上のものを設定できる。

const array = [1, 2]
array[5] = 100;
// [ 1, 2, <3 empty items>, 100 ]

Arrayの範囲エラーとなって例外になるわけでもなく、要素が追加されます。
要素は連続している必要はなく、大きなインデックスに値を設定したときには、Arrayは空のスロットを追加して配列を拡張します。

奇妙な挙動2: 文字列をインデックスに指定できる

array["5"] = 200;
array["hoge"] = 999;
// [ 1, 2, <3 empty items>, 200, hoge: 999 ]

Arrayのインデックスに文字列を指定できます。
もし数値に変換できる文字列の場合は数値のインデックスと同じ要素を操作します。
上記の例ではarray["5"]とarray[5]は同じ要素を指します。

array.hoge
array["hoge"]

この数値のインデックスと文字のインデックスが混在したArrayをループした場合、以下のような結果となります。

for (const v of array) { console.log(`  ${v}`); }
//  1
//  2
//  undefined
//  undefined
//  undefined
//  200

文字で設定したインデックスの要素については列挙されていません。
また、Array.lengthでArrayの長さを確認しても文字のインデックスの要素については考慮されていません。

console.log('length:', array.length); // 6

arrayのインデックスのキーをArray.prototype.keys()で検査してみます。

console.log('array:',  Array.from(array.keys()));
// array: [ 0, 1, 2, 3, 4, 5 ]

空のスロットを含む数値のインデックスはすべて列挙されており、文字列のインデックスは無視されます。

一方、Object.keys()を使用すると別の結果となります。

console.log('Object.keys:', Object.keys(array));
// Object.keys: [ '0', '1', '5', 'hoge' ]

このケースでは空のスロットのインデックスは列挙されず、文字列のインデックスは列挙されます。

このドキュメントでは、ECMAScriptの仕様書を見ながら、Arrayの奇妙な挙動についてどのような説明がされているかを確認したいと思います。

ヤツの Arrayの 正体はexotic objects

Arrayは配列インデックスのプロパティキーに対して特殊な処理を行っているオブジェクトであると解釈するとわかりやすいです。

ECMAScriptの仕様書の10.4.2 Array Exotic ObjectsではArrayを以下のように説明しています。

ECMAScriptの仕様書 10.4.2 Array Exotic Objects

10.4.2 Array Exotic Objects
An Array is an exotic object that gives special treatment to array index property keys (see 6.1.7). A property whose property name is an array index is also called an element.

配列インデックス(array index)とは

簡単に説明すると配列インデックスは0以上の整数に変換可能なプロパティキーです。

6.1.7 The Object Typeでは次のような説明になっています。

ECMAScriptの仕様書 6.1.7 The Object Typeより一部抜粋

An integer index is a property name n such that CanonicalNumericIndexString(n) returns an integral Number in the inclusive interval from +0𝔽 to 𝔽(2^{53} - 1). An array index is an integer index n such that CanonicalNumericIndexString(n) returns an integral Number in the inclusive interval from +0𝔽 to 𝔽(2^{32} - 2).

配列インデックスをより正確に表現するとCanonicalNumericIndexString(n)により数値表現[1]として解釈でき、かつ 0 以上 2^{32}-2 以下の整数になるプロパティキーです。

Arrayの[[DefineOwnProperty]]

ECMAScriptの仕様書中では仕様書の記述のための抽象的なアルゴリズムを説明するためのInternal Methodという概念が導入されています。
以下のようにObject.definePropertyや代入によるプロパティの作成の過程で[[DefineOwnProperty]]というInternal Methodが関与します。

const o = {};
o.prop2 = "test";
Object.defineProperty(o, "prop2", {
  value: 1,
  configurable: false
});

Table 4: Essential Internal Methodsでは[[DefineOwnProperty]](propertyKey, PropertyDescriptor) → Booleanを以下のように説明しています。

Create or alter the own property, whose key is propertyKey, to have the state described by PropertyDescriptor. Return true if that property was successfully created/updated or false if the property could not be created or updated.

Arrayでは、この[[DefineOwnProperty]]の処理が通常のアルゴリズムと異なる挙動をします。
以下のような実装でArrayの[[DefineOwnProperty]]が関与します。

const array = [1, 2, 3];
array[5] = 1234;
array["5"] = 1234;
array.hoge = 999
array.foo = 998
array.length = 1;

Arrayの[[DefineOwnProperty]]については 10.4.2.1に記載があります。

ECMAScriptの仕様書 10.4.2.1 [[DefineOwnProperty]] ( P, Desc )

10.4.2.1 [[DefineOwnProperty]] ( P, Desc )
The [[DefineOwnProperty]] internal method of an Array exotic object A takes arguments P (a property key) and Desc (a Property Descriptor) and returns either a normal completion containing a Boolean or a throw completion. It performs the following steps when called:

  1. If P is "length", then

    1. Return ? ArraySetLength(A, Desc).
  2. Else if P is an array index, then

    1. Let lengthDesc be OrdinaryGetOwnProperty(A, "length").

    2. Assert: lengthDesc is not undefined.

    3. Assert: IsDataDescriptor(lengthDesc) is true.

    4. Assert: lengthDesc.[[Configurable]] is false.

    5. Let length be lengthDesc.[[Value]].

    6. Assert: length is a non-negative integral Number.

    7. Let index be ! ToUint32(P).

    8. If index ≥ length and lengthDesc.[[Writable]] is false, return false.

    9. Let succeeded be ! OrdinaryDefineOwnProperty(A, P, Desc).

    10. If succeeded is false, return false.

    11. If index ≥ length, then

      1. Set lengthDesc.[[Value]] to index + 1𝔽.
      2. Set succeeded to ! OrdinaryDefineOwnProperty(A, "length", lengthDesc).
      3. Assert: succeeded is true.
    12. Return true.

  3. Return ? OrdinaryDefineOwnProperty(A, P, Desc).

1. If P is "length", thenについてはlengthというプロパティに対して更新を行なった場合の処理です。

const array = [1, 2, 3];
array.length = 1; // [1]

このような実装の場合に抽象処理ArraySetLengthが呼ばれて、配列の長さの変更を行います。

2. Else if P is an array index, thenはプロパティキーが配列インデックス(array index)の場合の処理です。
配列インデックスの場合の処理を並べると以下の通りです。

  • Arrayのlengthを取得する
  • indexをUInt32にする
  • Arrayのindexの要素を更新する
  • index >= length
    • Arrayのlengthをindex+1に更新する

3. Return ? OrdinaryDefineOwnProperty(A, P, Desc)は文字列などがプロパティキーとして指定された場合の処理です。この場合は通常のobjectに対する操作と同じです。

ここまでで冒頭で紹介したいくつかの奇妙な動作が説明されていることがわかります。

奇妙な挙動1: インデックスはArrayのサイズ以上のものを設定できる。

const array = [1, 2]
array[5] = 100;

これはarrayというオブジェクトに対して"5"というプロパティキーを作成しているだけです。
その際、array.length は index(5)+1=6に更新されています。

JavaScriptのArrayについては、C言語などの連続したメモリ空間を扱うものを配列として捉えるより、特殊なオブジェクトの形態であると捉えた方が理解しやすいです。

奇妙な挙動2: 文字列をインデックスに指定できる

array["5"] = 200;
array["hoge"] = 999;
// [ 1, 2, <3 empty items>, 200, hoge: 999 ]

array["5"]はarray[5]と同じです。arrayというオブジェクトに対して"5"のプロパティキーの値を更新しているだけです。
array["hoge"]は通常のオブジェクト操作と同じです。array.hoge=999と同じことをしているだけです。
"hoge"プロパティも"1", "2", "5"プロパティもオブジェクトのプロパティなのでObject.keys()で列挙されます。

console.log('Object.keys:', Object.keys(array));
// Object.keys: [ '0', '1', '5', 'hoge' ]

ただし、プロパティとして作成していない"2", "3", "4"は出力されません。

Array Iterator Objects

for...ofでは配列インデックスのプロパティキー0〜length-1に対応する要素を列挙します。
hogeなどの文字のプロパティキーのものは対象となりません。

これについては23.1.5 Array Iterator Objectsに記述があります。

ECMAScriptの仕様書 23.1.5 Array Iterator Objects

23.1.5 Array Iterator Objects
An Array Iterator is an object that represents a specific iteration over some specific Array instance object. There is not a named constructor for Array Iterator objects. Instead, Array Iterator objects are created by calling certain methods of Array instance objects.

あるArrayのオブジェクトに対してfor...ofなどの反復処理を表すオブジェクトとしてArray Iteratorオブジェクトがあります。
以下がArray Iteratorの構築を表す抽象処理です。

ECMAScriptの仕様書 23.1.5.1 CreateArrayIterator ( array, kind )

23.1.5.1 CreateArrayIterator ( array, kind )
The abstract operation CreateArrayIterator takes arguments array (an Object) and kind (key+value, key, or value) and returns an Object. It is used to create iterator objects for Array methods that return such iterators. It performs the following steps when called:

  1. Let iterator be OrdinaryObjectCreate(%ArrayIteratorPrototype%, « [[IteratedArrayLike]], [[ArrayLikeNextIndex]], [[ArrayLikeIterationKind]] »).
  2. Set iterator.[[IteratedArrayLike]] to array.
  3. Set iterator.[[ArrayLikeNextIndex]] to 0.
  4. Set iterator.[[ArrayLikeIterationKind]] to kind.
  5. Return iterator.

ArrayLikeNextIndexが0から始まることに注目してください。
ここで作成されたArray Iteratorは%ArrayIteratorPrototype%.next()により次のイテレーターを取得します。

ECMAScriptの仕様書 23.1.5.2.1 %ArrayIteratorPrototype%.next()

23.1.5.2.1 %ArrayIteratorPrototype%.next()

  1. Let O be the this value.
  2. If O is not an Object, throw a TypeError exception.
  3. If O does not have all of the internal slots of an Array Iterator Instance (23.1.5.3), throw a TypeError exception.
  4. Let array be O.[[IteratedArrayLike]].
  5. If array is undefined, return CreateIteratorResultObject(undefined, true).
  6. Let index be O.[[ArrayLikeNextIndex]].
  7. Let kind be O.[[ArrayLikeIterationKind]].
  8. If array has a [[TypedArrayName]] internal slot, then
    a. Let taRecord be MakeTypedArrayWithBufferWitnessRecord(array, SEQ-CST).
    b. If IsTypedArrayOutOfBounds(taRecord) is true, throw a TypeError exception.
    c. Let len be TypedArrayLength(taRecord).
  9. Else,
    a. Let len be ? LengthOfArrayLike(array).
  10. If index ≥ len, then
    a. Set O.[[IteratedArrayLike]] to undefined.
    b. Return CreateIteratorResultObject(undefined, true).
  11. Set O.[[ArrayLikeNextIndex]] to index + 1.
  12. Let indexNumber be 𝔽(index).
  13. If kind is KEY, then
    a. Let result be indexNumber.
  14. Else,
    a. Let elementKey be ! ToString(indexNumber).
    b. Let elementValue be ? Get(array, elementKey).
    c. If kind is VALUE, then
    i. Let result be elementValue.
    d. Else,
    i. Assert: kind is KEY+VALUE.
    ii. Let result be CreateArrayFromListindexNumber, elementValue »).
  15. Return CreateIteratorResultObject(result, false).

8.の処理についてはTypedArrayといったUint8Arrayなどの型を定義した場合の処理です。TypedArray用に境界チェック等を行なって配列の長さを取得してlenに格納します。
9.の処理ではTypedArray以外の場合でのlenの取得です。これはArrayオブジェクトのlengthプロパティを取得しています。
10.についてはindex ≥ lenの処理で、配列中のすべての要素を列挙した場合の処理です。
11.についてはO.[[ArrayLikeNextIndex]]に1を加算します。
12〜13はKEYを返すだけの処理とVALUEも返す場合の処理で分岐しています。

ここで注目すべきはArray Iteratorの列挙方法は0 〜 配列のlengthプロパティ - 1のプロパティキーのみを対象にして、そのキーまたは値を取得しているということです。
再び以下のコードを確認してみましょう。

const array = [ 1, 2];
array[5] = 200;
array['hoge'] = 999;
for (const v of array) { console.log(`  ${v}`); }
//  1
//  2
//  undefined
//  undefined
//  undefined
//  200

仕様を読んだ後であれば、hogeというプロパティキーがあったところで無視して列挙されることも、プロパティが存在しないため、undefinedと表示されることも納得できるかと思います。
またArrayのループ処理にfor ... inを使用してはいけない理由もわかります。for ... inは列挙可能なプロパティを取得する反復処理です。つまり文字列のプロパティが表示されたり、空スロットのインデックスが表示されなくなります。

Array.prototype.keys()については仕様上以下の定義がされています。

23.1.3.19 Array.prototype.keys ()
This method performs the following steps when called:

  1. Let O be ? ToObject(this value).
  2. Return CreateArrayIterator(O, key).

つまりCreateArrayIteratorを使用しているため、これは0〜length-1のプロパティキーの列挙になります。

以下の結果についても納得がいくことでしょう。

console.log('array:',  Array.from(array.keys()));
// array: [ 0, 1, 2, 3, 4, 5 ]

まとめ

今回はJavaScriptのArrayの奇妙な動作についてECMAScriptの仕様書を読んで確認しました。
一見すると奇妙な動作ですが、仕様上にちゃんと説明されている動作であることが確認できました。

ECMAScriptの仕様をいきなり読み出すと混乱するかと思うので、以下の解説を読んでから取り組んだ方がいいかと思います。

また興味深いことにv8の実装はECMAScriptの抽象処理やInternal Methodの内容を読んでから確認すると読みやすい実装になっています。
ArrayのDefineOwnPropertyを実装していると思われる箇所を以下に記載します。

Maybe<bool> JSArray::DefineOwnProperty(Isolate* isolate,
                                       DirectHandle<JSArray> o,
                                       DirectHandle<Object> name,
                                       PropertyDescriptor* desc,
                                       Maybe<ShouldThrow> should_throw) {
  if (IsName(*name)) {
    name = isolate->factory()->InternalizeName(Cast<Name>(name));
  }

  // 1. Assert: IsPropertyKey(P) is true. ("P" is |name|.)
  // 2. If P is "length", then:
  if (*name == ReadOnlyRoots(isolate).length_string()) {
    // 2a. Return ArraySetLength(A, Desc).
    return ArraySetLength(isolate, o, desc, should_throw);
  }
  // 3. Else if P is an array index, then:
  uint32_t index = 0;
  if (PropertyKeyToArrayIndex(name, &index)) {
    // 3a. Let oldLenDesc be OrdinaryGetOwnProperty(A, "length").
    PropertyDescriptor old_len_desc;
    Maybe<bool> success = GetOwnPropertyDescriptor(
        isolate, o, isolate->factory()->length_string(), &old_len_desc);
    // 3b. (Assert)
    DCHECK(success.FromJust());
    USE(success);
    // 3c. Let oldLen be oldLenDesc.[[Value]].
    uint32_t old_len = 0;
    CHECK(Object::ToArrayLength(*old_len_desc.value(), &old_len));
    // 3d. Let index be ToUint32(P).
    // (Already done above.)
    // 3e. (Assert)
    // 3f. If index >= oldLen and oldLenDesc.[[Writable]] is false,
    //     return false.
    if (index >= old_len && old_len_desc.has_writable() &&
        !old_len_desc.writable()) {
      RETURN_FAILURE(isolate, GetShouldThrow(isolate, should_throw),
                     NewTypeError(MessageTemplate::kDefineDisallowed, name));
    }
    // 3g. Let succeeded be OrdinaryDefineOwnProperty(A, P, Desc).
    Maybe<bool> succeeded =
        OrdinaryDefineOwnProperty(isolate, o, name, desc, should_throw);
    // 3h. Assert: succeeded is not an abrupt completion.
    //     In our case, if should_throw == kThrowOnError, it can be!
    // 3i. If succeeded is false, return false.
    if (succeeded.IsNothing() || !succeeded.FromJust()) return succeeded;
    // 3j. If index >= oldLen, then:
    if (index >= old_len) {
      // 3j i. Set oldLenDesc.[[Value]] to index + 1.
      old_len_desc.set_value(isolate->factory()->NewNumberFromUint(index + 1));
      // 3j ii. Let succeeded be
      //        OrdinaryDefineOwnProperty(A, "length", oldLenDesc).
      succeeded = OrdinaryDefineOwnProperty(isolate, o,
                                            isolate->factory()->length_string(),
                                            &old_len_desc, should_throw);
      // 3j iii. Assert: succeeded is true.
      DCHECK(succeeded.FromJust());
      USE(succeeded);
    }
    // 3k. Return true.
    return Just(true);
  }

  // 4. Return OrdinaryDefineOwnProperty(A, P, Desc).
  return OrdinaryDefineOwnProperty(isolate, o, name, desc, should_throw);
}

仕様書にあった記載がところどころにあり、理解しやすいものとなっています。
もし、JavaScriptで納得できない動作があった場合にECMAScriptの仕様書に戻って確認してみるのもいいかもしれません。

参考

脚注
  1. CanonicalNumericIndexString(n)は文字列が、数値に変換してまた文字列に戻しても同一になる数値表現かどうかを判定して数値またはundefinedを返却する抽象処理です。7.1.21 CanonicalNumericIndexStringに詳細が記載されています。 ↩︎

Discussion