Skip to content

オブジェクトモデル

JavaScript のオブジェクトは、名前(文字列またはシンボル)から値への対応づけを、実行時に自由に追加・削除できる動的なコレクションです。関数もオブジェクト、配列もオブジェクト、プロトタイプ機構もオブジェクト同士の連結で成り立っており、オブジェクトの表現はエンジンの性能とメモリ効率を左右する中核です。この章では、プロパティをどう格納するか(辞書方式と形状(hidden class)方式)、仕様が要求する列挙順、プロトタイプチェーンの探索コスト、そして配列の dense/sparse 二重表現とその整合性の罠を扱います。

オブジェクトに求められること

JavaScript のオブジェクトは、静的型付き言語の構造体とは違い、次の性質をすべて満たさなければなりません。

  • プロパティを実行時に追加・削除できる: obj.x = 1 で新しいプロパティが生える。
  • プロパティごとに属性を持つ: 各プロパティには値のほか、書き込み可能(writable)・列挙可能(enumerable)・再定義可能(configurable)という属性があり、Object.defineProperty で細かく制御できる。値を直接持つデータプロパティと、ゲッタ/セッタ関数を持つアクセサプロパティの区別もある。
  • 決まった順序で列挙できる: for...inObject.keysJSON.stringify などが返すキーの順序は、仕様で定められている(後述)。
  • プロトタイプチェーンをたどって継承する: 自身が持たないプロパティは、[[Prototype]] がつなぐ別のオブジェクトから探す。

これらを満たしつつ、頻繁なプロパティアクセスを速くすることが、オブジェクトモデル設計の目標です。

プロパティ格納の二つの方式

プロパティの集合を内部でどう持つか。代表的な選択肢が二つあります。

方式1: 辞書(ハッシュマップ)表現

もっとも素直なのは、オブジェクトごとにキーから「値+属性」への辞書(ハッシュマップ)を一つ持たせる方式です。

Object = { properties: HashMap<Key, {value, attributes}>, prototype: ... }
  • 利点: 実装が単純明快。プロパティの追加・削除がその場で完結し、どんな形のオブジェクトでも一様に扱える。プロパティを消したり属性を頻繁に変えたりするオブジェクトにも素直に対応できる。
  • 欠点: アクセスのたびにハッシュ計算とキー比較が要る。オブジェクトごとにキー文字列と属性を丸ごと保持するため、同じ形のオブジェクトが大量にあるとメモリが嵩む。列挙順を保つには挿入順を別途記録する必要がある。

小さなオブジェクトや、形が定まらず変化し続けるオブジェクトには十分実用的です。

方式2: 形状(hidden class)+スロット配列

多くの実用エンジンが採るのが、形状(shape、hidden class、map などとも呼ばれる)とスロット配列を分離する方式です。着眼点は「プログラム中のオブジェクトは、同じ順序で同じプロパティを持つものが大量に現れる」という経験則です。たとえば {x, y} という座標オブジェクトを何万個作っても、キーの並びと属性は全部同じで、違うのは値だけです。

そこで、キー・属性・各プロパティの格納位置(スロット番号)といった構造の情報を「形状」というオブジェクトに切り出して共有し、個々のオブジェクトは値だけを平坦な配列(スロット配列)に持ちます。

Shape = { {key: "x", attrs, slot: 0}, {key: "y", attrs, slot: 1}, prototype, ... }
Object = { shape: -> Shape, slots: [xの値, yの値] }

プロパティ x を読むときは、まず形状を見て「x はスロット 0」と分かれば、あとは配列の 0 番目を読むだけです。同じ構造のオブジェクトは同一の形状を指すため、形状は一度きりの保持で済みます。

プロパティを追加すると形状が変わります。エンジンは形状同士を遷移(transition)でつなぎ、たとえば「空の形状に x を足すと形状A、形状Aに y を足すと形状B」という木を作ります。同じ順序でプロパティを付けていくオブジェクトは、同じ遷移経路をたどって同じ形状にたどり着くため、形状が自然に共有されます。

flowchart LR
    S0["空の形状"]
    S0 -. "+x" .-> A["形状A<br/>x=slot0"]
    A -. "+y" .-> B["形状B<br/>x=slot0, y=slot1"]
    S0 -. "+y" .-> C["形状C<br/>y=slot0"]
    C -. "+x" .-> D["形状D<br/>y=slot0, x=slot1"]

