0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【オブジェクト指向】抽象クラスを復習しよう!

0
Posted at

はじめに

継承を使っていて、「子クラスで必ず実装してほしいメソッドがあるのに、書き忘れても誰も気づけない」という不便さを感じたことはないでしょうか。

抽象クラスを使うと、子クラスに「このメソッドは必ず実装してね」と強制でき、実装漏れをコンパイル時に検出できるようになります。実行時のクラッシュや、「ドキュメントに書いてあるから守ってね」という運用頼みの設計から卒業できるのが、この仕組みの価値です。

この記事では、継承記事で扱った Animal クラスを引き継ぎつつ、抽象クラスを使う理由と書き方を段階的に復習します。

この記事を読むと得られること

  • 抽象クラスと普通のクラスの違い、および抽象クラスを使うべき場面がわかる
  • 抽象メソッドが「コンパイル時の安全性」を高める仕組みを理解できる
  • バッチ処理や通知機能など、実務で抽象クラスが活きる場面をイメージできる

この記事は、継承(extends / super)を理解している前提で進めます。不安な方は、先に「継承を復習しよう!」を読むとスムーズです。

前提:押さえておきたい用語

抽象クラスの話に入る前に、この記事で使う用語を整理しておきます。継承記事と共通の用語もあるので、軽い復習として目を通してください。

用語 何のこと
クラス オブジェクトの設計図 class Animal { ... }
インスタンス クラスから作られた実体 new Dog("ポチ")
継承 (extends) 親クラスの機能を引き継ぐ仕組み class Dog extends Animal { ... }
具象クラス ふつうの、new できるクラス class Dog { ... }
抽象クラス new できない、継承されて使われる専用のクラス abstract class Animal { ... }
抽象メソッド 中身が書かれていない、子クラスで必ず実装するメソッド abstract makeSound(): string;

特に 「抽象クラス」「抽象メソッド」「具象クラス」 の3つが今回の主役です。以降で登場するたびに、

  • 抽象クラス = 直接は new できない、継承専用のクラス
  • 抽象メソッド = 中身なしで宣言されたメソッド(子クラスで必ず実装)
  • 具象クラス = ふつうの new できるクラス

と頭の中で置き換えながら読むと迷子になりにくいです。

抽象クラスとは

抽象クラスとは、単体では使わず、継承されることだけを前提とした「未完成のクラス」 のことです。

具体的には、次の特徴を持ちます。

  • 直接 new してインスタンスを作れない
  • 一部のメソッドを「中身なし」で宣言できる(= 抽象メソッド)
  • 子クラスは、その中身なしメソッドを 必ず実装 しなければならない

TypeScript では、abstract キーワードで次のように書きます。

abstract class Animal {
  abstract makeSound(): string; // 抽象メソッド(実装なし)
}

抽象クラスを使うと何ができるのか

継承だけではできなかった、次のことができるようになります。

  • 子クラスに対して 「このメソッドは必ず実装してね」と強制 できる
  • 実装漏れを "実行前(コンパイル時)" に検出 できる
  • 抽象的すぎて意味のないクラス(例: "ただの Animal")を誤って new されるのを防げる
  • 共通処理(実装あり)と、個別処理(実装を強制)を 1つのクラスに混在 できる

ひとことで言うと、抽象クラスは 「継承 + 実装の強制力」 です。継承に "お行儀の良さ" を加えたもの、とイメージしてください。

どういうときに使うのか

抽象クラスが向いているのは、次のような状況です。

  • 似たクラスが複数あって、共通の処理と、子クラスごとに違う処理が混在している
  • その「子ごとに違う処理」を、子クラスで必ず実装してほしい(書き忘れると致命的)
  • 親クラス単体では意味がない(抽象的すぎて使いようがない)
  • 実装漏れを実行時ではなく、コンパイル時に気づきたい

継承記事のバッチ処理で execute()throw new Error(...) で空実装にしていた箇所がありました。あの実装は、本来は抽象クラスで解決すべき典型的なケースです。記事の後半(業務例)で、より堅牢な設計へとアップデートする方法を解説します。

