はじめに
クラス構文を学び始めた方の中には「なぜわざわざメソッドのオーバーライドを行うのか」という疑問が浮かんだ方もいるかと思います。この理由はエンジニアによって様々な意見があるかと思いますが、オーバーライドを行う一つの理由として「ポリモーフィズムの活用」が挙げられます。
ポリモーフィズムとは、インスタンスの型に応じて同じメソッドでも異なるふるまいを実現する方法です。クラス継承において、メソッドのオーバーライドは親クラスで定義されたメソッドと同じ名のメソッドを定義することで成立します。メソッドをオーバーライドすることは、ポリモーフィズムを活用することと同じと考えることができます。
この記事では、ポリモーフィズムとは何かについて簡潔におさえ、クラス構文においてのポリモーフィズムの活用について解説します。クラス構文やクラス継承についての基本的な知識があることが前提となっているので、これらについて学んでいない方は以下の記事を参照いただくことをオススメします。
クラス構文の基本についてはこちら↓
クラス継承についてはこちら↓
この記事の対象者
- クラス構文の基本を理解している方
- メソッドをオーバーライドする意味を理解したい方
- 効果的なクラス構造の構築について学びたい方
この記事で学べること
- ポリモーフィズムの概要
- ポリモーフィズムのメリット
- JavaScriptでのインターフェースの模倣
- クラスメソッドとしてのポリモーフィズムの利用
ポリモーフィズムとは
ポリモーフィズムは、同じインターフェースを共有する異なる型のオブジェクトが同じメソッドを使用するための方法です。インターフェースとは、クラスがどのような機能を提供するかについてのルールを定めるためのものです。しかし、インターフェースを実装する機能はJavaScriptにはないので、ここでは、
「インターフェース = 親クラス」
「同じインターフェースを共有する異なる型のオブジェクト = 子クラスのインスタンス」
という解釈で構いません。
メソッド名が同じということは、メソッドを利用する目的も同じと考えることができます。しかし、型が異なれば、目的に応じた結果を返すための処理が異なる場合があります。こういった場合においてポリモーフィズムは利用されます。
ポリモーフィズムを活用するには、親クラスで定義されたメソッドを子クラスで適切にオーバーライドする必要があります。ポリモーフィズムを理解することは、クラス構文において柔軟で拡張性の高いコードを書く上で役に立ちます。
ポリモーフィズムのメリット
ポリモーフィズムはコードの拡張性や保守性を向上させる有効的な手法です。
ポリモーフィズムを活用することで異なる型のオブジェクトが同じメソッドを共有できるため、柔軟な実装が可能になります。これにより、新しいクラスを導入しても既存のコードを変更する必要がなくなり、機能の拡張において柔軟な実装を行うことができます。
とはいえ言葉だけではよく分からないと思うので、サンプルコードで簡単に解説します。まずはポリモーフィズムを使用しない例を挙げます。
// circleオブジェクトを描画する関数
function drawCircle(circle) {
// circleオブジェクトを描画する処理
}
// squareオブジェクトを描画する関数
function drawSquare(square) {
// squareオブジェクトを描画する処理
}
// 図形を描画する処理
function drawShapes(shapes) {
shapes.forEach(shape => {
if (shape.type === 'Circle') {
drawCircle(shape);
} else if (shape.type === 'Square') {
drawSquare(shape);
}
});
}
// オブジェクトの定義
const circle = { type: 'Circle' };
const square = { type: 'Square' };
// オブジェクトを描画
drawShapes([circle, square]);
上記の例では、円形を描画するdrawCircle()関数と、四角形を描画するdrawSquare()関数、これら呼び出すためのdrawShapes()関数を定義しています。
drawShapes()関数を呼び出す際、この関数の引数に描画したいオブジェクトを配列として渡すことで機能します。また、drawShapes()関数内では、オブジェクトのtypeプロパティの値を基に条件分岐を行っています。
上記の例に、さらに三角形を描画するdrawTriangle()関数を定義する場合を考えます。この場合、drawTriangle()関数の定義する以外にも、drawShapes()関数を修正しなければいけません。
// 図形を描画する処理
function drawShapes(shapes) {
shapes.forEach(shape => {
if (shape.type === 'Circle') {
drawCircle(shape);
} else if (shape.type === 'Square') {
drawSquare(shape);
// 以下を追加
} else if (shape.type === 'Triangle') {
drawTriangle(shape);
}
});
}
このような実装は、修正漏れの可能性があることはもちろん、drawShapes()関数を修正することによってこの関数が正しく機能するかをもう一度テストする必要があり、この問題は機能の拡張や保守性の維持を難しくさせる場合があります。
この問題の対策としてポリモーフィズムを活用してみます。方法はいろいろ考えられますが、ここではCircleクラスとSquareクラスを定義し、インスタンスメソッドとしてdraw()メソッドを定義します。
class Circle {
constructor() {
this.type = 'Circle'
}
draw() {
// Circleインスタンスを描画する処理
}
}
class Square {
constructor() {
this.type = 'Square'
}
draw() {
// Squareインスタンスを描画する処理
}
}
function drawShape(shapes) {
shapes.forEach((shape) => {
shape.draw();
});
};
const circle = new Circle();
const square = new Square();
drawShape([circle, square]);
上記のコードは「円形」を定義するCircleクラスと、「四角形」を定義するSquareクラスを定義しています。また、各クラスにはそれぞれdraw()という同じメソッドを持っています。サンプルコードでは処理に関しての実装は行っていませんが、実際には円形と四角形を描画するための処理は異なるものであり、このdraw()メソッドがポリモーフィズムとして作用します。
ポリモーフィズムを使用しない例との大きな違いは、drawShape()関数の処理です。ご覧のとおり、条件分岐がありません。これは、新たにTriangleクラスを定義した場合でもdrawShape()関数を修正する必要がないことを意味します。
function drawShape(shapes) {
shapes.forEach((shape) => {
shape.draw();
});
};
このように、新しいクラスを導入しても既存のコードを変更する必要がなくなることで、機能の拡張においては柔軟な実装を行うことができ、保守性の向上という面でも効果が期待できます(条件分岐が無くなって可読性が向上することもメリットと言えますね)。
ポリモーフィズムの活用
ここでは、クラス構文においてのポリモーフィズムの活用について解説します。クラス継承とメソッドのオーバーライドを効果的に使用することで、柔軟で保守性の高いコーディングを行うための助けになります。
インターフェースとポリモーフィズム
「インターフェースとは、クラスがどのような機能を提供するかについてのルールを定めるためのもの」と説明しましたが、前述のとおりJavaScriptにはインターフェースを実装する機能はありません。しかし、クラス継承とポリモーフィズムを活用することで、「あるクラスに対して特定のメソッドを実装することを強制させる」というようなインターフェースの役割を模倣することができます。
これにより、バグの早期発見や保守性の向上が期待されます。エラーメッセージを通じてメソッドがオーバーライドされるべきであることを他の開発者に伝えることができ、予測が難しいバグを未然に防ぐ効果があります。
どのようにインターフェースを模倣するかについて見ていきましょう。JavaScriptのクラスについて学ぼう【2.継承編】では、以下のようなクラスを定義しました(この解説に不要な部分は削除しています)。
class Animal {
constructor(name) {
this.name = name;
}
makeVoice() {
console.log('Some generic voice');
}
}
class Bird extends Animal {
constructor(name) {
super(name);
}
makeVoice() {
console.log('Chun!')
}
fly() {
console.log(this.name + ' is flying.');
}
}
class Cat extends Animal {
constructor(name) {
super(name);
}
makeVoice() {
console.log('Nyan!');
}
play() {
console.log(this.name + ' is playing.');
}
}
上記の例では、BirdクラスとCatクラスはAnimalクラスを継承しており、それぞれmakeVoice()メソッドが定義されています。この場合、インターフェースの役割を担うのは親クラスであるAnimalクラスです。ここから更に、Animalクラスを継承したDogクラスを定義すると考えましょう。また、DogクラスにはあえてmakeVoice()メソッドを定義しないでおきます。
class Dog extends Animal {
constructor(name) {
super(name);
}
}
Dogクラスのインスタンスを作成してmakeVoice()メソッドを呼び出すと、Animalクラスで定義されたmakeVoice()メソッドが実行されます。
const myDog = new Dog('Bubuko');
myDog.makeVoice();
// 結果:Some generic voice
このままでもコンソールを見ればDogにmakeVoice()メソッドが定義されていないことが分かります。しかし、これが許容されるべき実装であるかどうかは不明であり、makeVoice()のオーバーライドが必須ならバグの発見が遅れてしまいます。
この問題は、AnimalクラスのmakeVoice()メソッドが呼び出された場合に意図的にエラーを発生させることで解決できます。AnimalクラスのmakeVoice()メソッドを以下のように変更します。
class Animal {
…
makeVoice() {
throw new Error('This method should be overridden by subclasses');
}
}
…
これにより、DogクラスにmekeVoice()メソッドが定義されていない場合はエラーが発生して処理が中断します。このように、クラスの継承とポリモーフィズムを活用することで、親クラスにインターフェースのような役割を与えることができます。
クラスメソッドとポリモーフィズム
ポリモーフィズムは、同じ名前のメソッドが複数のクラスで異なるふるまいを持つことを指します。これにより、同じメソッド名で異なるクラスのインスタンスに対して一貫した操作を行うことができます。ポリモーフィズムの実装は、関数としてどのような型でも利用できるように実装する場合と、クラスメソッドやインスタンスメソッドのようなクラスの機能として切り分けて実装する場合が考えられます。
関数としてポリモーフィズムを実装した場合、特定のクラスに依存しないため汎用性が高いです。その分、複雑な処理になると可読性が損なわれる可能性があります。
クラスメソッドとしてポリモーフィズムを実装する場合、そのポリモーフィズムは特定のクラスで使用するものであることが明示的になります。関数の場合と比べて汎用性が損なわれる反面、比較的シンプルな記述になることや、クラスの機能として切り分けられることなどがメリットです。ここでは、クラスメソッドでのポリモーフィズムについて解説します。
「インターフェースとポリモーフィズム」の解説で使用したサンプルコードを使用します(Dogクラスは不要なので削除しています)。以下のような状態です。
class Animal {
constructor(name) {
this.name = name;
}
makeVoice() {
throw new Error('This method should be overridden by subclasses');
}
}
class Bird extends Animal {
constructor(name) {
super(name);
}
makeVoice() {
console.log('Chun!')
}
fly() {
console.log(this.name + ' is flying.');
}
}
class Cat extends Animal {
constructor(name) {
super(name);
}
makeVoice() {
console.log('Nyan!');
}
play() {
console.log(this.name + ' is playing.');
}
}
各クラスはmakeVoice()というメソッドが定義されていますが、BirdクラスとCatクラスはオーバーライドによってmakeVoice()のふるまいが異なります。makeVoice()メソッドをクラスメソッド内で実行することにより、ポリモーフィズムとして機能させることができます。
class Animal {
…
static interact(instance) {
console.log('Interacting with ' + instance.name);
instance.makeVoice();
}
}
上記の例では、「交流する」という意味合いのinteract()メソッドを定義しました。処理の内容はシンプルで、コンソールへのメッセージの出力とmakeVoice()メソッドの呼び出しです。Bird、Catクラスのインスタンスを生成してinteract()メソッドを呼び出してみましょう。
const myBird = new Bird('Chunta');
const myCat = new Cat('Chiro');
Animal.interact(myBird);
/*
結果:
Interacting with Chunta
Chun!
*/
Animal.interact(myCat);
/*
結果:
Interacting with Chiro
Nyan!
*/
ポリモーフィズムにより、条件分岐を行わずにインスタンスに応じてinteract()の処理を変化させることができています。
続いて、interact()メソッドにもう一つ機能を追加してみましょう。BirdクラスとCatクラスはそれぞれfly()メソッドとplay()メソッドという独自のメソッドが定義されていました。これらをinteract()メソッド内で呼び出すことは「交流する」という意味合いとして理にかなっているので、これを実装してみます。
class Animal {
…
static interact(instance) {
console.log(`Interacting with ${instance.name}`);
instance.makeVoice();
// 以下を追加
switch (instance.constructor.name) {
case 'Bird':
instance.fly();
break;
case 'Cat':
instance.play();
break;
default:
console.log('Unknown animal type');
break;
}
}
}
こちらも処理の内容はシンプルで、switch文の条件式にinstance.constructor.nameを指定して条件分岐を行っています。再度Animal.interact()メソッドを実行すると以下のような結果になります(長くなるのでmyBirdだけです)。
const myBird = new Bird('Chunta');
Animal.interact(myBird);
/*
結果:
Interacting with Chunta
Chun!
Chunta is flying.
*/
ポリモーフィズムは少しややこしいと感じるかもしれませんが、もう少し見ていきましょう。この機能の実装にswitch文を使用しましたが、fly()やplay()メソッドを呼び出すためのポリモーフィズムを実装することでswitch文を消すことができます。
class Animal {
// 以下を追加
performInteraction() {
throw new Error('Not implemented');
}
static interact(instance) {
console.log('Interacting with ' + instance.name);
instance.makeVoice();
// switch文を以下に変更
instance.performInteraction();
}
}
class Bird extends Animal {
…
// 以下を追加
performInteraction() {
this.fly();
}
}
class Cat extends Animal {
…
// 以下を追加
performInteraction() {
this.play();
}
}
各クラスにperformInteraction()というメソッドを追加しました。再度Animal.interact()メソッドを実行すると、正しく動作していることが確認できます。今度はmyCatと交流します。
Animal.interact(myCat);
/*
結果:
Interacting with Chiro
Nyan!
Chiro is playing.
*/
クラスメソッドでのポリモーフィズムの基本的な使い方は以上ですが、実は一つ問題が起きる可能性が潜んでいます。クラスメソッドもインスタンスメソッドと同様に継承が行われるため、BirdクラスやCatクラスからでもinteract()メソッドを呼び出すことができます。
const myBird = new Bird('Chunta');
const myCat = new Cat('Chiro');
Bird.interact(myBird);
Cat.interact(myCat);
// 以下はエラーが発生しません。
Bird.interact(myCat);
interact()メソッドは、Animalクラスに属するインスタンス全てに使用できることを想定しており、これはAnimalクラスで定義していることからも明らかだと考えることができます。しかし、開発者によって解釈が異なることはよくあることで、混乱を招くような記述はできないように配慮すると良いと思います。interact()メソッドを子クラスのクラスメソッドで呼び出せないようにしてみます。
class Animal {
…
static interact(instance) {
console.log('Interacting with ' + instance.name);
// 以下を追加
if (this.name !== 'Animal') {
throw new Error('interact method is not available in subclasses');
}
instance.makeVoice();
}
}
クラスメソッドでは、this.nameプロパティにアクセスすることでそのクラス名を取得することができます。上記の例ではクラス名がAnimalではない場合にthrowでErrorオブジェクトを投げて意図的にエラーを発生させています。エラーが発生すると処理が中断するので、instance.makeVoice()の実行は行われません。簡単な方法ではありますが何もしないよりは良いでしょう。他にも、コメントなどで詳細な情報を記載するとより安全かと思います。
最後に
この記事では、ポリモーフィズムの概要について触れ、クラス構文においてのポリモーフィズムの活用について解説しました。
ポリモーフィズムは機能というよりはテクニックの一種であり、ある程度プログラミングの基礎を理解していないと難しく感じるかもしれません。必ずポリモーフィズムを使用しなければならないというわけではありませんが、JavaScriptのライブラリやフレームワークを使用する場合、ポリモーフィズムについて理解していないとコードを読むのが辛くなる可能性があります。クラス継承やメソッドのオーバーライドといった基本的な概念を理解してポリモーフィズムを活用できるようになることは、JavaScriptでウェブ開発を行うための助けになるでしょう。
コードの読解力において、ポリモーフィズム以外にもデザインパターンやSetter/Getterという概念を理解することで読解力の向上が期待できます。以下の記事では、オブジェクト指向プログラミングにおいての基本的な機能であるSetter/Getterについて、クラス構文でのこれらの扱い方に焦点を当てて解説しています。
