はじめに
こんにちは!25卒として入社し、現在webフロントエンジニアとして働いている松原です!
今回、webを触れるにあたって避けては通れないJavaScriptを完全に理解するための第一歩として、理解が難しい(自分調べ)と言われる:
- プロトタイプ (prototype)
- プロトタイプベース継承 (Prototypal Inheritance)とその仕組み
について理解するまで調べてみたので、説明していきたいと思います。
特にクラスベース言語 (オブジェクト生成時に「クラス」を用いるスタイルの言語) を使ってきた人 (自分) がJSのプロトタイプを理解しようとすると、絶対ひっかかるポイントがあると思ったので、そこも交えて解説出来ればと思います。
プロトタイプとは
プロトタイプとはJSなどで用いられるプロパティやメソッドを継承するための仕組みです。
JSではクラスなどを用いた継承を使うクラスベース言語 (Java、C++、C#など) とは根本的に継承の仕組みが違い、この「プロトタイプ」という仕組みを使って継承を行うため、プロトタイプベース言語と呼ばれています。
このプロトタイプを用いた継承をプロトタイプベース継承 (Prototypal Inheritance) と言います。
JSで使われる継承:プロトタイプベース継承 (Prototypal Inheritance)
「プロトタイプベース継承」と言われても、通常のクラスベース言語とどう違うの?という話になるかと思います。そこでクラスベース言語との違いを交えながらプロトタイプベース継承が何か、と大まかな特徴について説明出来ればと思います。
プロトタイプベース継承は「オブジェクト」⇒ 「オブジェクト」
クラスベース言語で継承と聞くと、下記のようなクラスからクラスへの継承を主に想像するかと思います。
class Animal {
public void eat() { System.out.println("食べる"); }
}
class Dog extends Animal {
void speak() { System.out.println("ワン"); }
}
class Cat extends Animal {
void speak() { System.out.println("ニャン"); }
}
//上記でいうと、犬(Dog)も猫(Cat)も動物(Animal)を継承しているので、食べる(eat())事が出来ます。
ですがプロトタイプベース継承では、オブジェクト ⇒ オブジェクトという形で継承を行います。
プロトタイプベース継承とは、既存のオブジェクトをコピー・参照をしながら新しいオブジェクトを作成・継承する仕組みのことを言います。
つまり、プロトタイプベース言語はクラスという設計書はなく、実体同士をコピー・参照して、繋いでいき、新しい実体を生成します。
JSでは、「クラス」の代わりにコンストラクタ関数と呼ばれる雛形となる関数を定義し、そこから別のコンストラクタ関数へ継承したり、またインスタンスに継承したりすることが出来ます。
// 親コンストラクタ (コンストラクタ関数) (クラス定義と似ている)
function Animal() {}
Animal.prototype.eat = function() {
console.log("食べる");
};
// 子コンストラクタ(Dog)(コンストラクタ関数) (クラス定義と似ている)
function Dog() {}
// プロトタイプ継承 (Animalから「食べる」を継承している)
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
// 独自メソッド
Dog.prototype.speak = function() {
console.log("ワン");
};
// 子コンストラクタ(Cat)(コンストラクタ関数) (クラス定義と似ている)
function Cat() {}
// プロトタイプ継承 (Animalから「食べる」を継承している)
Cat.prototype = Object.create(Animal.prototype);
Cat.prototype.constructor = Cat;
Cat.prototype.speak = function() {
console.log("ニャン");
};
// 犬(Dog)も猫(Cat)も、動物(Animal)から継承しています。
let pochi = new Dog(); //ポチはDogから継承しています
let tama = new Cat(); //タマはCatから継承しています
ここで大事なポイントですが、
- 雛形となる「コンストラクタ関数」
- コンストラクタ関数から生成した実体となる「インスタンス」
これはどちらもJSでは「オブジェクト」だということです。
なのでJSでは:
-
コンストラクタ関数(オブジェクト)から別のコンストラクタ関数(オブジェクト)への継承
- クラスベース言語でいう所のクラスからクラスの継承
-
コンストラクタ関数(オブジェクト)からインスタンス(オブジェクト)への継承
- クラスベース言語でいうクラスから生成したインスタンスへの継承
は同じプロトタイプという継承の仕組みを経て成り立っています。
クラスベース言語で育った私からしたら、継承 = クラスからクラス という固定概念で育ってきたので、ここで引っかかってしまいました。
プロトタイプベース継承とは、既存のオブジェクトをコピー・参照をしながら新しいオブジェクトを作成・継承する仕組みのこと
プロトタイプベース継承では共通するものはコピーではなく共有
ここでは、プロトタイプベース継承を使ってコンストラクタ関数からインスタンスを作った際の話をします。
実はプロトタイプベースの継承ではインスタンスを作成した際、各々が全てのプロパティやメソッドのコピーを持つわけではなく、インスタンス固有のものだけ自身のコピーを持ち、それ以外の共通のもの(プロパティ・メソッド)などは全て親オブジェクト自体にあるものを参照します。
端的にいうと、インスタンス自身が持つものは自身のコピーを参照し、それ以外のものは全て親が持つものを参照するということだけ覚えておいて下さい。
※後で詳しく説明します。
クラスベース言語でのクラスはインスタンス生成毎に、全てのインスタンスが自身のコピーを持つため、ここでの違いにも引っかかりました。
共有することの何がいいの?
インスタンス毎に全てのプロパティやメソッドをコピーしていると、その分メモリを確保する必要があります。
ですが、共有できるものは1つのものを全員で共有する、という考え方によりインスタンスをいくつ増やしてもその分コピーを作る必要がないため、メモリの効率化に繋がります。
プロトタイプベース継承は動的
クラスベース言語の継承は「静的」であり、コンパイル時に継承関係が決まります。(インスタンスは必ずクラスの型に従う必要がある)
プロトタイプベース言語の継承は逆で「動的」であり、実行時に継承関係が決まります。
※これは後ほど詳しく説明します。
プロトタイプを使ったプロトタイプベース継承の仕組み
今まではプロトタイプベース継承とは何なのかを説明してきましたが、これがどのような仕組みで動いているか詳しく解説して行きます。
xxx.prototype プロパティ
先程、共有して使うメソッドなどは全て親オブジェクト自体にあるものを参照するという話をしました。
そこで親となるオブジェクトを定義する際に、何を共有したいかを明示的にする必要があります。この何を共有したいかを格納する場所がprototypeと呼ばれるコンストラクタ関数が持っているプロパティです。
全てのコンストラクタ関数は
prototypeプロパティを持っている
先程の例を振り返ってみます:
// 親コンストラクタ (コンストラクタ関数) (クラス定義と似ている)
function Animal() {}
Animal.prototype.eat = function() {
console.log("食べる");
};
// 子コンストラクタ(Dog)(コンストラクタ関数) (クラス定義と似ている)
function Dog() {}
// プロトタイプ継承 (Animalから「食べる」を継承している)
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
// 独自メソッド
Dog.prototype.speak = function() {
console.log("ワン");
};
// 子コンストラクタ(Cat)(コンストラクタ関数) (クラス定義と似ている)
function Cat() {}
// プロトタイプ継承 (Animalから「食べる」を継承している)
Cat.prototype = Object.create(Animal.prototype);
Cat.prototype.constructor = Cat;
Cat.prototype.speak = function() {
console.log("ニャン");
};
let pochi = new Dog(); //ポチはDogから継承しています
let tama = new Cat(); //タマはCatから継承しています
pochi.eat(); //Animalのeat()を参照
tama.eat(); //Animalのeat()を参照
Animal.prototype.eat = function() {
console.log("食べる");
};
pochi.eat(); //Animalのeat()を参照
tama.eat(); //Animalのeat()を参照
ここでは明示的にAnimal.prototypeにeat() という関数を定義しています。
このように書くことによって、動物(Animal)を継承する猫(Cat)のインスタンスであるpochi、tamaは親のprototypeに定義されているeat()を参照し、使うことができるようになります。
xxx.prototypeの中身
xxx.prototypeは「共有したいものを格納する場所」という言い方をしました。
その実際の中身はプロトタイプオブジェクトというまた別のオブジェクトとして、共有したいものが格納されています。
//Animal.prototypeの定義
Animal.prototype.eat = function() {
console.log("食べる");
};
//Animal.prototypeの中身 (プロトタイプオブジェクト)
{
constructor: Animal, // 自動生成
eat: ƒ () { console.log("食べる"); } // 後から追加
}
ポチやタマは親であるAnimalのプロトタイプオブジェクトの中身(Animal.prototype)を参照することによって、初めてeat()を呼べるようになるのです。
__proto__ と[[Prototype]]:親プロトタイプへの参照
ポチ、タマは親に定義されているeat()を参照して使う事が出来ましたが、どのように参照しているのでしょうか?
基本的に全てのJSオブジェクトには[[Prototype]]と呼ばれる、内部スロット(internal slot)という隠れた属性を持ちます。
そしてこの[[Prototype]]にこそ親オブジェクトへの参照・リンク (親のプロトタイプオブジェクトへの参照)が格納されています。
しかし、[[Prototype]]はJSコードでは直接アクセスできないため、__proto__ と呼ばれるアクセサを使って、[[Prototype]]を読み書きする事が出来ます。
[[Prototype]]という内部スロット(隠れた属性)に親への参照が格納されており、
__proto__アクセサを使って読み書きします。
コードを使って確認すると以下の様な結果になります:
console.log(pochi.__proto__ === Dog.prototype);
// true: ポチのプロトタイプは Dog.prototype
console.log(pochi.__proto__.__proto__ === Animal.prototype);
// true: pochiの親の親はAnimal (pochi => Dog => Animal)
プロトタイプチェーン
長くなってきましたが後もう少しです!
ここでは、プロトタイプチェーンについて解説します。
プロトタイプチェーンとは、プロパティやメソッドを見つけるために行う階層的な検索のことです。
今までの継承を全体的に見てみると、全て親への参照・リンクである[[Prototype]]によってチェーンのように繋がっています。
pochi.__proto__ === Dog.prototype
Dog.prototype.__proto__ === Animal.prototype
//pochi => Dog => Animal が[[Prototype]]によって繋がっている
そこで自分を起点に探索を始め、チェーン上を遡って1階層ずつ検索していく、というのが大まかな流れになります。
全体的な流れ
- まず自分を起点に自身が持つプロパティを検索。
- なければ
__proto__を使って親へのリンクを辿って遡り、親のプロトタイププロパティを検索 - なければ更にその親、と数珠つなぎに探索を進める
- 見つかったら探索終了
- 見つからなかったら、全てのオブジェクトの親であるObjectの
__proto__がnullに設定(親無し)されているため、最終的になければここで探索は終了
という流れになります。(自分を優先的に探索、なければ親、なければその親…)
// 親コンストラクタ (コンストラクタ関数) (クラス定義と似ている)
function Animal() {}
Animal.prototype.eat = function() {
console.log("食べる");
};
// 子コンストラクタ(Dog)(コンストラクタ関数) (クラス定義と似ている)
function Dog() {}
// プロトタイプ継承 (Animalから「食べる」を継承している)
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
// 独自メソッド
Dog.prototype.speak = function() {
console.log("ワン");
};
// 子コンストラクタ(Cat)(コンストラクタ関数) (クラス定義と似ている)
function Cat() {}
// プロトタイプ継承 (Animalから「食べる」を継承している)
Cat.prototype = Object.create(Animal.prototype);
Cat.prototype.constructor = Cat;
Cat.prototype.speak = function() {
console.log("ニャン");
};
let pochi = new Dog(); //ポチはDogから継承しています
let tama = new Cat(); //タマはCatから継承しています
pochi.eat(); //Animalのeat()を参照
tama.eat(); //Animalのeat()を参照
上記の例だと
ステップ 1:インスタンス自身をチェック
-
pochiオブジェクト自身にeatがあるか確認 - ないので次に進む
ステップ 2:pochi.__proto__ をチェック
-
pochi.__proto__はDog.prototype -
Dog.prototypeにeatはない → 次に進む
ステップ 3:Dog.prototype.__proto__ をチェック
-
Dog.prototype.__proto__はAnimal.prototype(ここで継承を設定している) -
Animal.prototypeにeatがある → これを使う
ステップ 4:メソッド実行
-
Animal.prototype.eatが呼ばれる -
console.log("食べる")が実行される
という流れになります。
このプロトタイプチェーンの検索はプロパティ参照時(実行中・ランタイム)に走るため、プロトタイプ継承は「動的」と言えます。
プロトタイプは動的に変更可能!?
「何を共有するか」を定義しているprototypeプロパティですが、これは実行時に動的に変更が可能です。
実行時に変更可能!?
そうなんです。クラスベース言語から来た人からしたら、クラスという設計書が実行時、動的に変わるようなものです。ここが一番ビックリしました笑
例を見て理解してみましょう:
//パート1
// 親コンストラクタ (コンストラクタ関数) (クラス定義と似ている)
function Animal() {}
Animal.prototype.eat = function() {
console.log("食べる"); //Animalのeat関数の定義 (最初)
};
//Animalから継承したDogコンストラクタ関数
function Dog() {}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
let pochi = new Dog(); //ポチ = Dogのインスタンス
// --- ここまで普通の継承 --- //
//パート2
pochi.eat(); // Output:「食べる」 ← まだ元のAnimal.prototype.eat
// ★ プロトタイプを後から変更 ★
Animal.prototype.eat = function() {
console.log("むしゃむしゃ食べる"); //アップデート後のeat()
};
pochi.eat(); // Output:「むしゃむしゃ食べる」
// ↑ pochi の中身は変えてないのに、参照先(Animal.prototype)が変わったため挙動が変わる
パート1まではポチ(pochiインスタンス)が今まで通り、プロトタイプチェーンを追って、親のAnimalが持つeat()を呼び:
「食べる」がアウトプットされます。
パート2では、eat()の内容を「むしゃむしゃ食べる」に再定義した後、もう一度ポチがeat()を呼んでいます。
ここで大事なのは実行時に継承関係が決まるということとプロトタイプチェーンの検索はプロパティ参照時に走るということ。
パート2のeat()の再定義したこの時に、prototypeに格納されているプロトタイプオブジェクトの値が新しい再定義した値に置き換えられます。
そしてこの再定義後に、pochi.eat()を呼ぶこのタイミングでプロトタイプチェーンの検索が走るため、親のAnimalのプロトタイプオブジェクト内を参照した時には既に置き換え後のため、新しい再定義後のeat()が呼ばれ:
「むしゃむしゃ食べる」がアウトプットされるということです!
最後に
プロトタイプやプロトタイプベース継承は私みたいな、クラスベース言語出身の方には理解しにくい部分があったりしますが、意外と分解してみるとシンプルな仕組みで成り立っている事がおわかり頂けたかなと思います。
この記事が少しでもプロトタイプ意味わからん!となっている人の手助けになれば嬉しいです!
▼新卒エンジニア研修のご紹介
レアゾン・ホールディングスでは、2025年新卒エンジニア研修にて「個のスキル」と「チーム開発力」の両立を重視した育成に取り組んでいます。 実際の研修の様子や、若手エンジニアの成長ストーリーは以下の記事で詳しくご紹介していますので、ぜひご覧ください!
▼採用情報
レアゾン・ホールディングスは、「世界一の企業へ」というビジョンを掲げ、「新しい"当たり前"を作り続ける」というミッションを推進しています。 現在、エンジニア採用を積極的に行っておりますので、ご興味をお持ちいただけましたら、ぜひ下記リンクからご応募ください。