0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

契約による設計 — 事前・事後・不変条件で責任境界を引く

0
Posted at

はじめに

「念のため」とバリデーションを重ねていったら、レビューで「ここは要らないのでは」と指摘されて答えに迷う——そんな経験はないでしょうか。
多重防御を真面目にやるほど、どこまで重ねれば十分なのかが見えなくなります。

この記事では、その「どこまで・誰が」を契約という観点で整理し直します。
クラス間で誰がどこまで責任を持つのかを言語化し、
事前条件・事後条件・不変条件という3つの道具で表現する考え方を扱います。

TL;DR

  • 不正データへの対処は
    「呼出側の責任」「呼ばれる側の責任」「両者の合意(契約)」の3択
  • 契約は事前条件・事後条件・不変条件という3つの道具で表現できる
  • 入力検証と契約は役割が違う。assertと例外も用途で使い分ける

1. 防御的プログラミングだけでは消耗する

多重防御の発想を素直にコードに落とすと、
いわゆる「防御的プログラミング」になります。
ECサイトの在庫を減らす関数を書く場面を想像してみます。
商品IDと数量を受け取って、在庫を減算するシンプルなメソッドです。

def decrement_stock(product_id, qty):
    if product_id is None:
        raise ValueError("product_id is required")
    if not isinstance(product_id, str):
        raise TypeError("product_id must be a string")
    if qty is None:
        raise ValueError("qty is required")
    if not isinstance(qty, int):
        raise TypeError("qty must be an integer")
    if qty <= 0:
        raise ValueError("qty must be positive")

    stock = stock_repository.find(product_id)
    if stock is None:
        raise ValueError("product not found")
    if stock.quantity < qty:
        raise ValueError("insufficient stock")

    stock.quantity -= qty
    stock_repository.save(stock)

本来やりたいのは「在庫を減らす」というたった3行のはずなのに、入口のチェックが10行近く並んでしまいました。
これだけならまだ我慢できるかもしれません。
問題は、このメソッドを呼び出すコードでも、似たようなチェックが書かれていることです。

同じことを各レイヤーで繰り返す状況は、自分も経験があります。
誰かが入れたチェックを取っていいのか分からないので、心配になって全員が独自にチェックを足す。
結果として、同じ条件が何箇所にも散らばり、修正したいときにどこを直せばいいのかすぐには分からなくなります。

さらに困るのは、不正な値を受け取ったときに「何を返すか」が決まっていない点です。
例外を投げる箇所もあれば、None を返す箇所もあり、ログだけ吐いて処理を続ける箇所もある。
各所で挙動がばらついて、呼出側はどう扱えばいいのか毎回悩むことになります。

防御的プログラミング自体は悪い考え方ではありません。
ただ、無計画にやると「どこで誰が責任を持つか」が見えなくなり、
コードはどんどん複雑になっていきます。
消耗の根っこは「責任の置き場所が決まっていないこと」だと、自分は捉えています。

2. 不正データへの対処は「責任の置き場所」を決める問題

防御的プログラミングが消耗する原因を整理し直すと、
本質的な選択肢は3つしかないことに気づきます。
「呼出側がチェックする」「呼ばれる側がチェックする」「両者で合意する」の3択です。

1. 呼出側の責任にする

呼出側が常に正しい値を渡すと約束し、呼ばれる側は何もチェックしません。
呼ばれる側のコードは究極にシンプルになりますが、
呼出側が一箇所でもサボると静かに壊れます。
間違いの検出が遅れ、原因の追跡も難しくなります。

2. 呼ばれる側の責任にする

呼ばれる側が全ての異常を検出し、リカバリーします。
呼出側は何も気にせず呼べる代わりに、呼ばれる側のコードは複雑になります。
前のセクションで見た「防御的プログラミングが過剰になる」状態に近いものです。

3. 両者の合意(契約)にする

呼ばれる側は「こういう入力を受け取ったら、こういう出力を保証する」と明示します。
呼出側はその範囲内で呼ぶことを約束します。
約束を破った側に責任があり、コード上もそれが見える形にします。

3つを並べて比較すると、それぞれのトレードオフが見えてきます。

