类型缩小(narrowing)

本节专门介绍几种 mypy 支持的类型缩小技巧。

类型缩小是指您说服类型检查器相信一个更广泛的类型实际上是更具体的类型,例如,类型为 Shape 的对象实际上是更窄的类型 Square

类型缩小表达式(expressions)

缩小类型的最简单方法是使用支持的表达式之一:

  • isinstance(),例如 isinstance(obj, float) 将把 obj 缩小为 float 类型。

  • issubclass(),例如 issubclass(cls, MyClass) 将把 cls 缩小为 Type[MyClass]

  • type,例如 type(obj) is int 将把 obj 缩小为 int 类型。

  • callable(),例如 callable(obj) 将把对象缩小为可调用类型。

  • obj is not None 将对象缩小为其 非可选形式

类型缩小是上下文相关的。例如,基于条件,mypy 只会在 if 分支中缩小表达式的类型:

def function(arg: object):
    if isinstance(arg, int):
        # 类型仅在 ``if`` 分支中缩小
        reveal_type(arg)  # 显示的类型: "builtins.int"
    elif isinstance(arg, str) or isinstance(arg, bool):
        # 在此 ``elif`` 分支中,类型以不同方式缩小:
        reveal_type(arg)  # 显示的类型: "builtins.str | builtins.bool"

        # 后续的缩小操作将进一步缩小类型
        if isinstance(arg, bool):
            reveal_type(arg)  # 显示的类型: "builtins.bool"

    # 回到 ``if`` 语句外,类型没有缩小:
    reveal_type(arg)  # 显示的类型: "builtins.object"

Mypy 理解 return 或抛出异常可能对对象的类型产生的影响:

def function(arg: int | str):
    if isinstance(arg, int):
        return

    # 此时 `arg` 不能是 `int`:
    reveal_type(arg)  # 显示的类型: "builtins.str"

我们还可以使用 assert 在相同上下文中缩小类型:

def function(arg: Any):
    assert isinstance(arg, int)
    reveal_type(arg)  # 显示的类型: "builtins.int"

备注

使用 --warn-unreachable 时,将类型缩小到某个不可能的状态将被视为错误。

def function(arg: int):
    # 错误:无法存在 "int" 和 "str" 的子类:
    # 方法签名不兼容
    assert isinstance(arg, str)

    # 错误:语句不可达
    print("所以 mypy 认为 assert 将始终触发")

在没有 --warn-unreachable 的情况下,mypy 只会不检查它认为不可达的代码。有关更多信息,请参见 不可达代码(Unreachable code)

x: int = 1
assert isinstance(x, str)
reveal_type(x)  # 显示的类型是 "builtins.int"
print(x + '!')  # `mypy` 类型检查通过,但在运行时失败。

issubclass

Mypy 还可以使用 issubclass() 来改善与类型和 metaclass 一起工作时的类型推断:

class MyCalcMeta(type):
    @classmethod
    def calc(cls) -> int:
        ...

def f(o: object) -> None:
    t = type(o)  # 我们必须在这里使用一个变量
    reveal_type(t)  # 显示的类型是 "builtins.type"

    if issubclass(t, MyCalcMeta):  # `issubclass(type(o), MyCalcMeta)` 不会工作
        reveal_type(t)  # 显示的类型是 "Type[MyCalcMeta]"
        t.calc()  # 可以

callable

Mypy 知道在类型检查期间哪些类型是可调用的,哪些类型不是。因此,我们知道 callable() 将返回什么。例如:

from collections.abc import Callable

x: Callable[[], int]

if callable(x):
    reveal_type(x)  # N: 显示的类型是 "def () -> builtins.int"
else:
    ...  # 将永远不会执行,并且在使用 `--warn-unreachable` 时会引发错误

callable 函数甚至可以将联合类型分成可调用和不可调用的部分:

from collections.abc import Callable

x: int | Callable[[], int]

if callable(x):
    reveal_type(x)  # N: 显示的类型是 "def () -> builtins.int"
else:
    reveal_type(x)  # N: 显示的类型是 "builtins.int"

Casts

