16
11

More than 3 years have passed since last update.

Python3.10の新機能(1) - 引数仕様変数 (Parameter Specification Variables)

Last updated at Posted at 2021-01-24

はじめに

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を使って型をパラメーター化しています。型引数Radd_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とキーワード引数 **kwargsobjectとしか表現できなかったところが、それぞれ P.argsP.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というデコレーター関数に与える関数fRequest型の引数が追加されています。この引数は内部で利用されていて、with_requestが返す関数はそれを除いたものになっています。これを表現するためにConcatinateを使い、f は「Request型とParamSpecのPの連結を引数とする」と書くことができます。with_requestが返す関数は 「ParamSpecのPを引数とする」となっているので、Request型の引数が除かれていることがわかります。

まとめ

Python 3.10で導入予定の引数仕様変数と連結オペレーターについて書いてみました。まだ静的型チェッカーで試せていないので、例の一部は間違っているかもなのですが、使い方が分かり次第アップデートしたいと思います。

いずれにしても、TypeScriptやRustなど型付き言語がもてはやされている(私としては回帰しているようにも思えますが)昨今ではPythonもうかうかしていられないというところでしょうか。Pythonでは過去互換の問題もあるので型付け言語になるのではなく、型のヒントをつけられるようにして外部のツールでチェックするという方針を取っていますが、型チェックのための仕様がどんどん充実していっているように見えます。また何か大きめの変更があれば取り上げてみたいと思います。

16
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
11