uhyohyo.net

JavaScript初級者から中級者になろう

九章第三回 継承とは

このページの最終更新日:

今回は継承というものについて解説します。継承とはざっくり言うと、あるオブジェクトの特徴をもったままで、さらに拡張したオブジェクトをつくるというものです。

ただ、先に少し、今回必要な予備知識について解説します。

apply, call

applyおよびcallは、関数が持つメソッドです。どちらも関数を呼び出すことができるメソッドです。

まずapplyから見ましょう。関数を呼び出すだけなら普通に関数()でいいような気がしますが、applyには2つの特徴があります。1つは関数内でのthisの値を指定できること、もう1つは引数列を配列で渡すことができることです。

では、applyのサンプルを見ましょう。


function test(x, y){
  console.log("thisは", this, "です!!!");
  console.log(x, y);
}

test.apply({hoge: 3}, ["foo", "bar"]);

実行してみると、applyメソッドによりtestが呼び出され、その際確かにthisは{hoge: 3}になり、引数x, yはそれぞれ"foo""bar"になっていることが分かります。

このthisの値を指定できる機能が今回紹介する継承で役に立ちます。また、引数列を配列で指定できるのは、配列の長さによって指定する引数の個数を変えることができるというメリットがあります。今回渡した配列は["foo", "bar"]という長さ2の配列なので、引数2つでtestが呼び出されました。(ただし、後者のメリットについてはES2015のspread operatorにお株を奪われてしまいました。これについてはまた今度紹介します。)

callは、thisの値を指定できるという点は同じですが、こちらは配列ではなく引数を1つずつ指定します。つまり、上のサンプルのapplyと同様のことをするには、test.call({hoge: 3}, "foo", "bar");とします。

ちなみに、applyはcallは関数が持つメソッドであると言いましたが、関数というのは前にも述べたように関数オブジェクトであり、実はそれはFunctionのインスタンスです。つまり、applyやcallはFunction.prototypeのメソッドとして存在しています。

arguments

もう1つ紹介したいのがargumentsです。これは関数の中で使える特殊な変数で、関数呼び出し時に渡された引数が一覧で入っている配列っぽいオブジェクトです。配列っぽいというのはDOMで出てきたNodeListなどと同様に、lengthプロパティに長さが入っていてarguments[0]のように要素にアクセスできるけど配列ではないということです。このような配列っぽいオブジェクトは結局配列と何が違うのかというと、配列には存在するさまざまなメソッドが存在しないという点です。なお、お察しとは思いますが配列はArrayのインスタンスなので、配列に存在するメソッドというのはArray.prototypeに用意されているメソッドを指します。

argumentsを使うことで、引数の長さによって動作を変えるようなメソッドを作ることができます。例えば、次の関数は与えられた引数を何個でも全て足して結果を返すという関数です。


function sum(){
  var result = 0;
  for(var i = 0; i < arguments.length; i++){
    result += arguments[i];
  }
  return result;
}

console.log(sum(1,2,3,4,5)); // 15

なお、この例ではfunction sum()となっているのでsumは引数を取らないように見えます。しかし、それは引数が渡されてもそれが入る変数がないということであり、引数を取ること自体はできます。そして、引数が入る変数が無くてもargumentsにはちゃんと入っています。

継承とは

では、本題に入りましょう。今回もRPGの例で考えてます。「敵」というオブジェクトを作ることにしましょう。


function Teki(n){
  this.name = n;
}
//現れる
Teki.prototype.appear = function(){
  console.log(this.name + "があらわれた!");
};

var zako1 = new Teki("ザコ敵");	//敵をつくる
zako1.appear();	//現れる

これは、前回同様のコードです。

さて、ひとくちに「敵」といっても、「スライム」とか「魔法使い」とか「ドラゴン」とかいろいろな種類の敵がいます。種類が違うということは、コンストラクタが違うということですね。

では、「敵」と「魔法使い」や「ドラゴン」の関係はどうでしょう。明らかに、「魔法使い」や「ドラゴン」は「敵」の一種です。このことは次のように影響してきます。「魔法使い」や「ドラゴン」はそれぞれ攻撃方法は違うでしょうが、例えばHPがあるという点では共通しています。つまり、HPは「敵」の特徴で、それは「魔法使い」や「ドラゴン」にも当然あります。そして、攻撃方法は「魔法使い」や「ドラゴン」独特の特徴なのです。

ここで、「魔法使い」や「ドラゴン」は、 それであると同時に「敵」でもあるのです。言い方を変えると、「魔法使い」や「ドラゴン」は「敵」にさらに追加の特徴を加えてできるものなのです。このことを指して、「魔法使い」や「ドラゴン」は「敵」を継承しているといいます。

継承のやり方

さて、継承の概念をJavaScriptで表現するにはどうやればいいのでしょうか。まず最初にサンプルを見てみましょう。「敵」を作り、さらにそれを継承する「ドラゴン」を作ります。


