ABEJA Tech Blog

中の人の興味のある情報を発信していきます

【Python 3.12】型ヒント機能がいつの間にか進化していたので、慌ててキャッチアップする

ABEJA でプロダクト開発を行っている平原です。 先日、バックエンドで使っているGo言語のお勉強しようと「go言語 100Tips ありがちなミスを把握し、実装を最適化する」を読んでいました。その中でinterfaceは(パッケージを公開する側ではなく)受け側で定義するべきという記述を見つけてPythonでも同じことできないかと調べていると(PythonではProtocolを使うとうまくいきそうです。)、どうやら型ヒント機能がかなりアップデートされていることに気づき慌てて再入門しました。(3.7, 3.8あたりで止まってました。。)

この記事では、公式ドキュメントを見ながら適当にコードを書き散らし、どの機能はどこまで使えるのか試してみたことをまとめてみました。

docs.python.org

環境

  • Python: 3.12.1
  • エディタ: Visual Studio Code
    • Pylanceでpython.analysis.typeCheckingModebasicにしています

気になった変更

Protocol

Python3.8から構造的部分型Protocolが使えるようになっていたみたいです。長い間見落としてました。。 これにより、機能を使う側がインターフェースを定義することができるようになります。(goのinterfaceと同じ) 受け手が何に使うのかの意図を示したり、モックするときに必要なメソッドのみ定義するだけでよかったりなどの使い道が考えられます。

from typing import Protocol


class Repository:
  def get(self, id: str) -> tuple[str, str]:
    return (id, "get")

  def create(self, id: str, value: str) -> tuple[str, str]:
    return (id, value)


class ReadonlyRepository(Protocol):
  def get(self, id: str) -> tuple[str, str]:
    ...


# 変更を伴わないことが明示される
def myfunc(repo: ReadonlyRepository):
  print(repo.get("abcd"))


# RepositoryはReadonlyRepositoryを継承していないが、
# 必要なメソッドが定義されているので受け入れられる。
myfunc(Repository())

Self

Python3.11から、自クラスを示すSelf型が利用できるようになりました。 継承されたときに継承先のクラスを示すのが特徴です。 なので、自クラスを再生成して返す場合には利用できません。

from typing import Self
import math


class Polyline:
    def __init__(self, line: list[tuple[float, float]]):
        self.line = line

    def rot(self, rad: float) -> Self:
        s = math.sin(rad)
        c = math.cos(rad)
        self.line = [(c*x-s*y, s*x+c*y) for x, y in self.line]
        return self

    def shift(self, dx: float, dy: float) -> Self:
        self.line = [(x+dx, y+dy) for x, y in self.line]
        return self

    def scale(self, sx: float, sy: float) -> Self:
        self.line = [(sx*x, sy*y) for x, y in self.line]
        return self


class PolylineWithColor(Polyline):
    def __init__(self, line: list[tuple[float, float]], color: tuple[int, int, int]):
        super().__init__(line)
        self.color = color


polyline = (
    PolylineWithColor([(0, 0), (1, 1)], (0, 0, 0))
    .rot(-math.pi/4)
    .shift(1, 0)
    .scale(2, 100)
)


print(Polyline([(0, 0), (1, 1)]).rot(-math.pi/4).shift(1, 0).scale(2, 100).line)

ジェネリクス

Python3.12からtyping.TypeVarやtyping.Genericsを使う必要がなくなりました。 ジェネリクスを使うだけならtypingモジュールが必要ありません。 関数の引数を記述する前に[]で型引数を指定できます。 typing.TypeVarはスコープがグローバルに広がってしまっていたので、 スコープが閉じてくれるのもうれしいです。

from collections.abc import Callable, Iterable, Generator


# 関数のジェネリクス
def mymap[T, U](collection: Iterable[T], f: Callable[[T], U]) -> Generator[U, None, None]:
  yield from (f(e) for e in collection)

x = mymap(range(10), str)


# クラスでもジェネリクスを使える
class MyFunction[T, U]():
  def __init__(self, f: Callable[[T], U]):
    self.f = f

  def __call__(self, x: T) -> U:
    return self.f(x)

  # メソッドでも使用できる(goではできないやつ)
  def compose[V](self, f: Callable[[U], V]) -> "MyFunction[T, V]":
    "合成関数を作成する"
    return MyFunction(lambda x: f(self.f(x)))

f = (
  MyFunction[int, int](lambda x: x + 100)
  .compose(str)
  .compose(lambda x: "<" + x + ">")
)
for x in mymap(range(4), f):
  print(x)

