クラスの型にまつわるあれこれ(2)〜クラスの継承とクラス間の型の関係について〜:TypeScriptのTypeあれこれシリーズ(6)
altJS、すなわちJavaScriptの代わりとなる言語の筆頭である「TypeScript」。TypeScriptという言語名が示す通り、JavaScriptに「Type」、つまり型の概念を持ち込んだものです。本連載では、このTypeScriptの型に関して、さまざまな方向から紹介していきます。前回は、オブジェクトのひな型となるクラスに関して、基本的な内容を紹介しました。今回はその続きとして、クラスの継承やクラスをnewしたオブジェクトそのものの型について紹介します。
継承
クラスを手軽に拡張する方法として、継承があります。この継承はJavaScriptにもある構文です。その構文を確認しながら、TypeScript独自の内容を紹介していきます。
継承とは
継承とは、あるクラスのメンバをそっくり引き継ぎ、そのクラスを拡張したクラスを作成できる仕組みです。具体例を見ていきましょう。例えば、リスト1のようなクラスCalcBaseがあるとします。
class CalcBase { private _baseNum = 0; // (1) constructor(baseNum: number) { // (2) this._baseNum = baseNum; } get baseNum(): number { // (3) return this._baseNum; } }
このクラスを踏まえて、リスト2のクラスを作成したとします。
class CalcAdd extends CalcBase { getAddedNum(addNum: number): number { return this.baseNum + addNum; // (1) } }
クラス宣言に注目してください。リスト1のように「class クラス名」という構文ではなく、これに続けて「extends」キーワードが記述されています。このextendsが継承を表します。このextendsの次に記述されたクラスを「親クラス」といい、それに対してこれから作成するクラスを、「子クラス」といいます。構文としては次のようになります。
[構文1]継承 class クラス名 extends 親クラス
継承では親クラスのメンバが含まれる
この子クラスでは、親クラスのメンバをそっくりそのまま含んだ状態となります(図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)
これは、CalcAddクラスにはコンストラクタが記述されていなくても、その親クラスであるCalcBaseのコンストラクタには、リスト1の(2)のように、引数が設定されているからです。もしこの引数を渡さなければ、図2のエラーとなります。
なお、newしたCalcAddクラスのメソッドgetAddedNum()を利用する場合は、リスト3の(2)のようなコードとなります。これに関しては、特に問題ないでしょう。
子クラスから利用できるメンバにつけるprotected
ところでリスト2の(1)で、親クラスのフィールドであるリスト1の(1)の_baseNumを直接利用しようとすると、図3のエラーとなります。
前回紹介したアクセス修飾子の一つであるprivateメンバは、クラス内からしかアクセスできません。これは継承の場合も同様で、確かに図1のように、子クラスであるCalcAddには親クラスのprivateフィールド_baseNumは含まれていますが、クラスが違うためアクセスできないようになっています。そのため、リスト1の(3)のゲッターを用意して、アクセスできるようにしています。ただし、_baseNumへのアクセスをゲッターにしてしまうと、リスト4のように子クラス外からも当然アクセスできてしまいます。
console.log(calcAdd.baseNum);
ここで、この_baseNumを外部に公開したくないとしたら、これはまずいです。このように、子クラスからは利用させたいが、外部に公開したくない場合に登場するのが、前回の表1にまとめた「protected」です。このprotectedを利用すると、CalcBaseクラスはリスト5のように、ゲッターが不要なコードにできます。
class CalcBase { protected _baseNum = 0; constructor(baseNum: number) { this._baseNum = baseNum; } }
さらに、子クラスであるCalcAddでは、リスト6のように直接_baseNumが利用できるようになります。
class CalcAdd extends CalcBase { getAddedNum(addNum: number): number { return this._baseNum + addNum; } }
一方、リスト4のようにクラス外からこの_baseNumを利用しようとすると、図4のエラーとなります。
コンストラクタのオーバーライドとsuper()
ここで、現状getAddedNum()メソッドの引数として受け取っている足し算用の値を、CalcAddクラスをnewする際に受け取れるようにしたいとします。となると、CalcAddにコンストラクタを記述する必要があります。これはつまり、親クラスであるCalcBaseのコンストラクタの上書きを意味します(図5)。このような、同じメソッドを子クラスで上書きすることを、「オーバーライド」といいます(第4回で紹介したオーバーロードと名前が似ているので注意してください)。
実際にオーバーライドを使ったのが、リスト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) } }
ここで注目するのが、(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();
通常メソッドのオーバーライド
コンストラクタだけでなく、通常のメソッドもオーバーライドできます。例えばCalcBaseクラスに、リスト9のようなshowDoubleAns()メソッドがあるとします。
class CalcBase { 〜省略〜 showDoubleAns(): void { console.log(2 * this._baseNum); } }
子クラスであるCalcAddでこのクラスをオーバーライドし、リスト10のような記述をしても、問題なく動作します。
class CalcAdd extends CalcBase { 〜省略〜 showDoubleAns(): void { console.log(2 * this.getAddedNum()); } }
このCalcAddをリスト11のように利用した場合、実行結果は「16」となり、オーバーライドされたリスト10のコードが実行されているのが分かります。
const calcAdd = new CalcAdd(5, 3); calcAdd.showDoubleAns();
オーバーライドメソッド内で親クラスのメソッドも実行
前項で紹介した通り、通常のメソッドをオーバーライドした場合、その処理は丸々子クラスのメソッド内の処理に置き換わります。その際、親クラスのメソッドの処理も行いたい場合、「super」を利用しリスト12のようなコードを記述します。
showDoubleAns(): void { super.showDoubleAns(); console.log(2 * this.getAddedNum()); }
この場合の実行結果は、「10 16」となり、親クラスのshowDoubleAns()メソッド、つまりリスト9のコードも実行されているのが分かります。
このsuperによる親クラスメソッドの呼び出しは、コンストラクタのsuper()とは違い、任意の位置に記述できます。例えば、リスト13のように順序を入れ替えると、それに合わせて実行結果は「16 10」となります。
showDoubleAns(): void { console.log(2 * this.getAddedNum()); super.showDoubleAns(); }
型アサーションとクラス間関係の真実
前節で紹介した継承を踏まえた上で、TypeScriptらしくクラス間の関係を型から見ていくことにします。
型アサーション
前節ではCalcBaseの子クラスとしてCalcAddのみの紹介でした。この他にも、例えば、CalcDivideなど、さまざまな子クラスがあるとして、それらを条件に従って生成する関数として、createCalc()があるとします。この関数の戻り値の型は、親クラスであるCalcBaseとします。例えば、リスト14のようなコードです。
function createCalc(): CalcBase { return new CalcAdd(5, 3); }
ここでは、CalcAddのみをリターンするコードとなっていますが、条件分岐を記述して、他の子クラスをリターンするコードを記述しても構いません。大切なのは戻り値の型がその親クラスであるCalcBaseとなっていることであり、それにもかかわらず実際にリターンされるインスタンスはその子クラスだということです。
この場合において、createCalc()を利用する場面を考えてみます。これは、リスト15のコードとなります。
const calc = createCalc(); // (1) calc.showDoubleAns(); // (2) const ans = calc.getAddedNum(); // (3) console.log(ans);
ところがこのコードは、(3)で図6のエラーとなります。
リスト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;
型アサーションには次の構文のようにasを利用します。
[構文2]型アサーション as 実際のデータ型
データ型においての親子兄弟の関係
前項の続きとして、親クラス型変数と子クラス型変数の関係をもう少し掘り下げます。
例えば、リスト17のAnimalクラスがあるとします。
class Animal { name = ""; constructor(name: string) { this.name = name; } showName() { console.log(this.name); } }
このクラスを継承した子クラスとして、リスト18のDogとCatがあるとします。
class Dog extends Animal { run() { console.log("わんわん"); } } class Cat extends Animal { cry() { console.log("にゃあ"); } }
そして、この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("はち");
これは、クラスの型が違うから当たり前に思えます。一方、親クラスであるAnimal型の変数pet0に対しては、リスト20のようにDogをnewしたものも、Catをnewしたものも代入できます。
let pet0: Animal; pet0 = new Dog("はち"); pet0.run(); // (1) pet0 = new Cat("ミーコ"); pet0.cry(); // (2)
このように、親クラス型の変数には、その子クラスをnewしたものは代入できます。この仕組みを利用したのが、実は前項で紹介した関数createCalc()の戻り値の型を親クラスとしたことです。
ただし、(1)や(2)ではエラーとなります。その原因は、まさに前項で解説済みのことで、pet0が実態にかかわらず、Animal型としかみられないからです。このエラーを解消する方法も、前項で紹介した型アサーションです。リスト21のように、いったんDog型に変換した変数を用意することで、run()メソッドが利用できるようになります。
const pet0d = pet0 as Dog; pet0d.run()
TypeScriptのクラス型の真実
実は、このクラスの型に関して、TypeScriptには面白い仕組みがあります。これに関しては、先に実例を紹介します。例えば、リスト22のTigerクラスがあるとします。
class Tiger { name = ""; constructor(name: string) { this.name = name; } showName() { console.log(this.name); } cry() { console.log("がおお"); } }
このTigerクラスのメンバを列挙すると、次のようになります。
- フィールドname
- コンストラクタ
- メソッドshowName()
- メソッドcry()
そして、このメンバ構成というのは、Catと全く同じです。このようなメンバ構成が全く同じクラスの場合、リスト23のコードが成り立ちます。
let pet = new Cat("たま"); pet = new Tiger("虎吉");
このコードによると、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 = "";
まとめ
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.