//敵を作る
function Teki(){
  this.hp = 100;
}
Teki.prototype.die = function(){
  console.log("やった! 敵を倒した!");
};

//ドラゴンを作る
function Dragon(){
  Teki.apply(this,arguments);
}
Dragon.prototype = new Teki;

//攻撃してHPを教えてもらう
Dragon.prototype.attack = function(){
  console.log("ドラゴンの攻撃! ドラゴンのHPは"+this.hp+"だ!");
};

var boss = new Dragon();	//インスタンスをつくる
boss.attack();	//攻撃してもらう
boss.die();     //死んでもらう

※注: 今回紹介している継承の方法にはまだ多少の問題があります。そのあたりの話は、十一章第七回を参照してください。

8行目と10行目が継承と関係のある部分です。とりあえずこのサンプルの動作を確認してみましょう。

変数bossに「ドラゴン」のインスタンスを作成して代入しています。そして、そのattackメソッドとdieメソッドを順にを呼び出しています。attackメソッドでは、自らのプロパティhpを表示しています(なぜ攻撃するとHPが分かるのかは気にしないことにしましょう)。dieメソッドを呼び出すと敵を倒したメッセージが出ます。

ここで、bossはDragonのインスタンスのはずなのに、Tekiのメソッドであるdieを呼び出すことができています。これは、DragonがTekiを継承している証拠です。また、attackメソッドの中で参照しているhpプロパティはTekiのコンストラクタで初期化されています。これは、new Dragon()としてDragonのインスタンスを作るさなかでTekiのコンストラクタも呼ばれていることを示唆しています。

さて、それでは継承の仕組みを説明していきます。まず、順番が逆になりますが、Dragon.prototype = new Teki;の部分を説明します。

なお、new Tekiという書き方ですが、これはnewの場合は関数呼び出しの()を省略できることを利用しています。意味はnew Teki()とするのと変わりません。

ここではなぜかTekiのインスタンスが作られており、さらにそれをDragon.prototypeに代入しています。

コンストラクタにはprototypeプロパティが存在すると述べましたが、このようにprototypeに代入することによってprototypeを好きなオブジェクトに差し替えることができます。そして今回はそれにさらにattackメソッドを追加しています。

こうすると何が起こるでしょうか。prototypeは、インスタンスがプロパティなどを持っていないときにそこから探すというものでした。そして、今回はその探す先がTekiのインスタンスです。つまり、Tekiのインスタンスが持っているプロパティ・メソッドはDragonのインスタンスも持っているという状況が実現しました。

特に、Tekiのインスタンスが持っていないプロパティを探した場合は何が起こるでしょうか。そう、Tekiのprototypeから探します。これにより、例えばboss.dieを探したときに最終的にTeki.prototype.dieにたどり着き、dieメソッドを使うことができたのです。

このように、prototypeをだんだんさかのぼって探していくことは、プロトタイプチェーンと呼ばれます。

プロトタイプチェーンの終端

やや余談ですが、ここでオブジェクトについて振り返ります。

オブジェクトを作るとき、new Object() という書き方をすることができました。今思えば、これはObjectのインスタンスを作っているということだったのです。つまり、オブジェクトは、Objectのインスタンスであるということです。

では、TekiとかDragonとか、new Object()ではない(Objectのインスタンスではない)ものはどうなのでしょう。 実は、TekiやDragonは密かにObjectを継承しているのです。つまり、継承先のオブジェクトのインスタンスは同時に継承元のインスタンスとしての役割も果たすから、すべてのオブジェクトはObjectのインスタンスであるということができます(注:ES5以降はこれにあてはまらない特殊なオブジェクトを作ることができますが、ここでは触れません。)

さて、すべてのオブジェクトはObjectインスタンスであるとはどういうことでしょう。もう一度振り返ると、あるインスタンスのプロパティを探しても無いときはコンストラクタのprototypeを探すのでした。例えば、Dragonのインスタンスの場合は、Dragon.prototypeを探します。

そして、DragonはTekiを継承しているのでDragon.prototypeがTekiのインスタンスとなり、Dragon.protoypeにも存在しないプロパティの場合は次にTeki.prototypeを探すことになるので、DragonのインスタンスはTekiのインスタンスとしての役割も果たしているということになっているのでした。

では、特に何も継承していないものはどうでしょう。実は、初期状態でprototypeは普通のオブジェクトです。普通のオブジェクトとは、つまりObjectのインスタンスです。

例えば、Teki.prototypeはObjectのインスタンスです。よって、Teki.prototypeに無いプロパティを探した場合はObject.prototypeを探すことになります。

ちなみに、Object.prototypeにもメソッドがあります。上の説明からすると、Object.prototypeにあるメソッドはすべてのオブジェクトが持っていることになります。これについては第十一章第四回で多少説明しています。

さて、これでやっとDragon.prototype = new Teki;の説明が終わりました。次はTeki.apply(this, arguments);の部分です。