ジェネリクスで受け取る型を制限する時は、[T: MyClass]のようにします。

from typing import Protocol, Self


class Shape(Protocol):
  def shift(self, x: float, y: float) -> Self:
    ...


class Rectangle:
    def __init__(self, l: float, t: float, r: float, b: float):
        if r < l:
            l, r = r, l
        if b < t:
            t, b = b, t
        self.l = l
        self.t = t
        self.r = r
        self.b = b

    def shift(self, x: float, y: float) -> "Rectangle":
        # 継承されてもRectangleを返すのでSelfは使えない
        return Rectangle(self.l + x, self.t + y, self.r + x, self.b + y)

    def __str__(self) -> str:
        return f"Rectangle: ({self.l},{self.t}) -> ({self.r},{self.b})"


def shift_one[T: Shape](a: T) -> T:
    return a.shift(1, 1)


rect = shift_one(Rectangle(0, 0, 9, 9))
text = shift_one("text") # error

TypeAlias

Python3.12からtypeを使って型エイリアスであることを明示できるようになりました。 型エイリアスのためにtypingモジュールはいらなくなったようです。 (typing.TypeAliasは非推奨になったみたいです。) ぱっと見だと従来とあまり変わらないですが、、 型エイリアスであることが一目でわかるようになるメリットがあると感じました。

import numpy as np
import numpy.typing as npt


type NDImage = npt.NDArray[np.uint8]
type Point2D = tuple[int, int]
type Color = tuple[int, int, int] | int


def draw_line(img: NDImage, p1: Point2D, p2: Point2D, color: Color):
  ...

クラスオブジェクトの型

typing.Typeの代わりにtypeを使えるようになりました。 クラスを受け取ることを明示するためにtypingはいらなくなったみたいです。 (Python3.9の時点でtyping.Typeは非推奨になっていたみたいです。。) (↓の例は公式からの引用です)

class User: ...
class ProUser(User): ...
class TeamUser(User): ...

def make_new_user(user_class: type[User]) -> User:
    # ...
    return user_class()

make_new_user(User)      # OK
make_new_user(ProUser)   # Also OK: ``type[ProUser]`` is a subtype of ``type[User]``
make_new_user(TeamUser)  # Still fine
make_new_user(User())    # Error: expected ``type[User]`` but got ``User``
make_new_user(int)       # Error: ``type[int]`` is not a subtype of ``type[User]``

LiteralString

Python3.11からリテラルの文字列と変数の文字列を識別できるようになりました。公式の例にあるように、コードエディタの機能でSQLインジェクションの危険性を事前に気づくこともできそうです。 (↓の例は公式からの引用です)

def run_query(sql: LiteralString) -> None:
    ...

def caller(arbitrary_string: str, literal_string: LiteralString) -> None:
    run_query("SELECT * FROM students")  # OK
    run_query(literal_string)  # OK
    run_query("SELECT * FROM " + literal_string)  # OK
    run_query(arbitrary_string)  # type checker error
    run_query(  # type checker error
        f"SELECT * FROM students WHERE name = {arbitrary_string}"
    )

TypedDictの必須要素

Python3.11からTypedDictの必須要素・任意要素であることを明示できるようになりました。 これまでは同じことをするために、必須要素と任意要素を別々で定義して継承する必要がありましたが、とても便利になりました。

from typing import TypedDict, Required, NotRequired

class Test(TypedDict):
  x: Required[int]
  y: Required[int]
  v: NotRequired[int]

# ok
t1: Test = {
  "x": 1,
  "y": 2,
}

# ok
t2: Test = {
  "x": 1,
  "y": 2,
  "v": 10,
}

# error
t3: Test = {
  "x": 1,
  "y": 2,
  "v": 10,
  "a": 1,
}

TypeGuard

Python3.10から型ガードが使えるようになっていました。 ただ、typescriptとは異なり、else節ではうまく判定してくれてなさそうです。

from typing import TypeGuard


def is_int(x: object) -> TypeGuard[int]:
  return isinstance(x, int)


def increment(x: int | str):
  if is_int(x):
    print(x + 1) # ok
  else:
    print("".join([chr(ord(e)+1) for e in x])) # error: else節では型を絞ってくれない

increment(1)
increment("abc")

TypeVarTuple

Python3.11からTypeVarTupleが使えるようになりました。 タプルの可変長の型引数として使えるようです。 Python3.12からはジェネリクスにも対応したことで、 かなり柔軟性が高くなりました。 関数の可変長引数の型引数としても使えます。 ジェネリクスで[*Ts]のような形式で記述すると、 暗黙的にTypeVarTupleとして扱われます。