プロパティを追加した順にたどって形状が枝分かれし、同じ順序なら同じ形状(B)を共有する一方、追加順が違うと同じ {x, y} でも別の形状(D)になる。

  • 利点: 形状が分かればプロパティアクセスがスロット番号の添字参照になり、ハッシュ計算が要らない。構造情報を共有するのでメモリ効率が良い。何より、後述するインラインキャッシュを可能にする。
  • 欠点: 実装が複雑。プロパティを付ける順序が違うオブジェクトは別々の形状になり、共有が効かない。プロパティの削除や属性変更が起きると形状の枝分かれが増える。形が定まらないオブジェクトでは遷移が乱立して、かえって効率が落ちる。

このため実用エンジンの多くは折衷を採ります。ふだんは形状+スロットで運用し、プロパティを次々に削除するなど形状方式に不向きな使い方を検出したオブジェクトだけを、辞書表現へ格下げ(dictionary mode への移行)します。

インラインキャッシュとのつながり

形状方式の最大の効用は、インラインキャッシュ(inline cache、IC)を可能にすることです。あるコード上のプロパティアクセス obj.x は、繰り返し実行されると多くの場合いつも同じ形状のオブジェクトに当たります。そこで「前回この位置で見た形状はこれで、x はスロット番号 0 だった」という組を命令のそばに覚えておけば、次回同じ形状なら形状の一致を確かめるだけでスロット参照に直行でき、プロパティ探索そのものを省けます。形状が値ごとに変わらない安定した識別子だからこそ、この「前回と同じか?」という高速判定が成り立ちます。辞書方式ではこの手が使いにくく、これが形状方式が広く採用される理由です。ICの具体的な構造や多相への対応、失敗時の扱いは、第V部「パフォーマンス」で詳しく扱います。

方式の比較

観点 辞書(ハッシュマップ) 形状(hidden class)+スロット
プロパティ読み書き ハッシュ計算+キー比較 スロット番号の添字参照
メモリ効率 各オブジェクトがキー・属性を保持 構造を共有し値のみ保持
追加・削除 その場で完結、安定 追加は形状遷移、削除は格下げを招きうる
列挙順の保持 挿入順を別途記録 形状がキー順を保持
インラインキャッシュ 効きにくい 効かせられる
実装コスト 小さい 大きい
得意な相手 形が変化し続けるオブジェクト 同じ形が大量に現れるオブジェクト

プロパティの列挙順

JavaScript は、オブジェクトの自身のプロパティキーを列挙する順序を仕様で定めています。エンジンはこの順序を再現しなければなりません。オブジェクトの内部メソッド [[OwnPropertyKeys]] が返すキーの並びは、次の三段です。

  1. 整数インデックスキー("0"、"1"、"42" のように、非負整数を正準表現した文字列キー)を、数値として昇順に並べる。
  2. 残りの文字列キーを、追加された順(挿入順)に並べる。
  3. シンボルキーを、追加された順に並べる。

つまり { b: 1, "2": 1, a: 1, "1": 1 } のキー列挙は "1", "2", "b", "a" になります。数値キーだけは挿入順を無視して昇順に繰り上がる点が要注意です。この規則のため、キー "10" は整数インデックスとして扱われますが、"10.5"" 3"、あるいは "01" のように正準な整数表現でない文字列は通常の文字列キーとして挿入順側に回ります。整数インデックスの範囲には上限(2^32 − 2 まで、すなわち 2^32 − 1 未満)があり、"4294967295" のようにそれを超える数値風のキーも文字列キー扱いです。

実装がこの順序を保証するには、格納方式に応じた工夫が要ります。

  • 辞書方式なら、挿入順を表す通し番号をプロパティに付けるか、挿入順を保つ連結リストを併走させ、列挙時に「整数キーを抜き出して昇順ソート → 残りを挿入順」で結合します。
  • 形状方式なら、形状がプロパティを追加順に並べて保持しているので、文字列キーの挿入順は自然に得られます。整数インデックスは後述の配列的な格納に回し、列挙時に昇順で先頭へ差し込みます。

順序を軽視すると、Object.keysJSON.stringify の出力順が仕様とずれ、シリアライズ結果の不一致という形で表面化します。

プロトタイプチェーン

