前回は、悪いPythonコードは何が悪いのかを整理しました。
- 役割(責務)が混ざっている
- ネストが深い
- dict に意味を押し込みすぎている
- HTTPやDBの事情がアプリの本題となる処理やルール(業務ロジック)に入り込んでいる
- エラーの表現が曖昧
今回はその続きとして、もう少し踏み込んで、
関数設計と型ヒント[補足注3]をどう考えるか
を、実際にコードを動かしながら整理します。
Pythonは関数を書きやすい言語です。
だからこそ、気を抜くと次のような関数が増えます。
- 引数が多い
- 何を返すのか分かりにくい
- 副作用[補足注2]が大きい
-
boolフラグで分岐し始める - 型ヒントがなく、呼び出し側の前提が曖昧
- 型ヒントを付けたが、逆に読みにくくなっている
今回は、こうした論点を読み物ではなくハンズオンとして整理します。
今回実施すること
今回は、次の4つを実際に体験します。
- 引数が多く、
boolフラグだらけの関数がどれだけつらいか確認する - 成功も失敗も
dictで返す関数が呼び出し側をどう濁らせるか見る -
dataclass[注1] / 例外 / キーワード引数 / 型ヒントで整理した版に置き換える -
mypy[注3] を使って、実行前に型のズレを見つける
さらに最後に、関数設計で有名な落とし穴であるmutable default argument[補足注4] も手を動かして確認します。
前提
この記事は、次の環境を前提に進めます。
- VS Code が使える
- Python 3.12 がインストール済み
- ターミナルでコマンドが打てる
0. 作業ディレクトリ作成
任意の場所で作業用ディレクトリを作成します。
mkdir python_practical_series_03
cd python_practical_series_03
仮想環境を作成します。
python3 -m venv .venv
仮想環境を有効化します。
macOS / Linux:
source .venv/bin/activate
Windows PowerShell:
.venv\Scripts\Activate.ps1
今回は型チェックも試したいので、mypy を入れます。
pip3 install mypy
今回のファイル構成
最終的に、今回のディレクトリはこうなります。
python_practical_series_03/
├─ .venv/
├─ bad_order.py
├─ run_bad_order.py
├─ better_order.py
├─ run_better_order.py
├─ type_check_demo.py
└─ mutable_default_demo.py
1. まずは「つらくなりやすい関数」を動かす
最初に、引数が多く、責務も多く、戻り値も曖昧な関数を動かします。
bad_order.py を作成してください。
def create_order(
user_id,
item_id,
quantity,
db,
is_admin=False,
with_discount=True,
send_mail=True,
save_log=True,
):
# ユーザーIDが不正なら失敗を dict で返す
if user_id <= 0:
return {"status": 400, "message": "invalid user"}
# 商品IDが存在しなければ失敗
if item_id not in db["items"]:
return {"status": 404, "message": "item not found"}
# 数量が 0 以下なら失敗
if quantity <= 0:
return {"status": 400, "message": "invalid quantity"}
# 商品情報を dict から取り出す
item = db["items"][item_id]
# 在庫不足なら失敗
if item["stock"] < quantity:
return {"status": 400, "message": "out of stock"}
# 元の合計金額を計算する
total_price = item["price"] * quantity
# 割引を適用するかをフラグで切り替えている
if with_discount:
if is_admin:
total_price = int(total_price * 0.8)
# 注文データを dict で組み立てる
order = {
"user_id": user_id,
"item_id": item_id,
"quantity": quantity,
"total_price": total_price,
}
# 保存処理もこの関数の中で行っている
db["orders"].append(order)
item["stock"] -= quantity
# ログ出力もフラグで切り替えている
if save_log:
print("order created:", order)
# メール送信もフラグで切り替えている
if send_mail:
print("send mail")
# 成功時も失敗時も dict で返す
return {"status": 200, "data": order}
次に、run_bad_order.py を作成します。
from bad_order import create_order
def build_db() -> dict:
# サンプル用の簡易 DB を毎回作り直す
return {
"items": {
1: {"name": "Keyboard", "price": 5000, "stock": 10},
2: {"name": "Mouse", "price": 3000, "stock": 0},
},
"orders": [],
}
def main() -> None:
db = build_db()
print("=== case 1: 一見動く ===")
# 位置引数でたくさんのフラグを渡している
# 呼び出し側から見て意図を読み取りにくい例
result = create_order(1, 1, 2, db, True, False, True, False)
print(result)
print()
print("=== case 2: 呼び出しが読みづらい ===")
print("create_order(1, 1, 2, db, True, False, True, False)")
print("どの True / False が何を意味するか、一目では分かりにくい")
print()
print("=== case 3: 呼び出し側が毎回 status を見る必要がある ===")
# 在庫切れのケースを作り、失敗時の戻り値を確認する
error_result = create_order(1, 2, 1, db)
print(error_result)
# 呼び出し側が毎回 status を見て分岐しないといけない
if error_result["status"] != 200:
print("呼び出し側で毎回 status を見て分岐が必要")
print()
print("=== case 4: quantity に文字列を渡す ===")
try:
# 型ヒントがないので、実行するまでこのズレに気づきにくい
create_order(1, 1, "2", db)
except Exception as error:
print(type(error).__name__, error)
if __name__ == "__main__":
main()
以下を実行します。
python3 run_bad_order.py
出力結果は次の通りです。
=== case 1: 一見動く ===
send mail
{'status': 200, 'data': {'user_id': 1, 'item_id': 1, 'quantity': 2, 'total_price': 8000}}
=== case 2: 呼び出しが読みづらい ===
create_order(1, 1, 2, db, True, False, True, False)
どの True / False が何を意味するか、一目では分かりにくい
=== case 3: 呼び出し側が毎回 status を見る必要がある ===
{'status': 400, 'message': 'out of stock'}
呼び出し側で毎回 status を見て分岐が必要
=== case 4: quantity に文字列を渡す ===
TypeError '<=' not supported between instances of 'str' and 'int'
ここで見てほしいのは、「一応動く」ことと「使いやすい関数であること」は別だという点です。
この出力には、すでに次のつらさが表れています。
-
True/Falseが並んでいて呼び出し意図が読み取りにくい - 成功時も失敗時も
dictで返すため、呼び出し側が毎回statusを見る必要がある -
quantityに"2"を渡しても、実行するまで型のズレに気づけない
つまり、今は動くけれど、レビューしづらく、変更にも弱く、実行して初めてズレに気づく構造です。
この関数の何がつらいのか
ここでは、いま見えたつらさを整理します。
1. 引数が多く、責務も多い
この関数は、
- 注文作成
- 割引判定
- 保存
- ログ出力
- メール送信
- エラー表現
までやっています。
つまり、関数シグネチャ[補足注1]を見ただけで責務が多い状態です。
2. bool フラグが呼び出しを読みにくくする
# この呼び出しは動くが、各 True / False の意味を
# 呼び出し位置だけで判断しないといけない
create_order(1, 1, 2, db, True, False, True, False)
この呼び出しは、書いた本人は分かっても、後から読む人にはかなりつらいです。
3. 正常も異常も dict で返している
成功時も失敗時も dict なので、呼び出し側は毎回 status を見て分岐しなければいけません。
4. 型ヒントがなく、前提が見えない
quantity に文字列を入れても、実行するまでズレに気づけません。
2. 少し整理した版に置き換える
ここから、いきなり大規模なアーキテクチャには行かず、まずは次の方向で改善します。
- 入力のまとまりを
dataclassで表す - 成功時は意味のある値を返す
- 異常時は例外で表現する
- 副作用は境界に寄せる
- 呼び出しやすさのためにキーワード引数を使う
better_order.py を作る
from dataclasses import dataclass, replace
from typing import Literal, Protocol
# 異常の種類ごとに例外を分ける
class InvalidUserError(Exception):
pass
class ItemNotFoundError(Exception):
pass
class InvalidQuantityError(Exception):
pass
class OutOfStockError(Exception):
pass
# ロールは "admin" / "member" のどちらかだけを受け付ける
UserRole = Literal["admin", "member"]
# 注文作成に必要な入力をまとめる
@dataclass(frozen=True)
class CreateOrderCommand:
user_id: int
item_id: int
quantity: int
user_role: UserRole = "member"
# 商品データを意味のある構造として表す
@dataclass(frozen=True)
class Item:
item_id: int
name: str
price: int
stock: int
# 成功時に返す注文データ
@dataclass(frozen=True)
class Order:
user_id: int
item_id: int
quantity: int
total_price: int
# save() を持つ repository なら扱える、という契約
class OrderRepository(Protocol):
def save(self, order: Order) -> None:
...
# 簡易的な in-memory repository 実装
class InMemoryOrderRepository:
def __init__(self) -> None:
self.orders: list[Order] = []
def save(self, order: Order) -> None:
self.orders.append(order)
def validate_user(*, user_id: int) -> None:
# ユーザーIDが不正なら例外を送出する
if user_id <= 0:
raise InvalidUserError("invalid user")
def get_item(*, item_id: int, item_store: dict[int, Item]) -> Item:
# 商品を取得し、なければ例外を送出する
item = item_store.get(item_id)
if item is None:
raise ItemNotFoundError("item not found")
return item
def calculate_discount_rate(*, user_role: UserRole) -> float:
# 管理者なら 20% 割引、一般ユーザーは割引なし
return 0.2 if user_role == "admin" else 0.0
def calculate_total_price(*, price: int, quantity: int, discount_rate: float) -> int:
# 元の合計金額を計算し、割引率を反映する
total_price = price * quantity
return int(total_price * (1 - discount_rate))
def create_order(*, command: CreateOrderCommand, item: Item) -> Order:
"""Create an order from validated input.
Raises:
InvalidUserError: If user_id is not positive.
InvalidQuantityError: If quantity is not positive.
OutOfStockError: If item stock is insufficient.
"""
# ユーザーの妥当性を確認する
validate_user(user_id=command.user_id)
# 数量チェック
if command.quantity <= 0:
raise InvalidQuantityError("quantity must be positive")
# 在庫不足チェック
if item.stock < command.quantity:
raise OutOfStockError("out of stock")
# ロールに応じた割引率と合計金額を計算する
discount_rate = calculate_discount_rate(user_role=command.user_role)
total_price = calculate_total_price(
price=item.price,
quantity=command.quantity,
discount_rate=discount_rate,
)
# 成功時は意味のある Order を返す
return Order(
user_id=command.user_id,
item_id=item.item_id,
quantity=command.quantity,
total_price=total_price,
)
def save_order(*, order: Order, repository: OrderRepository) -> None:
# 保存処理は repository に委譲する
repository.save(order)
def decrease_stock(*, item: Item, quantity: int, item_store: dict[int, Item]) -> None:
# replace() で在庫だけ変更した新しい Item を作る
item_store[item.item_id] = replace(item, stock=item.stock - quantity)
def place_order(
*,
command: CreateOrderCommand,
item_store: dict[int, Item],
repository: OrderRepository,
) -> Order:
# 注文作成の流れを組み立てる
item = get_item(item_id=command.item_id, item_store=item_store)
order = create_order(command=command, item=item)
save_order(order=order, repository=repository)
decrease_stock(item=item, quantity=command.quantity, item_store=item_store)
return order
改善版を動かす
run_better_order.py を作成してください。
from better_order import (
CreateOrderCommand,
InMemoryOrderRepository,
InvalidQuantityError,
Item,
ItemNotFoundError,
OutOfStockError,
place_order,
)
def build_item_store() -> dict[int, Item]:
# 商品データを Item dataclass で管理する
return {
1: Item(item_id=1, name="Keyboard", price=5000, stock=10),
2: Item(item_id=2, name="Mouse", price=3000, stock=0),
}
def main() -> None:
item_store = build_item_store()
repository = InMemoryOrderRepository()
print("=== case 1: 正常系 ===")
# 入力値を CreateOrderCommand にまとめる
command = CreateOrderCommand(
user_id=1,
item_id=1,
quantity=2,
user_role="admin",
)
# 注文作成の流れを呼び出す
order = place_order(
command=command,
item_store=item_store,
repository=repository,
)
print(order)
print("saved orders:", repository.orders)
print("remaining stock:", item_store[1].stock)
print()
print("=== case 2: quantity が不正 ===")
try:
bad_command = CreateOrderCommand(
user_id=1,
item_id=1,
quantity=0,
user_role="member",
)
place_order(
command=bad_command,
item_store=item_store,
repository=repository,
)
except InvalidQuantityError as error:
print(type(error).__name__, error)
print()
print("=== case 3: 商品が存在しない ===")
try:
missing_item_command = CreateOrderCommand(
user_id=1,
item_id=999,
quantity=1,
user_role="member",
)
place_order(
command=missing_item_command,
item_store=item_store,
repository=repository,
)
except ItemNotFoundError as error:
print(type(error).__name__, error)
print()
print("=== case 4: 在庫不足 ===")
try:
out_of_stock_command = CreateOrderCommand(
user_id=1,
item_id=2,
quantity=1,
user_role="member",
)
place_order(
command=out_of_stock_command,
item_store=item_store,
repository=repository,
)
except OutOfStockError as error:
print(type(error).__name__, error)
if __name__ == "__main__":
main()
以下を実行します。
python3 run_better_order.py
出力結果は次の通りです。
=== case 1: 正常系 ===
Order(user_id=1, item_id=1, quantity=2, total_price=8000)
saved orders: [Order(user_id=1, item_id=1, quantity=2, total_price=8000)]
remaining stock: 8
=== case 2: quantity が不正 ===
InvalidQuantityError quantity must be positive
=== case 3: 商品が存在しない ===
ItemNotFoundError item not found
=== case 4: 在庫不足 ===
OutOfStockError out of stock
ここで見てほしいのは、出力そのものの「意味の読みやすさ」です。
bad 版では戻り値がただの dict でしたが、改善版では
Order(user_id=1, item_id=1, quantity=2, total_price=8000)
のように、これは Order という概念であることが実行結果からも分かります。
さらに、
-
CreateOrderCommandに入力のまとまりが出ている -
Orderに成功時の戻り値の意味が出ている -
InvalidQuantityErrorなど、失敗の種類が例外名で表れている
という形で、関数の契約がかなり見えやすくなっています。
どこが改善されたのか
この時点で、かなり見通しが良くなっています。
1. 引数の意味が分かりやすくなった
# 入力値を dataclass にまとめると、各値の意味が読み取りやすい
command = CreateOrderCommand(
user_id=1,
item_id=1,
quantity=2,
user_role="admin",
)
位置引数で True, False, True を並べるより、意図がかなり明確です。
CreateOrderCommand にまとめたことで、「この関数に渡したい入力のまとまり」が名前付きで見えるようになっています。
これは引数の数を減らすこと自体が目的ではなく、引数の意味をまとめるための改善です。
2. 戻り値の意味が明確になった
成功時は Order を返し、異常時は例外で表現しています。
3. 副作用の境界が見えやすくなった
-
create_order()は注文を組み立てる -
save_order()は保存する -
decrease_stock()は在庫を減らす -
place_order()は流れを組み立てる
という形になっています。
4. 型ヒントが前提をコードに表している
-
user_idはint -
user_roleは"admin"か"member" -
repositoryはsave()を持つもの
という前提が、かなり読み取りやすくなりました。
型ヒントの価値は「厳密さ」だけではありません。
実務ではむしろ、呼び出し側が何を前提にしてよいのかを読み取りやすくすることが大きな価値です。
3. mypy で型のズレを実行前に見つける
型ヒントの価値は、コードを読んだときの分かりやすさだけではありません。
実行前に、ズレを検出しやすいことも大きいです。
type_check_demo.py を作成してください。
from better_order import CreateOrderCommand, InMemoryOrderRepository, Item, place_order
# 商品ストアは Item dataclass で管理する
item_store = {
1: Item(item_id=1, name="Keyboard", price=5000, stock=10),
}
repository = InMemoryOrderRepository()
# あえて型を間違えた command を作る
bad_command = CreateOrderCommand(
user_id="1",
item_id=1,
quantity="2",
user_role="guest",
)
# mypy で見ると、この呼び出し前に型のズレを検出できる
place_order(
command=bad_command,
item_store=item_store,
repository=repository,
)
このファイルは、あえて型を間違えています。
-
user_idがstr -
quantityがstr -
user_roleが"guest"
まずは、mypy を以下を実行します。
python3 -m mypy type_check_demo.py
補足: mypy
ざっくりいうと
mypy は、型ヒントをもとに、実行前のズレを見つけるための型チェッカーです。
この場面でできること
-
intのはずの場所にstrを渡していないか確認できる -
LiteralやProtocolの前提が崩れていないか見やすくなる - 実行してから気づくミスを早めに見つけやすい
最小例
# type_check_demo.py 側の例
command = CreateOrderCommand( # 注文入力を作ろうとしているが
user_id="1", # 本来 int のところへ str を渡している
item_id=1, # ここは int なので問題ない
quantity="2", # ここも本来 int なのに str を渡している
)
python3 -m mypy type_check_demo.py # mypy を実行して型のズレを確認する
出力結果は次のようになります。
type_check_demo.py:10: error: Argument "user_id" to "CreateOrderCommand" has incompatible type "str"; expected "int"
type_check_demo.py:12: error: Argument "quantity" to "CreateOrderCommand" has incompatible type "str"; expected "int"
type_check_demo.py:13: error: Argument "user_role" to "CreateOrderCommand" has incompatible type "Literal['guest']"; expected "Literal['admin', 'member']"
Found 3 errors in 1 file (checked 1 source file)
ここでのポイントは、型ヒントは「読むため」だけでなく「実行前にズレを見つけるため」にも効くことです。
Python 本体は型ヒントを実行時に強制しません。
でも mypy を使うと、
-
user_idにstrを渡している -
quantityにstrを渡している -
user_roleに想定外の値"guest"を渡している
といったズレを、実行前にかなり早く見つけられます。
つまり、型ヒントは 契約を明示するための道具 であると同時に、
レビュー前・実行前にズレを検出するための補助線 でもあります。
型ヒントはどこから付けるべきか
全部を一気に型で埋める必要はありません。
まずは次の順番で付けるとかなり効果が出やすいです。
- 公開関数
- 戻り値
- 境界をまたぐデータ
- 複雑なコレクション
- 必要に応じてローカル変数
今回でいうと、まず効いているのはこのあたりです。
CreateOrderCommandItemOrderplace_order()create_order()
つまり、境界や契約になる部分から型を付けるのがコスパが良いです。
4. mutable default argument の罠を体験する
関数設計の話では、これは外せません。
mutable_default_demo.py を作成してください。
def add_tag_bad(tag: str, tags: list[str] = []) -> list[str]:
# デフォルト引数のリストをそのまま使い回してしまう
tags.append(tag)
return tags
def add_tag_good(tag: str, tags: list[str] | None = None) -> list[str]:
# None を受け取り、関数内で新しいリストを作る
if tags is None:
tags = []
tags.append(tag)
return tags
print("=== bad ===")
# bad 版は前回までの状態が残ってしまう
print(add_tag_bad("python"))
print(add_tag_bad("fastapi"))
print(add_tag_bad("typing"))
print()
print("=== good ===")
# good 版は毎回新しいリストになる
print(add_tag_good("python"))
print(add_tag_good("fastapi"))
print(add_tag_good("typing"))
以下を実行します。
python3 mutable_default_demo.py
出力結果は次の通りです。
=== bad ===
['python']
['python', 'fastapi']
['python', 'fastapi', 'typing']
=== good ===
['python']
['fastapi']
['typing']
ここで見てほしいのは、bad 版の [] が毎回新しく作られているのではなく、
関数定義時に一度だけ作られた同じリストを呼び出し間で使い回していることです。
そのため、add_tag_bad("python") のあとに add_tag_bad("fastapi") を呼ぶと、
2回目の呼び出しでも前回の状態が残ってしまいます。
この罠は有名ですが、実務でも普通に起きます。
可変オブジェクトをデフォルト引数に置かず、None を使って中で初期化するのが定番です。
5. 今回のハンズオンから整理できること
ここまでで、関数設計と型ヒントについてかなり具体的に見えてきます。
1. 引数は「何個か」より「意味がまとまっているか」
問題なのは、引数が4個あること自体ではありません。
問題なのは、
- 似た値が並ぶ
-
boolフラグが増える - 順番を覚えないと読めない
という状態です。
今回でいうと、CreateOrderCommand にまとめたことで意図がかなり明確になりました。
2. 戻り値は「とりあえずdict」にしない
成功時は意味のある値を返し、異常時は例外で表現する方が、呼び出し側はシンプルになります。
3. 副作用は境界へ寄せる
- 計算
- 保存
- 通知
- ログ出力
が全部1つの関数にいると、テストしにくくなります。
4. 型ヒントは契約を明確にする
型ヒントは、Pythonを別言語に変えるためのものではありません。
むしろ、関数やデータの契約を明確にする補助線として使うと強いです。
5. docstring は「説明」より「契約」
今回の create_order() の docstring では、
- 何をするか
- どんな例外を投げるか
を短く書いています。
これは実装の逐語訳ではなく、呼び出し側が知りたい情報です。
補足: docstring
ざっくりいうと
docstring は、関数・クラス・モジュールの「説明を書くための文字列」です。
Python ではコメントとは別に、コードに近い場所へ正式な説明を残せます。
この場面でできること
- 関数の役割や引数、返り値の意味をその場で伝えられる
- エディタの補完や
help()から説明を確認できる - 「どう使う関数なのか」をコードの近くに残せる
最小例
def calculate_total(price: int, quantity: int) -> int:
"""商品の合計金額を返す。
Args:
price: 単価
quantity: 個数
Returns:
合計金額
"""
return price * quantity
まとめ
今回は、関数設計と型ヒントを、注文作成処理を題材に手を動かしながら整理しました。
今回の結論は次の通りです。
- 引数は「何個か」より「意味が明確か」で見る
-
boolフラグ引数が増えたら、責務の分離を疑う - 戻り値は「とりあえずdict」にせず、成功値と異常系を分ける
- 副作用は境界へ寄せる
- 型ヒントはまず境界から付ける
- 型ヒントは複雑さを増やすためではなく、意図を伝えるために使う
- mutable default argument の罠は必ず避ける
- docstring は契約を書く
型ヒントは万能ではありません。
でも、関数の契約を明確にし、設計の輪郭を見えやすくする道具としてはかなり強力です。
記事中記載事項の補足
補足注1
関数シグネチャ
関数名・引数・戻り値の並びのことです。呼び出し側から見た『使い方の説明書』に近いものです。
補足注2
副作用
戻り値を返す以外に外側へ影響を与えることです。保存、ログ出力、メール送信などが代表例です。
補足注3
型ヒント
この値は int や str のはず、という前提をコードに書く仕組みです。実行時に強制するものではありませんが、補完や型チェックに役立ちます。
補足注4
mutable default argument
デフォルト引数に [] や {} のような“中身を変えられる値”を置いてしまう落とし穴です。呼び出し間で値が共有されることがあります。
参考文献
注1
Python 公式ドキュメント, dataclasses — Data Classes
https://docs.python.org/3/library/dataclasses.html
注2
Python 公式ドキュメント, typing — Support for type hints
https://docs.python.org/3/library/typing.html
注3
mypy 公式ドキュメント, Getting started
https://mypy.readthedocs.io/en/stable/getting_started.html