型推論についての理解

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

シンボルとスコープ

Pythonにおいて、「シンボル」とはキーワードではないすべての名前のことを指します。シンボルには、クラス、関数、メソッド、変数、パラメータ、モジュール、型エイリアス、型変数などが含まれます。

シンボルは「スコープ」と呼ばれる範囲内で定義されます。スコープとはコードブロックに関連付けられ、そのコードブロック内でどのシンボルが利用可能かを定義するものです。スコープは「ネスト」され、コードは直接的なスコープとすべての「外部」スコープ内のシンボルを参照できます。

Pythonにおけるスコープの定義は以下の構造に基づいています:

  1. 「ビルトイン」スコープは常に存在し、常に最外層のスコープです。これはPythonインタープリターによって「int」や「list」などのシンボルで事前に満たされています。
  2. モジュールスコープ(時々「グローバル」スコープとも呼ばれます)は、現在のソースコードファイルによって定義されます。
  3. 各クラスは独自のスコープを持ち、メソッド、クラス変数、またはインスタンス変数を表すシンボルはクラススコープ内に現れます。
  4. 各関数およびラムダはそれぞれ独自のスコープを持ちます。関数のパラメータや関数内で定義された変数もスコープ内のシンボルになります。
  5. リスト内包表記も独自のスコープを持ちます。

型宣言

シンボルは明示的な型を指定して宣言可能です。「def」と「class」というキーワードは、それぞれ関数やクラスとしてシンボルを宣言するために使用されます。Pythonの他のシンボルは、特定の型を宣言せずにスコープに導入されることがあります。Pythonの最新バージョンでは、入力パラメータ、返り値、および変数の型を指定する新しい構文が導入されています。

パラメータや変数に型注釈を付けると、型チェッカーがそのパラメータや変数に割り当てられる値が指定された型に適合しているかを検証します。

次の例を見てみましょう:

def func1(p1: float, p2: str, p3, **p4) -> None:
    var1: int = p1    # これは型違反です
    var2: str = p2    # 型が一致しているため許可されます
    var2: int         # var2の再宣言であり、エラーです
    var3 = p1         # var3には宣言された型がありません
    return var1       # これも型違反です
シンボル シンボルカテゴリ スコープ 宣言された型
func1 関数 モジュール (float, str, Any, dict[str, Any]) -> None
p1 パラメータ func1 float
p2 パラメータ func1 str
p3 パラメータ func1
p4 パラメータ func1
var1 変数 func1 int
var2 変数 func1 str
var3 変数 func1

一度型が宣言されたシンボルを異なる型で再宣言することは許されません。

型推論

一部の言語では、すべてのシンボルが明確に型を宣言する必要がありますが、Pythonではシンボルは実行中にさまざまな値にバインドされる可能性があるため、その型は変わりうるものです。したがって、Pythonでシンボルの型を静的に宣言する必要はありません。

型宣言がないシンボルを検出すると、Pyrightはそのシンボルに割り当てられた値から型を推論しようとします。しかし、これから示す例のように、型推論が常に正確に(意図した通りの)型を割り出せるわけではなく、時には明示的な型注釈が求められます。また、型推論は計算量が多く、型注釈が与えられた場合の処理速度よりも遅くなることがあります。

「Unknown」の型

シンボルの型を推論できない場合、Pyrightはその型を「Unknown」として扱います。「Unknown」というのは「Any」の特殊な形態であり、型が宣言されておらず、推論も困難な場合に警告を発するオプションを提供します。これにより、型チェックの際に見落とされがちな「ブラインドスポット」が残ることになります。

単一割り当て型推論

型推論のもっとも基本的な形式は、シンボルに対して一度だけ値が割り当てられる場合です。このとき、推論される型はその値が持つ型に由来します。以下の例を参照してください:

var1 = 3                        # 推論された型はintです
var2 = "hi"                     # 推論された型はstrです
var3 = list()                   # 推論された型はlist[Unknown]です
var4 = [3, 4]                   # 推論された型はlist[int]です
for var5 in [3, 4]: ...         # 推論された型はintです
var6 = [p for p in [1, 2, 3]]   # 推論された型はlist[int]です

複数割り当て型推論

シンボルに対して異なる場所で複数回の割り当てが行われる場合、割り当てられた値は異なる型を持つことがあります。この場合、変数の推論された型は、それらの型の総和となります。

