🐯

SOLIDの原則を心で理解する - リスコフの置換原則

2024/06/30に公開

SOLIDの原則を心で理解することの探求を続け、TypeScriptの例を交えて説明します。

単一責任の原則 (SRP) オープン/クローズドの原則 (OCP) を見た後、今日は リスコフの置換原則 (LSP) に注目します。
この原則は1994年に バーバラ・リスコフジャネット・ウィング によって定義され、派生クラスが基底クラスの代わりに透過的に使用できる条件を定義しています。
言い換えれば、サブクラスがスーパークラスの代わりに使用されても、プログラムの期待される動作に影響を与えないことを保証します 🤩

原則の定義

リスコフの置換原則の最も明白な定義は下記の通りです。

まだ明確でない場合、長い説明よりも例を見た方がわかりやすいので、TypeScriptのいくつかの例を一緒に見てみましょう。

シンプルで理論的な例

次の例は独創的ではありません。一般的にリスコフの置換原則 (LSP)を説明するために使われるものです。しかし、シンプルで効果的であるという利点があります。

幾何学的な形状を表すクラスの階層があり、基底クラスとしてShapeクラス、派生クラスとしてRectangleクラスとCircleクラスがあると仮定しましょう。

abstract class Shape {
  abstract getArea(): number
}

class Rectangle extends Shape {
  constructor(
    private width: number,
    private height: number,
  ) {
    super()
  }

  getArea(): number {
    return this.width * this.height
  }
}

class Circle extends Shape {
  constructor(private radius: number) {
    super()
  }

  getArea(): number {
    return Math.PI * Math.pow(this.radius, 2)
  }
}

この例では、Shapeクラスは抽象クラスであり、形状の面積を計算するためのgetArea()メソッドを定義しています。RectangleクラスとCircleクラスはShapeのサブクラスであり、それぞれの内部ロジックに基づいてgetArea()メソッドを実装しています。

リスコフの置換原則のおかげで、Shapeのインスタンスが期待される場所であればどこでも、RectangleCircleのインスタンスを使用することができ、プログラムの動作を変更することはありません。

function printShapeArea(shape: Shape) {
  console.log(`Area: ${shape.getArea()}`)
}

const rectangle = new Rectangle(5, 3)
const circle = new Circle(2)

printShapeArea(rectangle) // 15
printShapeArea(circle) // ~12.566

上記の例では、printShapeArea関数はShape型のパラメータを受け取ります。渡されたshapeRectangleのインスタンスであれ、Circleのインスタンスであれ、関数の動作は常に同じです。

つまり、これら二つのクラスは確かに置換可能です。

さてさて、理論的で少し退屈な例を見たので、次はもう少し具体的なケースを見てみましょう。

具体的なケース

今回は、漫画やゲームの職業の例を取り上げます。リスコフの置換原則 (LSP) がどのようにそれを解決するのかを見ていきます。

最初のユニットタイプである基底クラス「戦士(Warrior)」をモデリングし、そこから派生をさせて「兵士(Soldier)」と「騎士(Knight)」を作成しました。

これら二つの職業は現在、攻撃(attack())と防御(parry())の二種類のアクションしか持っていません。現在のコードは次のようになっています

abstract class Warrior {
  abstract attack(): void // 攻撃
  abstract parry(): void  // 防御
}

class Soldier extends Warrior {
  attack() {
    console.log('Soldier attacks')
  }

  parry() {
    console.log('Soldier parries')
  }
}

class Knight extends Warrior {
  attack() {
    console.log('Knight attacks')
  }

  parry() {
    console.log('Knight parries')
  }
}

スタイリッシュ!

ただしさらに職業を追加したいと思い、3つ目に、野蛮で恐ろしい「狂戦士(Berserker)」を導入します。

この「狂戦士(Berserker)」は恐れを知らず、絶え間なく攻撃し、敵の隊列に恐怖をもたらします。

しかし、彼には盾がありません。この特性は内部でうまく管理できました。

class Berserker extends Warrior {
  attack() {
    console.log('Berserker attacks')
  }

  parry() {
    throw new Error('Berserker cannot parry')
  }
}

