Pyrightの静的型付け:上級者向け解説
型の特定
Pyrightでは、「type narrowing」と称される技術を用いて、コードの流れを基にして式の型を特定します。次の例を見てみましょう。
val_str: str = "hi"
val_int: int = 3
def func(val: float | str | complex, test: bool):
reveal_type(val) # int | str | complex のいずれかと表示
val = val_int # ここで val の型は int に特定される
reveal_type(val) # int と表示
if test:
val = val_str # ここで val の型は str に特定される
reveal_type(val) # str と表示
reveal_type(val) # int | str のいずれかと表示
if isinstance(val, int):
reveal_type(val) # int と表示
print(val)
else:
reveal_type(val) # str と表示
print(val)
この関数が始まるとき、型チェッカーはval
がfloat | str | complex
の型であることしか知りません。その後、既に型がintであることが分かっている値がvalに割り当てられます。 int
が float
のサブクラスとして扱われるため、この割り当ては有効です。この割り当てが行われた直後、型チェッカーは val
の型を int
と認識します。これは float | str | complex
よりも「狭い」(より具体的な)型とされます。type narrowingは、シンボルに新しい値が割り当てられるたびに行われます。
その後、さらにいくつかのコード行が進んだところで、条件分岐内で別の値がvalに割り当てられます。この時点でvalに割り当てられる値は型が str
であることが明らかなため、 val
の型は str
に特定されます。条件分岐の後、関数の本体にコードが戻ると、型チェッカーが実行時にどの条件ブロックが実行されるかを静的に判断できないため、valの特定された型は int | str
に戻ります。
型を特定する別の手法には、 if
、 while
、 assert
などの条件付きコードフロー文を使用する方法があります。これらの条件に基づいて適用されるコードブロックでは、条件に「保護された」型の特定が行われることから、これを「型ガード」とも呼ぶことがあります。たとえば、 if x is None:
という条件文を見ると、このif文の範囲内では x
が None
であると仮定することができます。示されたコードサンプルで、 isinstance
を用いた型ガードの例が挙げられています。型チェッカーは、 isinstance(val, int)
がTrueを返すのは val
が int
型の値を持っている場合だけであり、 str
型ではないことを認識しています。そのため、 if
ブロック内では val
が int
型の値を持っていると見なし、 else
ブロックでは val
が str
型の値を持っていると見なすことができます。これにより、型(この場合は int | str
)が肯定的な( if
)および否定的な( else
)テストを通じてどのように特定されるかを示しています。
以下は、type narrowingをサポートする式の形式です:
<ident>
(ここで<ident>
は識別子を意味します)<expr>.<member>
(<expr>
はサポートされる式であり、メンバーアクセスを行います)<expr>[<int>]
(<int>
は非負整数のインデックスを表す式です)<expr>[<str>]
(<str>
は文字列リテラルのインデックスを表す式です)
type narrowingが適用される式の例を挙げます:
my_var
employee.name
a.foo.next
args[3]
kwargs["bar"]
a.b.c[3]["x"].d
型ガード
割り当てを通じたtype narrowing に加えて、Pyrightは以下の型ガードを提供しています。
x is None
およびx is not None
x == None
およびx != None
x is ...
およびx is not ...
x == ...
およびx != ...
type(x) is T
およびtype(x) is not T
type(x) == T
およびtype(x) != T
x is E
およびx is not E
(Eはリテラルの列挙型またはboolを指します)x is C
およびx is not C
(Cはクラスを指します)x == L
およびx != L
(Lはリテラル型に評価される式を指します)x.y is None
およびx.y is not None
(xはNoneを含むフィールドで区別される型を指します)x.y is E
およびx.y is not E
(Eはリテラルの列挙型またはboolで、xはリテラル型を含むフィールドで区別される型を指します)x.y == LN
およびx.y != LN
(LNはリテラル式またはNoneで、xはリテラル型を含むフィールドまたはプロパティで区別される型を指します)x[K] == V
,x[K] != V
,x[K] is V
, およびx[K] is not V
(KとVはリテラル式で、xはリテラル型を持つTypedDictフィールドで区別される型を指します)x[I] == V
およびx[I] != V
(IとVはリテラル式で、xはIで示されるインデックスによって区別される既知の長さのタプルを指します)x[I] is B
およびx[I] is not B
(Iはリテラル式で、Bはboolまたは列挙リテラルで、xはIで示されるインデックスによって区別される既知の長さのタプルを指します)x[I] is None
およびx[I] is not None
(Iはリテラル式で、xはIで示されるインデックスによって区別される既知の長さのタプルを指します)len(x) == L
,len(x) != L
,len(x) < L
など(xはタプルで、Lは整数リテラル型に評価される式を指します)x in y
またはx not in y
(yはリスト、セット、フローズンセット、デキュー、タプル、辞書、デフォルト辞書、またはOrderedDictのインスタンスを指します)S in D
およびS not in D
(Sは文字列リテラルで、DはTypedDictを指します)isinstance(x, T)
(Tは型または型のタプルを指します)issubclass(x, T)
(Tは型または型のタプルを指します)callable(x)
f(x)
(fはPEP 647またはPEP 742で定義されたユーザー定義の型ガードを指します)bool(x)
(xはすべての場合において静的に真または偽と検証可能な任意の式を指します)x
(xはすべての場合において静的に真または偽と検証可能な任意の式を指します)
型ガードがサポートする式には、シンプルな変数名、ドットを使ったメンバーアクセス連鎖(例:a.b.c.d
)、単項のnot
演算子、論理のand
およびor
演算子、整数リテラルによるインデックスアクセス(例:a[2]
や a[-1]
)、関数呼び出しなどが含まれます。しかし、算術演算子や他の種類のインデックスアクセスなどの他の演算子はサポートされていません。
いくつかの型ガードは、肯定的な場合と否定的な場合の両方で型の絞り込みが可能です。通常、肯定的な場合はif文で、否定的な場合はelse文で利用されます。(型ガード式がnot演算子で修飾されると、肯定的および否定的な状況が反転します。)一部の場合には、肯定的または否定的な場合のいずれかでのみ型を絞り込むことが可能であり、両方ではできません。以下の例を見てみましょう:
class Foo: pass
class Bar: pass
def func1(val: Foo | Bar):
if isinstance(val, Bar):
reveal_type(val) # Bar
else:
reveal_type(val) # Foo
def func2(val: float | None):
if val:
reveal_type(val) # float
else:
reveal_type(val) # float | None
func1
の例では、if
文とelse
文で型の絞り込みが可能でした。一方、func2
の例では、肯定的な場合にのみ型の絞り込みが可能でした。これは、否定的な場合にval
がfloat
(具体的には0.0
)またはNone
のどちらかである可能性があるためです。
エイリアス条件式
Pyrightは、ローカル変数への割り当てがある特定の型ガード式もサポートしており、これを「エイリアス条件式」と称します。この場合、「c
」は型ガード式の形式のひとつが割り当てられた識別子であり、c = a is not None
や c = isinstance(a, str)
などの例が含まれます。この「c
」を条件の評価で使用することで、式a
の詳細な型の絞り込みが可能です。
この仕組みは、c
がモジュールまたは関数内のローカル変数で、一度限りの割り当てがされた場合にのみ適用されます。さらに、式a
がシンプルな識別子である場合(メンバーアクセス式や添字式ではない)、その関数またはモジュール内で一度だけ割り当てられた場合に限られます。式a
に対する単項のnot
演算子の使用は許可されていますが、論理and
やor
の使用は認められていません。
def func1(x: str | None):
is_str = x is not None
if is_str:
reveal_type(x) # str
else:
reveal_type(x) # None
def func2(val: str | bytes):
is_str = not isinstance(val, bytes)
if not is_str:
reveal_type(val) # bytes
else:
reveal_type(val) # str
def func3(x: list[str | None]) -> str:
is_str = x[0] is not None
if is_str:
# この方法は添字式には対応していないため、
# この例ではx[0]の型は特定されません。
reveal_type(x[0]) # str | None
def func4(x: str | None):
is_str = x is not None
if is_str:
# この方法は対象の式が別の箇所で再割り当てされている場合には有効ではありません。
# ここでの `x` は関数内の別の場所で再割り当てされているため、
# この場合、type narrowingは行われません。
reveal_type(x) # str | None
x = ""
暗黙のElseによる型の特定
「if
」や「elif
」節が対応する「else
」節なしで用いられた場合、Pyright
は通常、「if
」や「elif
」のブロックが実行されずにコードが通過されることを前提にします。ただし、型分析に基づいて「if
」や「elif
」の実行が保証されているときは、通過することが不可能と分析されることがあります。
def func1(x: int):
if x == 1 or x == 2:
y = True
print(y) # エラー:「y」がおそらく未割り当てであるため
def func2(x: Literal[1, 2]):
if x == 1 or x == 2:
y = True
print(y) # エラーなし
この仕組みは、すべての列挙型のメンバーや組合せ型の全ての型をカバーする場合に特に役立ちます。
from enum import Enum
class Color(Enum):
RED = 1
BLUE = 2
GREEN = 3
def func3(color: Color) -> str:
if color == Color.RED or color == Color.BLUE:
return "yes"
elif color == Color.GREEN:
return "no"
def func4(value: str | int) -> str:
if isinstance(value, str):
return "received a str"
elif isinstance(value, int):
return "received an int"
Any型の特定
通常、Any
型は絞りこみができません。このルールの例外は、isinstance
やissubclass
といった組み込みの型ガード、"match
"文におけるクラスパターンマッチング、そしてユーザー定義型ガードだけです。これらの場合を除き、Any
は元の状態を保持し、割り当てによっても変更されません。
a: Any = 3
reveal_type(a) # Any
a = "hi"
reveal_type(a) # Any
型引数として**Any
**が使われる場合の扱いも同じです。
b: Iterable[Any] = [1, 2, 3]
reveal_type(b) # list[Any]
c: Iterable[str] = [""]
b = c
reveal_type(b) # list[Any]
キャプチャされた変数の特定
外部スコープで変数の絞り込みがされた後、その変数が内部スコープの関数やラムダでキャプチャされるケースにおいて、Pyrightは特定の条件下で型を保持します。内部スコープの関数やラムダが定義された後、キャプチャされた変数が変更されず、さらに**nonlocal
やglobal
**バインディングによる他のスコープでの変更もない場合、特定された型が維持されます。
def func(val: int | None):
if val is not None:
def inner_1() -> None:
reveal_type(val) # int
print(val + 1)
inner_2 = lambda: reveal_type(val) + 1 # int
inner_1()
inner_2()
値に制約された型変数
型変数TypeVarが設定される際には、特定の2つ以上の型(値)に制約を設けることができます。
# 制約されていない型変数の例
_T = TypeVar("_T")
# 特定の値に制約された型変数の例
_StrOrFloat = TypeVar("_StrOrFloat", str, float)
特定の値に制約されたTypeVarが関数の引数などで使用される場合、そのTypeVarを使用するすべての場所で型が一致していなければなりません。
def add(a: _StrOrFloat, b: _StrOrFloat) -> _StrOrFloat:
return a + b
# `a` と `b` の引数は両方とも `str` で一致している
v1 = add("hi", "there")
reveal_type(v1) # str
# `a` と `b` の引数は両方とも `float` で一致している
v2 = add(1.3, 2.4)
reveal_type(v2) # float
# `a` と `b` の引数の型が異なるためエラー
v3 = add(1.3, "hi") # エラー
条件型と型変数
関数のシグネチャに型変数を使用する場合、型チェッカーは型の整合性を保証するための検証が必要です。以下の例では、入力パラメータと戻り値が共に型変数で指定されています。型チェッカーは、呼び出し元から**str
型の引数が渡された際、全ての処理経路でstr
が返されること、またfloat
型が渡された際にはfloat
**が返されることを保証しなければなりません。
def add_one(value: _StrOrFloat) -> _StrOrFloat:
if isinstance(value, str):
sum = value + "1"
else:
sum = value + 1
reveal_type(sum) # str* | float*
return sum
ここで、変数sum
の型が星印(*)で示されるのは、型チェッカーが「条件付き」型を内部的に追跡しているためです。この具体的なケースでは、パラメータがstr
型の場合はsum
もstr
型、float
型の場合はsum
もfloat
型であることが示されます。このように条件型を追跡することで、型チェッカーは戻り値の型が型変数_StrOrFloat
と一致していることを確認することができます。条件型は交差型の一種であり、具体的な型と型変数の両方のサブタイプとして扱われます。
「self」と「cls」パラメータの推論された型
メソッドのself
またはcls
パラメータの型注釈が省略されると、Pyrightはそのメソッドが属するクラスに基づいてその型を自動的に推論します。この推論された型は、クラス固有の型変数として内部的に管理されます。
具体的には、self
の型は「Self@ClassName
」として表され、ここで「ClassName
」はメソッドが定義されているクラスの名前を指します。また、クラスメソッドのcls
パラメータは「Type[Self@ClassName]
」という型を取ります。
class Parent:
def method1(self):
reveal_type(self) # Self@Parent
return self
@classmethod
def method2(cls):
reveal_type(cls) # Type[Self@Parent]
return cls
class Child(Parent):
...
reveal_type(Child().method1()) # Child
reveal_type(Child.method2()) # Type[Child]
オーバーロード
関数やメソッドが複数の異なる型のいずれかを返す場合、戻り値の型が入力引数の型に依存するときは、一連の@overload
シグネチャを用いると便利です。Pyrightは呼び出し時に、提供された引数に最も適合するオーバーロードシグネチャを選択します。
PEP 484
によって@overload
デコレータが導入され、その使い方が説明されていますが、型チェッカーがどのオーバーロードを「最適」と判断するかの詳細は定められていませんでした。Pyrightは以下の手順に従ってオーバーロードを選択します。
-
最初に、Pyrightは引数の数(アリティ)とキーワード引数の存在を基にオーバーロードの候補を絞り込みます。例えば、あるオーバーロードが2つの引数を必要としている場合、提供される引数が1つだけなら、そのオーバーロードは選択肢から外れます。また、呼び出しにキーワード引数が含まれているのに、オーバーロードがそれに対応するパラメータを持っていない場合も同様です。
-
次に、引数の型を検討し、それを各パラメータの定義された型と比較します。型が一致しないオーバーロードは候補から外れます。この過程で、双方向型推論が引数の式の型を決定する際に利用されます。
-
もし評価の結果、オーバーロードが1つだけ残る場合、そのオーバーロードが選ばれます。
-
複数のオーバーロードが残っている場合、通常、宣言された順に最初に残ったオーバーロードが選択されます。しかし、次の2つの例外があります。第一の例外:
*args
としてアンパックされた引数がオーバーロードシグネチャ内の*args
パラメータと一致する場合、これが通常の選択ルールを上書きします。第二の例外:引数がAny
やUnknown
に評価され、複数のオーバーロードが一致する場合、その一致は曖昧とされます。このような場合、Pyrightは残ったオーバーロードの戻り型を検討し、重複する型や他の型に包含される型を除外します。この過程の結果、1つの型だけが残る場合、その型が採用されます。もし複数の型が残る場合は、呼び出し式の型はUnknown
と評価されます。例えば、引数がAny
であるために2つのオーバーロードが一致し、戻り型がstr
とLiteralString
である場合、PyrightはLiteralString
がstr
の適切なサブタイプであるため、これを単にstr
に統合します。しかし、戻り型がstr
とbytes
である場合、これらは互いに重なりがないため、呼び出し式の型はUnknown
とされます。 -
オーバーロードが一つも残っていない場合、Pyrightは引数がユニオン型であるかどうかを検討します。ユニオン型の場合、その構成要素であるサブタイプに分解され、分解後の引数型を使ってオーバーロードの選択プロセスを再度実行します。このプロセスで2つ以上のオーバーロードがマッチした場合、それらの戻り型のユニオンが最終的な戻り型となります。この「ユニオン拡張」により、多くの引数がユニオン型に評価されると、組み合わせの爆発的な増加が起こり得ます。例えば、4つの引数それぞれが10のサブタイプに展開されるユニオン型である場合、最大10^4の組み合わせが考えられます。Pyrightは引数を左から右へとユニオンを展開していき、展開するシグネチャの数が64を超えると展開を中止します。
-
オーバーロードが残っておらず、すべてのユニオン型が展開されたにもかかわらず、提供された引数がどのオーバーロードシグネチャとも互換性がない場合、その事実を示す診断メッセージが生成されます。
クラス変数とインスタンス変数
ほとんどのオブジェクト指向プログラミング言語では、クラス変数とインスタンス変数をはっきりと分けて扱いますが、Pythonではこの区別が緩やかで、インスタンスがクラス変数を同名のインスタンス変数で上書きすることが可能です。
class A:
my_var = 0
def my_method(self):
self.my_var = "hi!"
a = A()
print(A.my_var) # クラス変数の値は0
print(a.my_var) # インスタンス生成時にはクラス変数の値を参照し0
A.my_var = 1
print(A.my_var) # クラス変数を1に更新
print(a.my_var) # まだインスタンス変数を設定していないのでクラス変数の値1が出力される
a.my_method() # my_methodでインスタンス変数my_varを設定
print(A.my_var) # クラス変数の値は1のまま
print(a.my_var) # インスタンス変数に設定された"hi!"が出力
A.my_var = 2
print(A.my_var) # クラス変数を2に更新
print(a.my_var) # インスタンス変数が"hi!"に設定されているため、その値が出力される
Pyrightは純粋なクラス変数、通常のクラス変数、純粋なインスタンス変数をそれぞれ異なる方法で扱い、明確に区別します。
純粋なクラス変数
PEP 526に記述されている通り、 ClassVar
アノテーションを使用して宣言されたクラス変数は「純粋なクラス変数」とみなされ、インスタンス変数による上書きは許されません。
from typing import ClassVar
class A:
x: ClassVar[int] = 0
def instance_method(self):
self.x = 1 # 型エラー: クラス変数をインスタンス変数で上書きすることはできません
@classmethod
def class_method(cls):
cls.x = 1
a = A()
print(A.x) # クラス変数の値
print(a.x) # クラス変数の値を参照
A.x = 1
a.x = 2 # 型エラー: クラス変数を上書きすることはできません
通常のクラス変数
ClassVar
アノテーションを使用せずに宣言されたクラス変数は、同名のインスタンス変数で上書き可能です。クラス変数の型はインスタンス変数の型と一致するとみなされます。
通常のクラス変数はクラスの本体で宣言されるのが一般的であり、クラスメソッド内での cls
を通じた宣言も可能ですが、可読性の観点からクラス本体での宣言が推奨されます。
class A:
x: int = 0
y: int
def instance_method(self):
self.x = 1
self.y = 2
@classmethod
def class_method(cls):
cls.z: int = 3
A.y = 0
A.z = 0
print(f"{A.x}, {A.y}, {A.z}") # 0, 0, 0
A.class_method()
print(f"{A.x}, {A.y}, {A.z}") # 0, 0, 3
a = A()
print(f"{a.x}, {a.y}, {a.z}") # 0, 0, 3
a.instance_method()
print(f"{a.x}, {a.y}, {a.z}") # 1, 2, 3
a.x = "hi!" # 型エラー: 型が一致しません
純粋なインスタンス変数
クラスのコンストラクタ内でselfを使って宣言された変数は、「純粋なインスタンス変数」とみなされます。これらの変数はクラスレベルでのアクセスはできません。
class A:
def __init__(self):
self.x: int = 0
self.y: int
print(A.x) # エラー: 'x' はインスタンス変数であり、クラス変数ではありません
a = A()
print(a.x)
a.x = 1
a.y = 2
print(f"{a.x}, {a.y}") # 1, 2
print(a.z) # エラー: 'z' は存在しないメンバーです
クラス変数とインスタンス変数の継承
クラス変数とインスタンス変数は、親クラスから子クラスに継承されます。親クラスで型が指定されたクラス変数やインスタンス変数には、派生クラスが値を割り当てる際に、その型に従う必要があります。
class Parent:
x: int | str | None
y: int
class Child(Parent):
x = "hi!"
y = None # エラー: 型の互換性がありません
派生クラスでクラス変数やインスタンス変数の型を再宣言することは可能ですが、 reportIncompatibleVariableOverride
が有効な場合、親クラスで宣言された型と一致する必要があります。変数が不変である場合は、サブタイプで再宣言が可能ですが、変数が可変の場合は再宣言に制限があります。
class Parent:
x: int | str | None
y: int
class Child(Parent):
x: int # 型エラー: 可変なため、'x' はサブタイプで再宣言できません
y: str # 型エラー: 互換性のない型で再宣言することはできません
親クラスで宣言された型は、派生クラスで値が割り当てられる場合でも保持されます。派生クラスでの割り当てによって型が上書きされることはありません。
class Parent:
x: object
class Child(Parent):
x = 3
reveal_type(Parent.x) # object
reveal_type(Child.x) # object
親クラスと派生クラスのいずれもクラス変数やインスタンス変数の型を宣言していない場合、型はそれぞれのクラスで推論されます。
class Parent:
x = object()
class Child(Parent):
x = 3
reveal_type(Parent.x) # object
reveal_type(Child.x) # int
型変数のスコープ
型変数は、そのスコープ内で使用する前に有効なスコープ(クラス、関数、または型エイリアス)にバインドされなければなりません。
Pyrightは型変数のバインドされたスコープを@記号を使用して表示します。例えば、 T@func
は型変数Tが関数 func
にバインドされていることを意味します。
S = TypeVar("S")
T = TypeVar("T")
def func(a: T) -> T:
b: T = a # TはT@funcを参照
reveal_type(b) # T@func
c: S # エラー:このコンテキストでSはバインドされたスコープを持っていません
return b
TypeVar
またはParamSpec
が関数のパラメータまたは戻り値の型注釈内に現れ、外部スコープにすでにバインドされていない場合、通常は関数にバインドされます。このルールの例外として、TypeVar
またはParamSpec
が関数の戻り値の型注釈内でのみ、かつ戻り値の型内の単一のCallable
内でのみ現れる場合、それは関数ではなくそのCallable
にバインドされます。これにより、関数がジェネリックなCallable
を返すことができます。
# Tはパラメータ型注釈に現れるためfunc1にバインドされます。
def func1(a: T) -> Callable[[T], T]:
a: T # OK なぜならTはfunc1にバインドされているからです
# Tは戻り値のCallableにのみ現れるため、func2ではなく戻りCallableにバインドされます。
def func2() -> Callable[[T], T]:
a: T # エラー なぜならこのコンテキストでTはバインドされたスコープを持っていないからです
# TはCallableの外で現れるため、func3にバインドされます。
def func3() -> Callable[[T], T] | T:
...
# このスコーピングのロジックは、戻り値の型注釈内で使用される型エイリアスにも適用されます。Tはfunc4ではなく戻りCallableにバインドされます。
Transform = Callable[[S], S]
def func4() -> Transform[T]:
...
型注釈コメント
Python 3.6以前のバージョンは変数の型注釈をサポートしていませんでした。Pyrightは、変数が割り当てられる同じ行の最後にあるコメント内の型注釈を認識します。
offsets = [] # type: list[int]
self._target = 3 # type: int | str
将来のPythonのバージョンでは、型注釈コメントのサポートが非推奨になる可能性があります。"reportTypeCommentUsage"診断はこのようなコメントの使用を報告し、それらをインライン型注釈に置き換えることができます。