リスコフの置換原則 (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"
この設計のポイント
- 行動を個別のクラスに切り分ける
- FlyingBehaviorやSwimmingBehaviorのように、各行動を独立したクラスとして定義します。
- 行動が追加される場合も、新しい行動クラスを作成するだけで対応できます(例: WalkingBehaviorなど)。
- Birdクラスは行動に依存しない
- Birdクラスには行動そのものを実装せず、行動を保持するオブジェクトに委譲します。
- これにより、Birdクラス自体の責務が軽くなり、拡張性が向上します。
- 必要に応じて行動を変更可能
- 動的に振る舞いを変更したい場合も、別の行動オブジェクトを割り当てるだけで実現可能です。
- 別の鳥クラスを追加する際も容易
- 新しい鳥クラスを追加する場合も、行動オブジェクトを組み合わせるだけで対応できます。
パターン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"
この設計のポイント
- モジュールを使うことで、必要な行動だけを簡単に追加できる。
- ただし、行動が増えるとモジュール数も増えるため管理が複雑になることがある。
- 複数の行動を簡単に組み合わせられるため、柔軟性が高い。
- 各行動を個別に呼べる(fly, swimなど)
- 各クラスの責務が明確になる。
まとめ
どういう使い方をしたいか、行動の実装をどう管理したいかによって、適切な設計パターンを選択することが重要です。
3. 技術や手法の"キモ"はどこにある?
-
振る舞いの一貫性
- サブクラスは、親クラスで定義された振る舞いを損なわないように設計します。
-
継承の正しい使い方
- 「○○は△△である」という関係が成り立つ場合にのみ、継承を使用します。
- 振る舞いが大きく異なる場合、継承ではなく委譲(composition)を検討します。
-
契約(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)は、サブクラスが親クラスとして振る舞えることを保証する設計原則です。
この原則を守ることで、コードの再利用性と予測性が向上し、設計がより堅牢になります。
Discussion