🐍

Pythonの型ヒントと共に進化するコード(#10: dataclasses と ClassVar)

に公開
これまでの連載記事


前回はスクリプトをモジュールに分割しました。

見通しは良くなったのですが、分割したことで別の課題が見えてきました。

今回の課題:離れ離れになったデータとロジック、そしてクラス変数の曖昧さ

今回は 2 つの課題に取り組みます。

1. 離れ離れになったデータとロジック

models.pymain.py の関係を見てみます。

models.py
class AddressDict(TypedDict):
    zipcode: str
    prefecture: str
    city: str
    town: str
    # ...
main.py
from models import AddressDict

def _build_full_address(address_data: AddressDict) -> str:
    return address_data["prefecture"] + address_data["city"] + address_data["town"]

AddressDict の定義は models.py にありますが、それを操作する _build_full_addressmain.py にあります。TypedDict は単なる構造を表す型なので、データと処理が分離されがちです。これ自体は仕様上自然な姿なのですが、設計上の凝集度という観点ではひっかかりが出てくるポイントでもあります。

設計には凝集度という指標があります。関連するデータと処理が近くにまとまっているほど凝集度が高い。逆に散在していると凝集度が低い。

今回のケースはまさに後者で、いくつかの懸念が生まれます。

  • AddressDict のフィールドを変更したとき、対応する処理の修正漏れが起きやすい
  • 「この住所データを整形するロジックはどこだろう」と探すとき、AddressDict の定義側だけ見ても手がかりがない
  • データと操作の対応関係がファイルをまたいでいるため、把握に余計な認知コストがかかる

とはいえ、TypedDict は構造の型であってメソッドを持てないのでデータとロジックが離れるのはある意味必然でもあります。

ただ、ここまで来るとデータとそれを扱うロジックを 1 つのまとまりとして扱いたいという欲求が出てくるのも自然かなと思います。凝集度の観点からもカプセル化できる構造に進めたいところです。

2. クラス変数とインスタンス変数の境界が曖昧になる問題

もう 1 つ、クラス変数の扱いで困ることがあります。

クラス変数とは、そのクラスの全インスタンスで共有される変数のことです。例えば Address モデルに API のエンドポイントパスをクラス変数として持たせたいとします。

今回導入する @dataclass を使うとデータ保持用のクラスを簡潔に定義できます。ただ、素朴に書くと問題が起きます。

@dataclass
class Address:
    # これは全インスタンスで共有したい定数
    API_PATH: str = "/v1/address"

    # これらはインスタンスごとに異なる
    zipcode: str
    prefecture: str

@dataclass はクラスの属性をもとに __init__ を自動生成します。上のように書くと API_PATH も初期化引数だと解釈されてしまいます。

API_PATH はクラス全体で共有される定数として扱いたいのに、これではインスタンスごとに書き換えられる値のようにも見えてしまいます。エラーにはならないけれど意図がブレてしまいます。

処方箋:データと振る舞いを一体化する dataclasses(と、ClassVar

この 2 つの課題を解決するのが dataclasses モジュールと ClassVar です。

from dataclasses import dataclass
from typing import ClassVar

@dataclass(frozen=True, slots=True)
class Address:
    # 👉 ClassVar でクラス変数であることを明示
    API_PATH: ClassVar[str] = "/v1/address"

    # 👉 以下はインスタンス変数
    zipcode: str
    prefecture: str
    city: str
    town: str

    # 👉 メソッドも定義できる
    def full_address(self) -> str:
        return self.prefecture + self.city + self.town

ポイントを整理します。

  • @dataclass__init__(初期化)や __repr__(文字列表現)、__eq__(同値比較)などを自動生成してくれます。ボイラープレートが減ってかなり楽です。

  • frozen=True を付けるとインスタンスがイミュータブル(不変)になります。address.prefecture = "大阪府" のような再代入を試みると FrozenInstanceError が発生します。

  • slots=True (Python 3.10+) はメモリ使用量を最適化し、属性アクセスを高速化します。ただし 継承時の制約などがあるので注意です。

  • ClassVar はクラス変数であることを dataclass と型チェッカーの両方に伝えます。詳しくは次のセクションで説明します。

dataclasses の一番のメリットは普通の Python クラスだということです。TypedDict と違ってメソッドを自由に追加できる。データとそれを操作する処理を同じ場所にまとめられるわけです。

ClassVar の仕組み

ClassVar を付けない場合、次のような __init__ が生成されてしまいます。

def __init__(self, API_PATH: str = "/v1/address", zipcode: str, ...): ...

API_PATH も初期化引数に含まれてしまいます。これだとインスタンスごとに異なる値を渡せてしまうように見える。

ClassVar[str] を付けると API_PATH__init__ の引数から除外され、zipcode, prefecture, ... だけがコンストラクタの引数になります。型チェッカーに対してもインスタンス属性としては使わないことを伝えられます。

dataclass オプションの組み合わせ

よく使う組み合わせを整理しておきます。

オプション 効果 ユースケース
デフォルト ミュータブル 一般的なデータ保持クラス
frozen=True イミュータブル+ハッシュ可能 値オブジェクト、辞書のキー
slots=True メモリ最適化 大量インスタンス生成
両方 イミュータブル+メモリ最適化 不変の値オブジェクトを多数生成

コードの進化:スクリプトへの適用

TypedDictdataclass に置き換えて、main.py にあった _build_full_addressAddress のメソッドとして移設します。

models.py
from dataclasses import dataclass
from typing import ClassVar

@dataclass(frozen=True, slots=True)
class Address:
    API_PATH: ClassVar[str] = "/v1/address"
    zipcode: str
    prefecture: str
    prefecture_kana: str
    city: str
    city_kana: str
    town: str
    town_kana: str

    def full_address(self) -> str:
        """都道府県・市区町村・町域を結合したフル住所を返す"""
        return self.prefecture + self.city + self.town

    def full_address_kana(self) -> str:
        """フル住所のカナ表記を返す"""
        return self.prefecture_kana + self.city_kana + self.town_kana
main.py
from models import ZipCode, Headers, Address, FormattedAddressDict, FormattedAddressWithKanaDict
from http_client import HttpClient, RequestsHttpClient

# _build_full_address は Address に移設したので削除

def fetch_and_format_address(
    zipcode: ZipCode,
    include_kana: bool,
    http_client: HttpClient,
    headers: Headers | None = None,
) -> str | None:
    # API パスをクラス変数から取得
    api_url = f"https://api.zipcode-jp.example{Address.API_PATH}"

    try:
        response = http_client.post(api_url, json={"zipcode": zipcode}, headers=headers)
        # ...

        # 👉 レスポンスを dataclass に変換
        address = Address(**response.json())

        # 👉 メソッドとして呼び出す
        full_address = address.full_address()

        result: FormattedAddressDict = {
            "zipcode": zipcode,
            "full_address": full_address,
            "prefecture": address.prefecture,
            "city": address.city,
            "town": address.town,
        }

        if include_kana:
            result_with_kana: FormattedAddressWithKanaDict = {
                **result,
                "full_address_kana": address.full_address_kana(),
            }
            return json.dumps(result_with_kana, indent=2, ensure_ascii=False)

        return json.dumps(result, indent=2, ensure_ascii=False)
        # ...

_build_full_address という独立した関数ではなく address.full_address() というメソッド呼び出しになりました。データと処理の関係がわかりやすくなりました。

ちなみに FormattedAddressDictTypedDict のまま残しています。外部に返すデータの型と内部のモデルは分けておいた方が後から変更しやすいので。

得られたものと、次の課題

今回のリファクタリングで得られたものを整理します。

  • _build_full_address という独立した関数が address.full_address() というメソッドになり、データと処理の関係が明確になった
  • ClassVar によってクラス変数とインスタンス変数の区別が型レベルで明示されるようになった
  • frozen=True でインスタンスがイミュータブルになり、意図しない書き換えを防げるようになった

ただ、まだ問題が残っています。main.py で API レスポンスから Address を作っている部分を見てください。

address = Address(**response.json())

** は辞書をキーワード引数に展開する構文です。response.json() の中身をそのままコンストラクタに渡せます。シンプルで良さそうですが、実はこれ危ないです。

API 側に building フィールドが追加されたらどうなるか。Address にはその引数がないのでこうなります。

TypeError: __init__() got an unexpected keyword argument 'building'

外部 API のスキーマ変更でドメインモデルが壊れてしまいます。これは避けたいところです。

次回予告

次回は Mapping などのコレクション抽象型を使ってこの問題を解決します。


処方後のコードはこちら

models.py

# models.py
from dataclasses import dataclass
from typing import ClassVar, NewType, TypedDict

ZipCode = NewType("ZipCode", str)
type Headers = dict[str, str]

@dataclass(frozen=True, slots=True)
class Address:
    API_PATH: ClassVar[str] = "/v1/address"
    zipcode: str
    prefecture: str
    prefecture_kana: str
    city: str
    city_kana: str
    town: str
    town_kana: str

    def full_address(self) -> str:
        """都道府県・市区町村・町域を結合したフル住所を返す"""
        return self.prefecture + self.city + self.town

    def full_address_kana(self) -> str:
        """フル住所のカナ表記を返す"""
        return self.prefecture_kana + self.city_kana + self.town_kana

# FormattedAddressDict は外部との I/F なので TypedDict のまま
class FormattedAddressDict(TypedDict):
    zipcode: str
    full_address: str
    prefecture: str
    city: str
    town: str

class FormattedAddressWithKanaDict(FormattedAddressDict):
    full_address_kana: str

http_client.py

# http_client.py
import requests as requests_lib
from typing import Protocol

from models import Headers

type JsonObject = dict[str, object]


class HttpResponse(Protocol):
    @property
    def status_code(self) -> int: ...
    def json(self) -> object: ...


class HttpClient(Protocol):
    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> HttpResponse: ...


class RequestsResponse:
    def __init__(self, response: requests_lib.Response) -> None:
        self._response = response

    @property
    def status_code(self) -> int:
        return self._response.status_code

    def json(self) -> object:
        return self._response.json()


class RequestsHttpClient:
    def __init__(self) -> None:
        self._session = requests_lib.Session()

    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> RequestsResponse:
        response = self._session.post(url, json=json, headers=headers)
        return RequestsResponse(response)

main.py

# main.py
import json
from typing import Any, cast

from models import ZipCode, Headers, Address, FormattedAddressDict, FormattedAddressWithKanaDict
from http_client import HttpClient, RequestsHttpClient


def fetch_and_format_address(
    zipcode: ZipCode,
    include_kana: bool,
    http_client: HttpClient,
    headers: Headers | None = None,
) -> str | None:
    """郵便番号から住所を取得し、整形して返す"""

    api_url = f"https://api.zipcode-jp.example{Address.API_PATH}"

    try:
        response = http_client.post(api_url, json={"zipcode": zipcode}, headers=headers)
        if response.status_code != 200:
            print(f"Error: Failed to fetch address. Status: {response.status_code}")
            return None

        response_data = cast(dict[str, Any], response.json())
        address = Address(**response_data)

        full_address = address.full_address()

        result: FormattedAddressDict = {
            "zipcode": zipcode,
            "full_address": full_address,
            "prefecture": address.prefecture,
            "city": address.city,
            "town": address.town,
        }

        if include_kana:
            result_with_kana: FormattedAddressWithKanaDict = {
                **result,
                "full_address_kana": address.full_address_kana(),
            }
            return json.dumps(result_with_kana, indent=2, ensure_ascii=False)

        return json.dumps(result, indent=2, ensure_ascii=False)

    except Exception as e:
        print(f"An error occurred: {e}")
        return None

# 実行例
if __name__ == "__main__":
    http_client = RequestsHttpClient()
    zipcode = ZipCode("1000001")
    result = fetch_and_format_address(
        zipcode, include_kana=True, http_client=http_client
    )
    if result is not None:
        print(result)

Discussion