JavaScript上級 #3 プロトタイプチェーン

JavaScript のオブジェクトモデルは他のオブジェクト指向言語とは違います。クラスベース(class-based)ではなく プロトタイプベース(prototype-based) です。ES2015 の class 構文はプロトタイプの上に被せられた糖衣構文(syntactic sugar)です。この記事ではその内側を覗きます。

すべてのオブジェクトは別のオブジェクトを参照する #

JavaScript のすべてのオブジェクトは [[Prototype]] という内部スロットを持ちます。これが別のオブジェクトを指します。

プロトタイプを見る
const obj = { a: 1 };
Object.getPrototypeOf(obj);   // Object.prototype

空のオブジェクト {} のプロトタイプは Object.prototype というビルトインオブジェクト。そこには toStringhasOwnProperty のようなメソッドが入っています。

自分にないプロパティも動作
const obj = { a: 1 };
obj.toString();   // '[object Object]'
//        ↑ obj に直接ないが Object.prototype にある

obj.toString を探すとき — JavaScript はまず obj 自身を見て、なければプロトタイプチェーンを上に辿っていって探します。Object.prototype.toString まで到達して見つかります。

これをプロトタイプチェーンルックアップと呼びます。JavaScript のほぼすべてのメソッド呼び出しがこの動作の上で起こります。

関数の prototype プロパティ #

関数はすべて prototype というプロパティを持ちます。

関数の prototype
function User(name) {
  this.name = name;
}

User.prototype.greet = function() {
  console.log(`こんにちは, ${this.name}`);
};

const u = new User('カーティス');
u.greet();   // こんにちは, カーティス

new User('カーティス') が実行されると:

  1. 新しい空のオブジェクトを作る
  2. そのオブジェクトの [[Prototype]]User.prototype に設定
  3. User の本体を実行しながら this にプロパティを追加
  4. オブジェクトを返す

なので u.greet を呼ぶと — u にはないけれどプロトタイプチェーンを辿って User.prototype.greet を見つけて呼び出します。

クラス = プロトタイプの糖衣 #

ES2015 の class 構文で同じコードを書き直すと:

class 構文
class User {
  constructor(name) {
    this.name = name;
  }
  greet() {
    console.log(`こんにちは, ${this.name}`);
  }
}

const u = new User('カーティス');
console.log(u);                   // User { name: 'カーティス' }
console.log(u.greet);              // [Function: greet]

内部的には正確に同じことが起こります。

class とプロトタイプの等価性
class User {
  greet() { /* ... */ }
}
// 上は実質的に次と同じ
function User() {}
User.prototype.greet = function() { /* ... */ };

class が登場してから直接 prototype にメソッドを付けるコードはほとんどなくなりましたが、この構造は変わっていません。

プロトタイプチェーン — 深く入っていく #

継承関係はチェーンが長くなることで表現されます。

継承 = プロトタイプチェーンの延長
class Animal {
  speak() { console.log('音'); }
}

class Dog extends Animal {
  bark() { console.log('ワンワン'); }
}

const d = new Dog();
d.bark();    // 'd' → 'Dog.prototype' で発見
d.speak();   // 'd' → 'Dog.prototype' (なし) → 'Animal.prototype' で発見

チェーン:

d
 ↓ [[Prototype]]
Dog.prototype  (bark)
 ↓ [[Prototype]]
Animal.prototype  (speak)
 ↓ [[Prototype]]
Object.prototype  (toString, hasOwnProperty)
 ↓
null

プロパティを探すとき、このチェーンを上に辿りながら検索します。最後まで見つからなければ undefined

チェーンの終わりは null #

チェーンの終わり
Object.getPrototypeOf(Object.prototype);   // null

Object.prototype のプロトタイプは null です。ここでチェーンが終わります。

自分のプロパティ vs 継承されたプロパティ #

own vs inherited
class User {
  constructor(name) {
    this.name = name;
  }
  greet() {}
}

const u = new User('カーティス');

Object.hasOwn(u, 'name');     // true — インスタンス自身のプロパティ
Object.hasOwn(u, 'greet');    // false — プロトタイプにある

'greet' in u;                  // true ('in' はチェーンまで検査)

Object.hasOwn は自分のプロパティだけを検査。in 演算子はチェーンまで辿ります。両者の違いを意識しなければならない場面が時々あります。

古いコードでは obj.hasOwnProperty('key') がよく見られますが、これは obj 自身に hasOwnProperty が定義されていると壊れる可能性があります。ES2022 の Object.hasOwn が安全な答えです。