Tekiは、thisで与えられたオブジェクトをTekiのインスタンスとして初期化するための処理を行うメソッドでしたね。今回の場合、Dragonのコンストラクタの中でTekiを呼び出しています。Dragonの中ではthisは初期化中のDragonのインスタンスになるオブジェクトです。このthisをTekiの中のthisとしてTekiを実行することで、thisに対してTekiの処理が行われます。今回の例ではここでthis.hpが設定されたので、bossはhpプロパティを持っていたのです。より抽象的に言えば、Dragonのインスタンスの初期化処理の中でTekiのインスタンスとしての初期化処理も行ったということです。DragonはTekiの一種という直感からいえば、DragonのインスタンスをTekiのインスタンスとしても扱うのはとても妥当ですね。

第二引数のargumentsですが、これは配列っぽいオブジェクトだと述べました。そして、applyの第2引数は配列で渡すと述べましたね。しかしapplyは寛容なので、配列っぽいオブジェクトでもセーフです。今回の場合、argumentsはDragonに渡された引数全てであり、それをTekiを呼び出す際の引数としてそのまま全て渡していることになります。

これは、継承元のコンストラクタが引数を取るときそれを継承先でも使えるようにする常套手段ですが、常にこうすべきとは限りません。今回の場合そもそもTekiに引数は必要ありませんからTeki.call(this);とかTeki.apply(this, []);などでも良いでしょう(ちなみにTeki.apply(this);という書き方もOKです)。場合に合わせて工夫してください。

まとめると、Teki.apply(this, arguments);の行は、Dragonコンストラクタの中で、Tekiのコンストラクタにも処理をしてもらうというものでした。Dragonのインスタンスは同時にTekiのインスタンスでもあるので、この処理はやるべきでしょう。

instanceof

ここでひとつ余談です。instanceofという演算子を紹介しておきます。

これは、オブジェクト instanceof コンストラクタの形で、オブジェクトがあるコンストラクタのインスタンスかどうか真偽値で返してくれる演算子です。

例えば、変数 instanceof Arrayとすると変数が配列かどうか判定できます(ただし配列の場合は、一章第三回で紹介したArray.isArrayのほうがよいでしょう)。これは、配列はArrayのインスタンスだからです。

さっきのTekiとDragonも使って試してみましょう。

//敵を作る
function Teki(){
}

//ドラゴンを作る
function Dragon(){
  Teki.apply(this,arguments);
}
Dragon.prototype = new Teki;

var boss = new Dragon();	//インスタンスをつくる

console.log(boss instanceof Dragon);	//インスタンスがDragonのインスタンスかどうか
console.log(boss instanceof Teki);	//インスタンスがTekiのインスタンスかどうか

結果はどちらもtrueです。確かに、DragonのインスタンスがTekiのインスタンスとしても扱われていますね。

サンプル

最後に、復習的なサンプルです。

//敵を作る
function Teki(n){
  this.name = n;
}
//現れる
Teki.prototype.appear = function(){
  console.log(this.name+"が あらわれた!");
};
//攻撃
Teki.prototype.attack = function(){
  console.log(this.name+"のこうげき!ダメージをうけた!");
};

//ドラゴンを作る
function Dragon(){
  Teki.apply(this,arguments);
}
Dragon.prototype = new Teki;
//ドラゴンの攻撃は強い
Dragon.prototype.attack = function(){
  console.log(this.name+"のこうげき!大ダメージをうけた!");
}

var zako = new Teki("ザコ");	//インスタンスをつくる
var boss = new Dragon("ボス");

zako.appear();	//ザコ現れる
boss.appear();	//ボス現れる
zako.attack();	//ザコの攻撃
boss.attack();	//ボスの攻撃

ザコとボスを作っています。コンストラクタにはどちらも引数で名前を指定しています。その引数を処理してプロパティnameに代入するのはTekiの仕事ですが、Dragonのインスタンスを作った場合でも、applyしていることでちゃんとTekiの仕事も行われています。

その後、appearです。appearは、Tekiにしかありません。しかし、DragonもTekiのインスタンスなので、どちらもTekiのappearを使っています。

次のattackでは、TekiにもDragonにもあり、中身が違います。ザコのほうはTekiのインスタンスなのでTekiの、ボスはDragonなのでDragonのを使っています。

このように、継承を利用することの利点は、まず「敵」という大元に、appearとかattackとか基本的な機能を作っておけば、すべての種類の敵(継承先)でそれが使えるということです。また、このように同名のメソッドを継承先に新たに作れば、そのメソッドを書き換えたかのように動作します。いずれの場合にも、attackというメソッドを呼び出せば、「攻撃」の処理をうまくやってくれるわけです。プログラムを組む側は、attackをするのが「敵」なのか、はたまた「ドラゴン」なのか「魔法使い」なのか(プログラム中に出てきませんでしたが)は気にする必要がありません。これはオブジェクト指向のひとつの便利な側面です。

※継承についてのより詳しい説明が十一章第七回にあります。実際にプログラムで継承を利用する際は、こちらも読んでおくことをおすすめします。