Python の ParamSpec で引数を完全に保持する型付け

ParamSpec は Python 3.10 で導入され、デコレータで元の関数の引数型を完全に保持できます。従来の Callable[…, T] では失われていた情報を維持できます。

問題の背景

従来の型付けでは、デコレートされた関数の引数情報が失われます。

from typing import Callable, TypeVar

T = TypeVar("T")

def decorator(func: Callable[..., T]) -> Callable[..., T]:
    def wrapper(*args, **kwargs) -> T:
        return func(*args, **kwargs)
    return wrapper

wrapper の型は Callable[..., T] となり、具体的な引数の型がわかりません。

ParamSpec の基本

from typing import ParamSpec, TypeVar, Callable

P = ParamSpec("P")
T = TypeVar("T")

def decorator(func: Callable[P, T]) -> Callable[P, T]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
        print("Before call")
        result = func(*args, **kwargs)
        print("After call")
        return result
    return wrapper

@decorator
def greet(name: str, count: int = 1) -> str:
    return f"Hello, {name}!" * count

型チェッカーは greet の型を (name: str, count: int = 1) -> str と正しく認識します。

P.args と P.kwargs

ParamSpec は 2 つの特殊属性を持ちます。

P.args

位置引数の型を表す。*args の型注釈に使う。

P.kwargs

キーワード引数の型を表す。**kwargs の型注釈に使う。

実用例:リトライデコレータ

from typing import ParamSpec, TypeVar, Callable

P = ParamSpec("P")
T = TypeVar("T")

def retry(times: int) -> Callable[[Callable[P, T]], Callable[P, T]]:
    def decorator(func: Callable[P, T]) -> Callable[P, T]:
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
            for attempt in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    if attempt == times - 1:
                        raise
            raise RuntimeError("Unreachable")
        return wrapper
    return decorator

@retry(3)
def fetch(url: str, timeout: int = 30) -> bytes:
    ...

fetch の型情報は完全に保持され、IDE の補完も正しく動作します。ParamSpec により、デコレータの型安全性が大幅に向上します。