一番シンプルな例で見てみる

継承記事の Animal / Dog / Cat を引き継いで、抽象クラスの使いどころを見てみます。

そもそも「継承だけ」だと何が困るのか

継承記事ではこう書いていました(再掲)。

class Animal {
  name: string;
  constructor(name: string) { this.name = name; }
  introduce(): string { return `${this.name}です`; }
}

class Dog extends Animal {
  bark(): string { return "ワン!"; }
}

class Cat extends Animal {
  meow(): string { return "ニャー!"; }
}

一見問題なさそうに見えますが、よく見ると2つ困りごとがあります。

  1. new Animal("名無し") ができてしまう
    • 「ただの動物」という概念は不自然です。本来、動物は必ず何かしらの具体的な種であるはず
  2. 「鳴き声を出す」共通メソッドが揃わない
    • 犬は bark()、猫は meow() でメソッド名がバラバラ
    • Animal[] にまとめて「全員に鳴かせる」といった統一的な扱いができない

これを両方解決するのが抽象クラスです。

抽象クラスにしてみる

abstract class Animal {
  name: string;

  constructor(name: string) {
    this.name = name;
  }

  introduce(): string {
    return `${this.name}です`;
  }

  // 子クラスで必ず実装する(抽象メソッド)
  abstract makeSound(): string;
}

class Dog extends Animal {
  makeSound(): string {
    return "ワン!";
  }
}

class Cat extends Animal {
  makeSound(): string {
    return "ニャー!";
  }
}

class Bird extends Animal {
  makeSound(): string {
    return "ピヨピヨ!";
  }
}

変更点は2つだけです。

  • Animal の宣言に abstract を付けた
  • makeSound()abstract 付きの中身なしメソッドとして宣言した

これで、次のようなことが起きます。

// 抽象クラスは直接インスタンス化できない(下記はコンパイルエラー)
// const animal = new Animal("名無し");
//                ^^^^^^ Error: Cannot create an instance of an abstract class.

// 子クラスならインスタンス化できる
const dog = new Dog("ポチ");
console.log(dog.introduce()); // "ポチです"
console.log(dog.makeSound()); // "ワン!"

// 全部の子クラスが makeSound を持つので、Animal[] として統一的に扱える
const animals: Animal[] = [new Dog("ポチ"), new Cat("タマ"), new Bird("ピヨ")];
for (const animal of animals) {
  console.log(`${animal.introduce()}${animal.makeSound()}`);
}
// "ポチです → ワン!"
// "タマです → ニャー!"
// "ピヨです → ピヨピヨ!"

先ほどの2つの困りごとが、両方まとめて解決されています。

困りごと 解決方法
new Animal(...) ができてしまう abstract class にして、そもそも new できなくした
鳴き声メソッドの名前がバラバラ abstract makeSound(): string; として、共通のメソッド名を親で定義

実装漏れがあるとどうなる?

抽象メソッドの実装を忘れると、コンパイル時点で エラーになります。

class Fish extends Animal {
  // makeSound を実装し忘れた!
}
// Error: Non-abstract class 'Fish' does not implement
//        inherited abstract member 'makeSound' from class 'Animal'.

これが抽象クラスの最大の嬉しさです。

実装を忘れても、実行するまで気づかない というバグが、型チェックの時点で封じられる

通常の継承で「空実装メソッド + throw で例外を投げる」という書き方をすると、実際にそのメソッドが呼ばれるまで エラーに気づけません。抽象クラスなら、クラスを書いた瞬間にエディタがエラーを表示してくれる 安心感があります。

なぜ親クラスを new したくないのか?

ここまでで「抽象クラスは new できない」と繰り返し出てきました。一見すると「不便な制約」に見えますが、実は 意図して禁止されている有益な制約 です。ここではその理由を、イメージで掴んでおきましょう。

履歴書フォーマットに例えてみる

抽象クラスの世界を、履歴書フォーマットに例えると直感的です。

