🐍

Pythonの型ヒントと共に進化するコード(#13: from __future__ import annotations)

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


前章では Final を導入し、定数を型レベルで保護できるようになりました。

ところで、コードの中に少し不自然な記述が残っています。

型注釈を文字列で囲んでいる箇所です。

def unmarshal_payload(cls, payload: Mapping[str, Any]) -> "Address": # これ

今回はこの文字列クォートの問題点を説明し、改善していきます。

今回の課題:前方参照の 2 つの辛み

models.pyAddress クラスを見てみましょう。

models.py
@dataclass(frozen=True, slots=True)
class Address:
    # ...

    @classmethod
    def unmarshal_payload(cls, payload: Mapping[str, Any]) -> "Address":  # 👈 文字列で囲んでいる
        return cls(...)

戻り値の型が "Address" と文字列で囲まれています。実はこれ、Python の評価順序の問題を回避するための前方参照というテクニックです。

私も最初に見たときは「なぜ型注釈を文字列にするの?」と困惑しました。理由はシンプルで、Python はコードを上から下へ順番に評価するため、unmarshal_payload メソッドの定義時点ではまだ Address クラスの定義が完了していないのです。
なのでそのまま Address と書いて実行すると NameError になってしまいます。

実行結果
NameError: name 'Address' is not defined

これを回避するために型名を文字列として記述する必要がありました。

ただし、この文字列による型注釈には以下の問題があります。

IDE による型補完・型チェックの扱い

文字列による型注釈は Python の構文上は有効ですが、補完・型解決・リネームといった各機能は IDE や LSP ごとにサポート範囲が異なります
一部の IDE/LSP では文字列の型注釈を解析して補完・型チェックを提供しますが、実体参照と同じ精度で扱われるかというと少し怪しいです。
そのため、可能であれば from __future__ import annotations を使って型注釈を実体参照の形式に書ける方が補完やリファクタリングの恩恵を安定して受けやすくなると思います。

リファクタリングで追従しない

クラス名を Address から PostalAddress に変更しても、文字列内の型名は自動で更新されません。
これは IDE の型解析能力とは別にコード変形(リネーム)処理が文字列を安全に扱えないという性質によるものです。
結果として、実行時エラーや型の不整合を手作業で潰す必要が生じます。

処方箋:from __future__ import annotations

これらの問題を解決するのが from __future__ import annotations です。

ファイルの先頭にこの import を追加すると、すべての型注釈が遅延評価されます。

どういうことかというと、型注釈がクラスオブジェクトではなく文字列として __annotations__ に保持されるようになります。

from __future__ import annotations なし(クラス定義後に参照する場合)
def create_address() -> Address:
    return Address()

create_address.__annotations__
# {'return': <class '__main__.Address'>}  # クラスオブジェクトとして評価される
from __future__ import annotations あり
from __future__ import annotations

def create_address() -> Address:
    return Address()

create_address.__annotations__
# {'return': 'Address'}  # 文字列のまま保持される

ここで重要なのは文字列になったから評価されないのではなく、from __future__ import annotations によって 型注釈が評価対象から外されるという点です。

通常、型注釈は関数やクラスの定義時に式として評価され、Address のような名前はその場で名前解決されます。一方、この future import を有効にすると型注釈は定義時には評価されず、
構文上の表現がそのまま文字列として __annotations__ に保存されます。

つまり、文字列で保持されているのは評価を遅らせた結果であり、文字列であること自体が評価を止めているわけではありません。

from __future__ import annotations を付与することで従来は前方参照のために開発者が手動で "Address" と書いていましたが、これを Python 側に任せられるようになります。

from __future__ import annotations

@dataclass(frozen=True, slots=True)
class Address:
    API_PATH: ClassVar[Final[str]] = "/v1/address"

    zipcode: str
    # ...

    @classmethod
    def unmarshal_payload(cls, payload: Mapping[str, Any]) -> Address:  # 👈 文字列不要
        return cls(...)

コード上では型注釈を文字列クォートで書く必要がなくなるため、IDE は通常の型参照として扱いやすくなり、補完やリネームの追従性が向上します。

応用:循環 import の回避

from __future__ import annotations は循環 import の回避にも役立ちます。

プロジェクトが成長すると、モデル同士が互いを参照する場面が出てくることもあると思います。たとえば EC サイトで「注文は顧客を持つ」「顧客は注文履歴を持つ」という関係があるとします。

models/order.py
from models.customer import Customer  # 型注釈のためだけに import

class Order:
    def get_customer(self) -> Customer: ...
models/customer.py
from models.order import Order  # 型注釈のためだけに import

class Customer:
    def get_orders(self) -> list[Order]: ...

OrderCustomer を import し、CustomerOrder を import しています。このコードを実行すると循環 import エラーが発生します。

ImportError: cannot import name 'Order' from partially initialized module 'models.order'
(most likely due to a circular import)

なお、厄介なことに循環 import は mypy では検出されず、実行時エラーとして遭遇します。
これは mypy が型解析を目的としたツールであり、Python のモジュール初期化順序までは検証しないためです。

# mypy を実行
$ mypy models/
Success: no issues found in 2 source files

# 実行
$ python main.py
ImportError: cannot import name 'Order' from partially initialized module 'models.order'

この点については mypy の公式ドキュメントでも実行時の問題は検出対象外であることが明示されています。

TYPE_CHECKING による回避

この問題は TYPE_CHECKING と文字列クォートを組み合わせることで回避できます。

models/order.py
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from models.customer import Customer  # 型チェック時のみ import

class Order:
    def get_customer(self) -> "Customer": ...  # 文字列クォートで回避
models/customer.py
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from models.order import Order  # 型チェック時のみ import

class Customer:
    def get_orders(self) -> "list[Order]": ...  # 文字列クォートで回避

TYPE_CHECKING は型チェック時のみ True になる定数です。
実行時には False となるため、このブロック内の import は実行されずモジュールの初期化順序に影響しません。

その結果、循環 import による実行時エラーを回避できます。mypy は TYPE_CHECKING ブロック内の import を認識するので、型チェックも正しく機能するということです。

ただし、前述したように文字列クォートを書き忘れると実行時に評価されてしまうので NameError になります。

from __future__ import annotations との組み合わせ

from __future__ import annotations を導入すると以下のようになります。

models/order.py
from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from models.customer import Customer

class Order:
    def get_customer(self) -> Customer: ...  # 文字列クォート不要
models/customer.py
from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from models.order import Order

class Customer:
    def get_orders(self) -> list[Order]: ...  # 文字列クォート不要

型注釈が実行時に評価されなくなるため、文字列クォートを書く必要がありません。TYPE_CHECKING ブロックに import を閉じ込めることで循環 import を回避しつつ、mypy には正しい型情報を伝えられます。

2 つの機能の役割を整理すると以下です。

機能 役割
from __future__ import annotations 型注釈の評価を遅延し、実行時評価を避ける
TYPE_CHECKING 型チェック時のみ import を実行する

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

各ファイルの先頭に import を追加し、文字列クォートを削除します。

models.py(変更後)

models.py
from __future__ import annotations  # 👈 ファイル先頭に追加

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:
    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などは変更なし) ...

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

