クラスの型にまつわるあれこれ(2)〜クラスの継承とクラス間の型の関係について〜TypeScriptのTypeあれこれシリーズ(6)

altJS、すなわちJavaScriptの代わりとなる言語の筆頭である「TypeScript」。TypeScriptという言語名が示す通り、JavaScriptに「Type」、つまり型の概念を持ち込んだものです。本連載では、このTypeScriptの型に関して、さまざまな方向から紹介していきます。前回は、オブジェクトのひな型となるクラスに関して、基本的な内容を紹介しました。今回はその続きとして、クラスの継承やクラスをnewしたオブジェクトそのものの型について紹介します。

» 2022年03月10日 05時00分 公開

この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。

「TypeScriptのTypeあれこれシリーズ」のインデックス

連載:TypeScriptのTypeあれこれシリーズ

継承

 クラスを手軽に拡張する方法として、継承があります。この継承はJavaScriptにもある構文です。その構文を確認しながら、TypeScript独自の内容を紹介していきます。

継承とは

 継承とは、あるクラスのメンバをそっくり引き継ぎ、そのクラスを拡張したクラスを作成できる仕組みです。具体例を見ていきましょう。例えば、リスト1のようなクラスCalcBaseがあるとします。

class CalcBase {
	private _baseNum = 0;  // (1)
	constructor(baseNum: number) {  // (2)
		this._baseNum = baseNum;
	}
	get baseNum(): number {  // (3)
		return this._baseNum;
	}
}
リスト1

 このクラスを踏まえて、リスト2のクラスを作成したとします。

class CalcAdd extends CalcBase {
	getAddedNum(addNum: number): number {
		return this.baseNum + addNum;  // (1)
	}
}
リスト2

 クラス宣言に注目してください。リスト1のように「class クラス名」という構文ではなく、これに続けて「extends」キーワードが記述されています。このextendsが継承を表します。このextendsの次に記述されたクラスを「親クラス」といい、それに対してこれから作成するクラスを、「子クラス」といいます。構文としては次のようになります。

[構文1]継承
class クラス名 extends 親クラス

継承では親クラスのメンバが含まれる

 この子クラスでは、親クラスのメンバをそっくりそのまま含んだ状態となります(図1)。

図1 子クラスには親クラスのメンバが全て含まれている

 CalcAddクラスには、メンバとしてgetAddedNum()メソッドしかありませんが、CalcBaseを継承しているため、親クラスであるCalcBaseのメンバである_baseNum、constructor()、baseNumゲッターが含まれた状態となります。そのためCalcAdd内では、リスト2の(1)のように、this.baseNumでbaseNumゲッターが利用できます。

 また、このCalcAddクラスをnewしようとすると、リスト3の(1)のように引数を渡す必要があります。

const calcAdd = new CalcAdd(5);  // (1)
const ans = calcAdd.getAddedNum(3);  // (2)
リスト3

 これは、CalcAddクラスにはコンストラクタが記述されていなくても、その親クラスであるCalcBaseのコンストラクタには、リスト1の(2)のように、引数が設定されているからです。もしこの引数を渡さなければ、図2のエラーとなります。

図2 親クラスのコンストラクタに引数が設定されているため引数は必要

 なお、newしたCalcAddクラスのメソッドgetAddedNum()を利用する場合は、リスト3の(2)のようなコードとなります。これに関しては、特に問題ないでしょう。

子クラスから利用できるメンバにつけるprotected

 ところでリスト2の(1)で、親クラスのフィールドであるリスト1の(1)の_baseNumを直接利用しようとすると、図3のエラーとなります。

図3 親クラスのprivateフィールドへのアクセスはエラーとなる

 前回紹介したアクセス修飾子の一つであるprivateメンバは、クラス内からしかアクセスできません。これは継承の場合も同様で、確かに図1のように、子クラスであるCalcAddには親クラスのprivateフィールド_baseNumは含まれていますが、クラスが違うためアクセスできないようになっています。そのため、リスト1の(3)のゲッターを用意して、アクセスできるようにしています。ただし、_baseNumへのアクセスをゲッターにしてしまうと、リスト4のように子クラス外からも当然アクセスできてしまいます。

