JavaScriptのクラスについて学ぼう【2.継承編】

JavaScript

はじめに

ES6(ECMAScript 2015)で導入されたクラス構文により、JavaScriptでのオブジェクト指向プログラミングにおいて、直感的に理解しやすいコードを記述できるようになりました。

クラスについての基本編では「オブジェクトの設計図」説明しましたが、クラスは「継承」という方法で構造化することができます。クラスの継承はコードの再利用性や保守性を向上させるための重要な概念です。プログラムの柔軟性を向上させることができ、大規模な開発においては必ずと言っていいほどよく利用されます。

この記事では、クラスを継承する方法に焦点を当てます。まずはクラスの基本構文を簡単におさらいし、継承の基本的な概念や、クラス継承の方法、複雑なクラス構造においてインスタンスが属するクラスを調べる方法について解説していきます。

この記事の対象者

  • JavaScript初学者の方
  • クラス構文を学びたい方
  • JavaScriptフレームワーク等に興味のある方

この記事で学べること

  • クラスの継承とは何か
  • なぜクラスの継承を行うのか
  • クラスを継承する方法
  • インスタンスが属するクラスを調べる方法

クラス構文の復習

まずはクラス構文の基本についておさらいします。基本編の記事を簡潔にまとめたものなので、より詳しく知りたい方は以下の記事が参考になるかと思います。

クラス構文は、JavaScriptにおいてオブジェクト指向プログラミングを行うための強力な機能です。クラスはオブジェクトを生成するための設計図であり、その設計図に基づいてオブジェクト(インスタンス)を作成します。

クラスの定義とインスタンスの生成

クラスはclassキーワードを使用して定義します。以下は基本的なクラスの構文です。

class Animal {
  constructor(name) {
    this.name = name;
  }

  // メソッドの定義
  makeVoice() {
    console.log('Some generic voice');
  }
}

// インスタンスの生成
const myPet = new Animal('Fluffy');

上記の例では、Animalクラスが定義され、それを元にmyPetという名前のインスタンスが生成されています。

コンストラクタとメソッドの定義

クラス内には特別なメソッドとしてconstructor()があります。これはインスタンスが生成された際に最初に呼び出されるメソッドで、主に初期化のために使用され、生成されるインスタンスのプロパティの設定やインスタンス生成時に一度だけ実行したい処理などを定義できます。

class Animal {
  // コンストラクタの定義
  constructor(name) {
    this.name = name;
  }

  // メソッドの定義
  makeVoice() {
    console.log('Some generic voice');
  }
}

上記の例では、constructor()メソッドがnameを受け取り、それをインスタンスのnameプロパティに設定しています。また、makeVoice()というメソッドも定義しています。

クラスのインスタンス化とメソッドの呼び出し

クラスのインスタンスは newキーワードを使用して生成します。生成されたインスタンスからは通常のオブジェクトと同じ方法でプロパティへのアクセスやメソッドを呼び出すことができます。

const myPet = new Animal('Fluffy');
myPet.makeVoice();
// 結果:Some generic voice

上記の例では、AnimalクラスのインスタンスmyPetを生成し、その後makeVoice()メソッドを呼び出しています。このようにして、クラスを使用してオブジェクト指向的なプログラミングが行えます。

以上がクラス構文の復習です。次に、これらの基本をベースにしてクラスの継承について詳しく見ていきます。

クラスの継承の基本

クラスの継承は、既存のクラス(スーパークラスまたは親クラス)のプロパティとメソッドを引き継ぎ、新しいクラス(サブクラスまたは子クラス)を定義する機能です。クラスの継承を利用することで、クラスを構造化してクラス毎の共通の機能と独自の機能を切り分けることができます。

まずは、クラスの継承方法を学ぶ前に、なぜクラスの継承が必要なのかについて見ていきます。

なぜクラスの継承が必要なのか

クラスはオブジェクトの設計図としての役割を持っており、値を変数として管理すると同様、オブジェクトをクラスとして管理すると便利であることは何となくでも理解できているかと思います。ここでは、なぜクラスの継承が必要なのかについて簡単に見ておきます。

クラスの継承が便利であることを理解するために、クラス構文を使用せずにanimalオブジェクトとbirdオブジェクトに紐づいたmyBirdというオブジェクトをリテラル表現で定義してみます。