立ち位置 何に相当するか 使える状態か
親クラス(抽象) 空欄だらけの履歴書フォーマット
「氏名: ____ / 年齢: ____」
未完成なので提出できない
子クラス(具象) 空欄を埋めた履歴書
「氏名: ポチ / 年齢: 3歳」
完成品として提出できる

フォーマット(親クラス)をそのまま提出する人がいたら困るので、そもそも提出できない仕組みにしてある——これが abstract class の役割です。

順番で見るとこうなる

ポイントを整理すると、

  • 完成品になるのは「子クラスで中身を埋めた瞬間」
  • new = 完成品を使い始める合図
  • 未完成なものを new すると、中身のないメソッドを呼んでクラッシュするだけ

つまり抽象クラスは、「new は禁止、継承だけはOK」という制約で、

「ちゃんと具体化してから使ってね」

を言語に守らせている、という仕組みになっています。

具体的に何が嬉しいのか

「親を new できない」ことで、次の3つの事故を コンパイル時に 防げます。

防げる事故 具体例
意味のないインスタンスが作られる new Animal("名無し")new BaseNotifier(...) のような、何も具体化されていないインスタンス
抽象メソッドが呼ばれてクラッシュする 中身がない send() が呼ばれて例外が飛ぶ、という事故
設計意図がドキュメント頼みになる 「このクラスは継承して使ってね」というコメントや規約が守られない、という人為ミス

特に ③ 設計意図がドキュメント頼みにならない のが大きいです。

  • コメント規約: // 注意: このクラスは継承して使ってください
  • README: 「このクラスは直接使わないでください」
  • コードレビュー: 「ここで new BaseNotifier してる人がいるよ」

これらは全部、破られる可能性がある防御策です。abstract class ならコンパイラが強制するので、そもそも誤用コードが書けません

ドキュメント10行より、abstract 1語のほうが強い

これが「親を new したくないケース」の正体です。

ToDoアプリでの抽象クラスの例

もう少し実用的な例で見てみます。ToDoアプリで、タスクの期限が近づいたら通知を送る仕組みを作りたいとします。通知手段は メール・Slack・LINE など複数 ありますが、「通知を送る」という行為自体はどれも共通です。

この 「共通部分 + 個別部分」 の構造がまさに抽象クラス向きです。

クラス図の <<abstract>> が「このクラスは抽象クラスですよ」の印です。

TypeScriptでの実装

// 抽象クラス:通知の基底クラス
abstract class BaseNotifier {
  protected recipient: string;

  // recipient = 受信者
  constructor(recipient: string) {
    this.recipient = recipient;
  }

  // 抽象メソッド:子クラスで必ず実装が必要
  abstract send(message: string): void;

  // 通常のメソッド:全ての通知で共通のフォーマット処理
  formatMessage(task: { title: string; dueDate: Date }): string {
    const due = task.dueDate.toLocaleDateString();
    return `【リマインダー】${task.title} の期限は ${due} です`;
  }
}

// 具象クラス:メール通知
class EmailNotifier extends BaseNotifier {
  private smtpServer: string;

  constructor(email: string, smtpServer: string) {
    super(email);
    this.smtpServer = smtpServer;
  }

  // 抽象メソッドの実装(必須)
  send(message: string): void {
    console.log(`[メール] ${this.recipient} に送信`);
    console.log(`   サーバー: ${this.smtpServer}`);
    console.log(`   内容: ${message}`);
  }
}

// 具象クラス:Slack通知
class SlackNotifier extends BaseNotifier {
  private webhookUrl: string;

  constructor(channelId: string, webhookUrl: string) {
    super(channelId);
    this.webhookUrl = webhookUrl;
  }

  send(message: string): void {
    console.log(`[Slack] #${this.recipient} に投稿`);
    console.log(`   Webhook: ${this.webhookUrl}`);
    console.log(`   内容: ${message}`);
  }
}

// 具象クラス:LINE通知
class LineNotifier extends BaseNotifier {
  private accessToken: string;

  constructor(userId: string, accessToken: string) {
    super(userId);
    this.accessToken = accessToken;
  }

