すべてのオブジェクトにはプロトタイプがある
JavaScript はプロトタイプベースの言語です。「プロトタイプベース」と聞くと難しそうに感じますが、考え方自体はシンプルです。すべてのオブジェクトは、裏側でもう 1 つのオブジェクト —— つまりプロトタイプ —— への参照を持っていて、自分が持っていないプロパティを要求されたときは、その参照をたどって「あっち側」に聞きに行く、というだけの話です。
rabbit には eats プロパティが自分自身には存在しません。JavaScript はまず rabbit を見に行き、見つからなければプロトタイプのリンクをたどって animal をチェックし、そこで eats: true を見つけて返します。flies の場合はチェーンを最後までたどっても見つからないので、undefined が返ります。
この「自分になければ親をたどる」という仕組みこそがプロトタイプの本質です。継承もメソッドも class 構文も、すべてこの上に成り立っています。
プロトタイプチェーンの仕組み
たどるのは一段階だけではありません。プロトタイプにもさらにプロトタイプがあり、その先にもプロトタイプがあり……という具合に、最終的に null に到達するまでチェーンが続きます。
実行してみると、まず rabbit、次に Object.prototype、最後に null と表示されるはずです。toString を自分で定義していないのに rabbit.toString() が動くのは、このメソッドがほぼすべてのチェーンの頂点にある Object.prototype に存在しているからです。
プロパティの参照はこのプロトタイプチェーンを下から上へたどって探しに行きます。一方で、代入は常にそのオブジェクト自身に書き込まれ、上までさかのぼることはありません。この非対称性は重要で、しょっちゅう人をハマらせるポイントでもあります。
コンストラクタ関数と .prototype
class 構文が登場する前は、同じような形のオブジェクトをたくさん作りたいとき、new と一緒に呼び出す コンストラクタ関数 を使うのが定番でした。
new User("Ada") を呼び出すと、裏では2つのことが起きています。
- 新しいオブジェクトが作られ、そのプロトタイプに
User.prototypeがセットされる。 Userが実行され、その際thisは新しく作られたオブジェクトに束縛される。
注目してほしいのは、greet が各インスタンスにコピーされるわけではないという点です。greet は User.prototype に一つだけ存在していて、ada も boris もプロトタイプチェーンをたどってそれを見つけに行くのです。最後の行が true になるのは、まさにこのためで、同じ関数を指しているからです。
prototype と __proto__ の違い
この2つの名前は、みんな一度はつまずくポイントです。関係はあるけれど、別物なんですよね。
User.prototypeは_コンストラクタ関数_側のプロパティです。new User(...)で作られたインスタンスのプロトタイプになるのが、このオブジェクトです。ada.__proto__(あるいはObject.getPrototypeOf(ada))は、_インスタンス_側から自分のプロトタイプを指すリンクです。
新しいコードでは obj.__proto__ ではなく Object.getPrototypeOf(obj) を使いましょう。__proto__ は互換性のために残されているレガシーなアクセサで、関数版のほうが公式な API です。
class は実はプロトタイプの糖衣構文
モダンな JavaScript では class 構文が書けますが、裏側では結局プロトタイプが動いています。次の2つのバージョンを見比べてみましょう。
greet は User.prototype に生えています。手書きで prototype にメソッドを追加した場合とまったく同じ場所です。class キーワードがやってくれるのは、見た目をすっきりさせること、ルールを厳しくすること(new を必須にするなど)、そして extends をきれいに書けるようにすること——このあたりが中心で、実行時の仕組みはプロトタイプそのものです。
これを知っておくと、エラーメッセージを読むときや this まわりのデバッグで効いてきます。「User.prototype.greet」というエラーが出ても、それは内部的に付けられた謎の名前ではなく、メソッドが実際に置かれている場所そのものなんです。
継承はプロトタイプチェーンが伸びるだけ
extends は、あるプロトタイプを別のプロトタイプにつなげるだけの仕組みです。親の prototype が、子の prototype の prototype になります。
rex.eat を参照すると、まず rex 本体を探し、次に Dog.prototype、そして Animal.prototype へとプロトタイプチェーンをたどっていきます。そこで eat が見つかり、this は rex に束縛されたまま呼び出されるわけです。extends がやってくれているのは、結局このチェーンを組み立てることだけなんですね。
プロトタイプを直接指定してオブジェクトを作る
実はコンストラクタは必須ではありません。Object.create(proto) を使えば、指定したプロトタイプを持つ新しいオブジェクトをそのまま作れます。
class も new もコンストラクタ関数も使っていません。2つのオブジェクトが1つのプロトタイプを共有することで、同じメソッドを使い回しているだけです。これがプロトタイプ継承の最も素朴な姿で、他の仕組みはすべてこの上に積み上がっています。
hasOwnProperty:自身のプロパティか、継承されたものか
プロパティの参照はプロトタイプチェーンを遡っていくので、"foo" in obj は継承されたプロパティに対しても true を返します。そのオブジェクト自身が持っているプロパティだけを区別したいときは、Object.hasOwn(もしくは従来からある hasOwnProperty)を使いましょう。
name はインスタンス側、greet はプロトタイプ側にあります。in 演算子は両方を見つけますが、Object.hasOwn は自身のプロパティしか拾いません。この違いは、for...in でループを回したりオブジェクトをシリアライズしたりするときに効いてきます。たいていの場合、欲しいのは自身のプロパティだけですよね。
組み込みプロトタイプを勝手に拡張しない
Array.prototype はプログラム中のすべての配列で共有されているので、そこにメソッドを生やすこと自体は できます:
// やめてください。
Array.prototype.last = function () {
return this[this.length - 1];
};
[1, 2, 3].last(); // 3
問題は「動かない」ことではありません。動くんです、ちゃんと。問題は、あらゆるライブラリ、あらゆる依存関係、そして将来のバージョンのJavaScriptまでが、その名前空間をあなたと共有することになる点にあります。いずれ Array.prototype.last が本物のメソッドとして、微妙に違うセマンティクスで追加された瞬間、あなた(あるいは誰か)のコードは気づきにくい形で壊れます。Array.prototype.flatten と Array.prototype.flat のあの一件は、この手の教訓として語り継がれている典型例ですね。
ヘルパーは、素直にスタンドアロンの関数として置いておきましょう:
共有される面が1つ減るので、衝突の心配もそのぶん減ります。
イメージとして押さえておきたいこと
余計なものをそぎ落とすと、JavaScript のプロトタイプは次の3つのルールに集約されます。
- すべてのオブジェクトはプロトタイプへのリンクを持っている(場合によっては
null)。 - プロパティの読み取りはプロトタイプチェーンを辿るが、書き込みは辿らない。
class、new、extendsは、自分でObject.createを書かなくてもこのチェーンを組み立ててくれる仕組みにすぎない。
この3点さえ頭に入っていれば、this の挙動、instanceof、メソッド解決、継承まわりの動きが一気に腑に落ちます。
次回:イベントループ
プロトタイプでオブジェクトモデルの話はいったん区切りです。次の章はがらっと話題が変わって、JavaScript が時間軸上でどうコードを実行していくのか、という話に入ります。タイマーや Promise、async/await の挙動を支えているのがイベントループで、非同期処理すべての土台になる仕組みです。
よくある質問
JavaScriptのプロトタイプとは何ですか?
JavaScriptのオブジェクトは、すべて内部的に別のオブジェクト(プロトタイプ)へのリンクを持っています。自分自身に存在しないプロパティにアクセスすると、JavaScriptはこのリンクを辿って上位のオブジェクトを探しに行きます。これがいわゆる「プロトタイプチェーン」で、一度定義したメソッドを複数のインスタンスで共有できるのはこの仕組みのおかげです。
__proto__とprototypeは何が違うのですか?
__proto__とprototypeは何が違うのですか?prototypeはコンストラクタ関数(やクラス)が持つプロパティで、newで生成したインスタンスのプロトタイプになるオブジェクトを指します。一方、__proto__(もしくはObject.getPrototypeOf(obj))は、インスタンス側から自分のプロトタイプを指す実際のリンクです。つまりinstance.__proto__ === Constructor.prototypeという関係になります。
JavaScriptのclassはプロトタイプのシンタックスシュガーにすぎないのですか?
ほぼその通りです。class Foo { bar() {} }と書くと、barはFoo.prototypeに追加されます。これはfunction Foo(){}を定義してFoo.prototype.bar = function(){}と書くのと実質同じです。classにはプライベートフィールドや厳密なセマンティクス、extendsやsuperといった書きやすい構文が加わっていますが、裏で動いているのは結局プロトタイプです。
Array.prototypeのような組み込みプロトタイプにメソッドを追加してもいいですか?
Array.prototypeのような組み込みプロトタイプにメソッドを追加してもいいですか?基本的にやめた方がいいです。Array.prototypeやObject.prototypeをいじると、プログラム内のすべての配列・オブジェクトに影響が及び、ライブラリ側のコードにも波及します。将来の言語仕様の追加と衝突したり、for...inループを壊す原因にもなります。自作のヘルパーは専用の関数やモジュールにまとめておくのが無難です。