Python は動的型言語のままです。実行時に型注釈で自動的にエラーが出る仕組みは、いまも入っていません。それでも 3.9 世代のコードと 3.14 のコードは見た目がはっきり違います。
変化した領域は、型注釈の表現力・構造の照合・注釈の評価タイミングの 3 つです。
型注釈: typing からの脱却
3.8 以前は、コンテナの型を書くのに typing からの import が必要でした。
from typing import Dict, List, Optional, Union
def find_user(user_id: Union[int, str]) -> Optional[Dict[str, str]]:
...
3.10 以降は組み込み型と | で書けます。
def find_user(user_id: int | str) -> dict[str, str] | None:
...
Optional[X] は X | None の別名にすぎないので、後者のほうが「値があるか、無いか」という意味が読み取りやすくなります。
ジェネリクスの宣言も 3.12 で専用構文になりました。旧式では型変数を外に置く必要がありました。
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
新式では関数のシグネチャに直接書けます。
def first[T](items: list[T]) -> T:
return items[0]
型エイリアスにも専用構文が入りました。
type UserId = int | str
type Pair[T] = tuple[T, T]
ここで注意すべきは、これらはすべて実行時に検査されないという点です。CPython は注釈を保持するだけで、型が違う値を渡しても止めません。mypy や Pyright を CI に入れて初めて意味を持ちます。型ヒントを実行時バリデーションだと誤解しないことが、Python では一貫して重要です。
構造の照合: match 文
3.10 で入った match は、switch の代替ではありません。値の一致ではなく構造の照合を行います。
def handle(message: object) -> str:
match message:
case {"type": "user", "id": int(user_id)}:
return f"user:{user_id}"
case ["move", int(x), int(y)]:
return f"move:{x},{y}"
case None:
return "empty"
case _:
return "unknown"
辞書・リスト・クラスを分解しながら、同時に型を確認して変数へ束縛できます。ガード条件も書けます。
@dataclass(frozen=True)
class Point:
x: int
y: int
def quadrant(point: Point) -> str:
match point:
case Point(0, 0):
return "origin"
case Point(x, y) if x > 0 and y > 0:
return "first"
case _:
return "other"
ただし Python の match は網羅性を保証しません。どのパターンにも当たらなければ、エラーにならずに次の行へ進みます。
最大の罠は捕捉パターンです。裸の名前は「その定数と比較する」ではなく「この名前に束縛する」と解釈されます。
match status:
case ACTIVE: # 常に成立し、status が ACTIVE に束縛される
...
定数と比較したいなら Status.ACTIVE のようにドット付きで書きます。ここは既存の if を機械的に置き換えるときに踏みやすい箇所です。
例外: グループとして扱う
並行タスクでは、複数の例外が同時に発生します。3.11 の ExceptionGroup と except* はこれを部分的に処理する仕組みです。
try:
async with asyncio.TaskGroup() as group:
group.create_task(load_user())
group.create_task(load_company())
except* TimeoutError as group:
for error in group.exceptions:
log(error)
except* は「グループの中からこの型のものだけを取り出す」という意味です。従来の except が最初の 1 つしか捕まえられなかった問題に対応しています。
非同期処理そのものも asyncio.gather() から TaskGroup へ移りました。
async def fetch_all() -> tuple[User, Company]:
async with asyncio.TaskGroup() as group:
user_task = group.create_task(fetch_user())
company_task = group.create_task(fetch_company())
return user_task.result(), company_task.result()
with を抜けるときに全タスクの完了を待ち、1 つ失敗すれば残りをキャンセルします。タスクの生存期間がブロック構造と一致する書き方です。
3.14 の変化
注釈の遅延評価が入りました。前方参照を文字列で囲む必要が減ります。
class Node:
def parent(self) -> Node | None: # 従来は "Node | None" と書く必要があった
...
その代わり、注釈を実行時に読むライブラリは影響を受けます。__annotations__ の即時評価を前提にしているコードは、注釈取得 API との互換性を確認する必要があります。
t-string も 3.14 の追加です。見た目は f-string に似ていますが、直ちに文字列へ展開しません。
name = "Alice"
message = f"Hello, {name}!" # 即座に str になる
message_template = t"Hello, {name}!" # 構造を保持したテンプレートオブジェクト
補間箇所が構造として残るので、HTML や SQL に対して専用のエスケープ処理を挟むレンダラを設計できます。f-string で SQL を組み立てて injection を招く、という定番の事故に対する言語側の回答です。
移行時に確認すること
- 廃止された標準ライブラリへの依存(
lib2to3など) - 注釈を実行時に直接読むフレームワーク
typing.List等の旧記法は動くが、新規コードは組み込みジェネリクスを使うmatchの捕捉パターンと定数パターンの取り違え- 型ヒントを実行時バリデーションだと誤解していないか
- free-threaded モードを使うなら C 拡張の対応状況
まとめ
- Python は動的型言語のままです。型注釈は検査器を通さなければ効きません。
Optional[Dict[str, str]]はdict[str, str] | Noneになり、ジェネリクスもdef first[T](...)と関数に直接書けるようになりました。matchは構造分解が強力ですが網羅性は保証されません。裸の名前が比較ではなく束縛になる点が最大の罠です。ExceptionGroupとexcept*、TaskGroupは、並行処理で複数の失敗が同時に起きる前提に言語が寄せた結果です。- 3.14 の注釈遅延評価は書き味を良くする一方、注釈を実行時に読むライブラリに影響します。
- t-string は文字列を組み立てる前に構造として扱えるようにするもので、injection 対策の土台になります。