りゅうじの学習blog

学習したことをアウトプットしていきます。

4/17 TypeScript クラスの継承

extendsを使用して、他のクラスの機能を継承する

継承とは

あるクラスに機能を追加・拡張した別のクラスを作成する機能です。

特徴

なぜ使うか

部分型関係にある複数のオブジェクト型を定義したい時に、それらのメソッドの実装に共通点があるような場合に継承が有用である。

デメリット

  • 継承を使うとプログラムの設計が複雑になる傾向がある
  • どんなロジックを表すために継承を使うといいかの答えがない(それだけできる機能が多いということ)
  • TypeScriptはクラスを使わなくても実装できるので、あえて継承を使うかは検討が必要である

継承(1)子は親の機能を受け継ぐ

使い方

  • extendsキーワードを使う
  • class クラス名 extends 親クラス {…} という構文で書く
  • クラス式の場合はクラス名を省略できるため class extends 親クラス と書く
class Person {
  constructor(public readonly name: string, public age: number) {}
  greeting() {
    console.log(`おはようございます、${this.name}でございます。年齢は${this.age}です。`)
  }
  incrementAge() {
    this.age += 1;
  }
}

class PremiumPerson extends Person {
  rank: number = 1;
}

const yano = new PremiumPerson('yano', 27);
console.log(yano.rank);  // 出力結果: 1
console.log(yano.name);  // 出力結果: yano
console.log(yano.greeting());  // 出力結果: おはようございます、yanoでございます。年齢は27です。
  • PremiumPersonクラスをインスタンス化した後に、継承したPersonクラスのプロパティ・メソッドも呼び出せています
  • PremiumPersonのコンストラクタの使い方はPersonコンストラクタと同じになる
  • PremiumPersonはPersonの機能を全て持っているため、PremiumPerson型はPerson型の部分型になる
  • Person型が必要なところにPermiumPerson型のオブジェクトを渡しても問題ない

Person型が必要なところにPermiumPerson型のオブジェクトを渡しても問題ないかの検証

class Person {
  constructor(public readonly name: string, public age: number) {}
  greeting() {
    console.log(`おはようございます、${this.name}でございます。年齢は${this.age}です。`)
  }
  incrementAge() {
    this.age += 1;
  }
}

class PremiumPerson extends Person {
  rank: number = 1;
}

function getMessage(p: Person) {
  return `おはす!!、${p.name}さん!!`
}

const yuki = new Person('yuki', 26);
const haga = new PremiumPerson('haga', 26);

console.log(getMessage(yuki));  
console.log(getMessage(haga));

出力結果

[LOG]: "おはす!!、yukiさん!!" 
[LOG]: "おはす!!、hagaさん!!"
  • 変数hagaはPremiumPersonのインスタンスなので、型もPremiumPersonなのですが、getMessageの引数の型はPerson型ですが、メソッドの呼び出しができています

継承(2)親の機能を上書きする

  • 子クラスでは親の機能を上書き(オーバーライド)できます
  • 親の機能を子クラスで再宣言することで実現できる
class Person {
  constructor(public readonly name: string, public age: number) {}
  greeting() {
    console.log(`おはようございます、${this.name}でございます。年齢は${this.age}です。`)
  }
  incrementAge() {
    this.age += 1;
  }
}

class PremiumPerson extends Person {
  rank: number = 1;
  greeting() {
    console.log(`こんにちは、プレミアム${this.name}でございます。年齢は${this.age}です。`)
  }
}

const yano = new PremiumPerson('yano', 27);
yano.greeting();

実行結果

"こんにちは、プレミアムyanoでございます。年齢は27です。"
  • Personクラスのgreetingメソッドを継承したPremiumPersonクラスでgreetingメソッドをオーバーライドできました

super呼び出しで親クラスのコンストラクタを呼び出す

class Person {
  constructor(public readonly name: string, public age: number) {}
  greeting() {
    console.log(`おはようございます、${this.name}でございます。年齢は${this.age}です。`)
  }
  incrementAge() {
    this.age += 1;
  }
}

class PremiumPerson extends Person {
  constructor(name: string, age: number, public rank: number){
    super(name, age);
  }
  greeting() {
    console.log(`こんにちは、プレミアム${this.name}でございます。年齢は${this.age}です。`)
  }
}

const yano = new PremiumPerson('yano', 27, 1);
console.log(yano);

実行結果

 PremiumPerson: {
  "name": "yano",
  "age": 27,
  "rank": 1
}
  • superで親クラスのコンストラクタを呼び出しています
  • superの引数は親クラスのコンストラクタの引数と合致させる必要があるため、name,ageを引数で渡しています
  • super呼び出しは必須であり、メソッドと異なり、コンストラクタの場合は挙動を完全に書き換えることは不可能です