オブジェクトが自身に持たないプロパティを読もうとすると、エンジンは [[Prototype]] リンクをたどって連なる別のオブジェクト(プロトタイプ)を順に探します。この連なりがプロトタイプチェーンで、継承を実現する仕組みです。プロパティ取得の内部メソッド [[Get]] は、自身になければ [[Prototype]] に委譲し、null に達するまでこれを繰り返します。

obj.foo の読み取り:
  o = obj
  while o != null:
    if o が foo を自身に持つ: return その値(アクセサなら getter を呼ぶ)
    o = o の [[Prototype]]
  return undefined

ここで重要なのはコストです。

  • 存在するプロパティでも、それが自身でなくチェーンの奥にあるほど、たどるオブジェクトの数だけ探索が増えます。ビルトインのメソッド(arr.push など)はプロトタイプ上にあるため、毎回チェーンをたどることになります。
  • 存在しないプロパティへのアクセスは最悪で、undefined を返すと決まるまでにチェーンの末端(null)まで全部たどり切る必要があります。「無いことの確認」がもっとも高くつくわけです。

この探索コストを抑えるのも、前述のインラインキャッシュの役割です。「この形状のオブジェクトに対する foo は、二つ上のプロトタイプのこのスロットにある」といった探索結果ごとキャッシュできれば、次回はチェーンを歩き直さずに済みます。ここでもプロトタイプ側の形状が安定していることが前提になり、実行中にプロトタイプを差し替える(Object.setPrototypeOf など)とキャッシュが無効化され、性能を大きく損ないます。プロトタイプは固定して使うのが速い、という指針はこの構造から来ています。

配列の表現

配列(Array)も内部的にはオブジェクトですが、「0, 1, 2, … と連続した整数インデックスに値が詰まっている」という典型的な使われ方に特化した表現を持つのが普通です。整数インデックスへのアクセスを、一般のプロパティ探索を経ずに配列添字の計算だけで済ませたいからです。

dense(密)表現

要素が隙間なく詰まっている配列は、連続したメモリ領域(バッキングストア)に値を並べるdense 表現で持ちます。

Array = { length: 論理的な長さ, dense: [v0, v1, v2, ...] }

インデックス i の読み書きは「範囲内かを確かめてから配列の i 番目に直接アクセス」で完結します。ハッシュも形状探索も要らない、もっとも速い経路です。これが整数インデックスアクセスのファストパスであり、一般プロパティアクセス(キーから形状/辞書を引く)との決定的な速度差になります。

sparse(疎)表現

一方、a[0] = 1; a[1000000] = 1 のように、まばらな位置にだけ値を置く配列を dense で持つと、100 万個分の空き枠を確保することになり、メモリを浪費します。そこでエンジンは、隙間が大きい・全体に対する要素の充填率が低い・巨大なインデックスが現れた、といった条件を検出すると、インデックスから値への辞書で持つsparse 表現へ切り替えます。

  • dense: 連続領域。アクセスは添字計算で最速。詰まっている配列に向く。隙間が増えるとメモリを浪費する。
  • sparse: インデックス→値の辞書。まばらでもメモリを食わない。アクセスは辞書引きのぶん遅い。

エンジンはこの二表現を、実行時の使われ方に応じて相互に移行させます。dense のまま遠いインデックスに書けば sparse へ、sparse が再び密に埋まれば dense へ、といった切り替えです。

観点 dense(密) sparse(疎)
格納 連続バッキングストア インデックス→値の辞書
インデックスアクセス 添字計算で最速 辞書引き
メモリ 長さぶん確保 実在要素ぶんのみ
得意な相手 詰まった配列 まばらな配列
切り替え 遠いインデックス書き込みで sparse 化 密に埋まれば dense 化

配列インデックスと通常プロパティ

配列オブジェクトにも arr.name = "x" のような通常のプロパティは付けられます。エンジンはキーを見て、配列インデックス(前節の整数インデックスの条件を満たすキー)なら dense/sparse の要素ストアへ、それ以外の文字列キーなら通常のプロパティ格納(辞書または形状+スロット)へと振り分けます。列挙順の規則で整数インデックスが先頭に昇順で来るのは、この要素ストアを先に、通常プロパティを後に並べることに対応します。

length と要素ストアの整合

配列の length は、単なる要素数ではなく「最大のインデックス + 1」を表す特別なプロパティで、しかも書き込み可能です。length を小さくすると、それ以上のインデックスの要素は削除されなければなりません(縮小)。逆にインデックス i に書き込むと、i >= length なら lengthi + 1 に自動で伸びます(拡大)。

