运行时的注解问题(Annotation issues)

惯用的类型注解使用有时会与特定 Python 版本所认为的合法代码相冲突。本节描述这些场景,并解释如何让代码重新运行。一般来说,我们有三种工具可供使用:

  • 使用 from __future__ import annotations (PEP 563)(这种行为可能在未来的 Python 版本中成为默认)。

  • 使用字符串字面量类型或类型注解。

  • 使用 typing.TYPE_CHECKING

在讨论具体问题之前,我们先介绍这些工具的使用。

字符串字面量类型和类型注解(literal)

Mypy 允许使用已弃用的 # type: 类型注解语法添加类型注解。这在 Python 3.6 之前是必需的,因为早期版本不支持变量的类型注解。例如:

a = 1  # type: int

def f(x):  # type: (int) -> int
    return x + 1

# 函数参数较多时的替代类型注解语法
def send_email(
    address,     # type: Union[str, List[str]]
    sender,      # type: str
    cc,          # type: Optional[List[str]]
    subject='',
    body=None    # type: List[str]
):
# type: (...) -> bool

类型注解不会引发运行时错误,因为注释不会被 Python 解释执行。

类似地,使用字符串字面量类型可以避免导致运行时错误的注解问题。

任何类型都可以作为字符串字面量输入,并且可以随意将字符串字面量类型与非字符串字面量类型组合:

def f(a: list['A']) -> None: ...  # OK,防止 NameError,因为 A 在后面定义
def g(n: 'int') -> None: ...      # 也 OK,虽然没用

字符串字面量类型不需要在 # type: 注释和 存根文件 中使用。

字符串字面量类型必须在同一模块中稍后定义(或导入)。它们不能用于解决跨模块的未解析引用。(有关处理导入循环,请参见 导入循环 的相关内容。)

未来(Futrue)的注解导入(PEP 563)

本文描述的许多问题是由于 Python 尝试对注解进行求值引起的。未来的 Python 版本(可能是 Python 3.14)将默认不再尝试对函数和变量的注解进行求值。这一行为在 Python 3.7 及更高版本中可以通过使用 from __future__ import annotations 实现。

这可以被视为对所有函数和变量注解的自动字符串字面量化。请注意,函数和变量的注解仍然需要是有效的 Python 语法。有关详细信息,请参阅:PEP 563。

备注

即使使用了 __future__ 导入,某些场景下仍可能需要字符串字面量或导致错误,通常涉及使用前向引用或泛型的情况,如:

# 基类示例
from __future__ import annotations

class A(tuple['B', 'C']): ...  # 此处需要字符串字面量类型
class B: ...
class C: ...

警告

某些库可能需要动态求值注解,例如,通过使用 typing.get_type_hintseval 。如果你的注解在求值时会引发错误(例如在 Python 3.9 中使用 PEP 604 语法),在使用此类库时需要小心。

typing.TYPE_CHECKING

typing 模块定义了一个常量 TYPE_CHECKING,它在运行时为 False,但在类型检查时被视为 True

由于 if TYPE_CHECKING: 语句中的代码不会在运行时执行,它提供了一种方便的方法来告诉 mypy 一些信息,而不会在运行时对代码进行求值。这对于解决 导入循环 问题最有用。

类名的前向引用(forward references)

Python 不允许在类未定义之前就引用该类对象(即前向引用)。因此,下面的代码不能按预期工作:

def f(x: A) -> None: ...  # NameError: name "A" is not defined
class A: ...

从 Python 3.7 开始,你可以添加 from __future__ import annotations 来解决这个问题,如下所述:

from __future__ import annotations

def f(x: A) -> None: ...  # OK
class A: ...

对于 Python 3.6 及以下版本,你可以将类型作为字符串字面量或类型注解输入:

def f(x: 'A') -> None: ...  # OK

# 也可以
def g(x):  # type: (A) -> None
    ...

class A: ...

当然,除了使用 future annotations 导入或字符串字面量类型外,你也可以将函数定义移到类定义之后。不过,这并不总是理想或可行的。

导入循环(Import cycles)

当模块 A 导入模块 B,而模块 B 又导入模块 A 时(可能是间接的,例如:A -> B -> C -> A ),就会发生导入循环。有时为了添加类型注解,你需要在模块中添加额外的导入,而这些导入可能会导致之前不存在的循环。这可能会在运行时引发以下错误:

ImportError: cannot import name 'b' from partially initialized module 'A' (most likely due to a circular import)

如果这些循环在运行程序时成为问题,可以使用一个技巧:如果导入仅用于类型注解,并且你使用了 a) future annotations import 或 b) 用字符串字面量或类型注解来表示相关注解,你可以将导入放在 if TYPE_CHECKING: 块中,这样它们在运行时不会被执行。例如:

文件 foo.py:

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import bar