アプローチ 責任の所在 呼ばれる側のコード 呼出側のコード 不正データの検出のしやすさ
1. 呼出側責任 呼出側 最小 重い 低(壊れるまで気づかない)
2. 呼ばれる側責任 呼ばれる側 重い 最小 中(その場で例外)
3. 契約 両者の合意 高(約束違反を即検出)

3番目の「契約」というアプローチが優れているのは、
責任境界がコード上で言語化される点です。
「呼ばれる側はここまで保証する。それ以外の責任は呼出側にある」と明示することで、
後からコードを読む人にも、設計の意図が伝わります。

「契約による設計」は、クラス間の合意を事前条件・事後条件・不変条件という形でコードに書き起こす考え方です。
次のセクションで、その3つの道具を1つずつ見ていきます。

3. 契約を構成する3つの道具

契約という言葉だけだと抽象的なので、具体的に何を書けばいいのかを見ていきます。
契約は3つの要素で表現できます。
事前条件・事後条件・不変条件です。

3.1 事前条件 — 呼ばれる前に成り立っているべき条件

事前条件は「メソッドが呼ばれる時点で成り立っていてほしい条件」です。
これが満たされない呼び出しは、メソッドにとって契約違反であり、本来受け付けるべきでない呼び出しになります。

前のセクションで登場した decrement_stock に事前条件を載せてみます。

def decrement_stock(product_id, qty):
    # 事前条件
    assert product_id is not None, "product_id is required"
    assert qty > 0, "qty must be positive"

    stock = stock_repository.find(product_id)
    stock.quantity -= qty
    stock_repository.save(stock)

assert を使う書き方も、if で例外を投げる書き方もあります。
どちらを選ぶかは状況によりますが、ここでは契約を「条件として明示する」ことに集中します。
具体的にどう書き分けるかは、この記事の後半で扱います。

事前条件を書くときに大切なのは、コードだけでなくドキュメントにも書くことです。
Pythonならdocstringに書いておくと、呼出側がメソッドを使うときに迷わずに済みます。

def decrement_stock(product_id, qty):
    """在庫を減算する

    事前条件:
        - product_id は None でないこと
        - qty は正の整数であること

    事後条件:
        - 減算後の在庫数 >= 0
    """
    ...

事前条件は「メソッドが仕事を引き受ける条件」と捉えると分かりやすいです。
条件を満たさない呼び出しは、そもそも受け付けない。
受け付けた以上は、責任を持って結果を返す。
レストランでメニューにない料理を頼まないのと似た感覚です。

なお、ここで採用している qty > 0 のような厳しめの条件は、契約を「強く」している例にあたります。
条件をどこまで厳しくするかは設計判断であり、その視点はこの後のセクションで扱います。

3.2 事後条件 — 呼んだ後に保証される条件

事後条件は「メソッドが正常終了した時点で成り立つべき条件」です。
事前条件が入口の契約なら、事後条件は出口の契約です。

ここで読者が抱きそうな疑問があります。
「自分で実装するメソッドの戻り値に、なぜわざわざチェックを書くのか?正しい値を返すように実装しているはずでは?」というものです。

理由はシンプルで、人間はバグを書くからです。(AIも同じく)
複雑なロジックを書いていれば、想定外の経路で意図しない値が返ることはあります。
事後条件を書いておくと、その瞬間に処理が止まるので、間違った値が下流に流れて二次被害が起きる前に問題を検知できます。
バグが残っているとき、不正な値で進むよりも、その場で止まってくれたほうが調査がはるかに楽です。

decrement_stock に事後条件を追加してみます。

def decrement_stock(product_id, qty):
    # 事前条件
    assert product_id is not None, "product_id is required"
    assert qty > 0, "qty must be positive"

    stock = stock_repository.find(product_id)
    old_quantity = stock.quantity
    stock.quantity -= qty
    stock_repository.save(stock)

    # 事後条件
    assert stock.quantity >= 0, "stock must not be negative"
    assert stock.quantity == old_quantity - qty, "decrement amount mismatch"

事後条件が事前条件と対になっている関係を、図で見るとイメージしやすくなります。

