👌

リスコフの置換原則 (Liskov Substitution Principle, LSP)

に公開

1. どんなもの?

リスコフの置換原則(Liskov Substitution Principle, LSP)は、サブクラスはその親クラスと置き換えても問題なく動作するべきという設計原則です。

例えば、Railsのモデルやサービスクラスを拡張して特定の機能を追加する場合でも、元の親クラスとして期待される動作が変わらないことを保証するための指針です。

2. 通常の実装方法と比べてどこがすごいの?

通常の方法

サブクラスで親クラスの振る舞いを変更したり、期待通りに動作しない場合、コード上で混乱が起こりやすくなります。

class Bird
  def fly
    puts "Flying in the sky"
  end
end

class Penguin < Bird
  def fly
    raise "Penguins cannot fly"
  end
end

def make_bird_fly(bird)
  bird.fly
end

bird = Bird.new
penguin = Penguin.new

make_bird_fly(bird)   # => "Flying in the sky"
make_bird_fly(penguin) # => エラー: Penguins cannot fly

この設計において、 Bird クラスは鳥が空を飛べることを前提になっています。
そのため、 Penguin クラスは fly メソッドでエラーを発生させています。

  • 課題:
    • 親クラスの期待通りに動作しないサブクラスを渡すと、コードが破綻する。
      • この設計(Bird クラス)の例では、例外が発生してしまう。
    • サブクラスの振る舞いが親クラスと一致しないため、予測不可能なバグを引き起こす可能性がある。
      • この設計(Bird クラス)の例では、親クラスと子クラスで振る舞いが異なる(例外発生の有無)

リスコフの置換原則に基づいた方法

リスコフの置換原則を適用することで、サブクラスを親クラスの完全な置き換えとして扱えるように設計します。

class Bird
  def fly
    puts "Flying in the sky"
  end
end

class Sparrow < Bird
end

# NOTE: PenguinクラスはBirdクラスを継承せず、flyメソッドを実装しない
class Penguin
  def swim
    puts "Swimming in the water"
  end
end

def make_bird_fly(bird)
  bird.fly
end

bird = Bird.new
sparrow = Sparrow.new

make_bird_fly(bird)   # => "Flying in the sky"
make_bird_fly(sparrow) # => "Flying in the sky"
# NOTE: Penguinクラス: ペンギンは飛ばないため、Birdクラスを継承していない。make_bird_flyメソッドはBirdクラスと同じ振る舞いを期待しており、Penguinクラスを渡すことができない
  • 利点:
    • サブクラスを親クラスとして扱っても破綻しない。
    • コードの予測性と再利用性が向上。
Birdクラスはどうすればよかった?

Birdクラスの設計パターン

パターン1: FlyingBirdクラスを作成する

ペンギンも鳥であるため、Bird クラスを継承したい。
そのため、 Bird クラスに fly メソッドを実装せず、代わりに FlyingBird クラスを作成して、fly メソッドを実装する。

class Bird
end

class FlyingBird < Bird
  def fly
    puts "Flying in the sky"
  end
end

class Penguin < Bird
  def swim
    puts "Swimming in the water"
  end
end

def make_bird_fly(bird)
  if bird.respond_to?(:fly)
    bird.fly
  else
    puts "This bird cannot fly"
  end
end
  • この設計では、Birdクラス自体には飛ぶ能力を持たせず、FlyingBirdというサブクラスに飛ぶ能力を追加しています。
  • また、Penguinは「飛ぶ」能力を持たないため、flyメソッドを実装せず、代わりにswimメソッドを実装しています。

パターン2: 動作を個別のクラスに切り分け、初期化時に組み合わせる。動作は行動クラスに委譲する

ペンギンも鳥であるため、Bird クラスを使って処理したい。
また、行動は複数クラスで共有できるようにしたい。

# 行動クラス(Behavior)
class Behavior
  def perform
    raise NotImplementedError, "This method should be overridden by subclasses"
  end
end

class FlyingBehavior < Behavior
  def perform
    puts "Flying in the sky"
  end
end

class SwimmingBehavior < Behavior
  def perform
    puts "Swimming in the water"
  end
end

# 基底クラス(Bird)
class Bird
  def initialize(*behaviors)
    @behaviors = behaviors
  end

  def perform_actions
    @behaviors.each(&:perform)
  end
end

# サブクラス
class Sparrow < Bird
  def initialize
    super(FlyingBehavior.new)
  end
end

class Penguin < Bird
  def initialize
    super(SwimmingBehavior.new)
  end
end

class Duck < Bird
  def initialize
    super(FlyingBehavior.new, SwimmingBehavior.new)
  end
end