  send(message: string): void {
    console.log(`[LINE] ${this.recipient} に送信`);
    console.log(`   内容: ${message}`);
  }
}

ポイントは、共通のフォーマット処理(formatMessage)は親に実装済みで、送信方法(send)だけ子クラスに強制しているというところです。

使ってみる

const task = { title: "レビュー依頼", dueDate: new Date("2024-12-01") };

const emailNotifier = new EmailNotifier("taro@example.com", "smtp.example.com");
const slackNotifier = new SlackNotifier("dev-team", "https://hooks.slack.com/...");
const lineNotifier = new LineNotifier("U1234567890", "access_token_here");

// 共通の formatMessage がどの通知でもそのまま使える
const message = emailNotifier.formatMessage(task);
// "【リマインダー】レビュー依頼 の期限は 2024/12/1 です"

// それぞれの方法で通知
emailNotifier.send(message);
// [メール] taro@example.com に送信
//    サーバー: smtp.example.com
//    内容: 【リマインダー】レビュー依頼 の期限は 2024/12/1 です

slackNotifier.send(message);
// [Slack] #dev-team に投稿
//    Webhook: https://hooks.slack.com/...
//    内容: 【リマインダー】レビュー依頼 の期限は 2024/12/1 です

// 抽象クラスは直接インスタンス化できない(下記はコンパイルエラー)
// const notifier = new BaseNotifier("test");

抽象クラスの重要ポイント

1. abstract キーワード

abstract には、クラスに付ける場合と メソッドに付ける場合の2つがあります。

付ける場所 意味
abstract class 〜 このクラスは直接 new できない(継承されて使われる専用)
abstract メソッド名(): 戻り値型; このメソッドは中身なしで宣言し、子クラスで必ず実装すること
// クラスに付ける
abstract class BaseNotifier {
  // メソッドに付ける(中身は書かない)
  abstract send(message: string): void;

  // 通常のメソッドは実装を書く
  formatMessage(task: { title: string }): string {
    return `【通知】${task.title}`;
  }
}

2. 抽象メソッドと通常メソッドを混ぜられる

抽象クラスの中では、抽象メソッド通常のメソッドを自由に混ぜられます。これが「共通部分は親に、個別部分は子に」という設計をきれいに表現できるポイントです。

abstract class BaseNotifier {
  // 抽象メソッド(子クラスで実装必須)
  abstract send(message: string): void;

  // 通常のメソッド(子クラスがそのまま使える)
  formatMessage(task: { title: string; dueDate: Date }): string {
    return `【リマインダー】${task.title} の期限は ${task.dueDate.toLocaleDateString()} です`;
  }

  // 通常のメソッド(共通のログ処理)
  protected log(action: string): void {
    console.log(`[${new Date().toISOString()}] ${action}`);
  }
}

この設計により、

  • 共通処理 → 親に1回書けば全子クラスで使える
  • 個別処理 → 子クラスで実装を強制
  • 共通処理の修正 → 親を直すだけで全子クラスに反映

という、継承のメリットを最大限引き出す使い方ができます。

3. 実装漏れをコンパイル時に検出できる

抽象メソッドを実装しない子クラスは、そもそもクラス宣言時点でコンパイルエラーになります。

// sendメソッドを実装していないのでエラー
class BrokenNotifier extends BaseNotifier {}
// Error: Non-abstract class 'BrokenNotifier' does not implement
//        inherited abstract member 'send' from class 'BaseNotifier'.

// sendメソッドを実装しているのでOK
class WorkingNotifier extends BaseNotifier {
  send(message: string): void {
    console.log(message);
  }
}

これが 「実行するまで気づかない実装漏れ」を防ぐ 仕組みです。

4. 普通のクラス(継承)との違い

抽象クラスと普通のクラス(具象クラス)の違いを表にするとこうなります。

特徴 通常のクラス 抽象クラス
インスタンス化(new できる できない
継承されることを前提にしているか 任意 前提
メソッド実装の強制 できない できるabstract メソッド)
実装を持てるか はい はい
// 通常のクラス
class Task {
  complete(): void { /* 実装あり */ }
}
const task = new Task(); // インスタンス化できる

