Solid原則シリーズ リスコフの置換原則~同じようで違うやつ~

開発ログ

はじめに

Solid原則シリーズで今回は3つ目のリスコフの置換原則について紹介していきます。
名前だけ見るとなんだか難しいですが、内容は意外とシンプルです!
では、一緒に整理していきましょう!

リスコフの置換原則とは

この原則は一言でいうと、**「親クラスの仲間なら、どの子クラスであっても同じように動かないといけない」**といった感じです。
原則違反している例は以下になります。

  • 親:「鳥」クラス(このクラスの仲間ならみんな空を飛べるよね?)
  • 子:「ペンギン」クラス(鳥の仲間ではあるけど飛べないよ、、、)
    ペンギンクラスは鳥クラスの子クラスであるのにも関わらず、飛ぶという性質を持たないので原則違反となります。

リスコフの置換原則に違反すると、、、

前回に引き続きFitnessGymのクラスで確認します。
新しく「インターバル(休憩)」を追加したくなったとします。

「休憩もトレーニングメニューの一部だし、同じグループ(WorkoutStrategy)に入れちゃえ!」と、深く考えずにWorkoutStrategyを親としてIntervalRestクラスを作ってしまいました。

ここでジムの仕様変更で「メニューごとのカロリー計算機能」が必要になったとします。
そのため、共通ルールであるWorkoutStrategyにカロリー計算のメソッドを追加することになりました。
すると、全体のコードは以下のようになります。

// 1. ルール(親インターフェース)
interface WorkoutStrategy {
  isMatch(targetMuscle: string): boolean;
  getAdvice(): string;
  getCalorie(): number; // ← 新しく「消費カロリー計算」の共通ルールも足しました!
}

// 2. 各部位ごとの具体的なクラス
class ChestWorkout implements WorkoutStrategy {
  isMatch(targetMuscle: string): boolean { return targetMuscle === "胸"; }
  getAdvice(): string { return "ベンチプレスを3セット、各10回行ってください。"; }
  getCalorie(): number { return 150; } // 胸トレの消費カロリー
}

class BackWorkout implements WorkoutStrategy {
  isMatch(targetMuscle: string): boolean { return targetMuscle === "背中"; }
  getAdvice(): string { return "ラットプルダウンを3セット、各12回行ってください。"; }
  getCalorie(): number { return 120; } // 背中の消費カロリー
}

class LegWorkout implements WorkoutStrategy {
  isMatch(targetMuscle: string): boolean { return targetMuscle === "脚"; }
  getAdvice(): string { return "スクワットを3セット、各15回行ってください。"; }
  getCalorie(): number { return 200; } // 脚トレの消費カロリー
}

// 3. インターバルクラス(親のルールを破る悪いクラス)
class IntervalRest implements WorkoutStrategy {
  isMatch(targetMuscle: string): boolean { return targetMuscle === "休憩"; }
  getAdvice(): string { return "プロテインを飲んで3分間休憩してください。"; }

  getCalorie(): number {
    // カロリー消費という属性は持たないため無理矢理エラーを差し込むことになる
    throw new Error("休憩中はカロリー計算できません!");
  }
}

// 4. アドバイザー(これらを取りまとめるクラス)
class WorkoutAdvisor {
  private strategies: WorkoutStrategy[];

  constructor(strategies: WorkoutStrategy[]) {
    this.strategies = strategies;
  }

  getWorkoutAdvice(targetMuscle: string): string {
    const strategy = this.strategies.find(s => s.isMatch(targetMuscle));
    return strategy ? strategy.getAdvice() : "ストレッチから始めましょう。";
  }

  // ★追加:本日の合計消費カロリーを計算する
  calculateTotalCalorie(): number {
    let total = 0;
    for (const strategy of this.strategies) {
      // 使う側は「WorkoutStrategyのルールを守っているなら、どれを呼んでも数字が返ってくる」と信じて疑わない
      total += strategy.getCalorie();
    }
    return total;
  }
}

そして、単一原則で作った本体のFitnessGymクラスからカロリー計算の処理を呼び出すようにします。

