「同じ型だよ」って聞いたから差し替えたのに壊れたんだけど?🫣

「同じ型だよ」って聞いたから差し替えたのに壊れたんだけど?🫣

これ絶対あるじゃん??これ絶対あるじゃん??

  • 親クラスを継承した子クラスが、あるメソッドで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が使える」って信じて書いてあるじゃん。でもCryptoPaymentrefundで例外を投げる。

これ、テストで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}"

CryptoPaymentPaymentProcessorだけを継承してて、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を設計レベルに広げて解説してる

一次資料

無料で読める入門記事


Dev-Here「ギャルでも分かる設計の勘ドコロ!」シリーズ 第11回