console.log(calcAdd.baseNum);
リスト4

 ここで、この_baseNumを外部に公開したくないとしたら、これはまずいです。このように、子クラスからは利用させたいが、外部に公開したくない場合に登場するのが、前回の表1にまとめた「protected」です。このprotectedを利用すると、CalcBaseクラスはリスト5のように、ゲッターが不要なコードにできます。

class CalcBase {
	protected _baseNum = 0;
	constructor(baseNum: number) {
		this._baseNum = baseNum;
	}
}
リスト5

 さらに、子クラスであるCalcAddでは、リスト6のように直接_baseNumが利用できるようになります。

class CalcAdd extends CalcBase {
	getAddedNum(addNum: number): number {
		return this._baseNum + addNum;
	}
}
リスト6

 一方、リスト4のようにクラス外からこの_baseNumを利用しようとすると、図4のエラーとなります。

図4 protectedメンバは親クラス内と子クラスからしかアクセスできない

コンストラクタのオーバーライドとsuper()

 ここで、現状getAddedNum()メソッドの引数として受け取っている足し算用の値を、CalcAddクラスをnewする際に受け取れるようにしたいとします。となると、CalcAddにコンストラクタを記述する必要があります。これはつまり、親クラスであるCalcBaseのコンストラクタの上書きを意味します(図5)。このような、同じメソッドを子クラスで上書きすることを、「オーバーライド」といいます(第4回で紹介したオーバーロードと名前が似ているので注意してください)。

図5 同じメソッドの上書きはオーバーライド

 実際にオーバーライドを使ったのが、リスト7のコードです。子クラスであるCalcAdd独自のコンストラクタが(1)です。

class CalcAdd extends CalcBase {
	private _addNum = 0;
	constructor(baseNum: number, addNum: number) {  // (1)
		super(baseNum);  // (2)
		this._addNum = addNum;  // (3)
	}
	getAddedNum(): number {
		return this._baseNum + this._addNum;  // (4)
	}
}
リスト7

 ここで注目するのが、(2)です。(1)で親クラスのコンストラクタを上書きしたとはいえ、親クラスのコンストラクタも依然として有効です。そして、その親クラスのコンストラクタは、引数を受け取らないと適切に動作しません。このように、オーバーライドしたコンストラクタでは、まず親クラスのコンストラクタを実行する必要があり、そのコードが「super()」という記述です。そして、その親クラスのコンストラクタに引数が設定されている場合は、(2)のように引数を渡します。

 なお、このようにコンストラクタ経由で受け取った足し算の値は、(3)でフィールド_addNumに格納しています。getAddedNum()メソッドでは(4)のように、この_addNumフィールドと親の_baseNumフィールドを加算しています。

 この場合のCalcAddクラスを利用する場合は、リスト8のコードとなり、new時に引数を2個渡す必要がある一方で、getAddedNum()メソッドを利用する場合には、リスト3の(2)とは違い、引数は不要となります。

const calcAdd = new CalcAdd(5, 3);
const ans = calcAdd.getAddedNum();
リスト8

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

 コンストラクタだけでなく、通常のメソッドもオーバーライドできます。例えばCalcBaseクラスに、リスト9のようなshowDoubleAns()メソッドがあるとします。

class CalcBase {
	〜省略〜
	showDoubleAns(): void {
		console.log(2 * this._baseNum);
	}
}
リスト9

 子クラスであるCalcAddでこのクラスをオーバーライドし、リスト10のような記述をしても、問題なく動作します。

class CalcAdd extends CalcBase {
	〜省略〜
	showDoubleAns(): void {
		console.log(2 * this.getAddedNum());
	}
}
リスト10

 このCalcAddをリスト11のように利用した場合、実行結果は「16」となり、オーバーライドされたリスト10のコードが実行されているのが分かります。

const calcAdd = new CalcAdd(5, 3);
calcAdd.showDoubleAns();
リスト11

