Python 3.14のアノテーション遅延評価—__future__はもう不要か

Python 3.14のアノテーション遅延評価—__future__はもう不要か | mohablog

Python 3.14 でアノテーションの評価タイミングが変わりました。定義時ではなく、__annotations__ を読んだ瞬間に評価されます。手元の 3.13.143.14.6 に同じスクリプトを流し、どこがどう変わるかを並べた記録です。

目次

評価は __annotations__ を読むまで待たされる

アノテーションの式に print を仕込むと、評価された瞬間が見えます。

def probe(name):
    print(f"  [評価された] {name}")
    return int


class Order:
    field: probe("Order.field")


def handler(user: probe("handler.user")) -> probe("handler.ret"):
    ...


print("--- 定義完了 ---")
print(handler.__annotations__)

3.13 と 3.14 に同じスクリプトを流す

Python 3.13.14 の出力。

  [評価された] Order.field
  [評価された] handler.user
  [評価された] handler.ret
--- 定義完了 ---
{'user': <class 'int'>, 'return': <class 'int'>}

Python 3.14.6 の出力。

--- 定義完了 ---
  [評価された] handler.user
  [評価された] handler.ret
{'user': <class 'int'>, 'return': <class 'int'>}

「定義完了」の行を挟んで、probe の呼び出しが後ろへ回りました。関数を定義しただけではアノテーションは評価されませんhandler.__annotations__ を読んだ時点で初めて式が走ります。

誰も読まないアノテーションは評価されない

Order.field は 3.14 側の出力に一度も現れません。Order.__annotations__ を読むコードが無いので、probe("Order.field") は最後まで呼ばれずにプロセスが終わります。What’s New の “PEP 649 & PEP 749: Deferred evaluation of annotations” にある通りの挙動です。

The annotations on functions, classes, and modules are no longer evaluated eagerly. Instead, annotations are stored in special-purpose annotate functions and evaluated only when necessary (except if from __future__ import annotations is used).

__annotate__ が式を抱えている

評価を先送りできるのは、アノテーション式が __annotate__ という隠し関数にコンパイルされているからです。引数はフォーマット番号ひとつ。

def g(x: int) -> str: ...

print(g.__annotate__)
print(g.__annotate__(1))

def h(x: int): ...
print(h.__annotations__ is h.__annotations__)
<function g.__annotate__ at 0x1047031c0>
{'x': <class 'int'>, 'return': <class 'str'>}
True

最後の True がキャッシュの証拠。初回アクセスで評価した辞書を保持し、2回目以降は同じオブジェクトを返します。評価コストが繰り返しかかることはありません。

クォートを外せる前方参照

自己参照するクラスと dataclass

まだ定義されていない名前をアノテーションに書いても、定義時には評価されないので通ります。

from dataclasses import dataclass


class Node:
    def add(self, child: Node) -> Node:
        ...


@dataclass
class Employee:
    name: str
    manager: Employee | None = None


print("Node.add:", Node.add.__annotations__)
print("Employee:", Employee.__annotations__)
print("instance :", Employee("moha", Employee("lead")))

3.13.14 では class Node の本体を実行する時点で落ちます。

  File "/tmp/pep649/t2_forward.py", line 4, in Node
    def add(self, child: Node) -> Node:
                         ^^^^
NameError: name 'Node' is not defined. Did you mean: 'None'?

3.14.6 では、クォートも from __future__ import annotations も無しでそのまま動きます。

Node.add: {'child': <class '__main__.Node'>, 'return': <class '__main__.Node'>}
Employee: {'name': <class 'str'>, 'manager': __main__.Employee | None}
instance : Employee(name='moha', manager=Employee(name='lead', manager=None))

定義順の並べ替えが要らなくなる

相互に参照し合うモデルを書くとき、これまでは片方をクォートで包むか、モジュール全体に future import を足すかの二択でした。3.14 ではどちらも要りません。移行ガイド “Implications for annotated code” も、クォートを外す方向を明示しています。

You will likely be able to remove quoted strings in annotations, which are frequently used for forward references.

ただし但し書きが続きます。アノテーションを読む側のライブラリが未対応だと、クォートを外した途端に実行時エラーへ変わります。

annotationlib が返す3つのフォーマット

3.14 で追加された annotationlib は、アノテーションをどの状態で受け取るかを選ばせます。未定義の名前が混ざったとき、フォーマットごとに振る舞いが分かれます。