// フィットネスジム本体のクラス。各クラスを呼び出すだけ
class FitnessGym {
  private memberManager: MemberManager;
  private billingService: BillingService;
  private machineManager: MachineManager;
  private workoutAdvisor: WorkoutAdvisor;
  private cleaningManager: CleaningManager;

  constructor(
    public gymName: string,
    monthlyFee: number,
    currentTaxRate: number,
  ) {
    this.memberManager = new MemberManager();
    this.billingService = new BillingService(monthlyFee, currentTaxRate);
    this.machineManager = new MachineManager();
    this.workoutAdvisor = new WorkoutAdvisor();
    this.cleaningManager = new CleaningManager();
  }

  // ---- 会員管理の委譲 ----
  registerMember(name: string): void {
    this.memberManager.registerMember(name);
  }

  // ---- 会計処理の委譲 ----
  calculateTotalCharge(): number {
    return this.billingService.calculateTotalCharge(
      this.memberManager.getMembers(),
    );
  }

  // ---- 機材管理の委譲 ----
  getMachineStatus(machineId: string): string {
    return this.machineManager.getMachineStatus(machineId);
  }

  // ---- トレーニング指導の委譲 ----
  getWorkoutAdvice(targetMuscle: string): string {
    return this.workoutAdvisor.getWorkoutAdvice(targetMuscle);
  }

  // ---- 清掃管理の委譲 ----
  getCleaningChecklist(): string[] {
    return this.cleaningManager.getCleaningChecklist();
  }

  // ★追加:ジムの会員に合計消費カロリーを教えてあげるメソッド
  getTodayGymCalorie(): number {
    return this.workoutAdvisor.calculateTotalCalorie();
  }
}

一見問題なさそうなコードに見えます。
しかし、いざ「今日の合計消費カロリーを知りたいな」とgetTodayGymCalorie を呼び出した瞬間、問題が発生します。

calculateTotalCalorie の処理の中でIntervalRest のカロリーを足そうとした瞬間、エラーがthrowされてしまいます。

WorkoutStrategy が親クラスなら休憩を示すIntervalRest も仲間でしょ」と追加してしまったせいでジムのシステムにバグが混入することになってしまいました。
それも実行するまでわからないという点も怖いですね。

リスコフの置換原則を守るなら

内容はシンプルです。アプローチとしては2つあります。

1つ目は、休憩中は消費カロリーを0として、数値を返すようにすることです。
これならcalculateTotalCalorieでカロリー計算する際もエラーを出さずに処理を完了させることができます。
インターフェースの約束(numberを返す)をしっかり守っているため、これも立派なリスコフの置換原則の守り方です。

そして2つ目は、「別の仕組みとして潔く隔離する」ことです。
1つ目でも解決ができるとはいえ、休憩中はカロリーという概念がないので仕様として0を常に返すのは違和感があると思います。
その場合は無理にWorkoutStrategy グループに入れようとせず、休憩専門のグループを新しく作ってそこに所属させるか、独立したクラスとして切り出すなどするのがいいと思います。

親の持つ属性を持たないのであれば潔く隔離する。それもリスコフの置換原則で非常に大切な考え方になります。

おわりに

リスコフの置換原則は「親クラスの仲間なら、どの子クラスであっても同じように動かないといけない」といった原則でした。

親クラスに持たない役割を持っていても「似てるしエラーを返しておけばいいでしょ」と場当たり的な対応をしても、いざ実行したときに予期せずバグが発生してしまいます。

この原則を知らなかった当時、僕もエラーをスローする形で対応してしまい、レビューで注意されてしまいました。
もし、レビューで見られてなかったら、、、と思うと怖いですね、、、。

今はAIも使えますし、もし、原則に違反しているのかどうか不安であれば「リスコフの置換原則」というキーワードを使って聞いてみるのもいいと思います。

ここまで見てくださっていただき、ありがとうございました!
次回はインターフェース分離の原則に入っていきます。

参考

コメント

タイトルとURLをコピーしました