# 各鳥のインスタンスを作成
sparrow = Sparrow.new
penguin = Penguin.new
duck = Duck.new

# 行動を実行
sparrow.perform_actions # => "Flying in the sky"
penguin.perform_actions # => "Swimming in the water"
duck.perform_actions
# => "Flying in the sky"
# => "Swimming in the water"

この設計のポイント

  1. 行動を個別のクラスに切り分ける
    • FlyingBehaviorやSwimmingBehaviorのように、各行動を独立したクラスとして定義します。
    • 行動が追加される場合も、新しい行動クラスを作成するだけで対応できます(例: WalkingBehaviorなど)。
  2. Birdクラスは行動に依存しない
    • Birdクラスには行動そのものを実装せず、行動を保持するオブジェクトに委譲します。
    • これにより、Birdクラス自体の責務が軽くなり、拡張性が向上します。
  3. 必要に応じて行動を変更可能
    • 動的に振る舞いを変更したい場合も、別の行動オブジェクトを割り当てるだけで実現可能です。
  4. 別の鳥クラスを追加する際も容易
    • 新しい鳥クラスを追加する場合も、行動オブジェクトを組み合わせるだけで対応できます。

パターン3: モジュールを使って行動を注入する

モジュールを利用して行動を定義し、各サブクラスで必要なモジュールをミックスインする

# 行動モジュール
module FlyingBehavior
  def fly
    puts "Flying in the sky"
  end
end

module SwimmingBehavior
  def swim
    puts "Swimming in the water"
  end
end

# 基底クラス
class Bird
end

# サブクラス
class Sparrow < Bird
  include FlyingBehavior
end

class Penguin < Bird
  include SwimmingBehavior
end

class Duck < Bird
  include FlyingBehavior
  include SwimmingBehavior
end

# 行動を実行
sparrow = Sparrow.new
sparrow.fly # => "Flying in the sky"

penguin = Penguin.new
penguin.swim # => "Swimming in the water"

duck = Duck.new
duck.fly # => "Flying in the sky"
duck.swim # => "Swimming in the water"

この設計のポイント

  1. モジュールを使うことで、必要な行動だけを簡単に追加できる。
  • ただし、行動が増えるとモジュール数も増えるため管理が複雑になることがある。
  1. 複数の行動を簡単に組み合わせられるため、柔軟性が高い。
  2. 各行動を個別に呼べる(fly, swimなど)
  3. 各クラスの責務が明確になる。

まとめ

どういう使い方をしたいか、行動の実装をどう管理したいかによって、適切な設計パターンを選択することが重要です。

3. 技術や手法の"キモ"はどこにある?

  1. 振る舞いの一貫性

    • サブクラスは、親クラスで定義された振る舞いを損なわないように設計します。
  2. 継承の正しい使い方

    • 「○○は△△である」という関係が成り立つ場合にのみ、継承を使用します。
    • 振る舞いが大きく異なる場合、継承ではなく委譲(composition)を検討します。
  3. 契約(Contract)の遵守

    • サブクラスは親クラスのインターフェースをそのまま実装するだけでなく、期待される結果を保証します。

4. 実装例

モデルの継承

親モデルを拡張して、特定のロジックを追加。

class Vehicle < ApplicationRecord
  def drive
    puts "Driving the vehicle"
  end
end

class Car < Vehicle
  def drive
    puts "Driving the car"
  end
end

class Bicycle < Vehicle
  def drive
    puts "Riding the bicycle"
  end
end

def start_journey(vehicle)
  vehicle.drive
end

car = Car.new
bicycle = Bicycle.new

start_journey(car)    # => "Driving the car"
start_journey(bicycle) # => "Riding the bicycle"
  • 実装ポイント:
    • サブクラスのdriveメソッドは親クラスの振る舞いを引き継ぎ、一貫性を保っている。

5. 議論はあるか?

メリット

  • 親クラスとサブクラスの一貫性が保証される。
  • サブクラスが親クラスとして扱えるため、コードの再利用性が向上。
  • 予測可能な動作により、バグの発生リスクを低減。

デメリット

  • サブクラスを作成する際に、親クラスの振る舞いを正確に理解し、従う必要がある。
  • 継承が適切でない場合、オーバーヘッドや設計の誤りにつながる。

議論

リスコフの置換原則を守ることは、継承の正しい使用に直結します。ただし、継承が適切でない場合、委譲(composition)を使用する方がより良い設計となる場合があります。

6. まとめ

リスコフの置換原則(LSP)は、サブクラスが親クラスとして振る舞えることを保証する設計原則です。

この原則を守ることで、コードの再利用性と予測性が向上し、設計がより堅牢になります。

GitHubで編集を提案

Discussion