Python3.10で追加された型ヒント関連機能から、 PEP 612 Parameter Specification Variables を解説します。
これまでの型ヒントでは、デコレータ などに代表される、既存の関数をラップして新しく関数を作成するような機能のサポートが十分ではありませんでした。
PEP 612 では、従来の 型変数 に加えてパラメータ仕様変数(Parameter Specification Variables) という種類の変数を新たにを追加し、関数の引数を参照して型定義を行えるようになりました。
デコレータの型ヒント¶
これまで、デコレータの型ヒントは、型変数と Callable型 を組み合わせて指定されていました。
次のデコレータ ensure_even
は、関数の戻り値をチェックし、値が偶数でなければ例外を発生するデコレータです。
def ensure_even(f):
def wrapper(*args, **kwargs):
ret = f(*args, **kwargs)
assert (ret % 2) == 0
return ret
return wrapper
@ensure_even
def f1(v):
return v+1
機能的には単純なデコレータですが、型ヒントを指定すると次のようになります。
from typing import Any, Callable, TypeVar, cast
F = TypeVar('F', bound=Callable[..., Any])
def ensure_even(f:F) -> F:
def wrapper(*args, **kwargs): # type: ignore
ret = f(*args, **kwargs)
assert (ret % 2) == 0
return ret
return cast(F, wrapper)
@ensure_even
def f1(v:int) -> int:
return v+1
print(f1(1)) # 2 を出力する
print(f1(2)) # AssertionErrorが発生する
ensure_even()
は、引数として指定される関数の型を型変数 F
で受け取り、同じ型の関数を返します。
実際に処理を行う関数 wrapper()
の引数と戻り値は、デコレートする関数と同じ型を指定しないといけませんが、そういった指定を記述する方法がありません。仕方がないので型定義のない関数として作成し、最後に F
に cast してつじつまを合わせています。
パラメータ仕様変数¶
PEP 612 では、関数の引数部分だけを取り出す パラメータ仕様変数(ParamSpec) を導入しました。パラメータ仕様変数を使うと、 ensure_even()
は次のように書けます。
from typing import Any, Callable, ParamSpec, TypeVar
P = ParamSpec('P') # 関数の引数の型のパラメータ仕様変数
R = TypeVar('R', bound=int) # 関数の戻り値の型の変数
def ensure_even(f: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
ret = f(*args, **kwargs)
assert (ret % 2) == 0
return ret
return wrapper
引数の型として
f: Callable[P, R]
と指定しています。これは、引数 f
は関数などの呼び出し可能オブジェクトで、その引数の型は パラメータ仕様変数 P
で、戻り値の型は型変数 R
で受け取るという指定です。従来、Callable
の最初の要素には [int, str]
や ...
などの引数しか指定できませんでしたが、パラメータ仕様変数も指定できるように拡張されました。
wrapper()
関数ではパラメータ仕様変数 P
を使って引数の型情報を参照し、位置引数とキーワード引数の型を指定しています。戻り値は型変数 R
の値を返します。cast()
を使わずに型を指定できていて、大分スッキリしました。
戻り値の型が異なるデコレータ¶
もう一つ、別のデコレータを考えてみましょう。is_even
は、関数の戻り値が偶数なら True
、奇数なら False
を 返すデコレータです。
def is_even(f):
def wrapper(*args, **kwargs):
ret = f(*args, **kwargs)
return (ret % 2) == 0
return wrapper
@is_even
def f2(v):
return v+1
print(f2(1)) # True
print(f2(2)) # False
print(f2("3")) # 実行時にエラー
f2()
は int
型の値を返す関数ですが、is_even
デコレータはこれを bool
型の値を返す関数に置き換えます。これまでのPythonでは、このように元の関数とデコレータで引数や戻り値が異なる場合には、うまく型を指定できませんでした。
この例では、次のようになります。
from typing import Any, Callable, TypeVar
F = TypeVar('F', bound=Callable[..., Any])
def is_even(f: F)->Callable[..., bool]:
def wrapper(*args: Any, **kwargs:Any)->bool:
ret = f(*args, **kwargs)
return (ret % 2) == 0
return wrapper
@is_even
def f2(v:int)->int:
return v + 1
print(f2(5)) # 型チェックOK/実行時も正常に動作
print(f2("10")) # 型チェックOK/実行時にはエラー
f2()
と wrapper()
は戻り値の型が異なっているので、cast
を使って wrapper()
の型を指定できません。このため、このデコレータを使うと、引数に文字列などの値を指定しても型チェックではエラーを検出できなくなっています。
しかし、パラメータ仕様変数を使えば、is_even()
も正しく型を指定できます。
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec('P') # 関数の引数の型のパラメータ仕様変数
R = TypeVar('R', bound=int) # 関数の戻り値の型の変数
def is_even(f: Callable[P, R]) -> Callable[P, bool]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> bool:
ret = f(*args, **kwargs)
return (ret % 2) == 0
return wrapper
@is_even
def f2(v:int)->int:
return v + 1
print(f2(5)) # 型チェックOK/実行時も正常に動作
print(f2("10")) # 型チェックNG
引数が異なるデコレータ¶
元になる関数と引数の数が異なるデコレータもよく使われます。次のデコレータは、引数に現在時刻を追加して呼び出します。
from datetime import datetime
def withtime(f):
def wrapper(*args, **kwargs):
return f(datetime.now(), *args, **kwargs)
return wrapper
@withtime
def f3(now, title, value):
print(now.astimezone(), value)
f1("Hello", 999) -> "2021-08-10 00:01:02.3333 Hello 999" と出力
従来、このように引数が追加されるデコレータも、戻り値が異なるデコレータと同じ理由でうまく型ヒントを指定できませんでしたが、PEP 612 では Concatenate
を使って一般的な引数の追加・削除を表現できるようになりました。
Concatenate
を使うと、withtime
デコレータは次のように書けます。
注 執筆時点では、PEP 612をサポートしているとされる Pyre でも、 Concatenate
をPEPの記述通りには動作させることができませんでした。以下は未確認なコードになります。
from typing import Callable, ParamSpec, TypeVar, Concatenate
from datetime import datetime
P = ParamSpec('P') # 関数の引数の型のパラメータ仕様変数
R = TypeVar('R') # 関数の戻り値の型の変数
def withtime(f: Callable[Concatenate[datetime, P], None]) -> Callable[P, None]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
f(datetime.now(), *args, **kwargs)
return wrapper
@withtime
def f3(now: datetime, title:str, value:int):
print(now.astimezone(), title, value)
Concatenate
は、引数の 連結 を指定する演算子で、デコレータに指定する関数の型を、次のように記述しています。
Callable[Concatenate[datetime, P], R]
このように指定すると、withtime()
には第一引数が datetime
型の関数だけを指定できます。パラメータ仕様変数 P
は2つ目以降の引数を参照する変数で、関数 f3
をデコレータに指定した場合は、P
は [str, int]
という引数の並びになります。
withtime()
で実際に実行される関数 wrapper()
の引数は、デコレート対象の関数から、一番目のdatetime
型引数を除いたものですので、パラメータ仕様変数 P
を使って指定できます。
本体の関数よりもデコレータに指定する引数のほうが多い場合も、Concatenate
を使って指定できます。次のデコレータ assert_eq
は、関数の結果が第一引数で指定した値と異なる場合は例外を送出します。
from typing import Any, Callable, ParamSpec, TypeVar, Concatenate
from datetime import datetime
P = ParamSpec('P') # 関数の引数の型のパラメータ仕様変数
R = TypeVar('R') # 関数の戻り値の型の変数
def assert_eq(f: Callable[P, R]) -> Callable[Concatenate[R, P], R]:
def wrapper(value: R, *args: P.args, **kwargs: P.kwargs) -> R:
ret = f(*args, **kwargs)
assert ret == value
return ret
return wrapper
@assert_eq
def f4(v:int):
return v * 2
f4(2, 1) # 2 == 1*2 なので正常終了
f4(3, 1) # 3 != 1*2 なので例外が発生
f4(3, "1") # 型チェックNG
元になる関数 f4()
は引数を一つだけ受け取る関数ですが、assert_eq
デコレータはもう一つ、比較する値も指定します。デコレータの戻り値は、次の型になっています。
Callable[Concatenate(R, P), R])
デコレータを呼び出すときの引数は、第一引数として比較する値を受け取り、2つ目以降はパラメータ仕様変数 P
で示される、デコレート対象の関数とおなじ引数となっています。