これでできる! クロスブラウザJavaScript入門

第14回プロトタイプと継承

こんにちは、太田です。前回は総集編的な内容でしたが、今回は一転して基礎編に戻ります。JavaScriptにおける継承の方法とその仕組みについて、今回から数回に分けて基礎的な部分からきっちり押さえていきたいと思います。

JavaScriptとオブジェクト指向

JavaScriptはプロトタイプベースのオブジェクト指向プログラミング言語と言われています。new演算子を用いることで、関数がコンストラクタとして働き、そのコンストラクタが持つプロトタイプオブジェクトのメソッド(プロパティ)を継承した新しいオブジェクトを作ることができます。

なお、オブジェクト指向という概念については今回は触れません。オブジェクト指向という概念を掴みきれていない、自信がないという方は、JavaScriptのprototypeをしっかりと理解してから改めてその概念を学んでみるとすんなりと理解できるかもしれません。さらに、できれば複数の言語で継承などの実装方法を学んでからオブジェクト指向という概念に触れてみると理解を深める助けとなると思います。逆に、いきなり概念から入ることはお勧めできません。

JavaScriptにおけるプロトタイプとnew

まずはごく簡単なプロトタイプとnew演算子のサンプルを見てみましょう。

プロトタイプとnewによるオブジェクトの生成
function Point(x, y){
  this.x = x;
  this.y = y;
}
// var Point = function(){ /*  */ };と書いてもよい
Point.prototype.length = function(){
  return Math.sqrt(this.x * this.x + this.y * this.y);
};
var point1 = new Point(3, 4);
console.log(point1.length()); // 5
console.log(point1);

