はじめに
2021年10月にリリースが予定されているPython3.10で新たに加わる変更をPython3.10の新機能 (まとめ)という記事でまとめ始めました。その中で比較的分量のある項目を別記事に切り出すことにしていますが、その第一弾として引数仕様変数(Parameter Specification Variables)を取り上げます。この変更は PEP-612 で詳しく述べられていて、この記事はそれを簡単にまとめて紹介したものです。
この変更で解決しようとしている問題
PEP-612は型ヒントの付け方に関する変更提案なのですが、例えば「関数を呼ぶ前にDBにログを記録する」という機能をデコレーターで実現することを考えます。Python 3.9ではこのように書けます。
from typing import Awaitable, Callable, TypeVar
R = TypeVar("R")
def add_logging(f: Callable[..., R]) -> Callable[..., Awaitable[R]]:
async def inner(*args: object, **kwargs: object) -> R:
await log_to_database()
return f(*args, **kwargs)
return inner
@add_logging
def takes_int_str(x: int, y: str) -> int:
return x + 7
await takes_int_str(1, "A")
await takes_int_str("B", 2) # 実行時にエラーになる
add_logging
というデコレーター関数は呼び出可能オブジェクト(関数)f
を引数に取り、別の呼び出可能オブジェクトを返します。そしてその返す関数inner
の中を見ると、非同期関数log_to_database()
をawait
で呼んでから、f
を呼び出してその結果を返しているのがわかります。
add_logging
に与える関数はなんでも良いので、引数も返り値も任意の型になります。返り値は一つだけなので TypeVar
を使って型をパラメーター化しています。型引数R
をadd_logging
に与える関数f
の返り値の型として、inner
の返り値をAwaitable[R]
と定義しています。同様に引数もパラメーター化したいのですが、引数は型が任意であるだけでなく、その数も任意であるため、Python 3.9ではパラメーター化できません。そのため、...
という型指定のない任意の引数という表現しかできません。結果的に、 await takes_int_str("B", 2)
という間違った呼び出しを静的チェッカーで見つけることができず、実行して初めてわかるエラーということになってしまいます。
この問題を解決するために「引数仕様変数」(Parameter Specification Variables)がPython 3.10から導入されます。
引数仕様変数
引数仕様変数は他の型変数と同じように定義されます。
from typing import ParamSpec
P = ParamSpec("P")
通常の型変数が TypeVar
を使うのに対して、新たに導入されたParamSpec
を使います。なお、これに引数として与える文字列は代入先の変数名と同じにする必要があるようです。それなら引数無しにして類推して欲しいと思うのですが、まあ今はこうなっているようです。
そして、ここで定義した引数仕様変数を使える場所ですが、Callable
の第一引数です。おさらいしておくと、Callable
は「呼び出可能オブジェクト」で第一引数に呼び出す引数列の型、第二引数に返り値の型を指定します。例えばこんな感じ。
from typing import Callable
def call_callback(f: Callable[[str], str], s: str) -> str:
return f(s)
def reversed_str(s: str) -> str:
return "".join(reversed(s))
print(call_callback(reversed_str, "abc")) # "cba"が出力されます
Python 3.9までは、この第一引数には、型あるいは型変数のリストかellipsis (...)のみが使えました。この例では明示的に「str型の引数を一つだけ取る関数」とされていて、問題なく静的型チェックも通ります。
ここで例えば call_callback
で二つの引数を取る関数も使いたくなったら ellipsisを使って「任意の引数」を表すしかなくなります。
from typing import Callable
def call_callback(f: Callable[..., str], *args: object) -> str:
return f(*args)
def reversed_str(s: str) -> str:
return "".join(reversed(s))
def multi_str(s: str, i: int) -> str:
return s * i
print(call_callback(reversed_str, "abc")) # "cba"が出力されます
print(call_callback(multi_str, "abc", 2)) # "abcabc"が出力されます
print(call_callback(reversed_str, "abc", 3)) # 静的型チェックは通るが実行時にエラー
最初の例と同様に、静的型チェックは通るけれども実行時にエラーになるという問題があります。
これが、Python 3.10ではこのように書けるようになります。
from typing import Callable, ParamSpec
P = ParamSpec("P")
def call_callback(f: Callable[P, str], *args: P.args, **kwargs: P.kwargs) -> str:
return f(*args, **kwargs)
def reversed_str(s: str) -> str:
return "".join(reversed(s))
def multi_str(s: str, i: int) -> str:
return s * i
print(call_callback(reversed_str, "abc")) # "cba"が出力されます
print(call_callback(multi_str, "abc", 2)) # "abcabc"が出力されます
print(call_callback(reversed_str, "abc", 3)) # 静的型チェックでエラーになります。
最後の行のcall_callback
の呼び出し引数の二つ目以降の型([str, int]
)が一つ目の引数で与えた関数の引数型([str]
)と異なるので静的型チェッカーでエラーになります(なるはず...)。
ただし、現状の最新版の mypyではまだParamSpecがサポートされておらず、サポートしていると言っているpyrightはチェックは通るのですがエラーを出してくれません。まだまだ新しい機能なので仕方ないと思いますが、私の使い方が間違っている可能性もあるので、その場合はわかり次第サンプルコードを修正します。
同様に最初に示した例もこのように書き換えることができます。
from typing import Awaitable, Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def add_logging(f: Callable[P, R]) -> Callable[P, Awaitable[R]]:
async def inner(*args: P.args, **kwargs: P.kwargs) -> R:
await log_to_database()
return f(*args, **kwargs)
return inner
@add_logging
def takes_int_str(x: int, y: str) -> int:
return x + 7
await takes_int_str(1, "A")
await takes_int_str("B", 2) # 静的型チェッカーでエラーを検出できる
これまで位置引数 *args
とキーワード引数 **kwargs
は object
としか表現できなかったところが、それぞれ P.args
、P.kwargs
というように与えられた関数の引数の型をそのまま指定できることがわかります。これによって、takes_int_str
はデコレート後も (x: int, y: str)
となっている型の引数しか受け付けないことになるので、 await takes_int_str("B", 2)
という呼び出しは静的型チェッカーでエラーになります。
連結オペレーター
上記の例はデコレーターに与える関数と返す関数の引数が同じパターンですが、そうではない場合もあります。その場合は、これも今回追加された 連結オペレーター(Concatenate
)を使うことができます。
from typing import Concatenate
def with_request(f: Callable[Concatenate[Request, P], R]) -> Callable[P, R]:
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
return f(Request(), *args, **kwargs)
return inner
@with_request
def takes_int_str(request: Request, x: int, y: str) -> int:
# request をここで使う
return x + 7
takes_int_str(1, "A")
takes_int_str("B", 2) # 静的型チェッカーでエラーを検出できる
この例では with_request
というデコレーター関数に与える関数f
に Request
型の引数が追加されています。この引数は内部で利用されていて、with_request
が返す関数はそれを除いたものになっています。これを表現するためにConcatinate
を使い、f
は「Request
型とParamSpecのP
の連結を引数とする」と書くことができます。with_request
が返す関数は 「ParamSpecのP
を引数とする」となっているので、Request
型の引数が除かれていることがわかります。
まとめ
Python 3.10で導入予定の引数仕様変数と連結オペレーターについて書いてみました。まだ静的型チェッカーで試せていないので、例の一部は間違っているかもなのですが、使い方が分かり次第アップデートしたいと思います。
いずれにしても、TypeScriptやRustなど型付き言語がもてはやされている(私としては回帰しているようにも思えますが)昨今ではPythonもうかうかしていられないというところでしょうか。Pythonでは過去互換の問題もあるので型付け言語になるのではなく、型のヒントをつけられるようにして外部のツールでチェックするという方針を取っていますが、型チェックのための仕様がどんどん充実していっているように見えます。また何か大きめの変更があれば取り上げてみたいと思います。