事前条件は「呼ばれる側が、呼出側に何を要求するか」、事後条件は「呼ばれる側が、呼出側に何を約束するか」と言い換えると、責任の方向が見えやすくなります。

3.3 不変条件 — オブジェクトの一貫性を保つ条件

事前条件と事後条件は、メソッド単位の話でした。
しかしオブジェクトを扱っていると、
メソッド単位ではなく「オブジェクト全体で常に成り立っていてほしい性質」もあります。
これを不変条件と呼びます。

例えば、ショッピングカートを表す ShoppingCart クラスを考えてみます。
商品を追加する add_item、商品を削除する remove_item、合計金額を計算する total_price などのメソッドがあります。
このクラスでは、商品の追加や削除がどんな順序で起きても、以下の条件は常に成り立っていてほしいはずです。

  • 合計金額は0以上
  • 商品の合計数は、登録されている各商品の数量の合計と一致する

これらは特定のメソッドの入口や出口ではなく、オブジェクトの生涯を通じて満たされるべき条件です。
実装としては、各publicメソッドの最後で「不変条件が崩れていないか」を確認する形になります。

class ShoppingCart:
    def __init__(self):
        self._items = {}  # {product_id: (price, quantity)}
        self._total_price = 0
        self._total_quantity = 0

    def add_item(self, product_id, price, quantity):
        assert quantity > 0, "quantity must be positive"

        if product_id in self._items:
            old_price, old_qty = self._items[product_id]
            self._items[product_id] = (price, old_qty + quantity)
        else:
            self._items[product_id] = (price, quantity)
        self._total_price += price * quantity
        self._total_quantity += quantity

        self._check_invariants()

    def remove_item(self, product_id):
        assert product_id in self._items, "product not in cart"

        price, quantity = self._items.pop(product_id)
        self._total_price -= price * quantity
        self._total_quantity -= quantity

        self._check_invariants()

    def _check_invariants(self):
        assert self._total_price >= 0, "total price must be non-negative"
        expected = sum(qty for _, qty in self._items.values())
        assert self._total_quantity == expected, "quantity mismatch"

各メソッドの最後で _check_invariants() を呼ぶ形にすると、不変条件のチェックを一箇所にまとめられます。
クラスが成長してメソッドが増えても、新しいメソッドの末尾でこの呼び出しを書くだけで済みます。

不変条件と事前/事後条件の位置関係を、図で整理してみます。

注意点として、メソッド実行の途中では一時的に不変条件が崩れることがあります。
例えば add_item の中で、合計金額を更新する前に商品リストだけ更新した瞬間など、
ごく短い時間だけ整合が取れない状態があり得ます。
これは許容されます。
重要なのは、メソッドが終わったときに不変条件が成り立っていることです。

4. 契約は「動かすと壊れる」 — 強弱と変更影響

契約は一度書いて終わりではありません。
仕様が変われば契約も変わります。
そのとき、契約を「強める」のと「弱める」のとでは、影響範囲がまったく違うという話があります。
これを意識しておかないと、ライブラリのバージョンアップや内部設計の変更で、思わぬ箇所が壊れることになります。

強い契約と弱い契約のトレードオフ

まず、契約には強い・弱いという強度の概念があります。

  • 強い契約: 受け付ける入力の範囲が狭い。例: qty > 0 かつ qty <= 100
  • 弱い契約: 受け付ける入力の範囲が広い。例: 制限なし、qty は何でも良い

強い契約は、呼ばれる側のコードがシンプルになる代わりに、呼出側に「正しい値を渡す責任」を要求します。
間違いを早期に検出できる利点があります。
弱い契約は逆で、呼出側は楽になる代わりに、呼ばれる側で多様な入力を扱う必要があり、コードは複雑になりがちです。

どちらが正解かは状況によります。
汎用ライブラリは多くの呼出側に使われるため、呼出側の負担を減らす意味で弱い契約を選ぶことが多いです。
一方、業務ロジックの内部では「この場面ではこの範囲しか来ないはず」と明示する強い契約のほうが、間違いに早く気づけます。

