社内勉強会の資料
概要
JSのプロトタイプについての理解と、その周辺の登場人物について理解する回。
もう過去何億人がこの話題について人に説明したかわからない程にはjsの基本のキですが、自分なりの解釈と自分なりの説明を書いてみます。
ここでの説明が理解できれば、MDNのプロトタイプに関する記述はすんなり読めるようになります。
メインの対象者としては、**「jsは普通に書いてるしプロトタイプもなんとなく知ってるけど、説明しろって言われるとちょっと言葉に詰まる」**という方です。
登場人物(tl;dr)
- コンストラクタ
-
new
演算しと共に使用される、オブジェクトを生成するための処理を書いた関数。
-
-
.prototype
- コンストラクタオブジェクトが持つ、コンストラクタ(自身)のプロトタイプへの参照を持つプロパティ。
-
[[prototype]]
- ECMAScriptの仕様において、インスタンスのプロトタイプへの参照を持つ内部プロパティ。
- privateなプロパティなので、jsコード上で参照することは出来ない。
-
.__proto__
- ECMAScriptには元々定義されていなかったが、ChromeやFireFoxなどには実装されている。
- インスタンスに存在する、コンストラクタのプロトタイプへの参照を持つプロパティ。
-
[[prototype]]
と同じだが、こちらはjsコード上からアクセス可能。 - ES6から初めて仕様として定義されたが、同時に非推奨となっている。
-
Object.getPrototypeOf()
- オブジェクトのプロトタイプを取得する関数。
- ES6から正式に仕様に存在し、
__proto__
に代わって推奨されるプロトタイプへのアクセス方法。
-
Object.setPrototypeOf()
- オブジェクトのプロトタイプを代入する関数。
- ES6から正式に仕様に存在し、
__proto__
に代わって推奨されるプロトタイプへのアクセス方法。
-
new
- コンストラクタから新しいオブジェクトを作る演算子。
-
class
- プロトタイプベース継承のシンタックスシュガー。
- クラスベースのオブジェクト指向におけるクラスとは本質的には別物。
流れ
説明の流れとしては、
- プロトタイプベースのオブジェクト指向の概念の理解
- プロトタイプをふんわりとコードで理解
- プロトタイプを実際のコードで理解
という感じで進めます。
プロトタイプベースのオブジェクト指向って?
オブジェクト指向 - wiki
The Early History Of Smalltalk
1, EverythingIsAnObject.
2, Objects communicate by sending and receiving messages (in terms of objects).
3, Objects have their own memory (in terms of objects).
4, Every object is an instance of a class (which must be an object).
5, The class holds the shared behavior for its instances (in the form of objects in a program list).
6, To eval a program list, control is passed to the first object and the remainder is treated as its message.
— Alan Kay
1, すべてはオブジェクトである。
2, オブジェクトはメッセージの受け答えによってコミュニケーションする。
3, オブジェクトは自身のメモリーを持つ。
4, どのオブジェクトもクラスのインスタンスであり、クラスもまたオブジェクトである。
5, クラスはその全インスタンスの為の共有動作を持つ。インスタンスはプログラムにおけるオブジェクトの形態である。
6, プログラム実行時は、制御は最初のオブジェクトに渡され、残りはそのメッセージとして扱われる。
全てがクラスベースであるという前提は時に物事を複雑化してしまう。
例えばメソッドが必ず何らかのクラスに所属するという前提は強すぎる場合がある。クラスベースでは委譲や代理(プロキシ)によって動作にバリエーションを与えるが、初めからバリエーションをもったインスタンスを作成できればそのような機構は必要ない。
またインスタンス変数とメソッドの違いとは何か、という問題もある。C++やJavaのpublicなメンバ変数(フィールド)などを別にすれば、インスタンス変数とアクセサメソッドはほとんど等価の概念である。
クラスが存在しない世界ではテンプレート処理によるインスタンス化ができないため、新しいオブジェクトは完全に空の状態か、または他のオブジェクトの複製(クローン)によって作成される。プロトタイプベースでの継承は一般にこのクローンによる特性の引き継ぎを指す。
プロトタイプベースのオブジェクト指向とは、クラスベースのオブジェクト指向とは違ったアプローチでオブジェクト指向を実装したもの。
クラスベースのオブジェクト指向は、クラスという共通の挙動を定義したオブジェクトをテンプレートとして、その挙動を共通に持つ新しいオブジェクトを複数作ることができる。
それに対して、プロトタイプベースのオブジェクト指向にはクラスが存在せず、プロトタイプと呼ばれる共通の挙動を格納したオブジェクトへの参照を持つことで、オブジェクトは共通の挙動を得る。
(この辺は言語によって細かい思想が違うので、一概に同じ言葉で説明出来ない部分もあると思うけど、概念的にはこう)
つまり、どちらにせよ
1, すべてはオブジェクトである。
2, オブジェクトはメッセージの受け答えによってコミュニケーションする。
3, オブジェクトは自身のメモリーを持つ。
これらの思想は受け継いだうえで、オブジェクト間で共通の挙動を、どう定義するかの思想が異なっている。
つまり、プロトタイプベースのオブジェクト指向とは、「複数のオブジェクトに共通の挙動を持たせる方法として、クラスを使用せず、プロトタイプを使用するオブジェクト指向」のこと。
改めてプロトタイプ
したがって、プロトタイプとは、クラスとは違う方法で、複数のオブジェクトに共通の挙動を与える仕組みのこと。
主な意味 原型、模範、原形
プロトタイプと聞くと、「何かを作る際にとりあえず作るもの」というようなイメージが日本語的には強いですが、プロトタイプベースのオブジェクト指向における「プロトタイプ」は「原型」という意味で捉える方がしっくり来ます。「ひな形」「テンプレート」とも言えるでしょう。
オブジェクト指向において、オブジェクトに対して共通の振る舞いを持たせることを「継承」と呼びますが、これはプロトタイプベースであっても同じです。
jsにおけるプロトタイプの実装
ここまではプロトタイプベースのオブジェクト指向の概念でした。
ここからは、具体的にjsにおけるプロトタイプベースのオブジェクト指向の実装を見ていきます。
どうやって、プロトタイプが複数のオブジェクトに共通の挙動を持たせているか、という話です。
複数のオブジェクトに共通の挙動を持たせる方法を雰囲気で理解
さて、jsにはクラスが存在しませんが、複数のオブジェクトに共通の挙動を持たせたい場面はプログラミングをしていると頻出します。
ここでは、わかりやすいようにプロトタイプの実装をプロトタイプを使わずにふんわり再現してみようと思います。
ここのコードは雰囲気なので実際には意図した挙動にならないものを含んでいます。
雰囲気を感じ取ってください()
例えば、5つの異なるオブジェクトが存在するとして、
const obj1 = { hoge: "HOGE" };
const obj2 = { piyo: "PIYO" };
const obj3 = { num: 123 };
const obj4 = { fuga: "fuga" };
const obj5 = { f: () => {} };
このオブジェクトたちを、文字列に変えたいとしましょう。
ログに吐きたいんです。名前は、toString
とでもしましょう。
全部に同じ処理を持たせようと思ったら、
const toString = () => {
// 文字列化する処理
};
obj1.toString = toString;
obj2.toString = toString;
obj3.toString = toString;
obj4.toString = toString;
obj5.toString = toString;
こんな感じでどうでしょう。
あれ、オブジェクトをJSON形式にする、toJSON
も入れたいですか?
一個ずつ代入しても良いですが、数が増えるとツラそうなので、一つのオブジェクトにして、全部にそれを渡しますか。
const commonFunctons = {
toString: () => { /* 文字列化する処理 */ },
toJSON: () => { /* JSON形式に変換する処理 */ },
};
// 変更されたくないので、触んないでね!という意味で、両はしをアンダーバー二つとかで囲っときましょう
obj1.__commonFunctions__ = commonFunctions;
obj2.__commonFunctions__ = commonFunctions;
obj3.__commonFunctions__ = commonFunctions;
obj4.__commonFunctions__ = commonFunctions;
obj5.__commonFunctions__ = commonFunctions;
こうすれば、複数のオブジェクトに同じ挙動を持たせることが出来そうですね。
プロトタイプの基本的な方針は、こういう感じです。
ここで言うcommonFunctions
がプロトタイプと呼ばれます。
共通の挙動をするオブジェクトたちの「原型」です。
ちなみにこれ、呼び出す時に
obj5.__commonFunctions__.toString();
こうなりますが、うーん、
obj5.toString();
こう呼び出したいですよね。
__commonFunctions__
は、もうルールとして、省略できるということにしましょう。
この、呼び出す際にプロトタイプの参照を省略して良い仕組みを、プロトタイプチェーンと呼びます。
__commonFunctions__
は、実際のjsで言う__proto__
に相当します。
__proto__
は、そのインスタンスオブジェクトが参照しているプロトタイプへの参照です。
上の例では、commonFunctions
オブジェクトへの参照ですね。
__proto__
はブラウザなどが勝手に実装しているプロパティでしたが、ES6から初めて仕様として定義されました。
が、同時に非推奨となっています。
(推奨の方法としてgetPrototypeOf
やsetPrototypeOf
が正式に定義されましたが、このページでは解説のしやすさと理解しやすさから__proto__
を使います。)
実際のprototypeの仕様
雰囲気を掴んだところで、本当のjsのprototypeの仕様を見ていきます。
ちなみに、関数もオブジェクト
jsはオブジェクト指向なので、だいたいのものがオブジェクトです。
さらに、jsでは関数が第1級オブジェクトなので、関数もオブジェクトです。
つまり、関数自体もプロパティを持ちます。
これを認識しておかないとプロトタイプの話においては結構混乱します。
function doSomething() {
console.log("something");
}
doSomething.hoge = "hoge"; // 問題なく通る
プロトタイプの確認
さて、わかりやすいように、具体的なオブジェクトを使いましょう。
const date1 = new Date();
const date2 = new Date();
const date3 = new Date();
Date
を使って新たにオブジェクトを3つ作りました。
先ほどの例で言えば、この3つのdateオブジェクトは、共通の挙動を持った一つのオブジェクト(何度も言いますが、これをプロトタイプと呼びます)(上の例で言うとcommonFunctons
オブジェクト)を参照しているはずです。
それはChromeなどのブラウザであれば、
date1.__proto__
date2.__proto__
date3.__proto__
で確認できます。
これは上一部しか表示してませんが、Dateオブジェクトでよく使う関数がオブジェクトの中に入っているのがわかりますよね。上の例で言うcommonFunctions
そのものの参照が取得出来ているのがわかると思います。
さらに、上の例では、全てのオブジェクトに、同じcommonFunctions
オブジェクトの参照を入れてましたよね?
なので、
date1.__proto__ === date2.__proto__ // true
date2.__proto__ === date3.__proto__ // true
date1.__proto__ === date3.__proto__ // true
こうなります。つまり、どのDateオブジェクトも、全て全く同じプロトタイプへの参照(厳密等価演算子===
による比較で判断できる)を保持しています。
プロトタイプ本体はどこに?
では、Dateオブジェクトがみんな参照している一つのプロトタイプは、実際どこにあるのでしょうか。
それは、Date
関数=Date
コンストラクタが所持しています。
コンストラクタが所持しているプロトタイプは、.prototype
でアクセス出来ます。
Date.prototype
によって得られたオブジェクトは、date1.__proto__
で得られたオブジェクトと全く同じです。
// 厳密等価演算子でtrueが返る
Date.prototype === date1.__proto__ // true
つまりjsでは、オブジェクトの共通の挙動として、自身のコンストラクタのプロトタイプを参照します。
プロトタイプチェーン
さて、プロトタイプは発見できたので、プロトタイプ上にある関数を実行してみましょう。
date1.getFullYear(); // 2019
2019が返ってきました。
でもこれ、よく考えると変ですよね?
getFullYear
を所持しているのはdate1
ではなく、Dateオブジェクトのプロトタイプ(date1.__proto__
であり、Date.prototype
)のはずです。
つまり、
date1.__proto__.getFullYear();
って呼び出すべきじゃない?
そこで登場するのがjsのプロトタイプチェーンという仕組みです。
jsでは、とあるオブジェクトに対して存在しないプロパティが参照された場合、そのオブジェクトのプロトタイプに同名のプロパティがあるかどうか探しに行きます。
そして見つかれば、そのプロパティを返します。
つまりdate1
で言えば、date1.getFullYear
が呼び出された時点で、まずdate1
オブジェクトの中にgetFullYear
プロパティが存在するかどうかをチェックします。もちろん、代入していないのでありません。
次に、date1
の__proto__
の中を探します。ここにはありました!ということで、Date.prototype.getFullYear
を返します。
この文字面、どこかで見覚えありませんか?
そう、MDNで調べるとよく出てくる記述です。
これこれ!
じゃあ、逆に言えば、date1
オブジェクトにgetFullYear
プロパティがあったら、そっち返すっていうことだよね?
const date1 = new Date();
date1.getFullYear = 123; // 問題なく通る
結果は...
date1.getFullYear(); // 123
123が返ってきました。
先ほど書いたプロトタイプチェーンの仕様通りですね。
しかし、ここまでは「チェーン」って言うほどチェーンしてません。
ということでここから、継承の話に移ります。
継承
jsで言う継承とは、プロトタイプの繋がりを指します。
プロトタイプチェーンは、そのオブジェクトのプロトタイプに該当するプロパティが存在しなかった場合、プロトタイプのプロトタイプに探しに行きます。
そしてそこにも無い場合はプロトタイプのプロトタイプのプロトタイプに...というように、プロトタイプをどんどん遡って行き、最終的にプロトタイプがnullを返すまでこのチェーンは続きます。
例えば、date1のプロトタイプをたどると、以下のようになります。
date1.__proto__ === Date.prototype // true
date1.__proto__.__proto__ === Object.prototype // true
date1.__proto__.__proto__.__proto__ === null // true
つまりここでは、Object > Date > date1
という継承関係になっていると言えます。
クラスベースで言うなら、
class Object {
}
class Date extends Object {
}
Date date1 = new Date();
という感じですね。
複数回のプロトタイプチェーン
先ほどのdate1
オブジェクトからは、hasOwnProperty
関数が実行できます。
date1.hoge = "HOGE";
date1.hasOwnProperty("hoge"); // true
この関数は「継承由来ではない特定のプロパティを持っているかどうか調べる関数」ですが、下記のように、Date.prototype
にはhasOwnProperty
関数は存在していません。
// `Date.prototype`に、`"hasOwnProperty"`という独自のプロパティが存在するかどうかを確認し、`false`が返っています(つまり持っていない)。
Date.prototype.hasOwnProperty("hasOwnProperty") // false
ではこのhasOwnProperty
は誰が持っているかというと、Object.prototype.hasOwnProperty
が持っています。
Object.prototype.hasOwnProperty("hasOwnProperty") // true
つまり、date1
に対してhasOwnProperty
プロパティが呼び出された場合、Date.prototype
を探して発見できず、さらにそのプロトタイプ(Object.prototype
)を探してそこで発見し、Object.prototype.hasOwnProperty
を返します。
プロトタイプとしてnull
が返ってくるまで見つからなかった場合、jsは返り値としてundefined
を返します。
プロトタイプチェーンの終端の実験
プロトタイプチェーンの終端がnull
だということは、自分でnull
を代入したらプロトタイプチェーンは止まるんでしょうか、やってみましょう。
date1.__proto__.__proto__ = null
これで、本来であればObject.prototype
を参照しているはずのdate1.__proto__.__proto__
がnull
になります。この状態で、Object.prototype
に存在するプロパティを呼び出すと...
date1.hasOwnProperty("hasOwnProperty")
Uncaught TypeError: date1.hasOwnProperty is not a function
at <anonymous>:1:7
関数じゃないって🤔
じゃあ...
date1.hasOwnProperty // undefined
undefined
でした。
というように、自身に存在していないプロパティでも、プロトタイプを再帰的に遡って該当するプロパティを引っ張ってくる仕組みが、プロトタイプチェーンです。
そして、このプロトタイプチェーンで繋がる関係を継承と呼びます。
コンストラクタ
さて、先ほどから普通に使用していますが、コンストラクタとはなんでしょうか。
jsにはクラスは存在しませんが、コンストラクタは存在します。
jsにはオブジェクトを作る方法が2種類存在し、それが純粋なオブジェクト生成(オブジェクトリテラルや、new Object()
など)と、new
演算子を使用したオブジェクト生成です。
後者のnew
を使う方法は、関数と共に記述します。JSにおいて、この**new
と共に使用される関数のことを、コンストラクタと呼びます。**
// `Date`がコンストラクタ
const date = new Date();
コンストラクタとなる関数の条件は、アロー関数ではないこと、のみです。
つまり、先ほどのdoSomething
という関数もnew
をつければコンストラクタになります。
const instance = new doSomething(); // 問題なく通る
コンストラクタ関数はアッパーキャメルケース(UpperCamelCase)で書く慣習がありますが、上記のように文法レベルでの制約はありません。
クラスベースのオブジェクト指向に慣れているとnew Date()
を見たときに「Dateクラスのインスタンスを作っているな」と見えるのですが、これはプロトタイプベースな目線で見ると、「Date
関数をコンストラクタとしてインスタンスオブジェクトを作っている」と言えるでしょう。
(ただ、ES6でclass構文が出来てからは、ここで言うコンストラクタをクラスと呼ぶようにもなりました。ので、実際のところ「Dateクラスのインスタンス」と言うのも正しいと思います。実際自分で書く際はclass構文の方が楽だし読みやすい...ただシンタックスシュガーであることに変わりはないので、実際に行われているのはプロトタイプを用いたオブジェクト生成です)
new
のお仕事
では、通常のオブジェクト作成とnew
によるオブジェクト作成は何が違うんでしょうか。
- 空の JavaScript オブジェクトを生成する
- このオブジェクト (のコンストラクター) を他のオブジェクトへリンクする
- ステップ 1 で新しく生成されたオブジェクトを this コンテキストとして渡す
- 関数が自分自身を返さない場合は this を返す
注目すべきは
- このオブジェクト (のコンストラクター) を他のオブジェクトへリンクする
です。
ここだけ曖昧な表現になっていてよくわかりませんが、具体的に意識するべきなのは以下の2点です。
- 新規作成したオブジェクトの
constructor
プロパティにコンストラクタ関数自体の参照を代入する。 - 新規作成したオブジェクトの
[[prototype]]
にコンストラクタ関数のプロトタイプの参照を代入する。
jsのES5までの仕様では、インスタンスオブジェクトのプロトタイプへの直接の書き込み権限がプログラマにありません([[prototype]]
がプライベートであり、かつ__proto__
は仕様に存在しなかったため)。
つまり、new
を使ってのみ、プロトタイプ継承を用いたオブジェクト生成をすることができます。
jsにおけるnew
とは、継承されたオブジェクトを作成するベストな方法であると言えるでしょう。
class構文とプロトタイプ継承
TODO: 同じものを書く場合の比較とかを頑張って書く
オートボクシング
TODO: プリミティブ値とプロトタイプの話を頑張って書く
プロトタイプ汚染
TODO: 頑張って書く
おしまい
ということで、現状まだ書いてないことがいっぱいあるのですが、プロトタイプの最低限を書いたので一旦終わりとします。
続きはまたどこかで頑張って書きます。