実装では、この論理的な長さ length と、バッキングストアの物理的な状態を常に一致させることが要点です。

  • 縮小時: length を下げるだけでなく、切り捨てた範囲の要素を要素ストアから確実に消す。物理配列に古い値が残ったまま length だけ縮めると、その古い値が「消えたはずの要素」として顔を出しかねない。
  • 拡大時: length を伸ばした結果できた隙間は「穴(hole、要素が存在しない状態で、undefined とは区別される)」として扱い、dense の物理容量とは別に論理長を管理する。物理容量は性能のため多めに確保することがあるが、論理長を超える枠を「存在する要素」と誤って見せてはいけない。

length と要素ストアがずれると、要素数の計算や列挙、push/pop の挙動が静かに狂います。長さは要素ストアと一体で更新する、という不変条件をあらゆる変更経路で守る必要があります。

実装上の罠: 単一の真実の源を保て

配列の dense/sparse 表現や、要素ストアと通常プロパティ格納の併存は、便利な一方で危険な落とし穴を抱えます。それは、同じ論理的なプロパティが二か所に格納されうる状況です。

たとえば、あるインデックスの値を dense バッキングストアに持つと同時に、別のプロパティテーブルにも同じインデックスのエントリを作ってしまう、といった事態が起こりえます。典型的な発生経路は、属性付きでプロパティを定義する処理(defineProperty 相当)が、デフォルト属性のデータプロパティに対してまで、要素ストアとプロパティテーブルの両方に値を書き込んでしまうケースです。こうして同じ値の「複製された置き場」が生まれます。

問題は、その後の読み書きが二つの置き場のうちどちらを正とするかで食い違うことです。

  • 読み取りは常に dense バッキングストアを優先する。
  • ところが一部の変更処理(たとえば要素を書き換えるミューテータ)は、プロパティテーブル側だけを更新して dense を更新しない。

すると、書き込みは反映されたはずなのに、読み取りは古い dense の値を返す。値は確かに更新したのに結果が古い、というサイレントな誤結果が生じます。しかもクラッシュせず、特定の操作順序でしか出ないため、極めて発見が難しいバグになります。

一般原則は明快です。一つの論理的な値は、権威ある置き場をただ一つに定めること。実現の仕方は二通りあります。

  1. 冗長な置き場を作らない: デフォルト属性のデータプロパティ(=素直な要素)は要素ストアだけに置き、プロパティテーブルには重複して持たせない。特別な属性が必要になった要素だけを、必要なときに一方へ寄せる。
  2. やむを得ず二重に持つなら、あらゆる変更経路で厳密に同期する: 値を変えるすべてのミューテータが、両方の置き場を漏れなく更新することを保証する。一つでも片方しか更新しない経路が残れば、そこが穴になる。

この罠は配列に限りません。dense と sparse の切り替え時に取りこぼす、要素ストアと通常プロパティ格納の境界で二重に持つ、など、同じデータの表現が複数あるところには常に潜みます。表現を複数持つこと自体は性能のために正当ですが、「どこか一つを正とし、他はそこから導く(あるいは常に同期する)」という規律を、読み取り経路と書き込み経路の両方で貫くことが、静かな破綻を防ぐ唯一の道です。

まとめ

  • オブジェクトはプロパティを実行時に増減でき、属性を持ち、決まった順序で列挙でき、プロトタイプチェーンで継承する。この全部を満たしつつ速くするのがオブジェクトモデルの課題である。
  • プロパティ格納は、単純で柔軟だがアクセスが遅い辞書方式と、構造を共有してスロット添字アクセスにする形状(hidden class)方式に大別される。形状方式はインラインキャッシュを可能にする。
  • 列挙順は「整数インデックスは昇順 → 文字列キーは挿入順 → シンボル」と仕様で定まり、実装はこの順序を再現しなければならない。
  • プロトタイプチェーンの探索は奥にあるほど、また「存在しないプロパティ」ほど高くつき、キャッシュはプロトタイプの安定を前提とする。
  • 配列は詰まった dense 表現とまばらな sparse 表現を使い分け、length は要素ストアと一体で整合を保つ必要がある。
  • 同じ論理的な値が二か所に格納されうる設計では、権威ある置き場を一つに定めるか、全変更経路で厳密に同期しないと、サイレントな誤結果を招く。

参考文献