const animal = {
  bird: {
    myBird: {
      name: 'Chunta',
      type: 'bird',
      makeSound() {
        console.log('Chun!');
      },
      fly() {
        console.log(this.name + ' is flying.');
      }
    },
  }
}

上記のコードはリテラル表現でBirdクラスのインスタンスを模倣しています。animalオブジェクトのプロバティにbirdオブジェクトを定義し、birdオブジェクトのプロバティにmyBirdオブジェクトを定義しています。myBirdオブジェクトがAnimalクラスを継承するBirdクラスのインスタンスに相当します。

どうなるかは想像できるかと思いますが、インスタンスの追加をリテラル表現で模倣してみます。

const animal = {
  bird: {
    myBird1: {
      name: 'Chunta',
      type: 'bird',
      makeSound() {
        console.log('Chun!');
      },
      fly() {
        console.log(this.name + ' is flying.');
      }
    },
    myBird2: {
      name: 'Chunko',
      type: 'bird',
      makeSound() {
        console.log('Chun!');
      },
      fly() {
        console.log(this.name + ' is flying.');
      }
    },
    myBird3: {…},
    myBird4: {…},
    myBird5: {…},
  }
}

上記のように、インスタンスが増えれば増えるほど冗長になります。この冗長性を回避するために便利なのがクラスということになります。

続いて、animalに属するcatオブジェクトが増えた場合を見てみましょう。Animalクラスを継承したCatクラスのインスタンスを模倣してみます。

const animal = {
  bird: {
    myBird1: {
      name: 'Chunta',
      type: 'bird',
      makeSound() {
        console.log('Chun!');
      },
      fly() {
        console.log(this.name + ' is flying.');
      }
    },
    myBird2: {…},
    myBird3: {…},
    myBird4: {…},
    myBird5: {…},
  },
  cat: {
    myCat1: {
      name: 'Chiro',
      type: 'cat',
      makeSound() {
        console.log('Nyan!');
      },
      play() {
        console.log(this.name + ' is playing.');
      }
    },
    myCat2: {
      name: 'Ohagi',
      type: 'cat',
      makeSound() {
        console.log('Nyan!');
      },
      play() {
        console.log(this.name + ' is playing.');
      }
    },
    myCat3: {…},
    myCat4: {…},
    myCat5: {…},
  }
}

catオブジェクトもbirdオブジェクトと同様、animalオブジェクトのプロパティとして定義しており、オブジェクトの階層構造は表現できています。したがって、リテラル表現でも、myCat1やmyBird1といったインスタンスにあたるオブジェクトを呼び出す場合は特に不具合はなく、呼び出し箇所に限定すれば可読性も変わりません。

しかし、オブジェクトの構造やプロパティの値、メソッドの追加といった変更を加えたい場合、該当のオブジェクト全てに対して修正を行う必要があり、修正もれの危険や手間が生じます。また、エラーハンドリングといった処理が必要な場合は複雑な処理を実装することになり、上記のコードはあまりにも現実的ではありません。このような問題を回避するために便利なのがクラスの継承です。

上記の例のとおり、animalオブジェクトに属する末端のオブジェクトは全てname、typeというプロパティとmakeSound()メソッドを持っています。また、animal.birdオブジェクトに属する末端のオブジェクトはfly()メソッドを持っており、animal.catオブジェクトに属する末端のオブジェクトはplay()メソッドを持っています。

共通するプロパティやメソッドをスーパークラスとして切り分け、独自のプロパティやメソッドの定義をサブクラス内で定義することで、コードの可読性と保守性が向上し、変更の際にも修正箇所を一か所にとどめることができます。

クラスの継承方法

クラスの継承を行うには、まず継承元となる親クラスを定義します。さきほど、クラス構文の復習としてAnimalクラスを定義しました。

class Animal {
  constructor(name) {
    this.name = name;
  }

  makeVoice() {
    console.log('Some generic voice');
  }
}

このAnimalクラスはとても抽象的です。動物の種類は様々なので、より具体的なクラスを定義することでふるまいを変化させることができます。ここではAnimalクラスを親クラスとし、このクラスを継承したBirdクラスを定義する方法について見ていきます。BirdクラスからAnimalクラスのconstructor()メソッドへアクセスする方法と、Animalクラスで定義されたメソッドのオーバーライド、Birdクラスへのメソッドの追加方法について解説します。

クラスの継承

