ということで、インスタンス共通のメソッドを定義するには、インスタンスに対してではなく、(少なくとも)コンストラクタによって定義する必要があることが分かった。しかし、「少なくとも」とただし書きが付いたことからも予想できるように、コンストラクタでメソッドを追加するのは好ましいことではない。
というのも、クラス(コンストラクタ)はインスタンスを生成する都度、それぞれのインスタンスのためにメモリを確保する。リスト3の例であれば、Animalクラスに属するname、sex、toStringという3つのメンバを設定するわけである。
ところが、toString「メソッド」については、すべてのインスタンスでそれぞれまったく同じ値を設定しているにすぎない。ここでは、Animalクラスでメソッドが1つ登録されているだけなので、さほど問題にはならないかもしれないが、メソッドが10も20も登録されているクラスだとしたらどうだろう。インスタンスごとに10も20ものメソッドを「無駄に」コピーしなければならなくなってしまう。これは当然、好ましい挙動ではない。
そこで登場するのが、「プロトタイプ」という考え方であるのだ。
JavaScriptにおけるすべてのオブジェクトは「prototype」という名前のプロパティを公開している。prototypeプロパティは、デフォルトで何らプロパティを持たない空のオブジェクト(プロトタイプ・オブジェクト)を参照しているが、適宜、必要に応じてメンバを追加することが可能である。
そして、ここで追加されたメンバは、そのままインスタンス化された先のオブジェクトに引き継がれる――もっといえば、prototypeプロパティに対して追加されたメンバは、そのクラス(コンストラクタ)を基に生成されたすべてのインスタンスから利用できるというわけだ。やや難しげないい方をするならば、
「関数オブジェクトをインスタンス化した場合、インスタンスは基となる関数オブジェクトに属するprototypeオブジェクトに対して、暗黙的な参照を持つことになる」
といい換えてもよいかもしれない。
そろそろ分かりにくくなってきたという方のために、ここで具体的なコードを見てみることにしよう。以下は、リスト4でコンストラクタ経由により追加したtoStringメソッドを、prototypeオブジェクト経由で追加するように書き換えた例である。
var Animal = function(name, sex) {
this.name = name;
this.sex = sex;
}
Animal.prototype.toString = function() {
window.alert(this.name + " " + this.sex);
};
var anim = new Animal("トクジロウ", "オス");
anim.toString(); // 「トクジロウ オス」
このようにAnimalクラスから生成されたインスタンス(ここではanim)は、Animal.prototypeプロパティによって参照されるオブジェクトを暗黙的に参照するようになる。
「プロトタイプ・ベースのオブジェクト指向」というと、(特に「クラス・ベースのオブジェクト指向」に慣れた諸氏にとっては)なじみにくい概念にも感じられるかもしれない。しかし、要は「単にクラスという抽象化された設計図が存在しない」のがJavaScriptの世界なのである。
JavaScriptの世界で存在するのは、常に実体化されたオブジェクトであり、新しいオブジェクトを作成するにも(クラスではなく)オブジェクトをベースにしているというだけだ。そして、新しいオブジェクトを作成するための原型を表すのが、それぞれのオブジェクトに属するプロトタイプ・オブジェクト(prototypeプロパティ)なのである。クラスという抽象的な概念を間に差し挟まない分、より直感的な世界に思えてこないだろうか。
■プロトタイプ・オブジェクトを介する利点
プロトタイプの概念が理解できたところで、話を戻そう。そもそも、コンストラクタでメソッドを追加するのは好ましくないという話から、プロトタイプが登場したわけであるが、プロトタイプを介することで何が変わるのだろうか。ポイントは2点だ。
(1)必要なメモリ量を節約できる
繰り返しであるが、プロトタイプ・オブジェクトの内容はそれぞれのインスタンスから暗黙的に参照されるものだ。具体的には、アプリケーションからオブジェクトのメンバを参照する場合、内部的には以下のような順序で検索が行われている。
最初にインスタンス側(ここではanim)にtoStringという名前のメンバが存在しないかを検索する。しかし、ここではインスタンス自身がtoStringというメンバを持たないので、暗黙的な参照をたどってプロトタイプ・オブジェクトを取得し、そのtoStringメソッドを取得するのである。
つまり、インスタンス化に際して、プロトタイプ・オブジェクト配下のメンバが個々のオブジェクトにコピーされるわけではないので、それぞれのオブジェクトで消費するメモリを節約できるというわけだ。
もっとも、ここでふと疑問がわき上がってくる。すべてのインスタンスが基となるオブジェクト(プロトタイプ)に対して暗黙的な参照を持つとすると、プロトタイプで提供されるメンバに対する変更は(いわゆるクラス変数やインスタンス変数のように)すべてのインスタンスで共有されてしまうのだろうか。
具体的なコードで確認してみよう。
var Animal = function() {};
Animal.prototype.name = "サチ";
var a1 = new Animal();
var a2 = new Animal();
window.alert(a1.name + "|" + a2.name); // 「サチ|サチ」
a1.name = "トクジロウ";
window.alert(a1.name + "|" + a2.name); // 「トクジロウ|サチ」
nameプロパティはプロトタイプ・オブジェクト(Animal.prototype)で宣言されたプロパティであるが、結果を見ても分かるように、あるインスタンス(ここではa1)に対して施された変更は異なるインスタンス(ここではa2)には反映されていないことが確認できる。
これはどうしたことだろう。結論からいってしまうと、プロトタイプに対する暗黙的な参照が利用されるのは、読み込みの場合だけであるのだ。書き込みはあくまでインスタンス自身に対して行われるため、プロトタイプに対して変更が影響することはない。
内部的な挙動については、以下の図を見てみるとよい。
初期状態では、インスタンスa1、a2ともにプロトタイプ・オブジェクトを参照しているわけであるが、a1.nameプロパティに対して新たな値が設定されたところで、インスタンスa1の側ではプロトタイプ・オブジェクトのnameプロパティを参照する必要がなくなる。よって、インスタンス側で用意されているnameプロパティが取得されるというわけだ*2。もちろん、この時点でインスタンスa2はnameプロパティを持たないので、そのまま暗黙の参照をたどってプロトタイプ・オブジェクトのnameプロパティを参照することになる。
*2 この状態を、インスタンスa1のnameプロパティがプロトタイプ・オブジェクトのnameプロパティを「隠ぺいする」といういい方をする場合もある。
ちなみに、この考え方はdelete演算子の場合でも同様である。delete演算子は、オペランドとして指定された配列要素やプロパティ/メソッドを削除するための演算子だ。
例えば、先ほどのリスト7の末尾に以下のようなコードを追加してみよう。
delete a1.name; // インスタンスa1のnameプロパティを削除
delete a2.name; // インスタンスa2のnameプロパティを削除
window.alert(a1.name + "|" + a2.name); // 「サチ|サチ」
インスタンスa1には独自の(プロトタイプ参照によって取得したのではない)nameプロパティが存在するので、delete演算子はこの値を削除する。一方、インスタンスa2には独自のプロパティは存在しないので、delete演算子は何も行わないというわけだ(暗黙的な参照をたどって、プロトタイプ・オブジェクトが操作されることはない*3)。結果、それぞれのオブジェクトの状態は以下の図のようになる。
インスタンスa1の側では、独自のプロパティが存在しなくなったので、再び暗黙的な参照をたどって、プロトタイプ・オブジェクトの値が有効になるというわけだ。繰り返しではあるが、インスタンス側でのメンバの追加/削除が、プロトタイプ・オブジェクトに対して影響を及ぼすことはないのである。
*3 もちろん、「delete Animal.prototype.name」のように記述すれば、プロトタイプ・オブジェクトのメンバを削除することも可能だ。もっとも、この場合は当然のことながら、このプロトタイプを引き継いでいるすべてのインスタンスに影響を及ぼすので注意すること。
[参考]undefined値によるプロトタイプ・オブジェクトのメンバの無効化
delete演算子ではなく、インスタンス側のプロパティにundefined(未定義)値を設定することで、疑似的にインスタンス側で(ほかのインスタンスに影響を及ぼすことなく)プロトタイプ・オブジェクトが提供するメンバを無効化することも可能だ。
ただし、delete演算子がプロパティそのものを削除するのに対して、undefinedキーワードはあくまでプロパティそのものの存在はそのままに、値を未定義に設定するだけである点に注意してほしい(厳密にはこの場合、インスタンスに対して値がundefinedであるnameプロパティを追加している)。つまり、for…inループでオブジェクト内のメンバを列挙した場合などには、undefinedキーワードで未定義となったプロパティは依然として表示されることになる。
var Animal = function() {};
Animal.prototype.name = "サチ";
var a1 = new Animal();
a1.name = undefined;
for (key in a1) {
window.alert(key + ":" + a1[key]);
} // 「name:undefined」
Copyright© Digital Advantage Corp. All Rights Reserved.