override修飾子

クラス内のプロパティ・メソッドの宣言に修飾子として付与するとオーバーライドであると宣言する効果がある。

class Person {
  constructor(public readonly name: string, public age: number) {}
  greeting() {
    console.log(`おはようございます、${this.name}でございます。年齢は${this.age}です。`)
  }
  incrementAge() {
    this.age += 1;
  }
}

class PremiumPerson extends Person {
  constructor(name: string, age: number, public rank: number){
    super(name, age);
  }
  override greeting() {
    console.log(`こんにちは、プレミアム${this.name}でございます。年齢は${this.age}です。`)
  }
}

const yano = new PremiumPerson('yano', 27, 1);
console.log(yano);
  • greetingメソッドにoverride修飾子を付与しました
  • overrideでないものに付与するとコンパイルエラーになります
  • デフォルトの設定では必須ではないから、付与しなくてもオーバーライドできていました

※あくまで明示・宣言するだけのものであり、もし間違っていたらコンパイルエラーとして指摘してもらうために書くものです。

nolmplicitOverrideコンパイラオプションと組み合わせて使う

tsconfig.jsonでnolmplicitOverrideオプションを有効にするとoverride修飾子をつけていないと、コンパイルエラーになります。

privateとprotected修飾子の動作と使いどころ

protected修飾子は、privateと同様にクラス外からのアクセスは不可能だが、子クラスからはアクセスできるプロパティ・メソッドを表すものです。(privateは子クラスからもアクセス不可能です)

protectedの問題点

使い方によっては、プログラムの複雑化・メンテナンス性の低下に繋がります。

子クラスでプロパティを書き換えることで、親クラスのロジックに予期せぬ影響を与えてバグを発生させるようなケースです。

class Person {
  private _isAdult: boolean;
  constructor(public readonly name: string, protected age: number) {
    this._isAdult = age >= 20;
  }
  
  public isAdult(): boolean {
    return this._isAdult;

  }
}

class PremiumPerson extends Person {
  public setAge(newAge: number) {
    this.age = newAge;
  }
}

const yano = new PremiumPerson('yano', 27);
console.log(yano.isAdult());  // true

yano.setAge(1);
console.log(yano.isAdult());  // ageを1に再設定しているのに、true

出力結果

true

true
  • 子クラスのみageを再設定できるsetAgeメソッドが存在しています。
  • 親クラスがageの書き換えを想定した実装になっていないので、ageが子クラスで書き換えられても、_isAdultを計算し直さないので、期待した結果が得られていません
  • protectedは子クラスによる好き勝手な干渉を受け入れるという意思表示になってしまう

※ protectedはこういった問題点があるので、極力privateを使用するべきです。

implementsキーワードによるクラスの型チェック

クラスを作成する際はimplementsキーワードによる追加の型チェックが利用可能です。

  • 構文は、クラス名 implements 型 {…} です
  • 継承と併用するにはextendsより後に、implements 型 を書く
  • クラスのインスタンスに与えられた型の部分型であるという宣言になる
type HasName = {
  name: string;
}

class User implements HasName {
  constructor(public name: string, private age: number) {}
  
  public isAdult(): boolean {
    return this.age >= 20;
  }
}
  • このコードのUserクラスのnameプロパティを削除するとコンパイルエラーになる

nameプロパティを削除

type HasName = {
  name: string;
}

class User implements HasName {
  constructor(private age: number) {}
  
  public isAdult(): boolean {
    return this.age >= 20;
  }
}

エラーメッセージ

Class 'User' incorrectly implements interface 'HasName'.
  Property 'name' is missing in type 'User' but required in type 'HasName'.(2420)
input.tsx(2, 3): 'name' is declared here.

HasNameにはnameプロパティが必要だがUserには存在しないという指摘です。

どんな時にimplementsキーワードは有用か

type Animal = {
  name: string;
  makeSound(): void;
}

class Dog implements Animal {
  name: string;
  
  constructor(name: string) {
    this.name = name;
  }
  
  makeSound() {
    console.log("Woof! Woof!");
  }
}

class Cat implements Animal {
  name: string;

  constructor(name: string) {
    this.name = name;
  }
  
  makeSound() {
    console.log("Meow! Meow!");
  }
}

この例では、DogクラスとCatクラスは共通の型エイリアスであるAnimalを実装しています。これにより、コードの一貫性、エラー検出、可読性、保守性が向上し、ポリモーフィズムを実現できます。

参考

プロを目指す人のためのTypeScript入門