def listify(arg: 'bar.BarClass') -> 'list[bar.BarClass]':
    return [arg]

文件 bar.py:

from foo import listify

class BarClass:
    def listifyme(self) -> 'list[BarClass]':
        return listify(self)

在存根中是泛型但运行时不是的类

有些类在类型存根文件中被声明为 泛型,但在运行时并不是泛型类。

在 Python 3.8 及更早的版本中,标准库中有几个例子,例如:os.PathLikequeue.Queue。对这些类进行下标操作会导致运行时错误:

from queue import Queue

class Tasks(Queue[str]):  # TypeError: 'type' object is not subscriptable
    ...

results: Queue[int] = Queue()  # TypeError: 'type' object is not subscriptable

为避免在注解中使用这些泛型时产生错误,只需使用 future annotations import (对于 Python 3.6 及以下版本可以使用字符串字面量或类型注解)。

当从这些类继承时,要避免错误,情况稍微复杂些,需要使用 typing.TYPE_CHECKING

from typing import TYPE_CHECKING
from queue import Queue

if TYPE_CHECKING:
    BaseQueue = Queue[str]  # 仅由 mypy 处理
else:
    BaseQueue = Queue  # mypy 不会看到,但在运行时执行

class Tasks(BaseQueue):  # OK
    ...

task_queue: Tasks
reveal_type(task_queue.get())  # 显示为 str

如果你的子类也是泛型类,可以使用以下方法(使用泛型类的旧语法):

from typing import TYPE_CHECKING, TypeVar, Generic
from queue import Queue

_T = TypeVar("_T")
if TYPE_CHECKING:
    class _MyQueueBase(Queue[_T]): pass
else:
    class _MyQueueBase(Generic[_T], Queue): pass

class MyQueue(_MyQueueBase[_T]): pass

task_queue: MyQueue[str]
reveal_type(task_queue.get())  # 显示为 str

在 Python 3.9 及更高版本中,我们可以直接继承 Queue[str]Queue[T],因为 queue.Queue 实现了 __class_getitem__(),因此类对象在运行时可以被下标操作。不过,如果你继承了某些第三方库中定义的泛型类,且这些类的泛型类型仅在存根中声明,那么即使你使用的是新版 Python,仍可能遇到问题。

使用在存根中定义但运行时不存在的类型

有时你可能使用的类型存根文件定义了一些你希望复用的类型,但这些类型在运行时并不存在。如果直接导入这些类型,代码在运行时会因为 ImportErrorModuleNotFoundError 而失败。与之前的章节类似,你可以通过使用 typing.TYPE_CHECKING 来解决这些问题:

from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from _typeshed import SupportsRichComparison

def f(x: SupportsRichComparison) -> None: ...

这里的 from __future__ import annotations 是必须的,避免在使用导入的符号时引发 NameError。有关更多信息和注意事项,请参见 future annotations 部分。

使用泛型内置类型

从 Python 3.9 开始(PEP 585),标准库中许多集合类型的类型对象支持在运行时进行下标操作。这意味着你不再需要从 typing 模块中导入对应的类型;可以直接使用内置集合或来自 collections.abc 的类型:

from collections.abc import Sequence
x: list[str]
y: dict[int, str]
z: Sequence[str] = x

从 Python 3.7 开始,也有限制性地支持这种语法:如果你使用了 from __future__ import annotations,mypy 会理解这种注解语法。然而,由于 Python 解释器在运行时并不支持这种方式,请务必注意 future annotations import 部分中提到的注意事项。

使用 X | Y 语法表示联合类型

从 Python 3.10 开始(PEP 604),你可以使用 x: int | str 来表示联合类型,而不是 x: typing.Union[int, str]

在 Python 3.7 及更高版本中,也有限制地支持这种语法:如果你使用了 from __future__ import annotations ,mypy 会理解这种语法在注解、字符串字面量类型、类型注解和存根文件中的使用。然而,由于 Python 解释器在运行时不支持这种方式(如果运行时评估 int | str ,会引发 TypeError: unsupported operand type(s) for |: 'type' and 'type' ),请注意 future annotations import 部分中提到的注意事项。

使用 typing 模块的新特性

你可能希望在比某些类型特性添加的 Python 版本更早的版本中使用它们,例如在 Python 3.6 中使用 LiteralProtocolTypedDict

最简单的方法是从 PyPI 安装并使用 typing_extensions 包来导入相关的特性,例如:

from typing_extensions import Literal
x: Literal["open", "close"]

如果你不希望依赖在更新的 Python 版本中安装 typing_extensions ,你可以使用以下方式:

import sys
if sys.version_info >= (3, 8):
    from typing import Literal
else:
    from typing_extensions import Literal

x: Literal["open", "close"]

这与 PEP 508 的依赖规范很好地配合: typing_extensions; python_version<"3.8"