Mypy 支持类型转换,通常用于将静态类型值强制转换为子类型。然而,与 Java 或 C# 等语言不同,mypy 的转换仅用于为类型检查器提供提示,并不会执行运行时类型检查。使用函数 typing.cast() 来执行类型转换:

from typing import cast

o: object = [1]
x = cast(list[int], o)  # OK
y = cast(list[str], o)  # OK (cast 不执行实际的运行时检查)

要支持像上面那样的运行时检查,我们需要检查列表中所有项的类型,对于大型列表来说,这将非常低效。类型转换用于消除虚假的类型检查器警告,并在类型检查器无法完全理解发生了什么时提供一点帮助。

备注

如果您想执行实际的运行时检查,可以使用断言:

def foo(o: object) -> None:
    print(o + 5)  # 错误:无法将 'object' 与 'int' 相加
    assert isinstance(o, int)
    print(o + 5)  # OK:此时 'o' 的类型是 'int'

对于类型为 Any 的表达式或将值分配给类型为 Any 的变量,您不需要进行类型转换,如前所述。您还可以将 Any 用作转换目标类型——这使您能够对结果执行任何操作。例如:

from typing import cast, Any

x = 1
x.whatever()  # 类型检查错误
y = cast(Any, x)
y.whatever()  # 类型检查 OK(运行时错误)

User-Defined Type Guards

Mypy supports User-Defined Type Guards (PEP 647).

A type guard is a way for programs to influence conditional type narrowing employed by a type checker based on runtime checks.

Basically, a TypeGuard is a "smart" alias for a bool type. Let's have a look at the regular bool example:

def is_str_list(val: list[object]) -> bool:
  """Determines whether all objects in the list are strings"""
  return all(isinstance(x, str) for x in val)

def func1(val: list[object]) -> None:
    if is_str_list(val):
        reveal_type(val)  # Reveals list[object]
        print(" ".join(val)) # Error: incompatible type

The same example with TypeGuard:

from typing import TypeGuard  # use `typing_extensions` for Python 3.9 and below

def is_str_list(val: list[object]) -> TypeGuard[list[str]]:
    """Determines whether all objects in the list are strings"""
    return all(isinstance(x, str) for x in val)

def func1(val: list[object]) -> None:
    if is_str_list(val):
        reveal_type(val)  # list[str]
        print(" ".join(val)) # ok

How does it work? TypeGuard narrows the first function argument (val) to the type specified as the first type parameter (list[str]).

用户定义的类型保护(Type Guards)

Mypy 支持用户定义的类型保护 (PEP 647).

类型保护是一种方法,允许程序根据运行时检查影响类型检查器采用的条件类型缩小。

基本上, TypeGuard 是对 bool 类型的一个“智能”别名。我们先来看一个常规的 bool 示例:

def is_str_list(val: list[object]) -> bool:
    """确定列表中的所有对象是否都是字符串"""
    return all(isinstance(x, str) for x in val)

def func1(val: list[object]) -> None:
    if is_str_list(val):
        reveal_type(val)  # 显示类型为 list[object]
        print(" ".join(val))  # 错误:类型不兼容

使用 TypeGuard 的相同示例:

from typing import TypeGuard  # 对于 Python 3.9 及以下版本使用 `typing_extensions`

def is_str_list(val: list[object]) -> TypeGuard[list[str]]:
    """确定列表中的所有对象是否都是字符串"""
    return all(isinstance(x, str) for x in val)

def func1(val: list[object]) -> None:
    if is_str_list(val):
        reveal_type(val)  # list[str]
        print(" ".join(val))  # 正确

这是如何工作的? TypeGuard 将第一个函数参数( val )缩小到作为第一个类型参数指定的类型( list[str] )。

备注

类型缩小 并不严格 . 例如,你可以将 str 缩小为 int:

def f(value: str) -> TypeGuard[int]:
    return True

注意:由于没有强制执行严格的缩小,因此很容易破坏类型安全。

然而,决心或缺乏信息的开发者有许多方法可以颠覆类型安全,最常见的方式是使用 castAny 。 如果一个 Python 开发者花时间学习并在其代码中实现用户定义的类型保护,那么可以安全地假设他们对类型安全感兴趣,并且不会以破坏类型安全或产生无意义结果的方式编写他们的类型保护函数。