さらに、次の画像はGoogle Chromeのデベロッパーツール(SafariのWebインスペクタでも同様)のコンソールで上記のコードを実行したアウトプットです(変数point1の中身は展開しています⁠⁠。

図1 Chromeでの実行結果
図1 Chromeでの実行結果

この結果を見ると、point1自身はxとyというプロパティだけ(正確には__proto__という隠しプロパティも持っている)しか持っていないことがわかります。そしてpoint1の__proto__オブジェクトにconstructorプロパティ(function Point)と、lengthメソッドが定義されています。さらにその__proto__オブジェクトにも幾つかのネイティブなメソッドが定義されていることが確認できます。

この__proto__はECMAScriptの仕様では定義されていない、いわゆる独自拡張のプロパティです。元々はFirefoxに由来し、Chrome、Safari、Operaなども__proto__を実装していますが、IEは対応していません。ただ、ECMAScript5ではObject.getPrototypeOfというメソッドが追加され、この__proto__に相当するオブジェクトを取得することが可能になっており、IE 9(プレビュー版)はObject.getPrototypeOfを実装しています。

なお、__proto__自体は非標準ですが、それに相当する概念はECMAScriptに存在しており、__proto__を実装していないブラウザ(主にIE)でも、__proto__に相当する隠しオブジェクトがあると考えて差し支えありません。

さて、上記サンプルコードのnewの辺りの処理をもう少し詳しく解説します。

  • まず関数(Point)を定義し、同時にPointがコンストラクタとなる

  • 関数のprototypeにメソッド(プロパティ)を追加定義し、このメソッドが新しいオブジェクトに継承されるメソッドとなる

  • 関数をnewで呼び出す
    その関数(Point)のprototypeオブジェクトを__proto__オブジェクトとする新しいオブジェクト(point1)が作られ、そのオブジェクト(point1)をthisとして関数Pointが呼び出される

ここで大事なのは、point1.__proto__ === Point.prototype であるという点です。つまり、Point.prototypeに新たなメソッドを追加したり、削除したとき、その影響はpoint1にも及ぶということです。では、Point.prototypeにメソッドを追加してみましょう。

プロトタイプの拡張
Point.prototype.distance = function(point){
  if (!(point instanceof Point)) {
    throw new Error('引数がPointのインスタンスでない');
  }
  var dx = this.x - point.x;
  var dy = this.y - point.y;
  return Math.sqrt(dx * dx + dy * dy);
};
var point2 = new Point(3, -4);
console.log(point1.distance(point2)); // 8
console.log(point1);
図2 Chromeでの実行結果
図2 Chromeでの実行結果

point1がnewで作られた後に、Pointにdistanceというメソッドを追加しました。このdistanceがpoint1からも利用できることが確認できます。

なお上記コードではinstanceofでPointを継承した変数であるか調べています。このようにinstanceofで特定コンストラクタから作られたインスタンスであるか調べることができます。

ただし、こういった制限を設けることはJavaScriptの柔軟さを損なうという見方もあります。例えば、次のようにxとyさえ存在すればよいという方針もあります。

プロトタイプの拡張#2
Point.prototype.distance = function(point){
  var dx = this.x - point.x;
  var dy = this.y - point.y;
  return Math.sqrt(dx * dx + dy * dy);
};
console.log(point1.distance({x:3, y:-4})); // 8

どちらの方針がよいかは一概には言えませんが、複数人・大規模になるほど制限を厳しくすることで全体として動きやすくなることが期待でき、逆に少人数・小規模では制限を設けないことでスピードを生み出すと期待できます。

ネイティブオブジェクトのprototype

__proto__はほぼすべてのオブジェクトに、prototypeはほとんどの関数に存在します。配列などのネイティブオブジェクトも例外ではありません。

配列のプロトタイプ
var array1 = [1, 2]; // new Array(1, 2)相当
console.log(array1.__proto__ === Array.prototype); // true
console.dir(array1);
図3 Chromeでの実行結果
図3 Chromeでの実行結果

配列array1はArrayコンストラクタのインスタンスであり、array1.__proto__はArray.prototypeと一致します。Pointの例から、Array.prototypeにメソッドを追加する(拡張する)とすべての配列にメソッドが追加される(すべての配列に影響がでる)ことがわかると思います。

配列のプロトタイプ
Array.prototype.insert = function(v, i){
  // i番目にvを挿入
  this.splice(i, 0, v);
  return this;
};
console.log(array1.insert(6, 1)); // [1, 6, 2]

このすべての配列に影響がでるというのは便利でもあり、同時に危険でもあります。この影響で既存のコードが動かなくなるという危険性が生まれるので、安易にネイティブオブジェクトのprototypeを拡張することはお勧めできません。もちろん、問題がないことを確認した上で拡張することを否定しませんし、むしろ個人的にはそういった拡張をよく行ないます。

なお、prototype拡張による具体的な問題点として、for inを用いた際にそのプロパティが列挙されてしまうという問題があります。

配列のfor in
for (var p in array1) {
  console.log(p, ':', array1[p]);
}
配列のfor inの結果
0 : 1
1 : 6
2 : 2
insert : function (v, i){
  // i番目にvを挿入
  this.splice(i, 0, v);
  return this;
}

Array.prototypeに定義したinsertメソッドが見えてしまっています。まず、配列に対してfor inは使わないほうがよいというのも覚えておくべきことですが、ネイティブオブジェクトのprototype拡張はどこかでfor inが使われているんじゃないかと注意しなければいけなくなるのでお勧めできません。

なお、これは先程のpoint1にfor inを用いた時も同様です。

point1のfor in
for (var p in point1) {
  console.log(p, ':', point1[p]);
}
point1のfor inの結果
x : 3
y : 4
length : function (){
  return Math.sqrt(this.x * this.x + this.y * this.y);
}
distance : function (point){
  var dx = this.x - point.x;
  var dy = this.y - point.y;
  return Math.sqrt(dx * dx + dy * dy);
}

こういったオブジェクト自身のプロパティか、__proto__のプロパティか区別がつかない問題に対して、Object.prototype.hasOwnPropertyというメソッドが用意されています。Object.prototypeということは、Objectを継承したオブジェクト(すなわちほぼすべてのオブジェクト)で利用可能なメソッドです。

for inとhasOwnProperty
for (var p in array1) {
  if (array1.hasOwnProperty(p)) {
    console.log(p, ':', array1[p]);
  }
}
for (var p in point1) {
  if (point1.hasOwnProperty(p)) {
    console.log(p, ':', point1[p]);
  }
}
for inとhasOwnPropertyの結果
0 : 1
1 : 6
2 : 2

x : 3
y : 4

ただ、hasOwnPropertyの使い方は覚えておくべきですが、なるべくhasOwnPropertyを使わないで済む実装にしたほうがよいかもしれません。

また、for inを通常は用いることがないであろうString、Number、Functionなどのネイティブオブジェクトであれば、prototypeを拡張しても問題が出にくいため、積極的に拡張してもよいでしょう。

まとめ

今回はプロトタイプベースの継承と、prototypeと__proto__の関係、hasOwnPropertyの使い方などを解説しました。次回はporototypeの継承をより詳しく見ていきます。

おすすめ記事

記事・ニュース一覧