Format未定義の名前の扱い用途
VALUE(=1)NameError を送出従来どおりの実体が欲しいとき
FORWARDREF(=3)ForwardRef プロキシに置換解決を後回しにしたいとき
STRING(=4)ソース上の文字列を返す実体が不要でシグネチャだけ見たいとき

同じ関数を3通りで読む

from annotationlib import get_annotations, Format


def make_report(rows: list[Undefined], limit: int = 10) -> Summary:
    ...


print("STRING     :", get_annotations(make_report, format=Format.STRING))
print("FORWARDREF :", get_annotations(make_report, format=Format.FORWARDREF))
try:
    get_annotations(make_report, format=Format.VALUE)
except NameError as e:
    print("VALUE      : NameError ->", e)
STRING     : {'rows': 'list[Undefined]', 'limit': 'int', 'return': 'Summary'}
FORWARDREF : {'rows': list[ForwardRef('Undefined', owner=<function make_report at 0x1095a2980>)], 'limit': <class 'int'>, 'return': ForwardRef('Summary', owner=<function make_report at 0x1095a2980>)}
VALUE      : NameError -> name 'Undefined' is not defined

FORWARDREFrows だけ挙動が入り組んでいます。list[...] の外枠は解決済みで、中の未定義部分だけが ForwardRef に差し替わる。解決できるところは解決する作りです。

ForwardRef は後から解決できる

受け取った ForwardRefevaluate() で実体に変換できます。名前が定義された後なら通ります。

from annotationlib import get_annotations, Format


def send(payload: Envelope) -> None:
    ...


ref = get_annotations(send, format=Format.FORWARDREF)["payload"]
print("解決前:", ref, type(ref).__name__)


class Envelope:
    pass


print("解決後:", ref.evaluate(globals=globals()))
print("VALUE :", get_annotations(send, format=Format.VALUE))
解決前: ForwardRef('Envelope', owner=<function send at 0x10a1e68d0>) ForwardRef
解決後: <class '__main__.Envelope'>
VALUE : {'payload': <class '__main__.Envelope'>, 'return': None}

なお __annotate__ を自前で Format.STRING 相当の 4 で叩くと NotImplementedError になります。インタプリタが生成する annotate 関数は VALUE しか実装していません。STRINGFORWARDREFannotationlib.call_annotate_function() が偽のグローバル環境を用意して合成します。

直接 g.__annotate__(4): NotImplementedError
call_annotate_function(STRING): {'x': 'int', 'return': 'str'}

TYPE_CHECKING で隠した import が実行時に落ちる

循環 import を避けるために if TYPE_CHECKING: の中へ import を追い込むのは定番の手です。3.13 までは future import と組み合わせればアノテーションが文字列化されるので、実行時に名前解決は起きませんでした。3.14 で future import を外すと、状況が変わります。

実体を要求した時点で NameError

from typing import TYPE_CHECKING, get_type_hints
from annotationlib import get_annotations, Format

if TYPE_CHECKING:
    from decimal import Decimal


def charge(amount: Decimal) -> bool:
    return True


try:
    print(charge.__annotations__)
except NameError as e:
    print("__annotations__ : NameError ->", e)
try:
    print(get_type_hints(charge))
except NameError as e:
    print("get_type_hints(): NameError ->", e)

print("Format.STRING     :", get_annotations(charge, format=Format.STRING))
print("Format.FORWARDREF :", get_annotations(charge, format=Format.FORWARDREF))
__annotations__ : NameError -> name 'Decimal' is not defined
get_type_hints(): NameError -> name 'Decimal' is not defined
Format.STRING     : {'amount': 'Decimal', 'return': 'bool'}
Format.FORWARDREF : {'amount': ForwardRef('Decimal', owner=<function charge at 0x109b61900>), 'return': <class 'bool'>}

読む側のコードを直す

get_type_hints() も同じ NameError で落ちます。実体を要求する関数なので当然の結果です。デコレータや DI コンテナのようにアノテーションを読むコードを書いているなら、素の __annotations__ 参照は危うい。

# 名前が解決できないと落ちる
hints = func.__annotations__

# 未定義は ForwardRef で受け取り、必要になった時点で evaluate する
from annotationlib import get_annotations, Format
hints = get_annotations(func, format=Format.FORWARDREF)

