🐍

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

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


前回の 11 日目では、変換ロジックとコレクション抽象型 Mapping を導入しました。

Mapping と変換メソッドによってドメインモデルは外部スキーマの変更から守られるようになりました。

ただコードを見渡すとまだ気になる箇所があります。マジックナンバーやマジックストリングがあちこちに散らばっている。これらは本来変更されるべきでない定数のはずです。

今回の課題:誤って変更されうる定数

コードの中にはアプリケーション全体で一貫して使われるべき値がいくつかあります。

main.py
def fetch_and_format_address(...):
    # この URL は変更されるべきでない定数では?
    api_url = f"https://api.zipcode-jp.example{Address.API_PATH}"

    # ステータスコード 200 もマジックナンバー
    if response.status_code != 200:
        ...
models.py
@dataclass(frozen=True, slots=True)
class Address:
    # ClassVar で宣言しているが、再代入は可能
    API_PATH: ClassVar[str] = "/v1/address"

現状いくつかの問題があります。

1. 意図が不明確

200"https://api.zipcode-jp.example" といったリテラルがコード内に散らばっていると、それが何を意味するのか、変更してよいのかどうかがわかりません。

2. 誤った変更を防げない

ClassVar はクラス変数であることを示すだけです。再代入してはいけないという意図は表現できまないので、以下のコードは型エラーになりません。

# 型チェッカーはこれを許してしまう
Address.API_PATH = "/v2/address"  # 意図しない変更!

3. 一貫性の欠如

同じ値が複数の場所でリテラルとして使われていると、一箇所だけ変更し忘れるリスクがあります。

処方箋:Finalによる定数宣言

この問題を解決するのが typing.Final です。Final は変数が一度だけ代入され、その後は変更されないことを型チェッカーに宣言します。

from typing import Final

# BASE_URL は変更不可の定数であることを宣言
BASE_URL: Final[str] = "https://api.zipcode-jp.example"

# 型チェッカーがエラーを報告する
BASE_URL = "https://other-api.example"  # エラー: Final で宣言された変数への再代入

Final のメリットは明確です。

  • 意図の明示
    • 定数であることが読み手へ明確に伝わる
  • 再代入の防止
    • 型チェッカーが再代入を検出してエラーを報告する

FinalClassVar の組み合わせ

クラス変数を定数として宣言したい場合は FinalClassVar を組み合わせます。

from dataclasses import dataclass
from typing import ClassVar, Final

@dataclass(frozen=True, slots=True)
class Address:
    # クラス変数かつ定数であることを宣言
    API_PATH: ClassVar[Final[str]] = "/v1/address"

    # インスタンス変数
    zipcode: str
    # ...

# 型チェッカーがエラーを報告する
Address.API_PATH = "/v2/address"  # エラー: Final で宣言された変数への再代入

応用:設定オブジェクトを Final で守る

Final は単独の定数だけでなく、設定オブジェクト全体を守るのにも使えます。

たとえば環境ごとに API の接続先やタイムアウト値が異なるケースを考えてみます。

from dataclasses import dataclass
from typing import Final

@dataclass(frozen=True)
class ApiConfig:
    base_url: str
    timeout_sec: int

DEV_CONFIG: Final[ApiConfig] = ApiConfig(
    base_url="https://api-dev.example",
    timeout_sec=5,
)

PROD_CONFIG: Final[ApiConfig] = ApiConfig(
    base_url="https://api.example",
    timeout_sec=2,
)

ここでは 2 つの仕組みを組み合わせています。

  • Final[ApiConfig] で変数への再代入を禁止
  • @dataclass(frozen=True) でオブジェクトの中身の変更を禁止

この二重防御により、以下のようなうっかりミスを型チェッカーが検出してくれます。

# 変数への再代入 → Final 違反
PROD_CONFIG = DEV_CONFIG  # error: Cannot assign to final name "PROD_CONFIG"

# 属性の変更 → frozen dataclass 違反
PROD_CONFIG.base_url = "https://debug.example"  # error: Property "base_url" defined in "ApiConfig" is read-only

この設定一式には手を入れてはいけないという意図を型で表現できるわけです。

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

それでは Final をプロジェクトに導入しましょう。

models.py のクラス変数に Final を追加し、main.py にはファイル上部で定数を定義します。

models.py (変更後)

# models.py
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, ClassVar, Final, NewType, TypedDict

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

@dataclass(frozen=True, slots=True)
class Address:
    # 👉 ClassVar と Final を組み合わせてクラス定数として宣言
    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":
        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"]),
        )

# ... (FormattedAddressDictなどは変更なし) ...

main.py (変更後)

# main.py
import json
from typing import Final

from models import ZipCode, Headers, Address, FormattedAddressDict, FormattedAddressWithKanaDict
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

        address = Address.unmarshal_payload(response.json())
        # ...

Final を導入したことで定数が型システムで保護され、誤った変更を開発時に検出できます。

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

今回のリファクタリングで定数を型レベルで保護できるようになりました。

Final によって重要な設定値が誤って変更されることを型チェッカーが防いでくれます。定数に意味のある名前を付けることでコードの可読性も上がりました。HTTP_OK200 よりもはるかに意図が明確です。

コードは着実に堅牢になっています。ただコードベースが成長するにつれて新たな課題が見えてきます。

# models.py の一部
@classmethod
def unmarshal_payload(cls, payload: Mapping[str, Any]) -> "Address":
    return cls(...)

戻り値の型注釈で "Address" と文字列で囲んでいることにお気づきでしょうか。

これは前方参照と呼ばれるテクニックで、クラス定義の途中でまだ定義が完了していない自分自身のクラス名を参照するために必要でした。

ただこの文字列による型注釈はタイプミスのリスクがあります。リファクタリングでクラス名を変更しても自動で追従してくれません。

次回予告

次回は from __future__ import annotations を導入してこの問題を解決します。

👉 13 日目: from __future__ import annotations


処方後のコードはこちら

models.py

# models.py
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, ClassVar, Final, NewType, TypedDict

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

@dataclass(frozen=True, slots=True)
class Address:
    # 👉 ClassVar と Final を組み合わせてクラス定数として宣言
    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"]),
        )

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 collections.abc import Mapping
from typing import Any, Final, cast

from models import ZipCode, Headers, Address, FormattedAddressDict, FormattedAddressWithKanaDict
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)

        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