オーバーライドメソッド内で親クラスのメソッドも実行

 前項で紹介した通り、通常のメソッドをオーバーライドした場合、その処理は丸々子クラスのメソッド内の処理に置き換わります。その際、親クラスのメソッドの処理も行いたい場合、「super」を利用しリスト12のようなコードを記述します。

showDoubleAns(): void {
	super.showDoubleAns();
	console.log(2 * this.getAddedNum());
}
リスト12

 この場合の実行結果は、「10 16」となり、親クラスのshowDoubleAns()メソッド、つまりリスト9のコードも実行されているのが分かります。

 このsuperによる親クラスメソッドの呼び出しは、コンストラクタのsuper()とは違い、任意の位置に記述できます。例えば、リスト13のように順序を入れ替えると、それに合わせて実行結果は「16 10」となります。

showDoubleAns(): void {
	console.log(2 * this.getAddedNum());
	super.showDoubleAns();
}
リスト13

型アサーションとクラス間関係の真実

 前節で紹介した継承を踏まえた上で、TypeScriptらしくクラス間の関係を型から見ていくことにします。

型アサーション

 前節ではCalcBaseの子クラスとしてCalcAddのみの紹介でした。この他にも、例えば、CalcDivideなど、さまざまな子クラスがあるとして、それらを条件に従って生成する関数として、createCalc()があるとします。この関数の戻り値の型は、親クラスであるCalcBaseとします。例えば、リスト14のようなコードです。

function createCalc(): CalcBase {
	return new CalcAdd(5, 3);
}
リスト14

 ここでは、CalcAddのみをリターンするコードとなっていますが、条件分岐を記述して、他の子クラスをリターンするコードを記述しても構いません。大切なのは戻り値の型がその親クラスであるCalcBaseとなっていることであり、それにもかかわらず実際にリターンされるインスタンスはその子クラスだということです。

 この場合において、createCalc()を利用する場面を考えてみます。これは、リスト15のコードとなります。

const calc = createCalc();  // (1)
calc.showDoubleAns();  // (2)
const ans = calc.getAddedNum();  // (3)
console.log(ans);
リスト15

 ところがこのコードは、(3)で図6のエラーとなります。

図6 CalcBase型だとgetAddedNum()メソッドがなくてエラーとなる

 リスト14にあるように、createCalc()関数の戻り値はCalcBase型であり、その戻り値を格納したリスト15の(1)の変数calcは、同じくCalcBase型です。そして、このCalcBaseには、getAddedNum()メソッドは定義されていません。(3)でのエラーは、このことが原因です。とはいえcalcの実態は、CalcBaseではなくCalcAddです。実際、(2)の実行結果は「10 16」であり、CalcAddのshowDoubleAns()メソッドが実行されているのが分かります。

 このように、見た目(宣言された型)は親クラスだが、その実態は子クラスということは多々起きます。特に、リスト15のような関数の戻り値を受け取る場合などは、よくあります。このような場合に、変数を実態に合わせて変換した上で利用します。これを「型アサーション」といい、リスト15の(1)のコードの代わりに、リスト16のコードを記述します。これで、図6のエラーは表示されなくなります。

const calc = createCalc() as CalcAdd;
リスト16

 型アサーションには次の構文のようにasを利用します。

[構文2]型アサーション
as 実際のデータ型

データ型においての親子兄弟の関係

 前項の続きとして、親クラス型変数と子クラス型変数の関係をもう少し掘り下げます。

 例えば、リスト17のAnimalクラスがあるとします。

class Animal {
	name = "";
	constructor(name: string) {
		this.name = name;
	}
	showName() {
		console.log(this.name);
	}
}
リスト17

 このクラスを継承した子クラスとして、リスト18のDogとCatがあるとします。

class Dog extends Animal {
	run() {
		console.log("わんわん");
	}
}
class Cat extends Animal {
	cry() {
		console.log("にゃあ");
	}
}
リスト18

 そして、このDogとCatそれぞれをnewした変数として、pet1とpet2があるとします。次に、リスト19のように、pet1に対してCatをnewしたものを、pet2にDogをnewしたものを代入しようとすると、図7のエラーとなります。