公式の移行ガイド “Implications for readers of __annotations__” が、標準ライブラリ自身の対応方針として同じ形を挙げています。

you may want to use annotationlib.get_annotations() with the FORWARDREF format, as the dataclasses module now does.

もうひとつ、”Related changes” に書かれた非互換もあります。インスタンス経由でクラスのアノテーションを読む書き方が塞がれました。

class Config:
    debug: bool

c = Config()
print(c.__annotations__)

3.13.14 は {'debug': <class 'bool'>} を返します。3.14.6 は AttributeError: 'Config' object has no attribute '__annotations__'。ドキュメントいわく「undocumented and accidental」だった挙動。クラス側から読めば従来どおり取れます。

速くなるのはアノテーションを誰も読まないときだけ

「定義時に評価しない」と聞くと import が一律で速くなりそうですが、実際は読む側の作りに完全に依存します。同じ規模のアノテーションを持つモジュールを2種類用意して測りました。フィールドは Optional[dict[str, list[tuple[int, str]]]] を 500 クラス × 10 個、合計 5000 個

素の class と dataclass で分ける

# plain.py : 誰もアノテーションを読まない
class Plain0:
    field0: Optional[dict[str, list[tuple[int, str]]]] = None
    ...

# heavy.py : dataclass デコレータが定義時に読む
@dataclass
class Model0:
    field0: Optional[dict[str, list[tuple[int, str]]]] = None
    ...

計測は python -X importtime で当該モジュールの自己時間を拾い、__pycache__ を消してコンパイル済みの状態を揃えたうえで7回の最小値を取りました。

python -X importtime -c "import plain" 2>&1 | grep " plain$"
計測対象Python 3.13.14Python 3.14.6
素の class(誰も読まない)4.4 ms2.7 ms-39%
dataclass(定義時に読む)88.0 ms89.8 ms+2%

dataclass 側で削減が出ない理由

@dataclass はデコレータが走る時点でフィールド定義を組み立てます。そのためにアノテーションを読む。読めば評価される。遅延評価は「先送り」であって「省略」ではないので、定義直後に読む相手には効きません。88.0 ms → 89.8 ms の微増は、annotate 関数を経由する分のオーバーヘッドと見ています。

Pydantic のモデルや SQLAlchemy の Mapped[]、FastAPI のエンドポイント登録も、定義時か起動直後にアノテーションを読む側です。速度改善を狙って 3.14 へ上げても、import 時間の数字は動きません。

読む側にいるライブラリ

定義時か起動直後にアノテーションを読むものを並べると、業務コードの大半が該当します。

  • dataclasses / attrs: デコレータ実行時にフィールドを組み立てる
  • pydantic: モデルクラスの構築時にバリデータを生成する
  • sqlalchemy: Mapped[] からカラム定義を解決する
  • fastapi: ルート登録時にシグネチャを読む
  • typeguard などの実行時型検査: 呼び出しのたびに参照する

どこを測るか

削減が乗るのは、アノテーションが書いてあるだけで実行時に誰も読まないコードです。型チェッカー向けにだけ注釈を付けた内部モジュール、大量の型エイリアスを抱えたユーティリティ、TYPE_CHECKING ブロックだけで完結する定義。CLI ツールのように起動時間が体感に直結する用途では、こうしたモジュールの比率がそのまま効きます。逆に、モデル定義が主体のアプリケーションでは import 時間はほぼ動きません。

今回も最初は dataclass 版だけを測って 88.0 ms → 89.8 ms を見ており、そこで止めていれば「効果なし」と結論づけるところでした。ベンチマーク対象に読む側が混ざっていないかどうかで、出る数字が二桁変わります。移行の判断材料にするなら、アノテーションを読むコードを含まないモジュールを分けて測ってください。

from __future__ import annotations は消せるのか

3.14 に上げた後も future import を残したままにすると、PEP 649 の恩恵は一切受けられません。What’s New の但し書き “(except if from __future__ import annotations is used)” の通りです。

VALUE を要求しても文字列が返る

from __future__ import annotations
from annotationlib import get_annotations, Format


def f(x: int, y: list[str]) -> bool:
    ...