# この例では、シンボルvar1の推論された型は `str | int` です。
class Foo:
    def __init__(self):
        self.var1 = ""

    def do_something(self, val: int):
        self.var1 = val

# この例では、シンボルvar2の推論された型は `Foo | None` です。
if __debug__:
    var2 = None
else:
    var2 = Foo()

曖昧な型推論

場合によっては、特定の式の型が曖昧になることがあります。例えば、空のリスト [] の型は何になるでしょうか?list[None]list[int]list[Any]Sequence[Any]Iterable[Any] のどれでしょうか?このような曖昧性は、意図しない型違反を引き起こす可能性があります。Pyrightは、文脈情報を活用してこのような曖昧さを解消する技術を用います。文脈が不明確な場合、ヒューリスティックが適用されます。

双方向型推論(期待される型)

Pyrightが型推論の曖昧さを排除するために使用する強力な手法の一つが双方向推論です。この技術は「期待される型」を基に機能します。

例として、空のリスト [] の型は一見曖昧ですが、このリストが関数に引数として渡される場面で、該当するパラメータが list[int] と注釈されている場合、Pyrightはこの文脈に基づき [] の型を list[int] と推論することができます。これにより、曖昧性が解消されるのです。

この手法を「双方向推論」と称します。通常の型推論では、割り当ての右辺(RHS)の型が先に評価され、それが割り当ての左辺(LHS)の型を導くのに対して、双方向推論では、LHSに明示された型がRHSの推論される型に影響を及ぼすことがあります。

いくつかの例を以下に示します:

var1 = []                       # RHSの型は曖昧です
var2: list[int] = []            # LHSの型がRHSの型を明確にします
var3 = [4]                      # 型はlist[int]と推定されます
var4: list[float] = [4]         # RHSの型はlist[float]となります
var5 = (3,)                     # 型はtuple[Literal[3]]と推定されます
var6: tuple[float, ...] = (3,)  # RHSの型はtuple[float, ...]となります

空のリストと辞書の型推論

ローカル変数やインスタンス変数を空のリスト([])や空の辞書({})で初期化することはよくありますが、他のコードパスでこれらを非空のリストや辞書で初期化することもあります。このような場合、Pyrightは非空のリストや辞書を基に型を推論し、「部分的にUnknownの型」に関するエラーを抑制します。

if some_condition:
    my_list = []
else:
    my_list = ["a", "b"]

reveal_type(my_list) # list[str]

戻り値の型推論

変数の割り当てと同じく、関数の戻り値の型も、関数内にある return 文から推論されることが一般的です。戻り値は、すべての return 文から返される型の和集合と見なされます。return 文がない場合は、None を返すと仮定されます。また、関数が return 文なしで終わり、関数の終端に到達可能な場合は、暗黙のうちに return None と見なされます。

# この関数には2つの明示的なreturn文と1つの暗黙的なreturn(最後に)があります。
# 宣言された戻り値の型がないため、Pyrightはreturn式に基づいて戻り値の型を推論します。
# この場合、推論される戻り値の型は `str | bool | None` です。

def func1(val: int):
    if val > 3:
        return ""
    elif val < 1:
        return True

NoReturn 戻り値の型

関数から戻るコードパスがない場合、例えばすべての実行パスで例外が発生するような場合、Pyrightは戻り値の型を NoReturn と推論します。ただし、@abstractmethod で装飾されている関数の場合、戻り値がなくても戻り値の型を NoReturn とは推論しません。これは、抽象メソッドが NotImplementedError などの例外を発生させる raise 文でよく実装されるためです。

class Foo:
    # 推論された戻り値の型は NoReturn です。
    def method1(self):
        raise Exception()

    # 推論された戻り値の型は Unknown です。
    @abstractmethod
    def method2(self):
        raise NotImplementedError()

ジェネレータの戻り値の型

ジェネレータ関数において、Pyrightは関数内で使用される yield 文からその戻り値の型を推論する能力を持っています。

呼び出し元の戻り値型推論

入力パラメータに型注釈がない場合が多く、その結果、Pyrightが関数の正確な戻り値の型を推論することが難しくなることがあります。例えば:

# この関数の戻り値の型は、パラメータ a と b の型が未注釈であるため、
# 利用可能な情報からは完全には推論できません。
# この場合、推論された戻り値の型は `Unknown | None` です。