JavaScriptでは、extendsキーワードを使用してクラスの継承を実現します。クラス名の後にextendsキーワードを使用することで、そのクラスがどのクラスを継承するかを指定することができます。

class Bird extends Animal {
  // プロバティ・メソッドを定義
}

上記の例では、BirdクラスがAnimalクラスを継承しています。Birdクラスには何も定義していませんが、この状態でもBirdクラスのインスタンスはAnimalクラスのメソッドを使用することができます。しかし、これだけではconstructor()の継承は行われません。

スーパークラスのconstructor()へアクセスする

constructor()メソッド内でsuper(プロパティ名)を記述することで、親クラスのconstructor()メソッドへアクセスすることができます。また、Birdクラス独自のプロパティは通常の方法で記述できます。

class Bird extends Animal {
  constructor(name, wingspan) {
    // 親クラスのコンストラクタ呼び出し
    super(name);
    // Birdクラス独自のプロパティ
    this.windspan = wingspan;
  }
}

上記のように、super()はサブクラス内で親クラスのメソッドやプロパティにアクセスするために使用されます。上記の例では、super(name) により、親クラスのコンストラクタにnameを渡しています。

プロパティの継承は複数選択することができます。Animalクラスにプロパティを追加してみましょう。

class Animal {
  constructor(name, type) {
    this.name = name;

    // 以下を追加
    this.type = type;
  }
  …
}

Birdクラスでtypeプロパティを使用するには、constructior()とsuper()の両方に追加する必要があります。

class Bird extends Animal {
  constructor(name, type) {
    super(name, type)
  }
}

Birdクラスからインスタンスを作成して、継承されているかを確認してみましょう。

const myBird = Bird('Chunta', 'bird');
consoole.log(myBird);
// 結果:Bird {name: 'Chunta', type: 'bird'}

Animalクラスのtypeプロバティに値が渡っていることが確認できました。続いて、BirdクラスのnameプロパティがAnimalクラスのnameプロパティに紐づいていることを確認してみましょう。Animalクラスのnameプロパティを変更してみます。

class Animal {
  constructor(name, type) {
    this.name = 'Mr.' + name;
    this.type = type;
  }
  …
}

Birdインスタンスのnameプロパティにも変更が反映されていることが確認できます。

const myBird = Bird('Chunta', 'bird');
consoole.log(myBird);
// 結果:Bird {name: 'Mr.Chunta', type: 'bird'}

このように、クラスの継承を行うことで、スーパークラスであるAnimalクラスのconstructor()を利用してBirdクラスのインスタンスの初期化を行うことができます。

メソッドのオーバーライドと追加

クラスの継承では、サブクラスは親クラスのメソッドをオーバーライド(上書き)することができます。これにより、サブクラスは親クラスのふるまいを変更できます。

メソッドのオーバーライド

AnimalクラスにはmakeVoice()メソッドが定義されていました。まずはこれをBirdインスタンスで呼び出してみましょう。

class Animal {
  …
  // メソッドの定義
  makeVoice() {
    console.log('Some generic voice');
  }
}
class Bird extends Animal {
  …
}

const myBird = Bird('Chunta');
myBird.makeVoice();
// 結果:Some generic voice

続いて、makeVoice()メソッドをBirdクラス側でオーバーライドしてみます。メソッドのオーバーライドの方法はシンプルで、親クラスのメソッド名と同じメソッド名で子クラス内にメソッドを定義するだけです。

class Bird extends Animal {
  …
  // 親クラスのmakeVoice()メソッドをオーバーライド
  makeVoice() {
    console.log('Chun!');
  }
}

上記の例では、makeVoice()メソッドをオーバーライドしてBirdクラス独自の鳴き声を出力するように変更しています。これにより、AnimalクラスのmakeVoice()ではなくBirdクラスのmakeVoice()が呼び出されます。

myBird.makeSound();
// 結果:Chun!
メソッドの追加

サブクラスでは、スーパークラスには存在しない新しいメソッドを追加することもできます。Birdクラスに以下を追加してみます。

class Bird extends Animal {
  …
  // メソッドの追加
  fly() {
    console.log(this.name + ' is flying.');
  }
}

const myBird = new Bird('Chunta');
myBird.fly();
// 結果:Mr.Chunta is flying.