print("__annotations__ :", f.__annotations__)
print("値の型          :", {k: type(v).__name__ for k, v in f.__annotations__.items()})
print("Format.VALUE    :", get_annotations(f, format=Format.VALUE))
print("eval_str=True   :", get_annotations(f, eval_str=True))
__annotations__ : {'x': 'int', 'y': 'list[str]', 'return': 'bool'}
値の型          : {'x': 'str', 'y': 'str', 'return': 'str'}
Format.VALUE    : {'x': 'int', 'y': 'list[str]', 'return': 'bool'}
eval_str=True   : {'x': <class 'int'>, 'y': list[str], 'return': <class 'bool'>}

Format.VALUE を明示しても文字列のままです。PEP 563 の文字列化が先に効いてしまうため、実体が欲しければ eval_str=True を渡して eval() を通す必要があります。future import が1行あるだけで、そのモジュールは 3.13 以前の世界に留まります

削除は 2029 年より後

移行ガイドの “from __future__ import annotations” セクションが、廃止のスケジュールを明記しています。

this statement is now deprecated and it is expected to be removed in a future version of Python. This removal will not happen until after Python 3.13 reaches its end of life in 2029, being the last version of Python without support for deferred evaluation of annotations.

3.14 時点で挙動は変わらず、警告も出ません。外す条件は「サポート対象が 3.14 以降だけになり、かつアノテーションを読むライブラリが対応済み」の2つが揃ったとき。片方でも欠けるなら急がなくて構いません。

3.13 以前も同じコードで書く

複数バージョンを面倒みるライブラリ側では、typing_extensions のバックポートが使えます。手元の 4.16.0 で確認しました。

import typing_extensions as te

def f(x: Undefined) -> int: ...

print(te.get_annotations(f, format=te.Format.STRING))
print(te.get_annotations(f, format=te.Format.FORWARDREF))
{'x': 'Undefined', 'return': 'int'}
{'x': ForwardRef('Undefined', owner=<function f at 0x109864040>), 'return': <class 'int'>}

Format enum と get_annotations() が揃っているので、3.13 以前でも FORWARDREF 相当の受け取り方が書けます。移行ガイドも “The external typing_extensions package provides partial backports” として同じ方針を案内しています。

文字列で受け取るときの制約

STRING は原文と一致しない

公式ドキュメントに “Limitations of the STRING format” という節があります。ソースコードの文字列そのままではなく、定数畳み込みと空白正規化を通った後の姿が返ります。

CONST = 3

def f(a: 2 + 3, b: "x"   +   "y", c: list [ int ], d: CONST) -> None:
    ...

print(get_annotations(f, format=Format.STRING))
{'a': '5', 'b': 'xy', 'c': 'list[int]', 'd': 'CONST', 'return': 'None'}

2 + 3'5' に、余分な空白は削られました。名前参照の CONST は畳まれずに残ります。ドキュメント生成やスタブ出力の元データに使うなら、この差が前提になります。

inspect.signature にも annotation_format が入った

inspect.signature()annotation_format が追加されました。シグネチャを表示するだけなら、実体を解決せずに済みます。

import inspect

def f(a: int) -> str: ...

print(inspect.signature(f))
print(inspect.signature(f, annotation_format=inspect.Format.STRING))
(a: int) -> str
(a: 'int') -> 'str'

3.13.14 で同じコードを流すと TypeError。ヘルプ表示や CLI のドキュメント生成でアノテーションの実体を必要としない場面では、こちらのほうが安全です。

まとめ

  • アノテーションは __annotations__ を読んだ瞬間に評価され、結果は同じ辞書としてキャッシュされる
  • クォート無しの前方参照が通る。自己参照する dataclass もそのまま書ける
  • annotationlibVALUE / FORWARDREF / STRING は、未定義の名前で挙動が分かれる
  • TYPE_CHECKING 配下の import に依存したアノテーションは、実体を要求すると NameError になる
  • import 時間の削減は「誰も読まない」場合に限られ、5000 アノテーションで 4.4 ms → 2.7 ms。dataclass では変化しない
  • from __future__ import annotations を残すと遅延評価は無効。削除は 2029 年以降で、急いで外す理由は無い
  • 3.13 以前と同じコードで書くなら typing_extensions 4.16.0 のバックポートを使う

型注釈の構文そのものは Pythonの新ジェネリクス構文 PEP 695—type文とdef f[T]の書き方、前方参照が絡む dataclass の書き方は Python dataclass実践—field・frozen・asdictの正しい書き方 に書いています。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次