一覧SE-02-1 · 設計の分割と契約

責務の分離と変更しやすい設計

料金規則・出力形式・呼出し側を分ける理由を、変更箇所と実際の計算結果から説明します。

説明

同じ料金の規則を画面表示用とJSON出力用の関数へそれぞれ書くと、仕様変更の際に片方だけ直してしまうことがあります。処理を分ける目的はファイル数を増やすことではなく、同じ理由で変わる処理をまとめ、別の理由で変わる処理を離すことです。

商品代5000円以上なら送料無料、それ未満なら500円という実装を使います。境界を3000円へ変更して片方の出力だけが変わる状態を確認した後、計算と表示を分けた構成へ置き換えます。準備済みのPythonファイルを読み、実行結果と依存関係を確認します。

料金規則・出力形式・呼出し側を分ける理由を、変更箇所と実際の計算結果から説明します。

1二つの出力で同じ料金を計算

最初に商品代3000円の明細を表示します。上段が文章、下段がJSONです。

python3 receipt.py 30003000円の明細を二形式で出力する

どちらも送料500円、合計3500円です。見た目は違いますが、同じ取引を表すので金額は一致している必要があります。

2表示関数に重複した規則

実装を読みます。text_receiptとjson_receiptは、それぞれ送料の条件式を持っています。

cat receipt.py二つの表示関数の実装を読む

送料規則を変更するには2箇所の修正が必要です。表示のための文字列処理と、業務上の料金計算が混在しており、同じ変更理由を持つ処理が分散しています。

3片方だけを変更した差分

3000円以上を無料にした修正案を確認します。差分にはtext_receiptの条件式だけが出ます。

diff -u修正案と現在のコードを比較する -L receipt.py -L receipt-partial.py receipt.py receipt-partial.py

JSON側は5000円のままです。重複した規則があると、1箇所の修正が済んでも全体の整合が保たれるとは限りません。

4変更漏れが金額の不一致になる

修正案を反映し、複数の金額で二形式の合計を照合します。compare.pyは文章とJSONから合計を取り出して比較します。

cp receipt-partial.py receipt.py片方だけを直した案を反映する python3 compare.py二つの出力の合計を照合する

3000円と4999円で、文章とJSONの合計が500円ずれます。2999円と5000円では一致するため、入力によって変更漏れが見えなくなることにも注意が必要です。

5料金計算を一箇所へ集める

分割案のpricing.pyを読みます。invoiceが明細の値を決め、standard_feeが通常配送の料金規則を持ちます。

cat pricing.py計算と料金規則の境界を確認する

invoiceの返すsubtotal・shipping・totalが表示側との接点です。表示側が送料を再計算しなければ、料金の変更をそれぞれの出力形式へ重複して反映する必要がなくなります。

6表示側は計算済みの値を使う

表示関数と呼出し側を確認します。呼出し側が一度だけinvoiceを呼び、その結果を二つの表示関数へ渡します。

cat renderers.py値の整形だけを行う関数を読む cat receipt-split.py python3 receipt-split.py 3000分割後の構成で明細を作る

二形式とも合計3000円です。料金計算の責務はpricing、整形の責務はrenderers、両者を組み合わせる責務は呼出し側にあります。これが、この例で処理を分けた単位です。

7依存の向きを確認

imports.pyはPythonの構文木からimportを取り出します。どのファイルが何を読み込むかを表示します。

python3 imports.py構文木からimport先を抽出する receipt-split.py pricing.py renderers.py依存を確認する3ファイル

呼出し側はpricingとrenderersを参照しますが、pricingは表示側を参照していません。料金の規則を理解するためにJSONやCSVの書式を読む必要がない構成です。ただしimport一覧だけで実行時の全依存を網羅できるわけではありません。

8同じ明細をCSVにもする

用意したCSV表示を加えて、同じ計算済み明細を三形式で出力します。CSVの列順は商品代・送料・合計です。

python3 formats.py同じ明細を文章・JSON・CSVで表示する

出力形式が増えても料金規則は一箇所です。共通化したのは偶然似た文字列処理ではなく、同じ変更理由を持つ料金計算です。異なる規則まで無理に共通化すると、逆に条件分岐が増える場合があります。

9料金規則を引数で差し替える

速達では商品代にかかわらず800円とします。policies.pyは通常の規則と、引数に渡した速達の規則で明細を作ります。

cat policies.py呼出し側が渡す関数を確認する python3 policies.py二つの料金規則で計算する