続いて、異なるタイプの「戦士(Warrior)」を戦わせてゲームの基本を説明する小さなチュートリアル機能を導入しました。

function fight(warrior1: Warrior, warrior2: Warrior) {
  warrior1.attack() // 攻撃
  warrior2.parry()  // 防御
}

この関数は二つのWarriorをパラメータとして受け取り、最初のWarriorが攻撃し、二番目のWarriorが防御するようにします。これを使って、最初の二つのWarriorを戦わせることができます。

const soldier = new Soldier()
const knight = new Knight()

fight(soldier, knight) // 兵士が攻撃し、騎士が防御する

しかし、BerserkerKnightを戦わせようとしたらどうなるでしょうか?

const knight = new Knight()
const berserker = new Berserker()

fight(berserker, knight) // 騎士が攻撃し、❌ エラー:狂戦士は防御できません 😱

確かに、私たちのBerserkerは盾を持っていないため、敵の攻撃を防御することができません!

したがって、設計上の問題があります。Berserkerクラスは、基底クラスWarriorによって定義された行動契約を守っていません。

この問題を解決する方法はいくつかありますが、最も簡潔な方法は、新しい抽象クラスまたはインターフェースShieldedWarriorを作成し、Warriorを拡張してparry()メソッドを宣言することです。

abstract class Warrior {
  abstract attack(): void
}

abstract class ShieldedWarrior extends Warrior {
  abstract parry(): void
}

class Soldier extends ShieldedWarrior {
  attack() {
    console.log('Soldier attacks')
  }

  parry() {
    console.log('Soldier parries')
  }
}

class Knight extends ShieldedWarrior {
  attack() {
    console.log('Knight attacks')
  }

  parry() {
    console.log('Knight parries')
  }
}

class Berserker extends Warrior {
  attack() {
    console.log('Berserker attacks')
  }
}

これにより、fight()関数に渡されるのがShieldedWarriorのインスタンスだけであることを静的に保証することができます。

function fight(warrior1: ShieldedWarrior, warrior2: ShieldedWarrior) {
  warrior1.attack()
  warrior2.parry()
}

リスコフの置換原則(LSP)違反の例

リスコフの置換原則の一般的な違反のいくつかは、サブクラスが基底クラスの契約を守らない場合や、予期しない動作を導入する場合に発生することがあります。

  • 基底クラスの動作の変更: サブクラスが基底クラスの期待される動作を変更する場合。
  • 未処理の例外: サブクラスが基底クラスやそのスーパークラスで宣言されていない例外をスローする場合。未処理の例外はプログラムの正常なフローを乱し、予期しないエラーを引き起こす可能性があります。
  • 実装の詳細への依存: サブクラスが基底クラスの実装の詳細に依存する場合、リスコフの置換原則(LSP)の違反が発生する可能性があります。サブクラスは、基底クラスの内部の詳細を知らずに、またはそれに依存せずに基底クラスに置き換えることができなければなりません。
  • 互換性のない値の返却: サブクラスが親クラスで定義されたものと互換性のないタイプの値を返す場合。例えば、基底クラスが特定のタイプを返すメソッドを定義している場合、サブクラスは異なるサブタイプや完全に異なるタイプを返してはなりません。

まとめ

リスコフの置換原則(LSP)をソフトウェア設計において遵守することで、コードの一貫性と柔軟性を保証します。サブクラスが基底クラスに置き換えられてもプログラムの期待される動作が変わらないようにすることで、コードの拡張性、保守性、再利用性を向上させます。

TypeScriptを使用することで、リスコフの置換原則(LSP)を効果的に適用し、サブクラスが基底クラスによって定義された契約と動作を尊重するクラス階層を作成できます。これにより、新しい機能を既存のコードを変更することなく追加できる、よりモジュール化されたシステムを構築できます。

リスコフの置換原則を開発に適用することで、堅牢で一貫性のある設計を促進します。これにより、コンポーネントが簡単に組み合わせて再利用できる、柔軟で拡張可能かつ堅牢なシステムが実現し、アプリケーションの調和のとれた進化への道が開かれます。

Discussion