前回は、関数設計と型ヒントを整理しました。
- 引数は「数」より「意味のまとまり」で見る
- 戻り値は成功値と異常系を分ける
- 副作用は境界に寄せる
- 型ヒントは境界から付ける
今回は、その続きとしてかなり迷いやすいテーマである
クラスにするべき場面 / 関数で十分な場面
を、実際に手を動かしながら整理します。
Pythonを書いていると、だいたい次のどちらかに寄りがちです。
- とにかく関数で書く
- とにかく何でもクラスにする
でも実際には、どちらにも偏りすぎない方が楽です。
Pythonの class は「使うと偉い」ものではなく、データと振る舞い、状態[補足注1]、契約、境界をどう表現したいかで選ぶ道具だと考えます。
今回実施すること
今回は、同じ「注文作成まわりの処理」を題材にして、次の4パターンを実際に作ります。
- 関数で十分な処理を関数で書く
-
データのまとまりを
dataclass[注1] で表す - **状態と不変条件[補足注2]**を普通の
classで表す - **保存先の差し替え境界[補足注3]**を
Protocol[注2] で表す
さらに途中で、あえて「クラスにしなくていいのにクラスにしてしまった例」も動かして、何が微妙なのかを確認します。
前提
- VS Code が使える
- Python 3.12 がインストール済み
- ターミナルでコマンドが打てる
今回は FastAPI は使いません。
純粋な Python スクリプトとして動かしていきます。
今回のゴール
最終的に、次のことを自分で説明できるようになるのがゴールです。
- なぜその処理は関数で十分なのか
- なぜそのデータは
dataclassが自然なのか - なぜその概念は普通の
classが向いているのか - なぜ保存先の境界は
Protocolが便利なのか
0. 作業ディレクトリ作成
任意の場所で作業用ディレクトリを作成します。
mkdir python_practical_series_04
cd python_practical_series_04
仮想環境を作成します。
python3 -m venv .venv
仮想環境を有効化します。
macOS / Linux:
source .venv/bin/activate
Windows PowerShell:
.venv\Scripts\Activate.ps1
今回は標準ライブラリ中心ですが、最後に mypy[注3] でも確認したいので入れておきます。
pip3 install mypy
今回のファイル構成
最終的にはこうなります。
python_practical_series_04/
├─ .venv/
├─ step1_bad_class.py
├─ step2_functions.py
├─ step3_dataclass_model.py
├─ step4_quantity_class.py
├─ step5_protocol_repository.py
└─ mypy.ini
1. まずは「クラスにしなくていいのにクラスにした例」を動かす
最初に、あえて少し微妙な例を作ります。
step1_bad_class.py を作成してください。
class PriceCalculator:
def calculate_total_price(self, price: int, quantity: int) -> int:
# 価格と数量から合計金額を計算するだけ
# self を使っていない点が、この class の違和感につながる
return price * quantity
def main() -> None:
# インスタンスを作ってからメソッドを呼んでいる
calculator = PriceCalculator()
total = calculator.calculate_total_price(price=5000, quantity=2)
print(f"total={total}")
if __name__ == "__main__":
main()
以下を実行します。
python3 step1_bad_class.py
出力結果は次の通りです。
total=10000
ここで見てほしいのは、「動いたかどうか」ではなく、class にした意味がどこにあるのかです。
この PriceCalculator は
-
selfに状態がない - インスタンスごとの差がない
- 不変条件もない
- 差し替え境界でもない
という状態なので、ただ関数を class の中に入れただけになっています。
この手の class は、Java や C# の感覚で「とりあえずクラスを作る」流れから入りやすいですが、Pythonでは無理に class にしなくてよいことが多いです。
2. 状態がない処理は、まず関数で書く
では同じ処理を、素直に関数にしてみます。
step2_functions.py を作成してください。
def calculate_total_price(price: int, quantity: int) -> int:
# 状態を持たない単純な計算は、まず関数で十分
return price * quantity
def calculate_discounted_price(price: int, rate: float) -> int:
# 割引率を使って割引後の価格を計算する
return int(price * (1 - rate))
def main() -> None:
total = calculate_total_price(price=5000, quantity=2)
discounted = calculate_discounted_price(price=total, rate=0.1)
print(f"total={total}")
print(f"discounted={discounted}")
if __name__ == "__main__":
main()
以下を実行します。
python3 step2_functions.py
出力結果は次の通りです。
total=10000
discounted=9000
この関数が自然な理由
- 入力が明確
- 出力が明確
- 副作用がない
- テストしやすい
-
selfが不要
こういう処理は、まず関数で十分です。
今回のテーマでかなり重要なのは、class にしない判断も立派な設計だと思います。
ハンズオン:無理にクラス化して違和感を確認する
同じ処理をまた class に戻してみると、違和感が見えやすくなります。
step2_functions.py の末尾に、次のコードを一時的に足してみてください。
class DiscountService:
def calculate_discounted_price(self, price: int, rate: float) -> int:
# ただの計算処理を class の中へ入れているだけ
return int(price * (1 - rate))
この2行は、step2_functions.py の main() 関数の中で、既存の print(f"discounted={discounted}") の直前 に追加してください。
つまり、追記位置はファイル末尾ではなく main() の内部です。
def main() -> None:
total = calculate_total_price(price=5000, quantity=2)
discounted = calculate_discounted_price(price=total, rate=0.1)
discount_service = DiscountService()
print(discount_service.calculate_discounted_price(price=10000, rate=0.1))
print(f"total={total}")
print(f"discounted={discounted}")
もちろん動きます。
でも、DiscountService という名前は立派なのに、やっていることはただの計算です。
ここでの違和感はかなり重要です。
Service という名前を付けると、それっぽく見えます。
でも実際には、
- 状態がない
- 契約境界でもない
- 複数の役割(責務)をまとめているわけでもない
なら、名前だけが大きくて中身は薄い class になりやすいです。
この時点では、
- Service というほどの責務がない
- インスタンス状態もない
- 差し替えたいわけでもない
ので、やはり関数の方が自然ですね。
3. 「データの意味」を表したいときは dataclass が強い
次に、商品データを dict で持つつらさを体験します。
まずは、あえて dict 版を書いてみます。
step3_dataclass_model.py を作成してください。
from dataclasses import dataclass
def run_dict_version() -> None:
# まずは dict で商品データを表す
item = {
"item_id": 1,
"name": "Keyboard",
"price": 5000,
}
print("dict version")
print(item["name"])
print(item["price"])
# わざと typo してみる
# print(item["prcie"])
# dataclass を使うと、データの形を名前付きで表現しやすい
@dataclass(frozen=True)
class Item:
item_id: int
name: str
price: int
def run_dataclass_version() -> None:
# 同じ商品データを dataclass で表す
item = Item(item_id=1, name="Keyboard", price=5000)
print("dataclass version")
print(item.name)
print(item.price)
if __name__ == "__main__":
run_dict_version()
print("---")
run_dataclass_version()
以下を実行します。
python3 step3_dataclass_model.py
出力結果は次の通りです。
dict version
Keyboard
5000
---
dataclass version
Keyboard
5000
比較: dict と dataclass の読みやすさを見る
第1回・第2回では dict の typo による壊れ方を体験しました。
この回では同じ体験を繰り返すのではなく、「データの意味をどう表すか」 に絞って見ます。
上記の結果のように、この時点ではどちらも同じ値を表示します。
ただし読み手に伝わる情報量は同じではありません。
-
dictは手軽だが、何のデータかがキー名に埋もれやすい -
dataclassは「これは商品データである」と名前付きで表現できる - 属性が定義として見えるので、後続の処理で前提を共有しやすい
この回で重視したいのは、dict の弱点をもう一度なぞることではなく、
関数・dataclass・普通の class をどう使い分けるか です。
この段階での結論
- 状態を持たない計算は関数
-
データのまとまりは
dataclass
この2つを切り分けるだけでも、かなり見通しが良くなります。
4. 不変条件を守りたいときは普通の class が強い
ここからが「普通の class が本当に向いている場面」です。
たとえば数量 quantity は、業務上 必ず正の整数 であってほしいとします。
ただの int で持つと、あちこちで毎回チェックが必要になります。
そこで、数量そのものを class にしてみます。
step4_quantity_class.py を作成してください。
class Quantity:
def __init__(self, value: int) -> None:
# Quantity は 0 以下を受け付けない
if value <= 0:
raise ValueError("quantity must be positive")
self._value = value
@property
def value(self) -> int:
# 内部に保持した数量を返す
return self._value
class Stock:
def __init__(self, quantity: int) -> None:
# 在庫は負の値を許さない
if quantity < 0:
raise ValueError("stock must be non-negative")
self._quantity = quantity
@property
def quantity(self) -> int:
# 現在の在庫数を返す
return self._quantity
def has_enough(self, requested: Quantity) -> bool:
# 必要数量を満たしているか判定する
return self._quantity >= requested.value
def decrease(self, requested: Quantity) -> None:
# 在庫不足ならここで止める
if not self.has_enough(requested):
raise ValueError("insufficient stock")
# 問題なければ在庫を減らす
self._quantity -= requested.value
def main() -> None:
stock = Stock(quantity=10)
requested = Quantity(3)
print(f"before={stock.quantity}")
stock.decrease(requested)
print(f"after={stock.quantity}")
if __name__ == "__main__":
main()
以下を実行します。
python3 step4_quantity_class.py
出力結果は次の通りです。
before=10
after=7
ここで見てほしいのは、Stock と Quantity が
ただ値を持っているだけではなく、ルールも一緒に持っている ことです。
-
Quantityは 0 以下を許さない -
Stockは負の在庫を許さない -
decrease()は在庫不足を自分で判断する
つまり、状態と不変条件がある概念は、普通の class がかなり自然ですね。
ハンズオン:不正な数量を入れてみる
main() のこの行を変更してください。
# わざと不正な数量を作る
# Quantity は生成時点でこの不正値を止める
requested = Quantity(0)
再以下を実行します。
python3 step4_quantity_class.py
すると、作成時点で失敗します。
ValueError: quantity must be positive
なぜ class が自然なのか
- 内部状態がある
- 不変条件がある
- その状態に対する自然な操作がある
- ルールを内部に閉じ込められる
つまり、状態と振る舞いが密接に結びついているからと言えます。
ハンズオン:関数版だとチェック漏れしやすいことを確認する
比較のため、step4_quantity_class.py の末尾に一時的に次の関数も追加してみてください。
def decrease_stock(current_quantity: int, requested: int) -> int:
# 関数版でも実装できるが、毎回チェックを書く必要がある
if requested <= 0:
raise ValueError("requested must be positive")
if current_quantity < requested:
raise ValueError("insufficient stock")
return current_quantity - requested
さらに main() の最後に、こう足してみます。
# 関数版では current_quantity / requested を毎回渡している
print(decrease_stock(current_quantity=10, requested=3))
この関数版も悪くありません。
ただし、毎回 requested のチェックを各関数で忘れずに書く必要があります。
ここで見てほしいのは、「関数が悪い」のではなく、
不変条件をどこで守りたいか です。
- 毎回関数内でチェックするなら関数版でもよい
- 数量という概念そのものにルールを持たせたいなら
Quantityclass が自然
という違いがあります。
一方 Quantity にしておくと、数量が正であることを、以後の処理である程度信じやすくなるのが強みです。
5. Service という名前に何でも押し込むと曖昧になりやすい
FastAPIやバックエンド実装では、よく OrderService を作りたくなります。
たとえば、こんな感じです。
class OrderService:
def create_order(self, user_id: int, item_price: int, quantity: int) -> int:
# 合計金額を計算する
total_price = item_price * quantity
# 保存処理をしているつもりの出力
print("save order")
# 通知処理をしているつもりの出力
print("send mail")
# いろいろ混ざっているが、とりあえず合計金額だけ返す
return total_price
一見まとまっているように見えますが、実際には
- 計算
- 保存
- 通知
が全部混ざっています。
つまり、Service は便利な名前すぎて、責務の逃げ場になりやすいです。
この回の目線
Service が絶対ダメという話ではありません。
ただし、XxxService と名付けたくなったら、まず
- これはただの関数ではないか?
- これは
dataclassではないか? - これは状態を持つ class ではないか?
- これは保存境界ではないか?
を一度見直してみるのが良いと思います。
6. 差し替え境界は Protocol が便利
最後に、保存先の差し替え境界を作ってみます。
step5_protocol_repository.py を作成してください。
from dataclasses import dataclass
from typing import Protocol
# 商品データは dataclass で表現する
@dataclass(frozen=True)
class Item:
item_id: int
name: str
price: int
class Quantity:
def __init__(self, value: int) -> None:
# Quantity は 0 以下を受け付けない
if value <= 0:
raise ValueError("quantity must be positive")
self._value = value
@property
def value(self) -> int:
return self._value
# 注文データも dataclass で表現する
@dataclass(frozen=True)
class Order:
user_id: int
item: Item
quantity: Quantity
@property
def total_price(self) -> int:
# 合計金額は item と quantity から計算できる
return self.item.price * self.quantity.value
# save() を持つものなら repository として扱える、という契約
class OrderRepository(Protocol):
def save(self, order: Order) -> None:
...
class InMemoryOrderRepository:
def __init__(self) -> None:
# 保存先をメモリ上のリストで表現する
self.orders: list[Order] = []
def save(self, order: Order) -> None:
# 保存処理をこの class に閉じ込める
self.orders.append(order)
def create_order(*, user_id: int, item: Item, quantity: Quantity) -> Order:
# 薄い生成処理なので関数で十分
return Order(user_id=user_id, item=item, quantity=quantity)
def save_order(*, order: Order, repository: OrderRepository) -> None:
# 保存先の詳細は repository に委譲する
repository.save(order)
def main() -> None:
item = Item(item_id=1, name="Keyboard", price=5000)
quantity = Quantity(2)
order = create_order(user_id=10, item=item, quantity=quantity)
repository = InMemoryOrderRepository()
save_order(order=order, repository=repository)
print(f"saved_count={len(repository.orders)}")
print(f"total_price={repository.orders[0].total_price}")
if __name__ == "__main__":
main()
補足: Protocol
ざっくりいうと
Protocol は、継承関係ではなく、「必要なメソッドを持っているか」で使えるかどうかを決めたいときに便利な型ヒントです。
この場面でできること
- repository の実装を後から差し替えやすくできる
- テスト用の実装と本番用の実装を同じ形で扱える
- 「このメソッドが必要」という契約を見える化できる
最小例
from typing import Protocol # Protocol を読み込む
class ItemRepository(Protocol): # 必要なメソッドだけを持つ契約を作る
def find_by_id(self, item_id: int): # 商品取得メソッドの形だけを定義する
... # 中身は実装側に任せる
以下を実行します。
python3 step5_protocol_repository.py
出力結果は次の通りです。
saved_count=1
total_price=10000
ここでは、役割がかなりきれいに分かれています。
-
create_order()は薄い生成処理なので関数 -
ItemとOrderはデータ表現なのでdataclass -
Quantityは不変条件を守りたいので普通のclass -
OrderRepositoryは差し替え境界なのでProtocol
つまり、全部を class にも、全部を関数にもしていないことが大事です。
ハンズオン:Protocol のありがたみを体感する
今の save_order() は、repository.save(order) ができるものなら受け取れる設計です。
では、わざと壊してみます。
step5_protocol_repository.py に次のクラスを追加してください。
class BrokenRepository:
def persist(self, order: Order) -> None:
# save() ではなく persist() しか持っていない
print("persisted", order)
そして main() のこの行を置き換えます。
# わざと Protocol の契約を満たしていない repository を使う
repository = BrokenRepository()
このまま python3 で実行すると、実行時エラーになります。
python3 step5_protocol_repository.py
想定されるエラー:
AttributeError: 'BrokenRepository' object has no attribute 'save'
ここで見てほしいのは、BrokenRepository が「リポジトリっぽい名前」でも、
save() を持っていなければこの文脈では使えないことです。
つまり重要なのはクラス名ではなく、
この境界で何の振る舞いを約束してほしいか です。
つまり、 保存先の境界として「何を満たしてほしいか」 が大事だと言えます。
mypy でも確認する
mypy.ini を作成してください。
[mypy]
python_version = 3.12
strict = True
そして BrokenRepository を使ったまま、型チェックします。
python3 -m mypy step5_protocol_repository.py
すると、save() を持っていないことが型チェックでも分かります。
環境によって文言は多少違いますが、たとえば次のようなエラーが出ます。
error: Argument "repository" to "save_order" has incompatible type "BrokenRepository"; expected "OrderRepository"
ここでのポイントは、Protocol が「継承関係」ではなく「必要な振る舞い」を表していることです。
つまり、
-
OrderRepositoryを継承していなくてもよい - でも
save()を持っていなければ適合しない
という形で、差し替え境界をかなり素直に表現できます。
7. この回の整理
ここまでで、かなりざっくり次のように整理できます。
まず関数でよい場面
- 計算
- 変換
- 判定
- 状態を持たない処理
- 小さく独立した責務
例:
# 状態を持たない単純な計算は、まず関数で十分
def calculate_total_price(price: int, quantity: int) -> int:
return price * quantity
dataclass がよい場面
- データの意味を表したい
-
dictから脱出したい - 属性のまとまりをはっきりさせたい
例:
from dataclasses import dataclass
# データのまとまりを表したいときは dataclass が自然
@dataclass(frozen=True)
class Item:
item_id: int
name: str
price: int
普通の class がよい場面
- 状態と振る舞いが密接
- 不変条件を守りたい
- 生成時点で不正値を止めたい
- 内部表現を守りたい
例:
class Quantity:
# 不変条件や内部状態を守りたいときは普通の class が自然
...
Protocol がよい場面
- 差し替えたい
- 契約を表したい
- 外部依存を抽象化したい
- テストダブルを差し込みたい
例:
class OrderRepository(Protocol):
# 差し替え境界では、必要な振る舞いだけを Protocol で表す
def save(self, order: Order) -> None:
...
まとめ
今回は、クラスにするべき場面 / 関数で十分な場面を、実際に手を動かしながら整理しました。
今回のポイントは次の通りです。
- class は「使うと偉い」ものではない
- 状態を持たない処理は、まず関数で十分なことが多い
- データのまとまりを表すなら
dataclassがかなり強い - 不変条件や状態を守りたいなら普通の
classが向いている - 差し替え境界には
Protocolが便利 -
Serviceという名前は便利だが、責務の逃げ場にもなりやすい - すべてを class にも、すべてを関数にもせず、概念ごとに道具を変えるのが大事
記事中記載事項の補足
補足注1
状態
そのオブジェクトが今どんな値を持っているか、という情報です。たとえば在庫数は Stock の状態です。
補足注2
不変条件
その概念でいつでも守られていてほしいルールです。たとえば数量なら『0より大きい』が不変条件です。
補足注3
差し替え境界
保存先や外部サービスをあとで入れ替えやすくするための接点です。
参考文献
注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