強い・弱いの判断は「ライブラリか業務ロジックか」「呼出側がどこまで責任を取れるか」で変わります。
チーム内で扱うコードでは強い契約を選び、外部に公開するAPIでは弱い契約を選ぶ、という使い分けは現実的な指針です。

契約を変更したときに何が壊れるか

既存の契約を変更するとき、変更の方向によって影響範囲がまったく違います。

例として、calculate_tax(value) というメソッドの事前条件を変更する場面を考えます。
変更前は value >= 0、変更後は value >= 100 とします。
これは「より厳しい条件」への変更、つまり契約を強めている変更です。

このとき何が起きるか。
今までこのメソッドを value=50 で呼んでいた呼出側は、変更後は契約違反になります。
今まで通っていた呼び出しが、急に通らなくなる。
これは破壊的変更です。

逆に、事前条件を value >= 0 から value >= -100弱める方向ならどうでしょうか。
今まで value=50 で呼んでいた呼出側は、変更後も問題なく呼べます。
受け付ける範囲が広がっただけなので、既存の呼出側は何も変わりません。

事後条件は、これと逆の関係になります。
事後条件「戻り値は0以上」を「戻り値は100以上」に強めると、呼出側にとっては保証が増えるだけなので壊れません。
逆に「戻り値は何でもあり」に弱めると、今までは負の値が来ない前提でコードを書いていた呼出側が壊れます。

これを表で整理すると、安全な方向と危険な方向が見えてきます。

変更内容 受け入れる入力/出力の範囲 既存の呼出側への影響
事前条件を強める 狭くなる 壊れる可能性あり(危険)
事前条件を弱める 広くなる 壊れない(安全)
事後条件を強める 狭くなる 壊れない(安全)
事後条件を弱める 広くなる 壊れる可能性あり(危険)

ポイントは「呼出側から見た契約の範囲が狭くなる方向は危険」「広くなる方向は安全」と覚えると、事前と事後で逆になる関係が一貫して理解できることです。

image.png

継承との関係 — リスコフの置換原則

——ここで少し寄り道を——

実はこの「契約変更の話」は、継承の場面にもそのまま当てはまります。
子クラスが親クラスのメソッドをオーバーライドするとき、
子クラスは親クラスの代わりに使えなければいけません。
これはオブジェクト指向の原則として知られていて、
SOLID原則の「L」、リスコフの置換原則(LSP)と呼ばれています。

子クラスを親クラスの代わりに使うということは、呼出側は親クラスの契約を前提にコードを書いている、ということです。
ならば、子クラスは親クラスの契約を勝手に強めてはいけません。
具体的には、子クラスの事前条件は親と同じか弱く、事後条件は親と同じか強くする必要があります。
これは前項の「壊さない方向」とまったく同じ規則です。

契約要素 子クラスでの方向 範囲の変化
事前条件 親と同じか弱める 受け付ける入力が広がる
事後条件 親と同じか強める 保証される出力が狭まる

つまりLSPは、契約変更の話を継承の文脈に当てはめただけ、と捉えることができます。
新しいルールを覚える必要はなく、「呼出側から見た範囲が狭くなる方向は危険」という同じ理屈が、継承にもそのまま効いている、というだけの話です。

5. 実務での落とし穴

契約の3要素を理解しても、実務では迷う場面が出てきます。
「ユーザー入力のバリデーションと契約はどう違うのか」
「assertと例外、どちらで書くか」
「失敗を例外で伝えるか戻り値で伝えるか」
などです。
それぞれを整理していきます。

5.1 入力検証と契約は別物

ここで一つ、自分も最初に混乱したポイントがあります。「ユーザー入力をvalidationしてるなら、契約も同じことしてるんじゃ?」という疑問です。

結論から言うと、入力検証と契約は別物です。役割が違います。

  • 入力検証: 外部(ユーザーや外部API)から来るデータが妥当かを判定する。不正なら親切なエラーメッセージを返す。「このフォームの数量欄に文字が入っていますよ」のような、ユーザーへのフィードバックが目的
  • 契約: コードの内側、クラス間のやり取りで前提が崩れていないかを確認する。崩れていたら早期に止める。バグの早期検出が目的

