🧩

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

に公開

はじめに

「多態性(ポリモーフィズム)」 は柔軟な設計を実現するための超強力な概念です。

しかし、ここにコンパイラさえ検知できない「落とし穴」があることをご存知でしょうか?

「型は合っている(継承もしている)。コンパイルエラーもない。なのに、実装を差し替えた瞬間にシステムが壊れた」

これは言語を問わず、多くの開発現場で起きるミステリーです。

今回は、この 「多態性の罠」 を回避し、安全にコードを拡張するための原則を解説します。

(多態性の基本については、こちらの記事で解説しています。)
https://zenn.dev/hashidev/articles/ada492742d55ae

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
}

実際にクライアントコードを動かしてみましょう。

main.go
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(拡張性)が安全に機能する土台が整うのです。

https://zenn.dev/hashidev/articles/89193b6e13d23f

Discussion