Pyrightの静的型付け:上級者向け解説

 
0
このエントリーをはてなブックマークに追加
Daichi Takayama
Daichi Takayama (高山 大地)

型の特定

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)

この関数が始まるとき、型チェッカーはvalfloat | str | complexの型であることしか知りません。その後、既に型がintであることが分かっている値がvalに割り当てられます。 intfloatのサブクラスとして扱われるため、この割り当ては有効です。この割り当てが行われた直後、型チェッカーは valの型を intと認識します。これは float | str | complexよりも「狭い」(より具体的な)型とされます。type narrowingは、シンボルに新しい値が割り当てられるたびに行われます。

その後、さらにいくつかのコード行が進んだところで、条件分岐内で別の値がvalに割り当てられます。この時点でvalに割り当てられる値は型が strであることが明らかなため、 valの型は strに特定されます。条件分岐の後、関数の本体にコードが戻ると、型チェッカーが実行時にどの条件ブロックが実行されるかを静的に判断できないため、valの特定された型は int | strに戻ります。

型を特定する別の手法には、 ifwhileassertなどの条件付きコードフロー文を使用する方法があります。これらの条件に基づいて適用されるコードブロックでは、条件に「保護された」型の特定が行われることから、これを「型ガード」とも呼ぶことがあります。たとえば、 if x is None: という条件文を見ると、このif文の範囲内では xNoneであると仮定することができます。示されたコードサンプルで、 isinstanceを用いた型ガードの例が挙げられています。型チェッカーは、 isinstance(val, int)がTrueを返すのは valint型の値を持っている場合だけであり、 str型ではないことを認識しています。そのため、 ifブロック内では valint型の値を持っていると見なし、 elseブロックでは valstr型の値を持っていると見なすことができます。これにより、型(この場合は 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の例では、肯定的な場合にのみ型の絞り込みが可能でした。これは、否定的な場合にvalfloat(具体的には0.0)またはNoneのどちらかである可能性があるためです。

エイリアス条件式

Pyrightは、ローカル変数への割り当てがある特定の型ガード式もサポートしており、これを「エイリアス条件式」と称します。この場合、「c」は型ガード式の形式のひとつが割り当てられた識別子であり、c = a is not Nonec = isinstance(a, str) などの例が含まれます。この「c」を条件の評価で使用することで、式aの詳細な型の絞り込みが可能です。

この仕組みは、cがモジュールまたは関数内のローカル変数で、一度限りの割り当てがされた場合にのみ適用されます。さらに、式aがシンプルな識別子である場合(メンバーアクセス式や添字式ではない)、その関数またはモジュール内で一度だけ割り当てられた場合に限られます。式aに対する単項のnot演算子の使用は許可されていますが、論理andorの使用は認められていません。

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型は絞りこみができません。このルールの例外は、isinstanceissubclassといった組み込みの型ガード、"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は特定の条件下で型を保持します。内部スコープの関数やラムダが定義された後、キャプチャされた変数が変更されず、さらに**nonlocalglobal**バインディングによる他のスコープでの変更もない場合、特定された型が維持されます。

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型の場合はsumstr型、float型の場合はsumfloat型であることが示されます。このように条件型を追跡することで、型チェッカーは戻り値の型が型変数_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は以下の手順に従ってオーバーロードを選択します。

  1. 最初に、Pyrightは引数の数(アリティ)とキーワード引数の存在を基にオーバーロードの候補を絞り込みます。例えば、あるオーバーロードが2つの引数を必要としている場合、提供される引数が1つだけなら、そのオーバーロードは選択肢から外れます。また、呼び出しにキーワード引数が含まれているのに、オーバーロードがそれに対応するパラメータを持っていない場合も同様です。

  2. 次に、引数の型を検討し、それを各パラメータの定義された型と比較します。型が一致しないオーバーロードは候補から外れます。この過程で、双方向型推論が引数の式の型を決定する際に利用されます。

  3. もし評価の結果、オーバーロードが1つだけ残る場合、そのオーバーロードが選ばれます。

  4. 複数のオーバーロードが残っている場合、通常、宣言された順に最初に残ったオーバーロードが選択されます。しかし、次の2つの例外があります。第一の例外:*argsとしてアンパックされた引数がオーバーロードシグネチャ内の*argsパラメータと一致する場合、これが通常の選択ルールを上書きします。第二の例外:引数がAnyUnknownに評価され、複数のオーバーロードが一致する場合、その一致は曖昧とされます。このような場合、Pyrightは残ったオーバーロードの戻り型を検討し、重複する型や他の型に包含される型を除外します。この過程の結果、1つの型だけが残る場合、その型が採用されます。もし複数の型が残る場合は、呼び出し式の型はUnknownと評価されます。例えば、引数がAnyであるために2つのオーバーロードが一致し、戻り型がstrLiteralStringである場合、PyrightはLiteralStringstrの適切なサブタイプであるため、これを単にstrに統合します。しかし、戻り型がstrbytesである場合、これらは互いに重なりがないため、呼び出し式の型はUnknownとされます。

  5. オーバーロードが一つも残っていない場合、Pyrightは引数がユニオン型であるかどうかを検討します。ユニオン型の場合、その構成要素であるサブタイプに分解され、分解後の引数型を使ってオーバーロードの選択プロセスを再度実行します。このプロセスで2つ以上のオーバーロードがマッチした場合、それらの戻り型のユニオンが最終的な戻り型となります。この「ユニオン拡張」により、多くの引数がユニオン型に評価されると、組み合わせの爆発的な増加が起こり得ます。例えば、4つの引数それぞれが10のサブタイプに展開されるユニオン型である場合、最大10^4の組み合わせが考えられます。Pyrightは引数を左から右へとユニオンを展開していき、展開するシグネチャの数が64を超えると展開を中止します。

  6. オーバーロードが残っておらず、すべてのユニオン型が展開されたにもかかわらず、提供された引数がどのオーバーロードシグネチャとも互換性がない場合、その事実を示す診断メッセージが生成されます。

クラス変数とインスタンス変数

ほとんどのオブジェクト指向プログラミング言語では、クラス変数とインスタンス変数をはっきりと分けて扱いますが、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"診断はこのようなコメントの使用を報告し、それらをインライン型注釈に置き換えることができます。

info-outline

お知らせ

K.DEVは株式会社KDOTにより運営されています。記事の内容や会社でのITに関わる一般的なご相談に専門の社員がお答えしております。ぜひお気軽にご連絡ください。