通常は3000円、速達は3800円です。invoice自身が配送方式を選ばず、呼出し側から必要な関数を受け取っています。このように依存する処理を外から渡す方法を依存性の注入と呼びます。

10差替え可能でも値の契約は守る

引数の商品代が負、料金関数が負額を返す、料金関数が文字列を返す、という3件を実行します。

python3 invalid-policy.py入力と料金関数の戻り値の違反を試す

3件ともValueErrorになります。関数を自由に差し替えられることと、どんな値でも受け付けることは別です。境界で値を確認すると、誤った明細が表示側へ流れるのを防げます。

まとめ

同じ料金規則の重複は変更漏れを生みます。規則を一箇所に集め、表示側は計算済みの値を使います。

責務の分割は変更理由と受け渡す値から決めます。ファイルや層を増やすこと自体を目的にしません。

依存する料金関数を外から渡しても、入力と戻り値に必要な条件は境界で確認します。

操作履歴と解説を振り返れます。

シェル / Python 3

1
python3 receipt.py 3000
商品代=3000 送料=500 合計=3500
{"shipping": 500, "subtotal": 3000, "total": 3500}
2
cat receipt.py
import json,sys

def text_receipt(subtotal):
    fee = 0 if subtotal >= 5000 else 500
    return f"商品代={subtotal} 送料={fee} 合計={subtotal+fee}"

def json_receipt(subtotal):
    fee = 0 if subtotal >= 5000 else 500
    return json.dumps({'subtotal':subtotal,'shipping':fee,'total':subtotal+fee},sort_keys=True)

if __name__ == '__main__':
    amount = int(sys.argv[1])
    print(text_receipt(amount))
    print(json_receipt(amount))
3
diff -u -L receipt.py -L receipt-partial.py receipt.py receipt-partial.py
--- receipt.py
+++ receipt-partial.py
@@ -1,7 +1,7 @@
 import json,sys
 
 def text_receipt(subtotal):
-    fee = 0 if subtotal >= 5000 else 500
+    fee = 0 if subtotal >= 3000 else 500
     return f"商品代={subtotal} 送料={fee} 合計={subtotal+fee}"
 
 def json_receipt(subtotal):
4
cp receipt-partial.py receipt.py python3 compare.py
2999: text=3499 json=3499 equal=True
3000: text=3000 json=3500 equal=False
4999: text=4999 json=5499 equal=False
5000: text=5000 json=5000 equal=True
5
cat pricing.py
def standard_fee(subtotal):
    return 0 if subtotal >= 3000 else 500

def invoice(subtotal,fee_policy=standard_fee):
    if type(subtotal) is not int or subtotal < 0:
        raise ValueError('subtotal must be a nonnegative integer')
    fee = fee_policy(subtotal)
    if type(fee) is not int or fee < 0:
        raise ValueError('shipping must be a nonnegative integer')
    return {'subtotal':subtotal,'shipping':fee,'total':subtotal+fee}
6
cat renderers.py cat receipt-split.py python3 receipt-split.py 3000
import json

def as_text(bill):
    return f"商品代={bill['subtotal']} 送料={bill['shipping']} 合計={bill['total']}"

def as_json(bill):
    return json.dumps(bill,sort_keys=True)

def as_csv(bill):
    return ','.join(str(bill[key]) for key in ('subtotal','shipping','total'))
import sys
from pricing import invoice
from renderers import as_text,as_json

if __name__ == '__main__':
    bill = invoice(int(sys.argv[1]))
    print(as_text(bill))
    print(as_json(bill))
商品代=3000 送料=0 合計=3000
{"shipping": 0, "subtotal": 3000, "total": 3000}
7
python3 imports.py receipt-split.py pricing.py renderers.py
receipt-split.py -> pricing, renderers, sys
pricing.py -> (none)
renderers.py -> json
8
python3 formats.py
商品代=3000 送料=0 合計=3000
{"shipping": 0, "subtotal": 3000, "total": 3000}
3000,0,3000
9
cat policies.py python3 policies.py
from pricing import invoice
from renderers import as_text

def express_fee(subtotal):
    return 800

for policy in (None,express_fee):
    bill=invoice(3000) if policy is None else invoice(3000,policy)
    print(as_text(bill))
商品代=3000 送料=0 合計=3000
商品代=3000 送料=800 合計=3800
10
python3 invalid-policy.py
ValueError: subtotal must be a nonnegative integer
ValueError: shipping must be a nonnegative integer
ValueError: shipping must be a nonnegative integer

演習完了