泛型 TypeGuards

TypeGuard 还可以与泛型类型一起使用(Python 3.12 语法):

from typing import TypeGuard  # use `typing_extensions` for `python<3.10`

def is_two_element_tuple[T](val: tuple[T, ...]) -> TypeGuard[tuple[T, T]]:
    return len(val) == 2

def func(names: tuple[str, ...]):
    if is_two_element_tuple(names):
        reveal_type(names)  # tuple[str, str]
    else:
        reveal_type(names)  # tuple[str, ...]

参数 TypeGuards

类型保护(Type guard)函数可以接受额外的参数(Python 3.12 语法):

from typing import TypeGuard  # 对于 `python<3.10` 使用 `typing_extensions`

def is_set_of[T](val: set[Any], type: type[T]) -> TypeGuard[set[T]]:
    return all(isinstance(x, type) for x in val)

items: set[Any]
if is_set_of(items, str):
    reveal_type(items)  # set[str]

方法 TypeGuards

方法也可以作为 TypeGuard:

class StrValidator:
    def is_valid(self, instance: object) -> TypeGuard[str]:
        return isinstance(instance, str)

def func(to_validate: object) -> None:
    if StrValidator().is_valid(to_validate):
        reveal_type(to_validate)  # Revealed type is "builtins.str"

备注

请注意, TypeGuard 不会缩小 selfcls 隐式参数的类型。如果需要缩小 selfcls 的类型,可以将值作为显式参数传递给类型保护函数:

class Parent:
    def method(self) -> None:
        reveal_type(self)  # Revealed type is "Parent"
        if is_child(self):
            reveal_type(self)  # Revealed type is "Child"

class Child(Parent):
    ...

def is_child(instance: Parent) -> TypeGuard[Child]:
    return isinstance(instance, Child)

赋值表达式 TypeGuards

有时你可能需要创建一个新变量,并同时将其缩小到某个特定类型。这可以通过将 TypeGuard:= operator 运算符结合使用来实现。

from typing import TypeGuard  # 使用 `typing_extensions` 适用于 `python<3.10`

def is_float(a: object) -> TypeGuard[float]:
    return isinstance(a, float)

def main(a: object) -> None:
    if is_float(x := a):
        reveal_type(x)  # N: Revealed type is 'builtins.float'
        reveal_type(a)  # N: Revealed type is 'builtins.object'
    reveal_type(x)  # N: Revealed type is 'builtins.object'
    reveal_type(a)  # N: Revealed type is 'builtins.object'

这里发生了什么?

  1. 我们创建了一个新变量 x ,并将 a 的值赋给它。

  2. 我们在 x 上运行 is_float() 类型保护。

  3. 它在 if 上下文中将 x 缩小为 float ,而不影响 a

备注

使用 isinstance(x := a, float) 也可以实现相同的效果。

局限性(Limitations)

Mypy 的分析局限于单个符号,无法跟踪符号之间的关系。例如,在以下代码中,很容易推断出如果 a 为 None,则 b 不能为 None,因此 a or b 将始终是 C 的实例,但 Mypy 无法识别这一点:

class C:
    pass

def f(a: C | None, b: C | None) -> C:
    if a is not None or b is not None:
        return a or b  # 返回值类型不兼容(获得 "C | None",预期 "C")
    return C()

在类型检查器中跟踪这种跨变量条件会增加显著的复杂性和性能开销。

你可以使用 assert 来说服类型检查器,使用类型转换来覆盖它,或者稍微详细地重写函数:

def f(a: C | None, b: C | None) -> C:
    if a is not None:
        return a
    elif b is not None:
        return b
    return C()

TypeIs

Mypy 支持 TypeIs (PEP 742).

TypeIs 窄化函数 允许你定义自定义类型检查, 这可以在条件的 if 和 else 分支中缩小变量的类型,类似于内置的 isinstance() 函数的工作方式。

TypeIs 是 Python 3.13 中的新特性;对于旧版 Python,可以使用来自 typing_extensions 的回溯。

考虑以下使用 TypeIs 的示例:

from typing import TypeIs

