【SOLID原則(LSP編)】なぜ実装を差し替えた途端にバグるのか? 多態性を「時限爆弾」にしないための設計原則

はじめに
「多態性(ポリモーフィズム)」 は柔軟な設計を実現するための超強力な概念です。
しかし、ここにコンパイラさえ検知できない「落とし穴」があることをご存知でしょうか?
「型は合っている(継承もしている)。コンパイルエラーもない。なのに、実装を差し替えた瞬間にシステムが壊れた」
これは言語を問わず、多くの開発現場で起きるミステリーです。
今回は、この 「多態性の罠」 を回避し、安全にコードを拡張するための原則を解説します。
(多態性の基本については、こちらの記事で解説しています。)
LSP = 多態性の「信頼性」のルール
LSP(リスコフの置換原則)
LSPは、オブジェクト指向設計における 「多態性を安全に保つための、見えないルール」 です。
継承関係にある「親クラスと子クラス」、あるいは「インターフェースと実装構造体」。これらは文法上、親(あるいはインターフェース)の代わりに、子(実装)を置換して使うことができます。
しかし、文法的に置換できることと、「システムを壊さずに安全に置換できること」は、全くの別物です。
LSPはこのギャップを埋めるための原則です。
呼び出し元の期待を裏切った多態性
ここで、LSPを無視した実装が引き起こす「災難」を見てみましょう。
type DataStore interface {
Save(data string) error
}
多態性を活かしてDataStoreインターフェース型のデータを受け取るクライアント側の関数:
func SaveUserData(store DataStore, data string) {
err := store.Save(data)
if err != nil {
fmt.Println("保存に失敗しました") // エラーハンドリング
return
}
fmt.Println("保存しました!") // 成功したという前提で進む
}
😃DataStoreの良い実装例:
// 契約通り、失敗時には error を返します。
type DiskStorage struct{}
func (d *DiskStorage) Save(userID string, data string) error {
fmt.Println(" [DiskStorage] ディスクへの保存を試みています...")
// シミュレーション: 空のデータは保存できない仕様とする
if data == "" {
fmt.Println(" [DiskStorage] 失敗: データが空です")
// 契約を守り、エラーを返す
return fmt.Errorf("invalid data: empty string")
}
// (ここで実際のディスク書き込み処理を行う想定)
fmt.Println(" [DiskStorage] 成功: ディスクに保存しました")
return nil
}
😥DataStoreの悪い実装例:
// MemoryStorage は DataStore の「悪い」実装例(LSP違反)です。
// 契約を破り、エラーを返さずにパニックを起こすケースがあります。
type MemoryStorage struct {
data map[string]string
maxCapacity int // シミュレーション用の容量制限
}
// コンストラクタ(初期化)
func NewMemoryStorage(capacity int) *MemoryStorage {
return &MemoryStorage{
data: make(map[string]string),
maxCapacity: capacity,
}
}
func (m *MemoryStorage) Save(userID string, data string) error {
fmt.Println(" [MemoryStorage] メモリへの保存を試みています...")
// 【ここがLSP違反!】
if len(m.data) >= m.maxCapacity {
panic("メモリオーバーフロー: 保存ができません。")
}
m.data[userID] = data
fmt.Println(" [MemoryStorage] 成功: メモリに保存しました")
return nil
}
実際にクライアントコードを動かしてみましょう。
func main() {
// 1. 良い実装(DiskStorage)を使う場合
// -> 問題なく動作する
diskStore := &DiskStorage{}
SaveUserData(diskStore, "user01", "大切なデータ")
SaveUserData(diskStore, "user02", "") // エラーケースも安全に処理される
// --------------------------------------------------
// 2. 悪い実装(MemoryStorage)に差し替えた場合
// -> クライアントがクラッシュする
fmt.Println("\n\n>>> 実装を MemoryStorage に差し替えます <<<")
// 容量が「1」しかないメモリストレージを作成
memoryStore := NewMemoryStorage(1)
// 1つ目の保存は成功する
SaveUserData(memoryStore, "user01", "データ1")
// 2つ目の保存で容量オーバーになる。
// ここで MemoryStorage がパニックを起こすため、
// クライアントコードの実行が強制的に中断される。
SaveUserData(memoryStore, "user02", "データ2") // <-- ここでクラッシュ!
fmt.Println("この行は実行されません(プログラムがクラッシュしたため)")
}
悪い実装例であるMemoryStorageは、コンパイルは問題なく通ります。
しかし、実行中に突然システムをクラッシュさせます。
これは、インターフェースを信じて使っていたクライアント(呼び出し元)に対する「裏切り」です。
元凶の分析:「文法」と「契約」の分離
なぜコンパイラは、この「裏切り」を検知できなかったのでしょうか?
それは、インターフェースには2つの側面があるからです。
(1)文法上のインターフェース(Syntax):
👀メソッド名、引数の型、戻り値の型。
❗️コンパイラがチェックできるのはこれだけ。
(2)対外的なインターフェース(Contract/Semantics):
👀そのメソッドがどう振る舞うべきかという「意味」や「期待」。
❗️コンパイラには見えない。人間が守るしかない。
今回のバグは、実装者が文法(1)だけを合わせ、「エラー時はパニックせずエラーを返す」という「契約(2)」を無視したことで発生しました。
これはまさに「パズルのピースの形(文法)は合っているからハマるけれど、描かれている絵柄(意味)が全く合っていない」状態です。

遠目には完成しているように見えても、近くで見ると絵は破綻しています。これがLSP違反の正体です。
LSPの再定義:契約による設計
では、どうすれば「絵柄」まで合わせることができるのでしょうか?
ここでLSPを、少し専門的な言葉で再定義します。
「派生型(実装)は、基本型(インターフェース)の『契約』を遵守しなければならない」
具体的には、以下の2つの条件を守ることです。
1. 事前条件(Preconditions)を強めない
⚠️親が「どんな引数でも受け入れる」と言っているのに、子が「特定の引数だと動かない」と言ってはいけません。
2. 事後条件(Postconditions)を弱めない
⚠️親が「必ず保存してエラーを返す」と約束しているのに、子が「保存せずにパニックする(約束の結果を返さない)」ことをしてはいけません。
先ほどのMemoryStorageは、この 「事後条件(期待される結果を返す)」 を破っていたため、LSP違反となったのです。
どうすればLSPを守れるか?
ここで、LSPに違反しないための具体的な対策をいくつか紹介します。
1. 契約による設計 (Design by Contract):
インターフェースのドキュメント(コメント)に、「データがない場合は必ずエラーを返すこと」と明記する。
2. インターフェース準拠テスト (Interface Compliance Tests):
各実装が契約を守っているかを検証する「共通テストスイート」を作る(Goのテクニック)。
3. 危険信号を察知する:
クライアント側で if type == *MemoryStorage のような型アサーションによる分岐を書きたくなったら、それはLSP違反の赤信号。
結論:信頼できるポリモーフィズムへ
多態性の強みは「実装の中身を気にせず使えること」にあります。
LSPを守り、契約(事前・事後条件)を遵守することで初めて、私たちはその強みを享受できるのです。
つまり、インターフェースは単なる「メソッドの集まり」ではありません。それは「振る舞いの保証書」です。
これにより、OCP(拡張性)が安全に機能する土台が整うのです。
Discussion