let pet1 = new Dog("ぽち");
let pet2 = new Cat("たま");
pet1 = new Cat("ミーコ");
pet2 = new Dog("はち");
リスト19
図7 Dog型変数にCatを代入しようとして表示されたエラー

 これは、クラスの型が違うから当たり前に思えます。一方、親クラスであるAnimal型の変数pet0に対しては、リスト20のようにDogをnewしたものも、Catをnewしたものも代入できます。

let pet0: Animal;
pet0 = new Dog("はち");
pet0.run();  // (1)
pet0 = new Cat("ミーコ");
pet0.cry();  // (2)
リスト20

 このように、親クラス型の変数には、その子クラスをnewしたものは代入できます。この仕組みを利用したのが、実は前項で紹介した関数createCalc()の戻り値の型を親クラスとしたことです。

 ただし、(1)や(2)ではエラーとなります。その原因は、まさに前項で解説済みのことで、pet0が実態にかかわらず、Animal型としかみられないからです。このエラーを解消する方法も、前項で紹介した型アサーションです。リスト21のように、いったんDog型に変換した変数を用意することで、run()メソッドが利用できるようになります。

const pet0d = pet0 as Dog;
pet0d.run()
リスト21

TypeScriptのクラス型の真実

 実は、このクラスの型に関して、TypeScriptには面白い仕組みがあります。これに関しては、先に実例を紹介します。例えば、リスト22のTigerクラスがあるとします。

class Tiger {
	name = "";
	constructor(name: string) {
		this.name = name;
	}
	showName() {
		console.log(this.name);
	}
	cry() {
		console.log("がおお");
	}
}
リスト22

 このTigerクラスのメンバを列挙すると、次のようになります。

  • フィールドname
  • コンストラクタ
  • メソッドshowName()
  • メソッドcry()

 そして、このメンバ構成というのは、Catと全く同じです。このようなメンバ構成が全く同じクラスの場合、リスト23のコードが成り立ちます。

let pet = new Cat("たま");
pet = new Tiger("虎吉");
リスト23

 このコードによると、Cat型で用意した変数petにTigerクラスをnewして代入しています。しかも、CatとTigerには、継承などの関係は全くありません。このコードが成り立つことから、CatクラスとTigerクラスが全く同じ型として扱われていることが分かります。

 実は、TypeScriptのクラス型というのは、そのクラス名ではなく、クラスのメンバ構成で同じかどうかを判断する、という仕組みとなっています。この仕組みを、「構造型システム」(Structural Typing)といいます。一方、クラス名の違いがそのまま型の違いとなる仕組みを、「公称型システム」(Nominal Typing)といい、JavaやPHPなどが該当します。

 なお、TypeScriptで、この構造型システムが働くのは、メンバがpublicの場合のみです。privateやprotectedメンバが含まれるクラスは、公称型システムとなります。例えば、リスト17のAnimalクラスのnameフィールドをリスト24のようにprivateにし、同じくTigerのnameフィールドもprivateにした途端、リスト23のコードは成り立たなくなり図8のエラーが表示されます。

private _name = "";
リスト24
図8 privateメンバが含まれたクラスどうしでは違う型として扱われる

まとめ

 TypeScriptのTypeに注目し、あれこれ紹介する本連載の第6回はいかがでしたか?

 今回は、前回に引き続き、TypeScriptでのクラスの扱いについて、継承やクラスをnewしたオブジェクトの型同士の関係について紹介しました。次回は、クラスの型について紹介する最後の回として、thisやstatic、抽象クラスを紹介します。

筆者紹介

WINGSプロジェクト

有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。

サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/
RSS(https://wings.msn.to/contents/rss.php
Twitter: @yyamada(https://twitter.com/yyamada
Facebook(https://www.facebook.com/WINGSProject


Copyright © ITmedia, Inc. All Rights Reserved.

スポンサーからのお知らせPR

注目のテーマ

Microsoft & Windows最前線2025
AI for エンジニアリング
ローコード/ノーコード セントラル by @IT - ITエンジニアがビジネスの中心で活躍する組織へ
Cloud Native Central by @IT - スケーラブルな能力を組織に
システム開発ノウハウ 【発注ナビ】PR
あなたにおすすめの記事PR

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。