def is_str(x: object) -> TypeIs[str]:
    return isinstance(x, str)

def process(x: int | str) -> None:
    if is_str(x):
        reveal_type(x)  # Revealed type is 'str'
        print(x.upper())  # Valid: x is str
    else:
        reveal_type(x)  # Revealed type is 'int'
        print(x + 1)  # Valid: x is int

在这个示例中,函数 is_str 是一个类型缩小函数,返回 TypeIs[str]。在 if 语句中使用时,xif 分支中被缩小为 str,在 else 分支中被缩小为 int

关键点:

  • 函数必须接受至少一个位置参数。

  • 返回类型被注解为 TypeIs[T],其中 T 是你希望缩小的类型。

  • 函数必须返回一个 bool 值。

  • if 分支(当函数返回 True )中,参数的类型被缩小到其原始类型和 T 的交集。

  • else 分支(当函数返回 False )中,参数的类型被缩小到其原始类型和 T 的补集的交集。

TypeIs vs TypeGuard

虽然 TypeIsTypeGuard 都允许你定义自定义类型缩小函数,但它们在重要方面存在差异:

  • 类型缩小行为: TypeIsifelse 分支中都缩小类型,而 TypeGuard 仅在 if 分支中缩小类型。

  • 兼容性要求: TypeIs 要求缩小后的类型 T 与函数的输入类型兼容。而 TypeGuard 没有这个限制。

  • 类型推断: 使用 TypeIs ,类型检查器可能通过将现有类型信息与 T 结合,推断出更精确的类型。

以下是一个演示 TypeGuard 行为的示例:

from typing import TypeGuard, reveal_type

def is_str(x: object) -> TypeGuard[str]:
    return isinstance(x, str)

def process(x: int | str) -> None:
    if is_str(x):
        reveal_type(x)  # Revealed type is "builtins.str"
        print(x.upper())  # ok: x is str
    else:
        reveal_type(x)  # Revealed type is "Union[builtins.int, builtins.str]"
        print(x + 1)  # ERROR: Unsupported operand types for + ("str" and "int")  [operator]

泛型 TypeIs

TypeIs 函数同样可以和泛型一起使用:

from typing import TypeVar, TypeIs

T = TypeVar('T')

def is_two_element_tuple(val: tuple[T, ...]) -> TypeIs[tuple[T, T]]:
    return len(val) == 2

def process(names: tuple[str, ...]) -> None:
    if is_two_element_tuple(names):
        reveal_type(names)  # Revealed type is 'tuple[str, str]'
    else:
        reveal_type(names)  # Revealed type is 'tuple[str, ...]'

TypeIs 带附加参数

TypeIs 函数可以接受除了第一个参数之外的附加参数。类型缩小仅适用于第一个参数。

from typing import Any, TypeVar, reveal_type, TypeIs

T = TypeVar('T')

def is_instance_of(val: Any, typ: type[T]) -> TypeIs[T]:
    return isinstance(val, typ)

def process(x: Any) -> None:
    if is_instance_of(x, int):
        reveal_type(x)  # 显示类型为 'int'
        print(x + 1)  # 正确
    else:
        reveal_type(x)  # 显示类型为 'Any'

方法中的 TypeIs

方法也可以作为 TypeIs 函数。请注意,在实例方法或类方法中,类型缩小适用于第二个参数(在 selfcls 之后)。

class Validator:
    def is_valid(self, instance: object) -> TypeIs[str]:
        return isinstance(instance, str)

    def process(self, to_validate: object) -> None:
        if Validator().is_valid(to_validate):
            reveal_type(to_validate)  # 显示类型为 'str'
            print(to_validate.upper())  # 正确: to_validate 是 str

赋值表达式中的 TypeIs

您可以将赋值表达式运算符 :=TypeIs 一起使用,以同时创建新变量并缩小其类型。

from typing import TypeIs, reveal_type

def is_float(x: object) -> TypeIs[float]:
    return isinstance(x, float)

def main(a: object) -> None:
    if is_float(x := a):
        reveal_type(x)  # 显示类型为 'float'
        # x 在这个块中被缩小为 float
        print(x + 1.0)