3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

第4回 クラスにするべき場面 / 関数で十分な場面をどう考えるか? FastAPIを書いてきた視点で整理してみる

3
Posted at

前回は、関数設計と型ヒントを整理しました。

  • 引数は「数」より「意味のまとまり」で見る
  • 戻り値は成功値と異常系を分ける
  • 副作用は境界に寄せる
  • 型ヒントは境界から付ける

今回は、その続きとしてかなり迷いやすいテーマである

クラスにするべき場面 / 関数で十分な場面

を、実際に手を動かしながら整理します。

Pythonを書いていると、だいたい次のどちらかに寄りがちです。

  • とにかく関数で書く
  • とにかく何でもクラスにする

でも実際には、どちらにも偏りすぎない方が楽です。
Pythonの class は「使うと偉い」ものではなく、データと振る舞い、状態[補足注1]、契約、境界をどう表現したいかで選ぶ道具だと考えます。


今回実施すること

今回は、同じ「注文作成まわりの処理」を題材にして、次の4パターンを実際に作ります。

  1. 関数で十分な処理を関数で書く
  2. データのまとまりdataclass[注1] で表す
  3. **状態と不変条件[補足注2]**を普通の class で表す
  4. **保存先の差し替え境界[補足注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.pymain() 関数の中で、既存の 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

比較: dictdataclass の読みやすさを見る

第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

ここで見てほしいのは、StockQuantity
ただ値を持っているだけではなく、ルールも一緒に持っている ことです。

  • 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 のチェックを各関数で忘れずに書く必要があります。

ここで見てほしいのは、「関数が悪い」のではなく、
不変条件をどこで守りたいか です。

  • 毎回関数内でチェックするなら関数版でもよい
  • 数量という概念そのものにルールを持たせたいなら Quantity class が自然

という違いがあります。

一方 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() は薄い生成処理なので関数
  • ItemOrder はデータ表現なので 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

3
1
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
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?