Pythonで静的な型付けを行いながらプログラミングを行うために使える型ヒントに関連する機能がPython 3.10でどのように強化されたかを紹介する。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
2021年10月4日にPython 3.10がリリースされた。主要な新機能や変更点をかいつまんでまとめると以下のようになる(詳しくは「What's New In Python 3.10」を参照されたい)。
前回はwith文の簡潔な記述、エラーメッセージの改善、EncodingWarning警告クラスについて見た。Pythonはバージョン3.5から型ヒントをサポートするようになった。今回は、その後も進化を続けている型ヒントに関連する新機能を紹介する。
ユニオン型は、変数やパラメーター、あるいは戻り値などが、ある型と別の型の値を持つことを表すのに使用する。主に型ヒントを記述するために使われる。
このような型を記述するのに、Python 3.9までは以下のようにtypingモジュールのUnion型を用いて次のように記述していた。
from typing import Union
num: Union[int, float] = 1.0 # 変数numは整数値か浮動小数点値を保持する
s: Union[str, bytes] = 1.0 # 変数sは文字列かバイト列のみを保持するはずだが……
def plus_one_309(x: Union[int, float]) -> Union[int, float]:
return x + 1
print(plus_one_309(num))
print(plus_one_309('string')) # 実行時にTypeError
最初の例では変数numはint型かfloat型の値を持つことが示されており、そこに1.0が代入されているので問題はない。一方、変数sはstr型かbytes型の値を保持するはずだが、1.0が代入されている。また、plus_one_309関数の定義では、パラメーターxには上と同じくint型の値かfloat型の値を受け取ることが示されている。そして、この関数を呼び出している2つのコードのうち、2つ目では文字列が渡されている。
このコードをPylanceによる型チェックを有効化したVisual Studio Code(以下、VS Code)で記述すると次のように問題ある部分がきちんとチェックされる(VS CodeでPylanceによる型チェックを有効にするには、"python.analysis.typeCheckingMode"項目の値を"basic"か"strict"にする)。
Python 3.10では、このようなユニオン型の指定を「|」演算子を用いて、「型0 | 型1 | ……」のように、より簡潔に記述できるようになった。例えば、上記のコードは以下のように書き換えられる(関数名を「plus_one_310」に変更している)。
num: int | float = 1.0 # 変数numは整数値か浮動小数点値を保持する
s: str | bytes = 1.0 # 変数sは文字列かバイト列のみを保持するはずだが……
def plus_one_310(x: int | float) -> int | float:
return x + 1
print(plus_one_310(num))
print(plus_one_310('string')) # 実行時にTypeError
これらの型情報はisinstance関数/issubclass関数に与えることも可能だ。
s: str | bytes = 'string'
if isinstance(s, str | bytes):
print(f'type of num is str or bytes')
ただし、isinstance関数/issubclass関数の第2引数にユニオン型を指定できるようになったのはPython 3.10からであり、Python 3.9で以下のようなコードを書いても実行時に例外が発生する。
from typing Union
s: Union[str, bytes] = 'string'
if isinstance(s, Union[str, bytes]): # TypeError
print(f'type of num is str or bytes')
次に上で見たplus_one_309関数を次に示すdecorate_309デコレーターで修飾することを考えてみよう(Python 3.9)。
from typing import Union
def decorate_309(f):
def inner(*args, **kwargs):
print('inner')
return f(*args, **kwargs)
return inner
@decorate_309
def plus_one_309(x: Union[int, float]) -> Union[int, float]:
return x + 1
decorate_309デコレーターは、元の関数を実行する前に「inner」と表示する処理を付加するだけのものだ。これに型ヒントを与えるとしたらどうなるだろう。
Pythonの型ヒントでは関数のような呼び出し可能なものは「Callable[[引数の型のリスト], 戻り値の型]」のように記述できる。つまり、decorate_309デコレーターは関数を受け取り、関数を返送するので、疑似コードで型ヒントを書くと次のようになる(「引数の型のリスト」を「引数」と、「戻り値の型」を「戻り値」と省略して表記)。
from typing import Union, Callable
def decorate_309(f: Callable[[引数], 戻り値]) -> Callable[[引数], 戻り値]:
def inner(*args, **kwargs):
print('inner')
return f(*args, **kwargs)
return inner
@decorate_309
def plus_one_309(x: Union[int, float]) -> Union[int, float]:
return x + 1
ここで、戻り値の型をtypingモジュールのTypeVar型(型変数を表すクラス)を使って表すことにしよう。
from typing import Union, Callable, TypeVar
R = TypeVar('R') # 戻り値の型をRとする
def decorate_309(f: Callable[[引数], R]) -> Callable[[引数], R]:
def inner(*args, **kwargs) -> R:
print('inner')
return f(*args, **kwargs)
return inner
@decorate_309
def plus_one_309(x: Union[int, float]) -> Union[int, float]:
return x + 1
decorate_309デコレーターの内部で定義しているinner関数は、このデコレーターの戻り値であるから、「Callable[[引数], R]」に一致する必要がある。すなわち、その戻り値の型はRとなるので、上のinner関数の定義ではそのことを記述してある。plus_one_309関数をこのデコレーターでラップすると、関数定義にある戻り値の型「Union[int, float]」がRにキャプチャーされる。
問題は[引数]の部分、つまりデコレーターが受け取る引数の型のリストだ。このデコレーターがどんな関数に使われるかは分からない。もちろん、plus_one_309関数はそうだが、別のパラメーターリストを持つ関数にも適用できる必要があるだろう。そうできるように[引数]を指定する必要がある。簡単にいえば何でも受け入れるということだ。Callableに対して「パラメーターリストの型を指定せずに、戻り値の型Rだけを指定する」には「Callable[..., R]」となる([引数]部分に「...」を指定するときにはそれを囲む[]は必要ない。ただし、全ての引数リストに対応するが、それは型チェックが行われないということでもある)。これを反映すると次のようになる。
from typing import Union, Callable, TypeVar
R = TypeVar('R') # 戻り値の型をRとする
def decorate_309(f: Callable[..., R]) -> Callable[..., R]:
def inner(*args, **kwargs) -> R:
print('inner')
return f(*args, **kwargs)
return inner
@decorate_309
def plus_one_309(x: Union[int, float]) -> Union[int, float]:
return x + 1
残るは、デコレーター内部で定義されているinner関数のパラメーターリスト「*args, **kwargs」の型ヒントをどうするかだ(inner関数は、今述べたように全ての引数リスト受け入れて、それをラップした関数にそのまま渡している。これはデコレーターとそれがラップする関数との間で、引数を受け渡すための典型的なやり方といえる)。ただし、可変長のパラメーターリストを適切に表現する型ヒントの構文がない。
ではどうするかというと、PEP 612では「*args: object, **kwargs: object」のようにしている。また、Pylanceによる型チェックを有効にしたVS Codeでこれらの型がどうなっているかを見ると「*args: Any, **kwargs: Any」となっていた。
ここではPylanceの表示を採用して、Python 3.9までは次のようなコードで型ヒントを記述したものとしよう。
from typing import Union, Callable, TypeVar, Any
R = TypeVar('R') # 戻り値の型をRとする
def decorate_309(f: Callable[..., R]) -> Callable[..., R]:
def inner(*args: Any, **kwargs: Any) -> R:
print('inner')
return f(*args, **kwargs)
return inner
@decorate_309
def plus_one_309(x: Union[int, float]) -> Union[int, float]:
return x + 1
ここでplus_one_309関数を呼び出すコードを書いてみる。
plus_one_309(1.5)
plus_one_309('foo')
既に述べたように、関数の引数に対する型チェックは実質的には行われていないので、2つ目のコードも型チェックが成功する(が、もちろん、実行時に例外が発生する)。これでは型付きでPythonのコードを記述する意味があまりない。
このようにある関数のパラメーターと別の関数のパラメーターとの間に依存関係がある際に、そのことを示す型ヒントを記述できるようにPython 3.10ではtypingモジュールにParamSpecとConcatenate演算子が追加された。ParamSpecを使うと、先のコードは次のように書き換えられる(デコレーターと関数名の名前を「_310」で終わるように変更している点に注意)。
from typing import Callable, TypeVar, ParamSpec
R = TypeVar('R') # 戻り値の型をRとする
P = ParamSpec('P') # パラメーターを表す型ヒント
def decorate_310(f: Callable[P, R]) -> Callable[P, R]:
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
print('inner')
return f(*args, **kwargs)
return inner
@decorate_310
def plus_one_310(x: int | float) -> int | float:
return x + 1
「P = ParamSpec('P')」とすることで、パラメーター仕様(関数のパラメーターの型)を表す変数が得られる。これは「Callable[P, R]」のようにCallableの第1引数として使用する。これにより、関数の位置引数とキーワード引数がPにキャプチャーされるが、関数のパラメーターに対して型ヒントを記述する際に、位置引数は上記コードのように「P.args」として、キーワード引数は「P.kwargs」として表現する。
このようにして先ほどと同様にplus_one_310関数を呼び出すコードを記述すると、次のように型チェックにより2つ目の関数呼び出しに問題あることが事前に分かるようになる。
デコレーターにより関数のパラメーターの数が変化する場合もある。このようなときには、typingモジュールのConcatenate演算子を使用する。詳細は省略するが、以下に例を示す。
from typing import Callable, TypeVar, ParamSpec, Concatenate
from datetime import datetime
R = TypeVar('R')
P = ParamSpec('P')
def reduce_param(f: Callable[Concatenate[str, P], R]) -> Callable[P, R]:
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
s = str(datetime.now())
return f(s, *args, **kwargs)
return inner
@reduce_param
def foo(s: str, x: int, y: int) -> str: # 3引数
return f's: {s}, x: {x}, y: {y}'
print(foo(1, 1)) # inner関数で第1引数を設定するので、foo関数は2引数で呼び出し
def add_param(f: Callable[P, bool]) -> Callable[Concatenate[int, P], bool]:
def inner(a: int, *args: P.args, **kwargs: P.kwargs) -> bool:
print(f'a: {a}')
return f(*args, **kwargs)
return inner
@add_param
def bar(x: int, y: int) -> bool: # 2引数
return x == y
print(bar(100, 0, 0)) # デコレーターから返された関数は3引数になっている
ざっくりと話をしておくと、ConcatenateはCallableの第1引数として使用して、そこにパラメーターの型を並べていき、最後にパラメーター仕様変数(P)を置く。このとき、パラメーター仕様変数以外の型は、関数の*argsと**kwargsに前置される位置引数の型を指定するようなものだ。
最初のreduce_paramデコレーターではそのパラメーターリストの内部で「Callable[Concatenate[str, P], R]」のようにして、戻り値の型は「Callable[P, R]」としている。これは、ラップする関数の第1パラメーターの型が文字列で、その後に*argsと**kwargsが並んでいるものとして考えられる。そして、inner関数の中でラップする関数の第1パラメーターに渡す値のセットアップが行われて呼び出される。そのため、戻り値となる関数では、パラメーターの数が1つ減ることになる。
次のadd_paramパラメーターでは戻り値の型が「Callable[Concatenate[int, P], bool]」となっている。よって、デコレーターが返す関数ではパラメーターの数が1つ増えることになる(この場合は、int型の値を第1引数に指定する必要がある)。
Copyright© Digital Advantage Corp. All Rights Reserved.