🐍

Pythonの型ヒントと共に進化するコード(#15: Self と ReadOnly)

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


前回は from __future__ import annotations を導入し、前方参照の問題を解消しました。

ただ main.pyfetch_and_format_address 関数はまだ多くの責務を抱えています。

  • API からデータを取得する
  • 取得したデータを加工して結果を整形する
  • JSON 文字列に変換する

これらが 1 つの関数の中に混在しており、責務の境界が曖昧です。拡張・修正が難しくなりがちです。

この記事では整形ロジックを分離して、柔軟で使いやすい API へと進化させます。

今回の課題:責務過多と可変性のリスク

現在の整形ロジックは fetch_and_format_address 関数の中に直接書かれています。

main.py の一部
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 はただの dictTypedDict)です。関数から返された後、誰かが 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_addresswith_kana のように自分自身を返すメソッドの戻り値として使うとメソッドチェーンが型安全になります。

models.py に AddressFormatter を追加
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.pyAddressFormatter クラスを追加します。main.pyfetch_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]は、かなり型安全な状態になっています。

地盤は固まりました。この状態であれば新しい機能を追加しても型の恩恵を受けられます。

次にやりたいのはデコレータです。ログ出力やリトライ処理をデコレータ化すればビジネスロジックと横断的関心事をきれいに分離できます。

ただ、汎用的なデコレータを作るには型安全性を保つことが重要です。任意の型を受け取って返すという仕組みを型レベルで正しく表現する必要があります。そのための道具が GenericsTypeVar です。次回はこれらの基礎を固め、その後の記事でデコレータの型付けに挑戦します。

処方後のコードはこちら

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

脚注
  1. 実はまだ脆い部分が残っています。連載の終盤で改善します。 ↩︎

Discussion