Pythonの型ヒントと共に進化するコード(#10: dataclasses と ClassVar)
これまでの連載記事
前回はスクリプトをモジュールに分割しました。
見通しは良くなったのですが、分割したことで別の課題が見えてきました。
今回の課題:離れ離れになったデータとロジック、そしてクラス変数の曖昧さ
今回は 2 つの課題に取り組みます。
1. 離れ離れになったデータとロジック
models.py と main.py の関係を見てみます。
class AddressDict(TypedDict):
zipcode: str
prefecture: str
city: str
town: str
# ...
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_address は main.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 |
メモリ最適化 | 大量インスタンス生成 |
| 両方 | イミュータブル+メモリ最適化 | 不変の値オブジェクトを多数生成 |
コードの進化:スクリプトへの適用
TypedDict を dataclass に置き換えて、main.py にあった _build_full_address を Address のメソッドとして移設します。
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
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() というメソッド呼び出しになりました。データと処理の関係がわかりやすくなりました。
ちなみに FormattedAddressDict は TypedDict のまま残しています。外部に返すデータの型と内部のモデルは分けておいた方が後から変更しやすいので。
得られたものと、次の課題
今回のリファクタリングで得られたものを整理します。
-
_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