// 抽象クラス
abstract class BaseNotifier {
  abstract send(message: string): void;
}
// const notifier = new BaseNotifier(); // インスタンス化できない(コンパイルエラー)

抽象クラスのメリット

メリット 説明
実装漏れを防げる 子クラスで abstract メソッドを実装し忘れるとコンパイルエラーになる
意味のない new を防げる "ただの Animal" や "ただの BaseNotifier" をインスタンス化されるのを防げる
共通処理と個別処理を1クラスに集約 共通は親で実装、個別は子で実装という役割分担がきれいに表せる
統一インターフェースで扱える BaseNotifier[] のように、親の型で子クラスを一括処理できる(ポリモーフィズム)

保守性の視点:未来の自分とチームへの「優しさ」

もう一歩踏み込むと、抽象クラスの価値は チーム開発や長期運用の場面 でさらに大きくなります。

自分一人で書いているときは「忘れない」「規約は守る」と思えますが、数ヶ月後の自分や、あとからプロジェクトに参加するメンバーは、設計の経緯や暗黙のルールを知りません。コメントや README は読み飛ばされるかもしれませんし、コードレビューでも毎回防ぎきれるとは限りません。

抽象クラスは、未来の自分や仲間が「間違った使い方を物理的にできないようにする」ための優しさ でもあります。「正しい使い方しかできない構造」をコードで表現しておくことで、ドキュメントやレビューに頼らずに設計意図を守り続けることができます。

業務でよく出てくる抽象クラスパターン:バッチ処理の改善

継承記事で紹介したバッチ処理の例を題材に、抽象クラスを導入するとどのように堅牢な設計になるか を見ていきます。

継承記事で書いたコード(復習)

class BatchJob {
  run(): void {
    this.log("開始");
    const start = Date.now();
    try {
      this.execute(); // ← 子クラスで実装する個別の処理
    } catch (e) {
      this.log(`エラー: ${e}`);
      throw e;
    } finally {
      this.log(`終了 (${Date.now() - start}ms)`);
    }
  }

  protected log(message: string): void {
    console.log(`[${this.constructor.name}] ${message}`);
  }

  protected execute(): void {
    throw new Error("子クラスで execute() を実装してください");
  }
}

このコード、動きはしますが2つの問題を抱えています。

  1. new BatchJob() ができてしまう
    • "ただのバッチ" って何? 単体で使う意味がないのに、new できてしまう
  2. execute() の実装漏れに気づくのが「実行時」
    • 子クラスで execute() を書き忘れても、クラス定義時点ではエラーにならない
    • バッチが実際に走ってから 「子クラスで execute() を実装してください」 という例外が出て初めて気づく

後者は特に、本番環境で夜間バッチが落ちて翌朝気づく、といった事故にもつながりかねない、見過ごせない問題です。

抽象クラスに進化させる

abstract を2箇所足すだけで、両方の問題が解決します。

abstract class BatchJob {       // ← クラスに abstract
  run(): void {
    this.log("開始");
    const start = Date.now();
    try {
      this.execute();
    } catch (e) {
      this.log(`エラー: ${e}`);
      throw e;
    } finally {
      this.log(`終了 (${Date.now() - start}ms)`);
    }
  }

  protected log(message: string): void {
    console.log(`[${this.constructor.name}] ${message}`);
  }

  protected abstract execute(): void;  // ← メソッドに abstract(中身を消す)
}

これで変わるのは次の2点です。

変わったこと 効果
new BatchJob()コンパイルエラー "ただのバッチ" を誤って作れなくなる
execute() を書き忘れた子クラスがコンパイルエラー 実装漏れが実行前に必ず気づける
// ちゃんと実装している場合
class UserSyncJob extends BatchJob {
  protected execute(): void {
    this.log("ユーザーデータを同期中...");
  }
}

// 実装を忘れた場合(クラス定義の時点でエラーになる)
class BrokenJob extends BatchJob {}
// Error: Non-abstract class 'BrokenJob' does not implement
//        inherited abstract member 'execute' from class 'BatchJob'.