プロトタイプを直接扱う道具 #

1) Object.create — プロトタイプを明示して作る #

Object.create
const animalProto = {
  speak() { console.log('音'); },
};

const dog = Object.create(animalProto);
dog.bark = function() { console.log('ワンワン'); };

dog.speak();   // 音 (プロトタイプから)
dog.bark();    // ワンワン (自身のプロパティ)

クラスを使わずプロトタイプを直接指定してオブジェクトを作る方法です。古い資料にはよく出てきますが、モダンなコードではほぼ class が標準です。

2) Object.create(null) — プロトタイプのないオブジェクト #

本当にクリーンな辞書(map)
const dict = Object.create(null);
dict.toString;    // undefined — Object.prototype もない

const normal = {};
normal.toString;  // [Function: toString]  — ビルトインが見える

キーと値の辞書としてオブジェクトを使うとき — toString のようなビルトインキーの衝突を防ぐには Object.create(null) が安全です。モダン JavaScript では Map がより良い答えですが、ライブラリ内部のコードで時々出会います。

プロトタイプの修正 — 推奨されない #

既存ビルトインの修正 — 絶対にしないこと
Array.prototype.first = function() {
  return this[0];
};

[1, 2, 3].first();   // 1

ビルトインのプロトタイプにメソッドを追加するパターン(いわゆる monkey patching)は非常に強力ですがほぼ常に事故を起こします。他のコード(ライブラリ、標準の新規メソッド)と衝突しやすいです。

自分のクラスの prototype には普通にメソッドを追加できますし、それは正常です。ビルトインを触るのは避けてください。

__proto__ — 古い動作 #

古い JavaScript には __proto__ という非公式のプロパティがありました。ES2015 で標準化されましたが使用は推奨されません

__proto__ — 古い方式
const obj = {
  __proto__: animalProto,   // 使用しないでください
};

// 推奨
const obj2 = Object.create(animalProto);

Object.getPrototypeOf / Object.setPrototypeOf / Object.create がモダンな答えです。

instanceof — プロトタイプチェーン検査 #

instanceof
class Animal {}
class Dog extends Animal {}

const d = new Dog();

d instanceof Dog;       // true
d instanceof Animal;    // true (チェーンに沿って親も)
d instanceof Object;    // true

x instanceof CC.prototype が x のプロトタイプチェーンのどこかにあるかを検査します。チェーンルックアップのもう一つの応用です。

関数もオブジェクト — 自分のプロパティを持てる #

JavaScript では関数もオブジェクトです。プロパティを直接付けられます。

関数にプロパティを付ける
function counter() {
  counter.count = (counter.count ?? 0) + 1;
  return counter.count;
}

counter();   // 1
counter();   // 2
counter();   // 3

このようなパターンはあまり使われませんが、JavaScript の関数が第一級オブジェクト(first-class object)であるという意味が見える場面です。

具体的な例 — 配列のメソッドチェーン #

配列メソッドはどこにある?
const arr = [1, 2, 3];

Object.hasOwn(arr, 'map');    // false
Object.hasOwn(arr, 'length'); // true

arr.__proto__ === Array.prototype;             // true
Array.prototype.__proto__ === Object.prototype; // true

チェーン:

[1, 2, 3]   (length, 0, 1, 2)
   ↓
Array.prototype   (map, filter, reduce, ...)
   ↓
Object.prototype   (toString, ...)
   ↓
null

arr.map を呼ぶと — arr 自身にはないので Array.prototype で見つけて実行します。すべての配列が同じ Array.prototype を共有しているので、メソッドが一度だけメモリ上に存在します。

まとめ #

この記事で整理した内容:

  • JavaScript はプロトタイプベース — クラス構文は糖衣
  • すべてのオブジェクトは [[Prototype]] で別のオブジェクトを指す
  • プロパティを探すときはプロトタイプチェーンを上に辿る
  • 関数の prototype プロパティが new 呼び出し時にインスタンスの [[Prototype]] に設定される
  • Object.hasOwn で自分のプロパティを検査(in はチェーンまで)
  • Object.create(...) でプロトタイプを明示して作成
  • ビルトイン prototype の修正は推奨 X
  • instanceof はプロトタイプチェーン検査

次の記事(#4 イベントループとタスク)では JavaScript の非同期の実際の動作 — イベントループ、マイクロタスクとマクロタスクの違い、そして Promise.thensetTimeout がなぜ違う順序で実行されるかを扱います。

X