Pythonの型ヒントと共に進化するコード(#15: Self と ReadOnly)
これまでの連載記事
- 1 日目: なぜ Recustomer が型を語るのか
- 2 日目: イントロダクション
- 3 日目: 脆いコードお披露目
- 4 日目: 辞書にスキーマを与える
TypedDict - 5 日目: 「ないこともある」を表現する
UnionとOptional - 6 日目: ドメインの意図を込める
NewTypeとTypeAlias - 7 日目:
ABCで「契約」を定義する - 8 日目:
Protocolで柔軟性を得る - 9 日目: 責務を分ける
- 10 日目:
dataclassesとClassVar - 11 日目: コレクション抽象型(Mapping)
- 12 日目:
Finalで定数を保護する - 13 日目:
from __future__ import annotations - 14 日目: コラム回:Typer のすゝめ
前回は from __future__ import annotations を導入し、前方参照の問題を解消しました。
ただ main.py の fetch_and_format_address 関数はまだ多くの責務を抱えています。
- API からデータを取得する
- 取得したデータを加工して結果を整形する
- JSON 文字列に変換する
これらが 1 つの関数の中に混在しており、責務の境界が曖昧です。拡張・修正が難しくなりがちです。
この記事では整形ロジックを分離して、柔軟で使いやすい API へと進化させます。
今回の課題:責務過多と可変性のリスク
現在の整形ロジックは fetch_and_format_address 関数の中に直接書かれています。
def fetch_and_format_address(...):
# ...
try:
# ... (データ取得ロジック) ...
# ▼▼▼ 整形ロジック ▼▼▼
payload = cast(Mapping[str, Any], response.json())
address = Address.unmarshal_payload(payload)
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)
# ...
この実装には 2 つの課題があります。
責務が多すぎる
データ取得と整形のロジックが同じ関数に混ざっています。将来「HTML 形式でも出力したい」「含める項目を動的に変更したい」といった要求が来たら、この関数を修正し続けることになります。修正のたびに既存のテストを壊すリスクが生まれ、変更の影響範囲も読みづらくなります。
可変性のリスク
生成された result はただの dict(TypedDict)です。関数から返された後、誰かが result["prefecture"] = "東京" のように値を書き換えてしまう可能性を型レベルでは防げません。
処方箋: fluent interface と ReadOnly
これらの課題を解決するため、以下のように改良してみます。
- 住所整形専用のフォーマッタ
AddressFormatterを導入する - このビルダーを型安全なメソッドチェーンで扱うために
Selfを使う -
fetch_and_format_addressから整形ロジックを切り離し、生成結果が変更されない前提で扱われることをReadOnlyで型レベルで明示する
AddressFormatter で整形ロジックを分離
まずは、住所整形の責務をカプセル化する AddressFormatter クラスを作ります。元々の fetch_and_format_address には「データ取得」と「例外処理」だけを残し、どう整形するか・どのフィールドを含めるかは AddressFormatter に任せます。
fetch_and_format_address の中でどの部分を Formatter に任せたいのかを検討してみます。
def fetch_and_format_address(... ) -> str | None:
# ... ここまでは API からデータを取得する処理 ...
try:
# ...
payload = cast(Mapping[str, Any], response.json())
address = Address.unmarshal_payload(payload)
# ▼▼▼ 住所取得後の整形と結果 dict の構築
# 👉 この塊を AddressFormatter に委譲したい
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:
# ... 例外処理 ...
return None
この「整形と dict 構築〜JSON への変換」を AddressFormatter へ引き渡します。fetch_and_format_address は呼び出しと例外処理だけを担当すればよくなります。
また、呼び出し側からは formatter.with_address(...).with_kana(...).build() のようにメソッドチェーンで扱えると読みやすいですね。
このチェーンを型レベルで支えるのが typing.Self です。
Self でメソッドチェーンを型安全にする
Self はそのメソッドが属するクラス自身の型を指します。with_address や with_kana のように自分自身を返すメソッドの戻り値として使うとメソッドチェーンが型安全になります。
from dataclasses import replace
from typing import Self
@dataclass(frozen=True, slots=True)
class AddressFormatter:
_address: Address | None = None
_include_kana: bool = False
def with_address(self, address: Address) -> Self:
return replace(self, _address=address)
def with_kana(self, include: bool = True) -> Self:
return replace(self, _include_kana=include)
def build(self) -> FormattedAddressDict | FormattedAddressWithKanaDict:
# ... 整形ロジック ...
なお、上記で使っている replace は、データクラスのインスタンスをコピーして指定したフィールドだけを変更した新しいインスタンスを返す関数です。元のインスタンスは変更されません。
-> Self と注釈すると、formatter.with_address(addr).with_kana() のように流れるような読み書きが可能です。このパターンは流れるようなインターフェース(fluent interface) と呼ばれます。
ReadOnly で「変更しない前提」を型レベルで明示する
4 日目で紹介した TypedDict のフィールドを「読み取り専用」としてマークするのが typing.ReadOnly です。
from typing import ReadOnly
class FormattedAddressDict(TypedDict):
zipcode: ReadOnly[str]
full_address: ReadOnly[str]
prefecture: ReadOnly[str]
city: ReadOnly[str]
town: ReadOnly[str]
ReadOnly を使うと、このデータは参照するだけで変更してはならないという契約を型チェッカーに強制できます。
コードの進化:スクリプトへの適用
AddressFormatter を導入して整形ロジックを分離しましょう。
models.py に AddressFormatter クラスを追加します。main.py の fetch_and_format_address はこのフォーマッタを使って結果を組み立てるように書き換えます。
models.py(変更後)
# ...
from dataclasses import replace
from typing import Self
# (Address, FormattedAddressDictなどの定義は同じ)
# 👉 住所整形ロジックをカプセル化するビルダー
# frozen=True により不変にすることで、インスタンスの使い回しや共有が安全になる
@dataclass(frozen=True, slots=True)
class AddressFormatter:
_address: Address | None = None
_include_kana: bool = False
def with_address(self, address: Address) -> Self:
# self を変更せず、新しいインスタンスを返す
return replace(self, _address=address)
def with_kana(self, include: bool = True) -> Self:
# self を変更せず、新しいインスタンスを返す
return replace(self, _include_kana=include)
def build(self) -> FormattedAddressDict | FormattedAddressWithKanaDict:
if self._address is None:
raise ValueError("Address must be set before building.")
base: FormattedAddressDict = {
"zipcode": self._address.zipcode,
"full_address": self._address.full_address(),
"prefecture": self._address.prefecture,
"city": self._address.city,
"town": self._address.town,
}
if self._include_kana:
with_kana: FormattedAddressWithKanaDict = {
**base,
"full_address_kana": self._address.full_address_kana(),
}
return with_kana
return base
main.py(変更後)
# ...
from models import AddressFormatter # 👉 AddressFormatterをインポート
def fetch_and_format_address(...):
# ...
try:
# ... (データ取得ロジックは同じ) ...
payload = cast(Mapping[str, Any], response.json())
address = Address.unmarshal_payload(payload)
# 👉 フォーマッタを使って結果を組み立てる
formatter = AddressFormatter()
# 👉 Self の導入により、複数のメソッドをチェーンして記述が可能
result = formatter.with_address(address).with_kana(include_kana).build()
return json.dumps(result, indent=2, ensure_ascii=False)
# ...
このリファクタリングにより fetch_and_format_address 関数はデータ取得に専念し、整形方法は AddressFormatter に委譲できました。
得られたもの
今回のリファクタリングで責務がかなりすっきり分かれました。
整形ロジックは AddressFormatter が引き受けます。Self のおかげでメソッドチェーンの型が正しく保たれ、ReadOnly で完成後のデータは変更しないという約束を型レベルで伝えられるようになっています。
ここまでの状態を眺めてみると、このコードはもう単なる動くスクリプトではありません。責務ごとに分離された、再利用可能なコンポーネントの集まりになってきています。
次回予告
ここまでの連載を通して、このスクリプトに型ヒントを段階的に適用してきました。
その結果、一部を除いて[1]は、かなり型安全な状態になっています。
地盤は固まりました。この状態であれば新しい機能を追加しても型の恩恵を受けられます。
次にやりたいのはデコレータです。ログ出力やリトライ処理をデコレータ化すればビジネスロジックと横断的関心事をきれいに分離できます。
ただ、汎用的なデコレータを作るには型安全性を保つことが重要です。任意の型を受け取って返すという仕組みを型レベルで正しく表現する必要があります。そのための道具が Generics と TypeVar です。次回はこれらの基礎を固め、その後の記事でデコレータの型付けに挑戦します。
処方後のコードはこちら
models.py
# models.py
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, replace
from typing import Any, ClassVar, Final, NewType, ReadOnly, Self, TypedDict
ZipCode = NewType("ZipCode", str)
type Headers = dict[str, str]
@dataclass(frozen=True, slots=True)
class Address:
API_PATH: ClassVar[Final[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
@classmethod
def unmarshal_payload(cls, payload: Mapping[str, Any]) -> Address:
"""APIレスポンスからAddressオブジェクトを生成する"""
return cls(
zipcode=str(payload["zipcode"]),
prefecture=str(payload["prefecture"]),
prefecture_kana=str(payload["prefecture_kana"]),
city=str(payload["city"]),
city_kana=str(payload["city_kana"]),
town=str(payload["town"]),
town_kana=str(payload["town_kana"]),
)
# 👉 ReadOnly を追加して不変性を保証
class FormattedAddressDict(TypedDict):
zipcode: ReadOnly[str]
full_address: ReadOnly[str]
prefecture: ReadOnly[str]
city: ReadOnly[str]
town: ReadOnly[str]
class FormattedAddressWithKanaDict(FormattedAddressDict):
full_address_kana: ReadOnly[str]
# 👉 住所整形ロジックをカプセル化するビルダーを追加
# frozen=True により不変にすることで、インスタンスの使い回しや共有が安全になる
@dataclass(frozen=True, slots=True)
class AddressFormatter:
_address: Address | None = None
_include_kana: bool = False
def with_address(self, address: Address) -> Self:
# self を変更せず、新しいインスタンスを返す
return replace(self, _address=address)
def with_kana(self, include: bool = True) -> Self:
# self を変更せず、新しいインスタンスを返す
return replace(self, _include_kana=include)
def build(self) -> FormattedAddressDict | FormattedAddressWithKanaDict:
if self._address is None:
raise ValueError("Address must be set before building.")
base: FormattedAddressDict = {
"zipcode": self._address.zipcode,
"full_address": self._address.full_address(),
"prefecture": self._address.prefecture,
"city": self._address.city,
"town": self._address.town,
}
if self._include_kana:
with_kana: FormattedAddressWithKanaDict = {
**base,
"full_address_kana": self._address.full_address_kana(),
}
return with_kana
return base
http_client.py
(この章での変更はありません)
main.py
# main.py
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Any, Final, cast
from models import (
ZipCode,
Headers,
Address,
AddressFormatter, # 👉 AddressFormatterをインポート
)
from http_client import HttpClient, RequestsHttpClient
# 定数
BASE_URL: Final[str] = "https://api.zipcode-jp.example"
HTTP_OK: Final[int] = 200
def fetch_and_format_address(
zipcode: ZipCode,
include_kana: bool,
http_client: HttpClient,
headers: Headers | None = None,
) -> str | None:
"""郵便番号から住所を取得し、整形して返す"""
api_url = f"{BASE_URL}{Address.API_PATH}"
try:
response = http_client.post(api_url, json={"zipcode": zipcode}, headers=headers)
if response.status_code != HTTP_OK:
print(f"Error: Failed to fetch address. Status: {response.status_code}")
return None
payload = cast(Mapping[str, Any], response.json())
address = Address.unmarshal_payload(payload)
# 👉 フォーマッタを使って結果を組み立てる
formatter = AddressFormatter()
result = formatter.with_address(address).with_kana(include_kana).build()
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)
👉 16 日目: TypeVar で実現する Generics
-
実はまだ脆い部分が残っています。連載の終盤で改善します。 ↩︎
Discussion