継承記事で紹介した テンプレートメソッドパターン は、このように抽象クラスと組み合わせることで真価を発揮します。「枠は親で、中身は子で必ず書く」という契約を言語の型システムに守ってもらえるようになるわけです。

抽象クラスの注意点

1. 多重継承はできない

TypeScript(と JavaScript)では、クラスは 親を1つしか持てません。抽象クラスも同じで、子クラスは1つの抽象クラスしか継承できません。

abstract class A {}
abstract class B {}

// こういうことはできない(コンパイルエラー)
// class C extends A, B {}

「複数の契約を同時に満たしたい」というケースには、次の記事で扱う インターフェース のほうが向いています。

2. 抽象クラス vs インターフェース

使い分けの目安は次のような感じです(詳細は次の記事で扱います)。

使いたいこと 向いているもの
共通の実装をまとめたい 抽象クラス
単に「このメソッドを持ってね」という契約だけ決めたい インターフェース
1つの親から複数の機能を受け継ぎたい 抽象クラス
複数の契約を同時に満たしたい インターフェース

抽象クラスは「実装も渡せるし、契約も強制できる」ハイブリッドな存在。インターフェースは「契約だけを扱う純粋な存在」です。

継承と抽象クラスの違い

ここまでの内容を、「どちらを使うべきか」を判断するための早見表としてまとめておきます。

判断早見表

次の問いに上から順に答えていくと、自然と選ぶべきものが決まります。

問いかけ YES のとき NO のとき
親クラス単体を new することに意味があるか 普通のクラスでOK 抽象クラス候補へ進む
子クラスに「このメソッドは必ず実装してね」と強制したいか 抽象クラスを使う 普通のクラスで十分
実装漏れをコンパイル時に検出したいか 抽象クラスを使う 普通のクラスで十分
共通処理と個別処理を1つのクラスに同居させたいか 抽象クラスが向いている 普通のクラスで十分
実装は持たず「契約」だけを決めたいか インターフェース(次記事) 抽象クラス or 普通のクラス

ざっくり言うと、「再利用だけ」なら普通のクラス、「強制したい・禁止したいことがある」なら抽象クラス、「契約だけ決めたい」ならインターフェース、という基準です。

コードで並べて見るとこうなる

継承記事で扱ったバッチ処理を例にすると、違いが一番くっきりします。

// 普通のクラス(継承のみ)
class BatchJob {
  run(): void { /* 共通の流れ */ }
  execute(): void {
    throw new Error("子クラスで実装してください");
    // ↑ 実際に呼ばれるまでエラーに気づけない
  }
}

class BrokenJob extends BatchJob {}  // ← 定義時点ではエラーなし
new BrokenJob().run();               // ← 実行して初めて例外が飛ぶ(遅い)
// 抽象クラス
abstract class BatchJob {
  run(): void { /* 共通の流れ */ }
  abstract execute(): void; // ← 中身なしで宣言
}

class BrokenJob extends BatchJob {}
// Error: Non-abstract class 'BrokenJob' does not implement
//        inherited abstract member 'execute'.
// クラスを書いた瞬間にエラーになる(早い)

「実行して初めてわかるバグ」が「書いた瞬間にわかるバグ」になる のが、抽象クラスの最大の価値です。

まとめ

最後に、この記事で登場した仕組みと用語を整理しておきます。「どちらを選ぶか」の判断基準は前章の早見表を参照してください。

登場した要素 役割・書き方
抽象クラス 継承されて使われる前提の、未完成なクラス
abstract class 〜 クラス全体を「継承専用」として宣言する構文
abstract メソッド(): 戻り値; 中身を書かないメソッド宣言で、子クラス側に実装を委ねる
抽象メソッドと通常メソッドの混在 同じクラス内に共存できるため、共通処理は親・個別処理は子というすみ分けを1クラスで表現できる
代表的な使いどころ テンプレートメソッドパターン(バッチ処理、通知、インポート処理など)
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?