def func1(a, b, c):
    if c:
        return a
    elif c > 3:
        return b
    else:
        return None

全てのパラメータに型注釈がない場合、Pyrightは「呼び出し元の戻り値型推論」という手法を用います。この手法では、関数への呼び出し時に提供された引数の型を基に型推論を行います。型注釈がない関数が別の関数を呼び出す際には、この呼び出し元の戻り値型推論を再帰的に適用することが可能ですが、実用的なパフォーマンスの観点からこの再帰の回数は制限されています。

def func2(p_int: int, p_str: str, p_flt: float):
    # var1 の型は呼び出し元の戻り値型推論に基づいて `int | None` と推論されます。
    var1 = func1(p_int, p_int, p_int)

    # var2 の型は `str | float | None` と推論されます。
    var2 = func1(p_str, p_flt, p_int)

パラメータ型の推論

通常、関数やメソッドの入力パラメータには型注釈が必要ですが、Pyrightは注釈されていないパラメータの型を特定の状況下で推論することが可能です。

インスタンスメソッドにおいて、最初のパラメータ(通常 self と命名される)は Self 型と推論されます。

クラスメソッドにおいて、最初のパラメータ(通常 cls と命名される)は type[Self] 型と推論されます。

メソッド内のその他の注釈されていないパラメータについて、Pyrightは基底クラスの同名メソッドを探索し、そこでパラメータが注釈されていれば、その型情報を子クラスの同名パラメータに「継承」します。これは、基底クラスのメソッドが同じ署名(同数のパラメータと同じ名前)である場合に限られ、オーバーロードがない場合に適用されます。

class Parent:
    def method1(self, a: int, b: str) -> float:
        ...

class Child(Parent):
    def method1(self, a, b):
        return a

reveal_type(Child.method1)  # (self: Child, a: int, b: str) -> int

基底クラスからの型情報を継承しても、戻り値の型は継承されません。この場合、通常の戻り値型推論技術が適用されます。

デフォルト引数を持つ注釈されていないパラメータの場合、そのパラメータ型はデフォルト値の型から推論されます。デフォルト値が None の場合、推論される型は Unknown | None です。

def func(a, b=0, c=None):
    pass

reveal_type(func)  # (a: Unknown, b: int, c: Unknown | None) -> None

この推論手法は、デフォルト引数を持つ入力パラメータを伴うラムダにも適用されます。

cb = lambda x = "": x
reveal_type(cb)  # (x: str) -> str

リテラル

Python 3.8から、リテラル型 のサポートが導入されました。これにより、Pyrightのような型チェッカーは特定のリテラル値(str、bytes、int、bool、enum値)を追跡できるようになりました。他の型同様、リテラル型も宣言することが可能です。

# この関数は1、2、または3のいずれかの値を返すことが許可されています。
def func1() -> Literal[1, 2, 3]:
    ...

# この関数には特定の3つの文字列値「r」、「w」、または「rw」のいずれかが指定される必要があります。
def func2(mode: Literal["r", "w", "rw"]) -> None:
    ...

Pyrightが型推論を行う場合、通常リテラル型を自動的に推論することはありません。以下の例を見てみましょう:

# もしPyrightがvar1の型をlist[Literal[4]]と推論した場合、
# このリストには4以外の値を追加する際にエラーが発生します。
# そのため、Pyrightはより一般的な型であるlist[int]を推論することになります。
var1 = [4]

タプル式

双方向推論のヒントがなければ、タプル式の型を推論する際に、Pyrightはタプルが固定長であると見なし、各要素が可能な限り具体的な型を持つと仮定します。

# 推論された型は tuple[Literal[1], Literal["a"], Literal[True]]
var1 = (1, "a", True)

def func1(a: int):
    # 推論された型は tuple[int, int]
    var2 = (a, a)

    # もし型を tuple[int, ...] (すなわち、不定長の均一なタプル)にしたい場合、
    # 型注釈を使用する必要があります。
    var3: tuple[int, ...] = (a, a)

リスト式

双方向推論のヒントがない場合にリスト式の型を推論するとき、Pyrightは次のようなヒューリスティックを使用します:

  1. リストが空([])である場合、list[Unknown] と仮定します(他のコードパスでその変数に既知のリスト型が割り当てられていない限り)。
  2. リストに少なくとも一つの要素が含まれ、すべての要素が同じ型Tである場合、型 list[T] を推論します。
  3. リストに複数の異なる型の要素が含まれている場合、挙動は strictListInference 設定によって異なります。デフォルトではこの設定はオフに設定されています。
    • strictListInference がオフの場合、list[Unknown] を推論します。
    • オンの場合は、すべての要素の型の和集合を用いて、list[Union[(elements)]] を推論します。