from typing import overload, TypeGuard, Any


def is_not_empty[T, *Ts](tup: tuple[()] | tuple[T, *Ts]) -> TypeGuard[tuple[T, *Ts]]:
  return 0 < len(tup)


@overload
def head(tup: tuple[()]) -> None:
  ...
@overload
def head[T, *Ts](tup: tuple[T, *Ts]) -> T:
  ...
def head[T, *Ts](tup: tuple[()] | tuple[T, *Ts]) -> T | None:
  if is_not_empty(tup):
    return tup[0]
  return None


x = head(())
x = head((1,))
x = head((1, 2, 3))


@overload
def tail(tup: tuple[()]) -> tuple[()]:
  ...
@overload
def tail[*Ts](tup: tuple[Any, *Ts]) -> tuple[*Ts]:
  ...
def tail[*Ts](tup: tuple[()] | tuple[Any, *Ts]) -> tuple[()] | tuple[*Ts]:
  if is_not_empty(tup):
    # インデックスアクセスということしか見ていなくてうまくいかない
    return tup[1:] # type: ignore
  return ()


y = tail(())
y = tail((1,))
y = tail((1,2,3))


# 関数の可変長引数の型としても使える
def to_tuple[*Ts](*args: *Ts) -> tuple[*Ts]:
  return args


z = to_tuple(1, 12, "")

Unpack

Python3.11でUnpackというものが追加されたみたいです。 TypedDictをkwargsの型定義として使うことができるようになります。 (TypeVarTupleの[*Ts]の表記でも暗黙的に使用されているようです。) これはあえて使う意味は分かりませんでした。

from typing import TypedDict, Unpack


class Foo(TypedDict):
  x: str
  y: int


def foo(**kwargs: Unpack[Foo]):
  print(kwargs['x'])
  print(kwargs['y'])


foo(x="hoge", y=123) # ok
foo(x="foo", y=12, z=112) # error

ParamSpec, Concatenate

Python3.10から、関数の型定義が強化されました。 Callableの第一引数として定義されていた関数の引数の型定義が、 ParamSpecとして使えるようになったようです。 ジェネリクスでは[**P]のように指定するとParamSpecとして解釈されます。 また、Concatenateを利用することでParamSpecを拡張することができます。 Concatenate[TypeVar1, TypeVar2, ParamSpec1]のようにすると、 ParamSpec1にTypeVar1, TypeVar2が追加された新しいParamSpecとして解釈されます。

from typing import Concatenate
from collections.abc import Callable
from functools import wraps
from datetime import datetime
import json
import time


# ParamSpecの例
# 関数実行前後にログを出力するデコレータ
def with_log[T, **P](f: Callable[P, T]) -> Callable[P, T]:
  @wraps(f)
  def func(*args: P.args, **kwargs: P.kwargs):
    print(json.dumps({
      "event": "start",
      "function": f.__name__,
      "input": {"args": args, "kwargs": kwargs},
      "timestamp": datetime.now().isoformat(),
    }))
    result = f(*args, **kwargs)
    print(json.dumps({
      "event": "end",
      "function": f.__name__,
      "output": result,
      "timestamp": datetime.now().isoformat(),
    }))
    return result
  return func


@with_log
def test(x: int, *, y: str) -> str:
  time.sleep(1)
  return "TEST!!"


test(10, y="y value")


# Concatenateの例
# 関数の第一引数を固定して、新しい関数を作成する
def default[T, **P, R](f: Callable[Concatenate[T, P], R], v: T) -> Callable[P, R]:
  def func(*args: P.args, **kwargs: P.kwargs):
    return f(v, *args, **kwargs)
  return func


def add(x: float, y: float) -> float:
  return x + y


add_ten = default(add, 10)
print(add_ten(4))

感想

型エイリアスやジェネリクスなど、特に3.12のアップデートではtypingモジュールを超えてPythonの文法に影響を与える変更が型ヒントの機能のために行われているように思います。 エディタも言語機能も型ヒント機能のサポートが強くなってきていて、静的型付け言語から入った身からすると安心できてうれしいです。

We Are Hiring!

ABEJAは、テクノロジーの社会実装に取り組んでいます。 技術をどのようにして社会やビジネスに組み込んでいくかを考えるのが好きな方はもちろん、アジャイルの開発体制の中で幅広い技術を活用・習得したい方も、下記採用ページからエントリーください! (新卒の方のエントリーもお待ちしております)

https://careers.abejainc.com/