
「同じ型だよ」って聞いたから差し替えたのに壊れたんだけど?🫣
これ絶対あるじゃん??
- 親クラスを継承した子クラスが、あるメソッドで
raise NotImplementedErrorしてる - 「このインターフェースなら同じように動くでしょ」って思って差し替えたら、子クラスだけ落ちた
- テストは全部グリーンなのに、特定の子クラスを渡したときだけ本番で例外が飛ぶ
if isinstance(obj, SpecificChild):みたいな分岐がじわじわ増えてる
あるよね? あるよね絶対??
これ全部、Broken Substitute(約束破りサブクラス)、以降「約束破り」って呼ぶね、が原因なんだよね。
てかシンプルに言うとね
「同じ親から生まれた子クラスのはずなのに、差し替えたら呼ぶ側のコードが壊れる。」
「それの何がまずいの?」ってなる気持ち、わかる!
「親と同じ型です」って顔してるくせに同じように動かないって、使う側からしたら詐欺じゃん。その話、ちゃんとするね。
ちょっと待って、先に全然関係ない話していい?🏠
コンビニの話するね。
駅前にある「ファミマート(仮名)」。ここに店長の田中さんがいて、もう3年目なんだけどさ。レジ打ち、返品対応、年齢確認、宅配便の受付——マニュアル通りに完璧にこなす人なんだよね。
ある日、田中さんがインフルで1週間休むことになった。代わりに本部からヘルプの佐藤さんが来たの。
「田中さんと同じ店員マニュアルで研修済みです。問題なく回せます」
って本部は言ってた。
初日、まあまあ普通だったの。レジ打ちはできてた。
2日目の夕方。常連のおばちゃんが来て、こう言った。
「昨日買ったお弁当、消費期限切れだったの。返品したいんだけど」
佐藤さんの返事がこれ。
「あー、返品はやってないんで」
おばちゃんポカーンだよね。
えっ……田中さんのときは普通にやってくれたじゃん……
佐藤さん、研修で「レジ打ち」しか覚えてなくて、返品対応は「知らない」からスルーしてたの。
3日目。もっとヤバいのが起きたんだよね。
高校生が缶チューハイ持ってレジに来た。佐藤さん、年齢確認ボタンをポチッと押して、そのまま通した。身分証の確認なし。
隣にいた副店長の鈴木さんが慌てて止めた。
「ちょっと待って! 未成年に酒売っちゃダメでしょ!」
佐藤さんはこう言った。
「え、ボタン押せばOKじゃないんすか?」
マニュアルに「身分証を目視で確認する」って書いてあるのに、そこ飛ばしてたの……
結果どうなったかっていうとさ。
- おばちゃんがGoogleに星1レビュー投稿。「返品すらできないコンビニ」
- 未成年への酒販売が本部にバレて、本部が店舗を指導した
- 副店長の鈴木さんが始末書を書くハメになった
- 田中さん、病み上がりで出勤したら「お前がいない間にこの店ボロボロだよ」って言われた
同じ「店員」のはずなのに……代わりにならなかった……
これが本番で起きてるやつが、約束破りってこと。
じゃあコードで見てみよっか👀
コードに戻すとこうなるんだよね。ちょっと見てみて?
佐藤さん = CryptoPayment、田中さん = CreditCardPayment、コンビニのマニュアル = PaymentProcessor。
from abc import ABC, abstractmethod
class PaymentProcessor(ABC):
"""決済を処理するマニュアル"""
@abstractmethod
def charge(self, amount: int) -> str:
"""支払いを受けて、取引IDを返す"""
...
@abstractmethod
def refund(self, transaction_id: str) -> None:
"""取引IDを受けて、返金する"""
...
class CreditCardPayment(PaymentProcessor):
"""クレカ決済(= 田中さん)"""
def charge(self, amount: int) -> str:
print(f"クレカで {amount}円を決済")
return f"cc_{amount}"
def refund(self, transaction_id: str) -> None:
print(f"取引 {transaction_id} を返金")
class CryptoPayment(PaymentProcessor):
"""暗号通貨決済(= 佐藤さん)"""
def charge(self, amount: int) -> str:
print(f"暗号通貨で {amount}円を決済")
return f"crypto_{amount}"
def refund(self, transaction_id: str) -> None:
# 「返品はやってないんで」
raise NotImplementedError("暗号通貨は返金できません")
使う側のコードを見てみて。
def process_cancellation(processor: PaymentProcessor, tx_id: str):
"""キャンセル処理。どの決済手段でも同じフローのはず"""
processor.refund(tx_id)
print("返金完了メールを送信")
print("在庫を戻す")
# 田中さん(クレカ)→ 問題なし
cc = CreditCardPayment()
tx = cc.charge(3000)
process_cancellation(cc, tx) # ちゃんと返金される
# 佐藤さん(暗号通貨)→ ここで壊れる
crypto = CryptoPayment()
tx = crypto.charge(3000)
process_cancellation(crypto, tx) # NotImplementedError で死ぬ
process_cancellationは「PaymentProcessorを受け取ればrefundが使える」って信じて書いてあるじゃん。でもCryptoPaymentはrefundで例外を投げる。
これ、テストでCreditCardPaymentだけ通してたら全部グリーンなんだよね。本番でCryptoPaymentが初めて返金フローに入ったとき——深夜1時に500 Internal Server Error。
わたしも昔これやったことあって。「継承してるから大丈夫」って思い込んでたら、特定の子クラスだけrefundで落ちるっていう。お客さんから「返金されてない」って問い合わせ来て初めて気づいた。あれはほんとキツかった。
やばいのまだあんだけど、バグに気づけない問題😭
田中さんの代わりに佐藤さんが来て、返品を断ったり年齢確認を飛ばしたりしたじゃん。
もし「店員が正しく動いてるかチェックするリスト」を作るとしたらさ。田中さんだけを想定してたらシンプルなんだよね。レジ打ち✅ 返品対応✅ 年齢確認✅。
でも佐藤さんが来たことで「この人、返品のとき何するか」「年齢確認のとき何するか」を佐藤さん専用にもチェックしなきゃいけなくなる。
代わりの人が増えるたびに「こいつだけ変な動きしない?」ってチェック項目が爆発するの。3人目の代打、4人目の代打……全員のクセを把握してないとチェックできない。
コードでも同じなんだよね。
# 「全員同じように動く」前提なら、テストは1セットでOK
def test_refund(processor: PaymentProcessor):
tx = processor.charge(1000)
processor.refund(tx) # 全員これが通るはず
# でも約束破りがいると、こうなる
def test_refund_credit():
processor = CreditCardPayment()
tx = processor.charge(1000)
processor.refund(tx) # OK
def test_refund_crypto():
processor = CryptoPayment()
tx = processor.charge(1000)
# えっ、これどうテストすんの?
# NotImplementedError を「期待する」テストを書く?
# それって「壊れてることを正しいと認める」テストじゃない?
def test_refund_bank_transfer():
# 銀行振込は? refund の挙動違う?
...
def test_refund_convenience_store():
# コンビニ決済は?
...
子クラスが増えるたびに「こいつは約束守ってる? 守ってない?」を全メソッド分チェックしなきゃいけなくなる。
約束破りは壊れやすいだけじゃなく、バグに気づけない設計でもあるんだよね。
分けてた会社の話もするね〜✨
じゃあ同じコンビニで「ちゃんとやってた別の店舗」の話するね。
この店舗は、ヘルプのバイトを呼ぶとき**「あなたがやる仕事はここまで」**をめっちゃ明確にしてたの。
返品対応ができないバイトには、最初から返品カウンターに立たせない。年齢確認が必要な商品は、研修が終わるまでそのレジに入れない。
何が起きなかったかっていうと:
- おばちゃんの返品は返品担当がちゃんと対応した → 星1レビューなし
- 年齢確認は研修済みスタッフだけが担当 → 法令違反なし
- 副店長の始末書もなし → 定時退社
- 田中さんが復帰しても「お前がいない間も普通に回ってたよ」って言われた
コードに戻すとこうなるんだよね。田中さんの話 = クラスの設計ってことで見てみて?
from abc import ABC, abstractmethod
class PaymentProcessor(ABC):
"""決済を処理する"""
@abstractmethod
def charge(self, amount: int) -> str: ...
class RefundablePayment(PaymentProcessor):
"""返金もできる決済"""
@abstractmethod
def refund(self, transaction_id: str) -> None: ...
class CreditCardPayment(RefundablePayment):
"""クレカ — 決済も返金もできる"""
def charge(self, amount: int) -> str:
print(f"クレカで {amount}円を決済")
return f"cc_{amount}"
def refund(self, transaction_id: str) -> None:
print(f"取引 {transaction_id} を返金")
class CryptoPayment(PaymentProcessor):
"""暗号通貨 — 決済だけ。返金はしない"""
def charge(self, amount: int) -> str:
print(f"暗号通貨で {amount}円を決済")
return f"crypto_{amount}"
CryptoPaymentはPaymentProcessorだけを継承してて、refundを持ってない。「返金できないのに返金メソッドがある」って状態がそもそも起きないんだよね。
返金フローはRefundablePaymentを受け取る関数にする。
def process_cancellation(processor: RefundablePayment, tx_id: str):
"""返金できる決済だけが来る"""
processor.refund(tx_id)
print("返金完了メールを送信")
# クレカ → RefundablePayment だから渡せる
cc = CreditCardPayment()
tx = cc.charge(3000)
process_cancellation(cc, tx) # OK
# 暗号通貨 → PaymentProcessor であって RefundablePayment じゃない
crypto = CryptoPayment()
tx = crypto.charge(3000)
# process_cancellation(crypto, tx) ← 型エラー! そもそも渡せない
エディタが赤線で教えてくれる。 本番に行く前に弾かれるから、深夜1時の500 Internal Server Errorは起きない。
第2回のDIPで「間にルールを挟む」って話したじゃん。でも挟んだ先の子クラスがルール通りに動かなかったら、DIPの意味がないってこと。LSPは「挟んだ先も約束を守れ」って話なんだよね。
ちゃんとした名前もあるから一応言うね📚
リスコフの置換原則(Liskov Substitution Principle、以降LSP)とは、「親の代わりに子を突っ込んでも、呼ぶ側が壊れちゃダメ」っつーこと。
Barbara Liskov(バーバラ・リスコフ)って人が1987年に最初に言い出した。1994年にJeannette Wingと一緒に論文でちゃんとまとめたやつ。SOLIDの「L」がこれね。
難しそうに聞こえるけど、要は**「代わりって言ったなら、ほんとに代わりになってる?」**を問い続けるだけなんだよね。
これだけ覚えて帰って💅
合言葉はこれ。ikan(入れ替え壊れたありえん)
コードを書くとき、この問いを投げてみて。
「この子クラスを親の代わりに渡したとき、呼ぶ側は何か特別に気にしなきゃいけないことある?」
もし「ある」なら、それは約束破り。
たとえば:
- 「
CryptoPaymentを渡すときはrefundを呼ばないでね」← これ言わなきゃいけない時点でアウト - 「
refundする前にcan_refund()で確認してね」← 使う側に余計なことを気にさせてる時点でアウト
呼ぶ側が「こいつ誰だっけ?」って気にしなくていい状態が、差し替えOKの本当の意味。
コンビニに戻すと、「この店員さん、返品できる人? できない人?」って毎回確認しなきゃいけない時点で「同じ店員」じゃないってこと。
まとめるとこういうことね
| 観点 | 約束破り(LSP違反) | 差し替えOK(LSP準拠) |
|---|---|---|
| 子クラスの振る舞い | 親と同じメソッドがあるが、例外を投げたり動きが違う | 親と同じ約束を守る。呼ぶ側は子クラスの存在を意識しない |
| テスト | 子クラスごとに「何が違うか」を把握してテストを書く | 親のテストが子にもそのまま通る |
| 呼ぶ側のコード | isinstanceチェックや子クラス固有の分岐が増える | 型だけ見ればいい。分岐不要 |
| バグの発見 | 本番で特定の子クラスが通ったときに初めて爆発する | コンパイル時・型チェック時に弾ける |
| 拡張 | 子クラスを足すたびに呼ぶ側も直す | 子クラスを足すだけ。呼ぶ側は何もしない |
呼ぶ側が何も知らなくていい。その状態を作るだけで、深夜の500 Internal Server Errorは防げるっつーこと。
沼りたい人はこっちも読んで📖
権威ある書籍
- Barbara Liskov, Jeannette Wing『A Behavioral Notion of Subtyping』(1994) — LSPの正式な定義が載ってる論文。学術寄りだけど短い
- Robert C. Martin『Agile Software Development: Principles, Patterns, and Practices』(2002) — SOLID原則をまとめた本。LSPの章は実例が豊富
- Robert C. Martin『Clean Architecture』(2017) — LSPを設計レベルに広げて解説してる
一次資料
- Wikipedia — Liskov substitution principle: https://en.wikipedia.org/wiki/Liskov_substitution_principle
無料で読める入門記事
- DigitalOcean「SOLID Design Principles Explained」: https://www.digitalocean.com/community/conceptual-articles/s-o-l-i-d-the-first-five-principles-of-object-oriented-design
- Zenn・Qiitaで「リスコフの置換原則 Python」で検索すると日本語の実装例が見つかる
Dev-Here「ギャルでも分かる設計の勘ドコロ!」シリーズ 第11回