ABEJA でプロダクト開発を行っている平原です。 先日、バックエンドで使っているGo言語のお勉強しようと「go言語 100Tips ありがちなミスを把握し、実装を最適化する」を読んでいました。その中でinterfaceは(パッケージを公開する側ではなく)受け側で定義するべきという記述を見つけてPythonでも同じことできないかと調べていると(PythonではProtocolを使うとうまくいきそうです。)、どうやら型ヒント機能がかなりアップデートされていることに気づき慌てて再入門しました。(3.7, 3.8あたりで止まってました。。)
この記事では、公式ドキュメントを見ながら適当にコードを書き散らし、どの機能はどこまで使えるのか試してみたことをまとめてみました。
環境
- Python: 3.12.1
- エディタ: Visual Studio Code
- Pylanceで
python.analysis.typeCheckingMode
をbasic
にしています
- Pylanceで
気になった変更
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は、テクノロジーの社会実装に取り組んでいます。 技術をどのようにして社会やビジネスに組み込んでいくかを考えるのが好きな方はもちろん、アジャイルの開発体制の中で幅広い技術を活用・習得したい方も、下記採用ページからエントリーください! (新卒の方のエントリーもお待ちしております)