Help us understand the problem. What is going on with this article?

【JavaScript】Object とは似て非なる Array の異常さを明らかにする ~exotic object とは~

イントロダクション

そもそも 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()

prototype chain.png

2. length プロパティ

array object の length プロパティは特別なものであり、普通のプロパティとは異なる振る舞いをする。ここで、length が data property1 であることを確認しておこう。

Object.getOwnPropertyDescriptor([], "length")
// {value: 0, writable: true, enumerable: false, configurable: false}

もし data property ではなく accessor property であれば、valuewritable の代わりに getset が存在するはずである。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"]

array length change.png

これも、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、argumentsbind した function、proxy object などがある。

この [[DefineOwnProperty]] メソッドは下の図のように、プロパティへの代入が行われるときに呼び出される(あくまで仕様を追うときに参考になればいいという図)。

js property assign under the hood

下のように、array object は [[DefineOwnProperty]] メソッドにおいてプロパティ名が length のときとインデックスであるときに特別な処理をしていることが分かる。ここまでで説明した array object の特殊な挙動は、この内部メソッドを見ればすべて説明がつく。

array_defineownproperty.png

まとめ

array object は基本的には object であるが、普段使う JavaScript の知識だけでは説明できないような振る舞いをする。このような奇妙な振る舞いを理解するためには、言語仕様で定義されている内部メソッドなどの低レベルな所まで見なければいけない。筆者は array の「object っぽいけど違う」所が気持ち悪く感じ、ネット上で調べても答えが得られなかったため、自分で言語仕様を読んで理解し記事を書くに至ったが、もし他の誰かの参考になれば幸いである。

仕様の該当箇所

本文中にあげた部分は除く。


  1. プロパティには2種類あり、data property と accessor property である。前者は普通のプロパティで、一つの値を格納するだけである。後者は getter と setter と呼ばれる関数によってそれぞれ"値の取得"、"値の代入"を実現するものである。 

  2. array の length プロパティは最初から non-configurable なので writable 属性を変更できないはずでは?と思うかもしれないが、実は一般的に non-configurable であっても writabletrue から false にすることは特別に許されている。仕様の該当部分: 9.1.6.3 ValidateAndApplyPropertyDescriptor 

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away