上記の例では、Birdクラスにfly()メソッドを追加して鳥が飛んでいる挙動を表現しています。 このfly()メソッドはBirdクラス独自のもので、AnimalクラスやAnimalクラスを継承した他のクラスのインスタンスからは呼び出すことができません。

このように、クラスの継承を利用してメソッドのオーバーライドと追加を活用することで、親クラスの機能を拡張し、サブクラス独自のふるまいを実現することができます。

親クラスと子クラスの関係

クラスの継承において、親クラスと子クラスは階層構造を形成します。これは、オブジェクトの階層構造を表現することと同義と考えることができます。

これは、抽象的なクラス(Animalクラス)とそのより具体的なクラス(Birdクラス)の関係を表しています。親クラスは一般的な機能を提供し、子クラスはそれを拡張または変更する目的で使用されます。

クラスの階層構造

クラスの継承において、親クラスは抽象的な概念や共通のふるまいを表し、子クラスはより具体的な概念や個別のふるまいを表します。例えば、Animalクラスが親クラスであり、BirdやCatクラスがそれを継承すると次のような階層構造ができます。

         Animal (スーパークラス)
           / \
          /   \
        Bird   Cat (サブクラス)

クラスの継承は上記のような二階層の構造しかできないわけではありません。子クラスを継承した子クラスを定義することもでき、階層構造を自由に表現することができます。

         Animal
           / \
          /   \
        Bird   Cat
       /   \
   Penguin  Owl

上記の場合、PenguinクラスとOwlクラスは、BirdクラスとAnimalクラス両方の特徴を引き継ぎます。クラスの階層構造が複雑になってくると、あるインスタンスがどのクラスを継承しているのかが分かりにくくなる問題があります。こういった直面に出くわした場合の対応について見ていきます。

インスタンスが属するクラスを調べる

JavaScriptでは、instanceof演算子を使用することでインスタンスが特定のクラスに属しているかを調べることができます。instanceof演算子の使い方は、「インスタンス instanceof クラス」と記述します。インスタンスが指定したクラスに属する場合はtrueを、属しない場合はfalseを返します。

Animalクラスを継承したCatクラスと、Birdクラスを継承したPenguinクラスを新たに定義して試してみましょう。

class Animal {
  …
}
class Bird extends Animal {
  …
}

// Penguinクラスを追加
class Penguin extends Bird {
  constructor(name) {
    super(name);
  }

  makeVoice() {
    console.log('Kue!');
  }

  walk() {
    console.log(this.name + ' is walking.');
  }
}

// Catクラスを追加
class Cat extends Animal {
  constructor(name) {
    super(name);
  }

  makeVoice() {
    console.log('Nyan!');
  }

  play() {
    console.log(this.name + ' is playing.');
  }
}

Penguinクラスのインスタンスが特定のクラスに属しているかを調べるには、以下のように記述します。

const myPet = new Penguin('Tansoku');

// myPetが属するクラスを調べる
console.log(myPet instanceof Animal);  // true
console.log(myPet instanceof Bird);    // true
console.log(myPet instanceof Penguin); // true
console.log(myPet instanceof Cat);     // false

上記の例では、myPetインスタンスがAnimalクラスとBirdクラス、Penguinクラスに属していることが確認できます。しかし、Catクラスには属していないため、「myPet instanceof Cat」ではfalseが返されます。Catクラスのインスタンスでは以下のような結果になります。

const myPet = new Cat('Chiro');

// myPetが属するクラスを調べる
console.log(myPet instanceof Animal);  // true
console.log(myPet instanceof Bird);    // false
console.log(myPet instanceof Penguin); // false
console.log(myPet instanceof Cat);     // true

ちなみに上記の例ではコンソールに出力していますが、instanceof演算子は条件式でも使用できます。

インスタンスが特定のクラスに属しているかを調べる方法は分かりましたが、これはどんなクラスが定義されているかがが分かっていることが前提です。時には、「どんなクラスが定義されているかは分からないけど、インスタンスがどのクラスに属しているか調べたい」という状況もあるかと思います。とは言っても簡単で、調べるだけならconsole.log()あるいはconsole.dir()でできます。

const myPet = new Penguin('Tansoku');
console.log(myPet);

属するクラスによって条件分岐を行う際、クラス名を文字列などで取得したい場合があります。クラス名はインスタンスのconstructor.nameプロパティに格納されるので、以下のように取得することができます。

