プロトタイプチェーンの解決アルゴリズムと内部スロット
__proto__とprototypeの混同が消える。プロパティ探索が内部スロット[[Prototype]]のリンクをどう辿るか、Object.createや継承の内部動作をECMAScript仕様の原理から読み解きます。
- 1.プロパティ取得は[[Get]]の手続きで、自身のプロパティに無ければ内部スロット[[Prototype]]のリンクを辿り、見つかるかnullに達するまで上方向へ探索する。
- 2.prototypeは「関数オブジェクトが持つ、newで生成するインスタンスの[[Prototype]]になる器」。__proto__は「任意オブジェクトの[[Prototype]]を読み書きするアクセサ」で、両者は別物。
- 3.代入と取得は非対称。取得はチェーンを遡るが、代入はアクセサやデータプロパティの可否を見たうえで自身に新しいプロパティを作る(シャドーイング)。
なぜ「チェーン」を仕様から理解するのか
JavaScriptの継承はクラスではなくオブジェクトからオブジェクトへの委譲で成り立っています。obj.toString が定義した覚えのない場所で動くのも、class extends が実は糖衣構文なのも、すべてプロトタイプチェーンの探索アルゴリズムひとつで説明できます。ここを「なんとなく上にたどる」で済ませると、__proto__ と prototype の混同、代入時に親が書き換わると思い込むシャドーイングの誤解、Object.create(null) の挙動などでつまずきます。本稿はECMAScript仕様の内部メソッド([[Get]] など)と内部スロット [[Prototype]] を基準に、探索が何をどの順で見るのかを原理から説明します。前提として JavaScript の値とオブジェクトの区別、エンジン実装の視点は JavaScriptエンジンの内部 を押さえると理解が速いです。
内部スロット [[Prototype]] とは何か
仕様上、すべての通常オブジェクトは [[Prototype]] という内部スロットを1つ持ちます。内部スロットは言語から直接見えない、エンジンが各オブジェクトに紐づける状態で、[[Prototype]] の値は「別のオブジェクト」または null のどちらかです。これがチェーンの**リンク(1本のポインタ)**そのものです。
重要なのは、[[Prototype]] はプロパティではないこと。obj のプロパティ一覧(Object.keys で見えるもの)とは別の、隠れた1本のリンクです。このリンクを言語から読み書きする公式の口が、後述する Object.getPrototypeOf / Object.setPrototypeOf と、レガシーなアクセサ __proto__ です。
各オブジェクトの [[Prototype]] は高々1つ。つまりプロトタイプチェーンは分岐のない片方向の連結リストで、終端は必ず null です。多重継承は言語機構としては存在せず、Object.assign 等で「形をコピー」しているだけです。探索が分岐しないという事実が、解決アルゴリズムが単純な反復ループで書ける理由になります。
[[Get]]:プロパティ取得の解決アルゴリズム
obj.key(=obj["key"])の評価は、仕様の内部メソッド [[Get]](key, receiver) に対応します。通常オブジェクトの [[Get]] を擬似コードで表すと、本質は次の反復です。
OrdinaryGet(O, key, receiver):
desc = O.[[GetOwnProperty]](key) # 自身の所有プロパティを調べる
if desc is undefined: # 自身に無い
parent = O.[[GetPrototypeOf]]() # [[Prototype]] を1段上がる
if parent is null: return undefined # 終端。見つからなかった
return parent.[[Get]](key, receiver) # 親に委譲して再帰
if desc is データプロパティ: return desc.[[Value]]
if desc is アクセサプロパティ: # getter を持つ
getter = desc.[[Get]]
if getter is undefined: return undefined
return getter を receiver を this として呼ぶ
ポイントは3つです。第一に、探索は自身→親→親の親と上方向のみへ進み、null に達したら undefined を返して終わります(例外は投げません)。第二に、最初に見つかった所有プロパティで打ち切ること。下位(自身に近い側)の定義が上位を覆い隠します。第三に、見つかったのがアクセサ(getter)なら、その getter は receiver(元の obj)を this として呼ばれます。だからプロトタイプ上のメソッド内の this は、呼び出した側のインスタンスを指します——これが委譲の要です。
prototype と proto と [[Prototype]] の区別
混同の元凶はここです。名前が似た3者はまったく別の役割を持ちます。
| 記号 | 何に付くか | 正体 | 役割 |
|---|---|---|---|
| [[Prototype]] | すべてのオブジェクト | 内部スロット(隠しリンク) | チェーンの1本のリンク。探索が辿る対象 |
| __proto__ | Object.prototype 由来のアクセサ | getter/setter | [[Prototype]] を読み書きする公式でない口(レガシー) |
| prototype | 関数オブジェクトのみ | 通常のプロパティ | new で作るインスタンスの [[Prototype]] になる器 |
決定的な違いは、prototype は関数だけが持つ普通のプロパティで、[[Prototype]] はすべてのオブジェクトが持つ内部スロットだということです。new F() を実行すると、新しいオブジェクトの [[Prototype]] に F.prototype が代入されます(F 自身ではなく F.prototype)。つまり次が成り立ちます。
function F() {}
const o = new F();
Object.getPrototypeOf(o) === F.prototype; // true
o.__proto__ === F.prototype; // true(同じものを別経路で見ているだけ)
F.prototype.__proto__ === Object.prototype; // true(関数の prototype の親)
Object.getPrototypeOf(Object.prototype); // null(チェーンの終端)
__proto__ は Object.prototype 上のアクセサプロパティとして定義されているため、Object.create(null) で作ったオブジェクトには __proto__ のアクセサが効きません(チェーン上に Object.prototype が無いから)。仕様でも __proto__ は付録B(Webブラウザ互換)の機能で、コードでは Object.getPrototypeOf / Object.setPrototypeOf を使うのが正道です。
実行時に [[Prototype]] を差し替えると、エンジンはそのオブジェクトに紐づく形の最適化(V8の隠しクラスとインラインキャッシュ)を捨てて脱最適化します。Object.create で最初から正しい親を与えるべきで、__proto__ 代入や setPrototypeOf でのチェーン張り替えはホットパスでは避けます。
Object.create と継承の内部動作
Object.create(proto) は「[[Prototype]] が proto である空オブジェクトを作る」だけのプリミティブです。new のような関数呼び出しもコンストラクタも介在しません。これが委譲継承の最小単位です。
const animal = { speak() { return `${this.name} makes a sound`; } };
const dog = Object.create(animal); // dog.[[Prototype]] === animal
dog.name = "Rex"; // 所有プロパティとして name を持つ
dog.speak(); // "Rex makes a sound"
// 探索: dog に speak 無し → animal で発見 → this は dog(receiver)
class 構文も内部はこの仕組みです。class Dog extends Animal をおおまかに展開すると、(1) Dog.prototype.[[Prototype]] を Animal.prototype に、(2) Dog.[[Prototype]](コンストラクタ自身)を Animal に設定します。つまりインスタンス側のチェーンとコンストラクタ側のチェーンの2本が同時に張られます。前者がメソッド解決、後者が static メンバの継承を担います。
| 継承の張り方 | 親になるもの | コンストラクタの介在 |
|---|---|---|
| Object.create(p) | p がそのまま [[Prototype]] | なし(最小プリミティブ) |
| new F() | F.prototype が [[Prototype]] | あり(F が this 初期化を実行) |
| class B extends A | A.prototype と A の2系統 | あり(super で連鎖) |
代入は非対称:シャドーイングの原理
取得 [[Get]] がチェーンを遡るのに対し、代入 obj.key = v(内部メソッド [[Set]])は親を書き換えません。[[Set]] のおおまかな手続きは次のとおりです。
obj自身にkeyの所有プロパティがあり、それがデータプロパティなら、その値を更新する。- 自身に無ければチェーンを上にたどり、アクセサ(setter)が見つかればその setter を呼ぶ(
thisはobj)。 - setter が無く、上位に書き込み可能なデータプロパティしか無い、または何も無ければ、
obj自身に新しいデータプロパティを作成する(シャドーイング)。
つまり、親プロトタイプにあるデータプロパティと同名の代入をしても、親は不変で、子オブジェクトに覆い隠す(shadow)所有プロパティが新設されるだけです。これがプリミティブを共有しつつ書き換えは局所化される仕組みの核心です。
const proto = { count: 0 };
const a = Object.create(proto);
a.count++; // 読み: proto.count(0) → 書き: a 自身に count=1 を新設
proto.count; // 0 のまま。proto は書き換わらない
a.count; // 1(a の所有プロパティが proto を覆い隠す)
プロトタイプにミュータブルなオブジェクト/配列を置くと話が変わります。proto.tags = [] を全インスタンスが共有し、a.tags.push(x) は「a.tags を取得してから配列を破壊的変更」する操作なので、シャドーイングは起きず全インスタンスに波及します。代入(=)はシャドーイングするが、取得して中身を変えるのは共有物に効く——この非対称が事故の元です。インスタンス固有の状態はコンストラクタ内で各自に持たせます。
メソッド探索のコストと in / hasOwnProperty
obj.method() のたびに、エンジンは原理的にはチェーンを線形に探索します。チェーンが深いほど、また見つからないプロパティ(undefined 確定までに終端 null まで全段走査する)ほどコストが上がります。実際のV8はこの探索結果をインラインキャッシュに記録し、同じ形なら次回は固定オフセットの読み出しに短絡しますが、形が多様だと汎用探索へ劣化します。
判定演算子の違いも探索の射程で説明できます。
| 式 | 見る範囲 | 用途 |
|---|---|---|
| key in obj | チェーン全体(継承プロパティ含む) | 存在するか(親由来でも true) |
| obj.hasOwnProperty(key) | 自身の所有プロパティのみ | 自分が直接持つか |
| Object.hasOwn(obj, key) | 自身の所有プロパティのみ | 上記の現代的な代替(null原型でも安全) |
| for...in | 列挙可能 + チェーン全体 | 継承分も列挙(要注意) |
for...in が継承プロパティまで列挙するのは、ループが各キーで [[Get]] 相当のチェーン探索を行うからです。自身の分だけ欲しければ Object.keys か Object.hasOwn でふるいます。なお DOM ノードのように深いチェーンを持つオブジェクトもありますが、構造の把握は DOM を参照してください。
まとめ
プロトタイプチェーンは、各オブジェクトの内部スロット [[Prototype]] が作る分岐なしの連結リストです。取得 obj.key は内部メソッド [[Get]] に従い、自身に無ければ [[Prototype]] を1段ずつ上がり、最初に見つかった所有プロパティで打ち切るか null で undefined を返します。getter は元の obj を this に呼ばれ、これが委譲の本質です。名前の似た三者は別物——[[Prototype]] は全オブジェクトの隠しリンク、__proto__ はそれを読み書きするレガシーなアクセサ、prototype は関数だけが持ち new 時にインスタンスの [[Prototype]] になる器です。Object.create(p) は親を直接与える最小プリミティブで、class extends はインスタンスとコンストラクタの2系統を張る糖衣です。最後に取得と代入の非対称——取得は遡るが代入はシャドーイングで自身に新設し親を汚さない、ただし取得して中身を破壊的変更すると共有物に波及する。この一貫した規則を押さえれば、継承まわりの挙動はすべて予測できます。
Web/フロントエンド Article
プロトタイプチェーンの解決アルゴリズムと内部スロットを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
prototypeは「関数オブジェクトが持つ、newで生成するインスタンスの[[Prototype]]になる器」。__proto__は「任意オブジェクトの[[Prototype]]を読み書きするアクセサ」で、両者は別物。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「JavaScript / プロトタイプ」に近いか確認する。
- 強みである「プロパティ取得は[[Get]]の手続きで、自身のプロパティに無ければ内部スロット[[Prototype]]のリンクを辿り、見つかるかnullに達するまで上方向へ探索する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。