イントロダクション
そもそも array とは0番目の値、1番目の値、...と一列に値を並べたもので、 object は key-value のペアの集まりであり、まったく異なるデータ構造である。
しかし、array は object としての性質も持っており、自由にプロパティの追加などができる。
const arr = [1, 2, 3]
arr.prop = "value"
一方で、length
プロパティに代入できる値に制限があるなど、object とは異なる部分もある。
const a = []
a.length = -2 // Uncaught RangeError: Invalid array length
「単に setter/getter 持ちのプロパティ(accessor property1)なのでは?」と思うかもしれないが、実はそうではなく、普通の data property1 である。この記事では、このような array の異常性を列挙した上で、それを実現する仕組みを説明する。
1. 持っているメソッドが違う
array object は .slice()
メソッドなど、通常のオブジェクトにはないメソッドを持っているが、これは簡単に説明がつく。
array object は Array.prototype
を継承しており、さらにこれは Object.prototype
を継承している。Array.prototype
がいくつかのメソッドを定義しているため、array object は object が持つメソッドだけでなく array 特有のメソッドにもアクセスできるのである。array が iterable であるのも、Array.prototype
が [Symbol.iterator]
メソッドを持っているからである。
また、以下のように toString()
メソッドの挙動が異なることも、「Object.prototype
が持つ toString()
メソッドを Array.prototype
がオーバーライドしているから」と説明がつく。
const obj = {0: 1, 1: 2}
const arr = [1, 2]
obj.toString() // "[object Object]" <- Object.prototype.toString()
arr.toString() // "1,2" <- Array.prototype.toString()
2. length
プロパティ
array object の length
プロパティは特別なものであり、普通のプロパティとは異なる振る舞いをする。ここで、length
が data property1 であることを確認しておこう。
Object.getOwnPropertyDescriptor([], "length")
// {value: 0, writable: true, enumerable: false, configurable: false}
もし data property ではなく accessor property であれば、value
と writable
の代わりに get
と set
が存在するはずである。length
プロパティには getter も setter もないことが分かる。
3. length
以上のインデックスに代入すると length
も増える
length
以上のインデックスに対して代入をすると、length
の値はそのインデックス+1になる。
const arr = []
arr.length // 0
arr[2] = 10
arr.length // 3
arr // [empty, empty, 10]
当たり前と言えばそうだが、object として考えると異常な振る舞いである。新しいプロパティを追加しただけで、別の data property の値が変わるというのは、普通の object ではありえない。
ちなみに、要素を delete
しても length
は変わらない。
const arr = [1, 2, 3]
arr.length // 3
delete arr[2]
arr.length // 3
arr // [1, 2, empty]
4. length
プロパティを non-writable にすると length
以上のインデックスに代入できない
length
プロパティの writable
属性を false
にして2値を変更できなくすると、length
以上のインデックスに対する代入ができなくなる。
const arr = Object.defineProperty([], "length", {writable: false})
arr[3] = 10
arr // []
arr.length // 0
strict モードならエラーが発生する。
"use strict"
const arr = Object.defineProperty([], "length", {writable: false})
arr[3] = 10 // Uncaught TypeError
3. のことを考えれば納得できるが、やはり普通の object ではありえない挙動である。
5. length
プロパティを変えると実際の要素数も変わる
length
を減らすと、length
以上のインデックスを持つ要素は削除される。
const arr = [1, 2, 3, 4, 5]
arr.length = 3
arr // [1, 2, 3]
該当するプロパティも消えていることが分かる。
Object.getOwnPropertyNames(arr) // [ '0', '1', '2', 'length' ]
このように、length
プロパティを変えることで他のプロパティに影響を与えるという点においても、array object が異常であることが伺えるだろう。
逆に length
を増やすと、empty な要素が末尾に追加される。
const arr = [1, 2, 3]
arr.length = 6
arr // [1, 2, 3, empty, empty, empty]
しかし、プロパティの数が増えるわけではない。length
は 6 であるのに、3
4
5
のプロパティは存在しないことが分かる。
Object.getOwnPropertyNames(arr) // [ '0', '1', '2', 'length' ]
empty な要素というのは、該当するプロパティが存在しない要素を意味するのである。
6. length
を減らすときに non-configurable な要素があるとそこで止まる
length
を減らそうとすると、削除される予定の要素の中にもし configurable
属性が false
であるものがいくつかあれば、その中で最も大きいインデックスの所で length
が止まる。これは、non-configurable なプロパティは削除できないからであるが、それでも中途半端に長さが減るというのは意外である。
const arr = ["a", "b", "c", "d", "e"]
// プロパティ "2" を non-configurable にする
Object.defineProperty(arr, "2", {
configurable: false
})
arr.length = 0
arr.length // 3
arr // ["a", "b", "c"]
これも、strict モードではエラーが発生するが、length
が減らなくなるわけではない。
"use strict"
const arr = ["a", "b", "c", "d", "e"]
Object.defineProperty(arr, "2", {
configurable: false
})
arr.length = 0 // Uncaught TypeError: Cannot delete property '2' of [object Array]
arr.length // 3
7. length
は 0 から 2^32 - 1 まで
array object の 長さは 0 から 2^32 - 1 までの整数値しかとれない。これも array の長さを表すという点では不思議なことではないが、setter があるわけでもないのにある種の validation が行われるのはやはり異常である。
const arr = []
arr.length = -1 // Uncaught RangeError: Invalid array length
arr.length = 1.5 // Uncaught RangeError: Invalid array length
arr.length = 2 ** 32 // Uncaught RangeError: Invalid array length
ちなみに、上記の条件さえみたせば数値の string 表現でもよい。
const arr = []
arr.length = "3"
arr.length // 3
もっと言えば、Number()
関数に渡して有効な数値になる値ならばなんでもよい。
const arr = []
const length = { valueOf: () => 3 }
Number(length) // 3
arr.length = length
arr.length // 3
ちなみに内部的にはこの数値変換はなぜか2回行われる。
const arr = []
const length = { valueOf: () => { console.log("oops"); return 3 } }
arr.length = length // "oops" が2回出力される
8. インデックスは 0 から 2^32 - 2 まで
length
と同様に、 array インデックスとして有効な整数は 0
から 2^32 - 2
までであり、それ以外の値をキーにもつプロパティを作っても、配列の要素とはならない。
const arr = []
arr[2 ** 32 - 1] = 1 // 範囲外
arr.length // 0
Object.getOwnPropertyNames(arr) // ["length", "4294967295"] (プロパティとしては追加されている)
arr[2 ** 32 - 2] = 1 // 範囲内
arr.length // 4294967295
タネ明かし
ここまでで述べたような array object の特殊な挙動はいったいどのようにして実現されているのだろうか?
実は JavaScript の object は「プログラマーからは一切見えない内部的なメソッド」を持っている。これらのメソッドはその object に対して何らかの操作をしたときにそれに対応したものが呼び出されるようになっている。Array object はこのうち [[DefineOwnProperty]]
というメソッドだけ、通常の object とは異なった定義がされているのである。下記のリンクは ECMAScript 2019 の仕様の該当部分である。
内部メソッドは object が作られるときに、その object の内部スロットに明示的にセットされる。例えば array が作られるときは下のように、通常 object とは異なる [[DefineOwnProperty]]
メソッドがセットされるのである。
...
5. Let A be a newly created Array exotic object.
6. Set A's essential internal methods except for [[DefineOwnProperty]] to the default ordinary object definitions specified in 9.1.
7. Set A.[[DefineOwnProperty]] as specified in 9.4.2.1.
...
(from 9.4.2.2 ArrayCreate)
Side Note: 普通のプロパティやメソッドと異なり、内部メソッドは継承されない。例えば array の slice
メソッドは Array.prototype
から継承したものであり、array が作られるたびにわざわざメソッドをセットしているわけではない。しかし、内部メソッドは object を作るたびに毎回その内部スロットにセットするようになっている。
このように、通常の object の内部メソッドをオーバーライドしているような object を exotic object といい(exotic=風変わりな)、例としては Array、TypedArray、arguments
、bind
した function、proxy object などがある。
この [[DefineOwnProperty]]
メソッドは下の図のように、プロパティへの代入が行われるときに呼び出される(あくまで仕様を追うときに参考になればいいという図)。
下のように、array object は [[DefineOwnProperty]]
メソッドにおいてプロパティ名が length
のときとインデックスであるときに特別な処理をしていることが分かる。ここまでで説明した array object の特殊な挙動は、この内部メソッドを見ればすべて説明がつく。
まとめ
array object は基本的には object であるが、普段使う JavaScript の知識だけでは説明できないような振る舞いをする。このような奇妙な振る舞いを理解するためには、言語仕様で定義されている内部メソッドなどの低レベルな所まで見なければいけない。筆者は array の「object っぽいけど違う」所が気持ち悪く感じ、ネット上で調べても答えが得られなかったため、自分で言語仕様を読んで理解し記事を書くに至ったが、もし他の誰かの参考になれば幸いである。
仕様の該当箇所
本文中にあげた部分は除く。
- 代入式: 12.15.4 Runtime Semantics: Evaluation
- exotic object: 4.3.7 exotic object
- array index の定義: 6.1.7 The Object Type
- 数値への変換処理: 7.1.3 ToNumber ( argument )
-
プロパティには2種類あり、data property と accessor property である。前者は普通のプロパティで、一つの値を格納するだけである。後者は getter と setter と呼ばれる関数によってそれぞれ"値の取得"、"値の代入"を実現するものである。 ↩ ↩2 ↩3
-
array の
length
プロパティは最初から non-configurable なのでwritable
属性を変更できないはずでは?と思うかもしれないが、実は一般的に non-configurable であってもwritable
をtrue
からfalse
にすることは特別に許されている。仕様の該当部分: 9.1.6.3 ValidateAndApplyPropertyDescriptor ↩