const myPet = new Penguin('Tansoku');
console.log(myPet.constructor.name);
// 結果:Penguin

属するクラスのスーパークラスを取得する場合は、Object.getPrototypeOf()メソッドを使用します。以下のようになります。

console.log(Object.getPrototypeOf(myPet.constructor).name);
// 結果:Bird

以下の方法でも取得できる場合がありますが、削除予定あるいは削除された機能のため(ブラウザによって異なります)非推奨です。

// __proto__は非推奨です
console.log(myPenguin.constructor.__proto__.name);

更に上の階層の親クラスを取得したい場合、Object.getPrototypeOf()メソッドを入れ子にする必要がありますが、可読性が著しく低いので関数を作ると良いと思います。以下が簡単な関数の例です。

/**
 * インスタンスのクラス名を取得する関数。
 * @param {Object} instance - クラス名を取得したいインスタンス。
 * @param {number} [levels=1] - 取得する階層の深さ。デフォルトは1。
 * @returns {string | null} - インスタンスのクラス名。クラスを継承していない場合 || Objectの場合はnull。
 */
function getClassName(instance, levels = 1) {
  let currentPrototype = Object.getPrototypeOf(instance);

  while (currentPrototype && levels > 0) {
    const { constructor } = currentPrototype;
    if (constructor && constructor !== Object) {
      levels--;
      if (levels === 0) return constructor.name;
    }
    currentPrototype = Object.getPrototypeOf(currentPrototype);
  }

  return null; // インスタンスがクラスを継承していない場合
}

console.log(getClassName(myPenguin));
// 結果:Penguin

console.log(getClassName(myPenguin, 2));
// 結果:Bird

console.log(getClassName(myPenguin, 3));
// 結果:Animal

分かりやすくするために関数名を「getClassName」としていますが、実際の開発で使う場合はCSSのクラスと混同する人がいるかも知れないので変更したほうが良いと思います。

ちなみに、この関数は通常のオブジェクトでも使用できます(Objectの場合はnullです)。

let p = document.createElement('p');
console.log(getClassName(p)); // HTMLParagraphElement
console.log(getClassName(p, 2)); // HTMLElement
console.log(getClassName(p, 3)); // Element
console.log(getClassName(p, 4)); // Node
console.log(getClassName(p, 5)); // EventTarget
console.log(getClassName(p, 6)); // null

まとめ

ES6で導入されたクラス構文は、JavaScriptにおいてオブジェクト指向プログラミングをサポートするための強力な機能を提供しています。特にクラス構文やクラスの継承は、コードの再利用性や柔軟性を向上させる上で重要な役割を果たします。

  • クラスの継承: extendsキーワードを使用して既存のクラスを継承し、新しいクラスを定義することができます。これにより、既存のクラスを再利用し、新しい機能を追加したり機能を変更したりできます。
  • メソッドのオーバーライドと追加: サブクラスでは親クラスのメソッドをオーバーライドしてふるまいを変更できます。また、新しいメソッドを追加することで、サブクラス独自の機能を拡張できます。

これらの概念を理解することで、柔軟で保守性の高いコードを設計するスキルが向上します。クラスの継承はオブジェクト指向の重要な概念であり、JavaScriptにおいても継承を効果的に活用することで、より洗練されたコードを実現できます。

最後に

この記事では、クラス継承に焦点を当て、継承の基本的な概念や、クラス継承の方法、複雑なクラス構造においてインスタンスが属するクラスを調べる方法について解説しました。

しかし、クラス構文を学び始めた方の中には「なぜわざわざメソッドのオーバーライドを行うのか」という疑問が浮かんだ方もいるかと思います。この理由はエンジニアによって様々な意見があるかと思いますが、オーバーライドを行う一つの理由として「ポリモーフィズムの活用」が挙げられます。

ポリモーフィズムとは、同じメソッド名でもインスタンスによって異なるふるまいを実現する方法です。メソッドをオーバーライドすることは、ポリモーフィズムを活用していることと同じと考えることができます。

ポリモーフィズムの活用により、クラス設計やメソッドの定義において柔軟性を高める効果があります。クラス継承の基本を学んだ方は、次のステップとしてポリモーフィズムについて学ぶと良いと思います。以下の記事ではクラスにおいてのポリモーフィズムの活用について解説しているので、参考にしていただけると幸いです。

タイトルとURLをコピーしました