from __future__ import annotations により文字列クォートなしで自分自身のクラス名を参照できるようになりました。
その結果、IDE における補完やリファクタリングが正しく追従するだけでなく、型チェックも効くようになりました。

また、TYPE_CHECKING と組み合わせることで循環 import が回避できることも紹介しました。
ただし、これはあくまで現実的な回避策であって依存関係が複雑化しているサインでもあるため、可能なら責務分割や依存方向の整理も検討すべきです。

型注釈まわりの課題はひとまず解消できました。次はコード全体を見渡して残っている設計上の課題を確認します。

ここで、main.pyfetch_and_format_address 関数を見てみると、まだ多くの責務を抱えていることに気が付きます。

main.py の一部
def fetch_and_format_address(...) -> str | None:
    try:
        # データ取得
        response = http_client.post(api_url, json={"zipcode": zipcode}, headers=headers)
        # ...
        address = Address.unmarshal_payload(payload)

        # 整形ロジック
        full_address = address.full_address()
        result: FormattedAddressDict = {
            "zipcode": zipcode,
            "full_address": full_address,
            # ...
        }

        # JSON 変換
        return json.dumps(result, indent=2, ensure_ascii=False)
    except Exception as e:
        # ...

データ取得、整形、JSON 変換が 1 つの関数に混在しています。

これ、例えば将来的に

  • HTML 形式でも出力したい
  • 含める項目を動的に変更したい

等といった要求が来たら、この関数を拡張・修正し続けることになります。

さらに別の課題にも気が付きます。
整形ロジックで生成された result はただの dictTypedDict)なので、関数から返された後に値を書き換えられてしまう可能性を型レベルでは防げません。

これらの課題は 15 日目で SelfReadOnly を使って解決します。

次回予告

次回はコラム回として、型ヒントを活用した CLI 開発ライブラリ Typer を紹介しようと思います!

👉 14 日目: コラム回:Typer のすゝめ

処方後のコードはこちら

models.py

# models.py
from __future__ import annotations  # 👈 追加

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:
    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
from __future__ import annotations  # 👈 追加

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
from __future__ import annotations  # 👈 追加

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