層として描くと、両者の位置づけが見えやすくなります。

左の2つ(外部・入力検証層)は外部との境界、右の2つ(アプリ内部・クラス内ロジック)は境界の内側です。
入力検証は境界で親切なエラーメッセージを返す役割、契約は境界の内側で前提を守る役割を担います。

実務的な指針として、入力検証で済んだ条件を契約で再チェックする必要は基本的にありません。
アーキテクチャ上「ここから先のコードには検証済みのデータしか来ない」という前提を作っておけば、内部のクラスは安心して動けます。

ただし、絶対に壊れてほしくない不変条件は、両方でチェックしてもよいと思います。重複コストよりも安全性を優先する判断です。

5.2 assert と例外、どちらを使うか

事前条件・事後条件をコードに書くとき、assertと例外のどちらを使うかは悩ましいテーマです。

観点 assert 例外
書きやすさ 1行で簡潔 条件分岐+raise文
本番での実行 Pythonでは -O オプションで無効化される 必ず実行される
個別ハンドリング 一律で AssertionError 例外クラスを分けて個別対応可能
主な用途 「絶対に起きないはず」の確認 呼出側にcatchさせたい異常

Pythonの場合、python -O で実行するとassert文は実行されません。
本番環境で -O を使う運用なら、assertは「実質的に動かないもの」になります。
一方で例外は常に実行されるので、確実にチェックしたい場面では例外のほうが安心です。

自分の中での使い分けの目安は、こんな感じです。

  • ライブラリやユーティリティクラス: 例外を選ぶ。呼出側がコントロールしたいので、catchできる形にする
  • 業務ロジックの内部で「ここまで来ているならデータは正しいはず」という確信がある場面: assertを選ぶ。バグ検出用の早期警告として機能する
  • 「この条件が破れたら絶対止めてほしい」場面: 例外を選ぶ。assert無効化のリスクを避ける

言語によって事情も変わります。
Pythonのassertは無効化できる仕組みですが、Javaのassertも -ea オプションを付けないと動かない設計になっています。
一方で、契約を言語レベルで持つEiffelのような例もあります。
「この言語ではassertが本番で動くのか」をチームで確認しておくと、判断のブレが減ります。

5.3 ソフトリターンという第3の選択肢

もう一つ、現場で時々出てくる選択肢があります。
例外を投げる代わりに、「失敗を表す値」を返す方法です。
None や空文字列、-1 のような特殊値などを返す書き方で、
ソフトリターンと呼ばれることがあります。

文字列をtrimするユーティリティ関数を例に考えてみます。

# 例外版
def trim(s):
    if s is None:
        raise ValueError("input must not be None")
    return s.strip()

# ソフトリターン版
def trim(s):
    if s is None:
        return ""
    return s.strip()

呼出側にとっては、ソフトリターン版のほうが楽です。None が来ても呼出側は普通の文字列として扱えます。catchを書く必要もありません。

ただ、ソフトリターンは万能ではありません。
「呼出側が異常状態でも処理を続けられる」場面ならソフトリターンが向きますが、
「処理を続けてはいけない」場面では例外のほうが安全です。
例えば、税金計算で負の値が来たときに 0 を返すと、何も払わずに済んでしまうかもしれません。
これは続けてはいけない場面です。

戻り値の意味が曖昧になりやすい点にも注意が必要です。
None を返す関数の場合、
呼出側がそれを「正常な空の結果」と取り違えるリスクがあります。
None は失敗を意味します」というルールをドキュメントに書いておくか、
あるいは型システムで失敗と成功を区別できる仕組みを使うのが安全です。

おわりに

契約という観点を持つと、AIが出してきたコードのセルフレビューが楽になります。
「念のため」と並んだチェックを「これは事前条件、ここは事後条件」と判断できると、
「どこまでチェックすべきか」の迷いが責任境界の議論に切り替わります。

最初の一歩は、手元のメソッドの事前条件をdocstringに1行書いてみることから。
その1行は責任境界の言語化であり、
次にAIにそのコードを触らせるときのガードレールにもなります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?