これらのヒューリスティックは、双方向推論のヒントを使用してオーバーライドすることが可能です(例えば、割り当て式のターゲットに宣言された型を通じて)。

var1 = []                       # 推論される型は list[Unknown]

var2 = [1, 2]                   # 推論される型は list[int]

# strictListInference 設定に依存する型
var3 = [1, 3.4]                 # 推論される型は list[Unknown] (off)
var3 = [1, 3.4]                 # 推論される型は list[int | float] (on)

var4: list[float] = [1, 3.4]    # 推論される型は list[float]

セット式

双方向推論のヒントがない場合、セット式の型を推論する際にPyrightは以下のヒューリスティックを採用します:

  1. セットに少なくとも一つの要素が含まれ、すべての要素が同じ型Tである場合、型 set[T] を推論します。
  2. セットに複数の異なる型の要素が含まれる場合、挙動は strictSetInference 設定により異なります。デフォルトではこの設定はオフにされています。
    • strictSetInference がオフの場合、set[Unknown] を推論します。
    • それ以外の場合は、すべての要素の型の和集合を使用して、set[Union[(elements)]] を推論します。

これらのヒューリスティックは、双方向推論のヒントの使用によってオーバーライドすることができます(例えば、割り当て式のターゲットに宣言された型を通じて)。

var1 = {1, 2}                   # 推論される型は set[int]

# strictSetInference 設定に依存する型
var2 = {1, 3.4}                 # 推論される型は set[Unknown] (off)
var2 = {1, 3.4}                 # 推論される型は set[int | float] (on)

var3: set[float] = {1, 3.4}    # 推論される型は set[float]

辞書式

双方向推論のヒントがない場合に辞書式の型を推論する際、Pyrightは以下のヒューリスティックを使用します:

  1. 辞書が空({})の場合、dict[Unknown, Unknown] と仮定します。
  2. 辞書に少なくとも一つの要素が含まれ、すべてのキーが同じ型Kで、すべての値が同じ型Vである場合、型 dict[K, V] を推論します。
  3. キーまたは値の型が異なる複数の要素を含む辞書の場合、挙動は strictDictionaryInference 設定に依存します。デフォルトではこの設定はオフです。
    • strictDictionaryInference がオフの場合、dict[Unknown, Unknown] を推論します。
    • それ以外の場合は、すべてのキーと値の型の和集合を使用し、dict[Union[(keys)], Union[(values)]] を推論します。
var1 = {}                       # 推論される型は dict[Unknown, Unknown]

var2 = {1: ""}                  # 推論される型は dict[int, str]

# strictDictionaryInference 設定に依存する型
var3 = {"a": 3, "b": 3.4}       # 推論される型は dict[str, Unknown] (off)
var3 = {"a": 3, "b": 3.4}       # 推論される型は dict[str, int | float] (on)

var4: dict[str, float] = {"a": 3, "b": 3.4}

ラムダ

ラムダ式は、Pythonでは入力パラメータに型注釈を直接付ける構文がないため、型チェッカーにとって特に挑戦的です。これらのパラメータの型は、文脈に基づいて双方向型推論を使用して推論される必要があります。文脈が提供されていない場合、ラムダの入力パラメータ(そして多くの場合、戻り値の型も)はUnknownとなります。

# var1の型は (a: Unknown, b: Unknown) -> Unknown です。
var1 = lambda a, b: a + b

# この関数は比較関数のコールバックを受け取ります。
def float_sort(list: list[float], comp: Callable[[float, float], bool]): ...

# この例では、ラムダの入力パラメータ a と b の型は、float_sort
# 関数が二つのfloatを入力として受け入れるコールバックを期待しているため、
# floatと推論されることができます。
float_sort([2, 1.3], lambda a, b: False if a < b else True)
このようなケースでは、ラムダを使用する関数やメソッドがどのような型のパラメータを期待しているかに依存することになり、その期待に基づいて型が推論されます。この推論メカニズムは、ラムダ式を使いやすくする一方で、文脈の不足が型の不明確さをもたらす原因にもなります。
info-outline

お知らせ

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