0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python 3.14で型注釈を直接読むコードが壊れる理由

0
Posted at

Python 3.14で型注釈を直接読むコードが壊れる理由

型ヒントを静的解析にしか使っていないなら、Python 3.14への移行で困る場面は多くありません。問題になるのは、DI、バリデーション、自作デコレータなどが実行時に型注釈を読む場合です。

Python 3.14では、通常の注釈が定義時ではなく、必要になった時点で評価されるようになりました。__annotations__を「型情報が保存された普通のdict」とみなす実装をやめ、注釈を取得するAPIを境界に置くのが安全です。

__annotations__を読む小さなDIで問題を見る

依存する型を関数の引数注釈から取得して、その型のインスタンスを渡すだけのDIを考えます。

まず、ありがちな実装です。

# Python 3.14.7

class Repository:
    def find(self) -> str:
        return "data"


def handler(repo: Repository) -> str:
    return repo.find()


def inject(func):
    kwargs = {
        name: annotation()
        for name, annotation in func.__annotations__.items()
        if name != "return"
    }
    return func(**kwargs)


print(inject(handler))

出力は次のとおりです。

data

これだけなら3.14でも動きます。func.__annotations__へアクセスした時点で注釈が評価され、Repositoryという実際のクラスが得られるためです。

では、前方参照を入れます。

# Python 3.14.7

def handler(repo: Repository) -> str:
    return repo.find()


class Repository:
    def find(self) -> str:
        return "data"


def inject(func):
    kwargs = {
        name: annotation()
        for name, annotation in func.__annotations__.items()
        if name != "return"
    }
    return func(**kwargs)


print(inject(handler))

Python 3.14ではこれも動きます。

一方、同じソースを通常のPython 3.13で実行すると、handlerの定義時点でRepositoryが存在しないためNameErrorになります。

ここで重要なのは「3.14なら前方参照が使える」という結果だけではありません。3.13以前と3.14では、注釈式を評価するタイミングそのものが違います。

3.14では注釈の「評価する時刻」が変わった

Python 3.14で型注釈を直接読むコードが壊れる理由

Pythonの注釈には、現在までに大きく三つの評価モデルがあります。

通常のPython 3.13以前では、注釈は定義時に即時評価されます。

def f(value: Later):
    pass

class Later:
    pass

fを定義した時点ではLaterが存在しないので、そこで失敗します。

一方、Python 3.7以降で使える次の指定では挙動が違います。

from __future__ import annotations

この場合、注釈は文字列化されます。たとえばLaterは実際のクラスではなく"Later"として保持されます。

そしてPython 3.14のデフォルトは、そのどちらでもありません。PEP 649とPEP 749によるdeferred evaluation、つまり遅延評価です。

概念的にはこうです。

3.13以前
関数定義
  ↓
注釈式を評価
  ↓
型オブジェクトを保持

future annotations
関数定義
  ↓
注釈を文字列として保持

3.14
関数定義
  ↓
評価方法を保持
  ↓
注釈が必要になる
  ↓
注釈式を評価

公式ドキュメントにも、3.0から3.13までのデフォルトは即時評価、from __future__ import annotationsでは文字列化、3.14以降のデフォルトは遅延評価と整理されています。

この変更には実用上の意味があります。前方参照を自然に書けるだけでなく、使われない注釈をモジュールのimport時に評価する必要もなくなります。

ただし、ランタイムで注釈を読む側から見ると話が変わります。

__annotations__という属性の見た目が同じでも、その値がそこへ到達するまでの仕組みは同じではありません。特にライブラリ側で「辞書を直接取得して自前で文字列を評価する」「特定バージョンの内部表現を前提に分岐する」といったコードを持っていると、この差を自分で吸収することになります。

Python 3.14.7は2026年8月5日に公開されています。3.13以前だけを前提にした型イントロスペクションを持っているなら、今の時点で確認しておいた方がよい境界です。

annotationlibを取得境界に置く

3.14では、注釈を扱うための標準モジュールannotationlibが追加されています。

先ほどのDIなら、__annotations__を直接読む必要はありません。

# Python 3.14.7
# 外部依存なし

import annotationlib


def inject(func):
    annotations = annotationlib.get_annotations(func)

    kwargs = {
        name: annotation()
        for name, annotation in annotations.items()
        if name != "return"
    }
    return func(**kwargs)


def handler(repo: Repository) -> str:
    return repo.find()


class Repository:
    def find(self) -> str:
        return "data"


print(inject(handler))

出力は同じです。

data

一見すると、単に

func.__annotations__

を

annotationlib.get_annotations(func)

へ置き換えただけです。

ただ、この一行をAPI境界にする意味は大きいです。Python公式のAnnotations Best Practicesでも、3.14以降では__annotations__への直接アクセスを避け、annotationlib.get_annotations()を使うことが推奨されています。3.10から3.13では、その役割をinspect.get_annotations()が担います。

さらにannotationlibでは、何を取得したいかをFormatで選択できます。

import annotationlib

annotations = annotationlib.get_annotations(
    handler,
    format=annotationlib.Format.VALUE,
)

VALUEは評価後の値です。DIで実際のクラスを取得したいなら、この形が扱いやすいです。

一方、まだ解決できない名前が混ざる可能性があるライブラリならFORWARDREFがあります。

annotations = annotationlib.get_annotations(
    handler,
    format=annotationlib.Format.FORWARDREF,
)

解決できるものは実際の値になり、解決できないものはForwardRefとして取得できます。

ドキュメント生成など、型を実行時オブジェクトとして必要としない処理ならSTRINGも選べます。

annotations = annotationlib.get_annotations(
    handler,
    format=annotationlib.Format.STRING,
)

つまり「注釈を読む」という処理を、単なる属性アクセスではなく、

何のために
どの評価状態で
注釈が必要なのか

というAPIとして扱えるようになっています。

ここはDIのようなコードを書くときに特に効きます。DIが必要としているのは__annotations__というPython内部の保存場所ではなく、「この引数が実行時にどの型を要求しているか」だからです。

型として完全に解決した結果が必要なら、typing.get_type_hints()も候補になります。

from typing import get_type_hints

hints = get_type_hints(handler)

get_type_hints()は前方参照の解決に加え、クラスなら基底クラスの注釈もマージするなど、annotationlib.get_annotations()より型ヒント向けの処理を行います。

そのため私は、実装を次のように分けて考えます。

注釈そのものを取得したい
    annotationlib.get_annotations()

型ヒントとして解決済みの結果が欲しい
    typing.get_type_hints()

内部表現を調査する明確な理由がある
    __annotations__

__annotations__を禁止するというより、アプリケーションが欲しい情報に対して一段低い抽象度のAPIを選ばない、という話です。

バージョン境界では値だけでなく失敗条件をテストする

3.13と3.14の両方をサポートするライブラリでは、正常系の辞書比較だけでは足りません。

最低限、前方参照を含む関数、from __future__ import annotationsを使うモジュール、まだ定義されていない名前を含む注釈をテスト対象に入れたいところです。

特に最後が重要です。

def handler(repo: NotDefinedYet) -> None:
    pass

3.14のannotationlibには、評価方法を選べる理由があります。Format.VALUEは実際に注釈を評価するので、未定義名があればNameErrorになり得ます。Format.FORWARDREFなら、解決できない名前をForwardRefとして扱えます。

つまり遅延評価は「評価しなくてよくなった」という意味ではありません。失敗する可能性のある評価が、定義時から取得時へ移動したと捉えた方が実装上は分かりやすいです。

もう一つ注意したいのがセキュリティです。注釈は単なる型名とは限らず、Pythonの式を書けます。公式のannotationlibドキュメントも、注釈の取得に伴って任意のコードが実行され得ることを明記しています。外部から渡されたPythonコードを「型情報だから安全」と考えて評価する設計にはできません。

3.13以前も含めて一つのライブラリとして対応するなら、3.14ではannotationlib.get_annotations()、3.10から3.13ではinspect.get_annotations()を使う互換層を薄く作る方法があります。あるいは、要件が「型ヒントを解決すること」なら最初からtyping.get_type_hints()を境界にする方が自然な場合もあります。

Python 3.14への対応で確認すべきなのは、__annotations__の出力が変わったかだけではありません。注釈を「いつ評価するのか」と「評価できない名前をどう扱うのか」をライブラリ側で暗黙に決めていないかを見る必要があります。3.14だけを対象にできるコードならannotationlibへ寄せやすいですが、3.13以前も支える場合は、future annotationsを含むテストを別プロセスの各Pythonバージョンで実行して境界を確認するのが確実です。

参考

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?