目录

静态类型检查

基础

目前 Python 开发中最令人兴奋的事情是静态类型。从 Python 3.0 开始,我们有了函数注解,从 3.6 开始,有了变量注解。在 3.5 中,我们得到了一个“typing”库,它提供了描述类型的工具。这就是静态类型提示的样子

def f(x: int) -> int:
    return x * 5

这在运行时没有任何作用,除了存储对象。如果您添加 from __future__ import annotations,它甚至不会存储实际对象,只存储您在此处输入的字符串,因此,任何可以通过 Python 解析器的对象都可以在此处使用。

但这并不意味着它没有用!首先,它可以帮助阅读者。了解预期的类型确实可以帮助您更好地了解正在发生的事情,以及您可以做和不能做的事情。

但关键目标是:静态类型检查!有一系列静态类型检查器,其中最“官方”且最著名的就是 MyPy。您可以将其视为 C++ 等编译语言的“编译器”;它检查以确保您没有对类型撒谎。例如,将任何不是 int 的东西传递给 f 将导致 mypy 检查失败,在您运行或部署任何代码之前

您的测试无法测试所有可能的分支,所有代码行。MyPy 可以(尽管默认情况下它不会,因为它是逐步类型检查)。您可能有一些很少运行的代码,这些代码需要远程资源,或者速度很慢等等。所有这些都可以由 MyPy 检查。它还使您在类型方面保持(过于)诚实。

添加类型

有三种方法可以添加类型。

  1. 它们可以作为注解内联。通常最适合 Python 3 代码。
  2. 它们可以放在特殊的“类型注释”中。最初是为 Python 2 代码设计的,仍然需要正确的导入。
  3. 它们可以放在一个单独的文件中,与原始文件同名,但扩展名为 .pyi。这对于类型存根或您不想添加导入或修改原始代码的情况很重要。您可以通过这种方式为编译文件或您无法控制的库添加注解。

如果您有一个无法控制的库,您可以为其添加“类型存根”,然后将存根目录提供给 MyPy。MyPy 将从您的存根中提取类型。例如,如果您正在为 Raspberry Pi 编写代码,您可以为 Pi 库添加存根,然后验证您的代码,而无需安装任何 Pi 专用库!

您不必为每个对象添加类型 - 在大多数情况下,您只需要为函数的参数和返回值添加类型。在运行 MyPy 时,您可以使用 reveal_type(...) 来显示任何对象的推断类型,这就像一个打印语句,但在类型检查时,或者使用 reveal_locals() 来查看所有本地类型。

配置

默认情况下,MyPy 的工作量最小,这样您就可以逐步地将其添加到代码库中。默认情况下

  • 所有未类型化的变量和返回值将为 Any
  • 未类型化函数内的代码根本不进行检查。

您可以将配置添加到 pyproject.toml(以及一些文件本身),或者您可以彻底地传递 --strict,它将开启所有内容。尝试尽可能多地开启,并不断增加直到您能够使用完整的 strict 检查运行。有关配置建议,请参见 样式页面

为了让一个库支持类型,它必须 a) 使用三种方法中的任何一种添加类型,以及 b) 添加一个 py.typed 空文件来表明在其中查找类型是可以的。MyPy 还查看 typeshed,这是一个包含(大多数)标准库类型提示的库。

顺便说一下,一些已类型化的第三方库会忘记最后一步!

功能

类型缩小

类型检查的关键功能之一是类型缩小。类型检查器会监控变量的类型,并在出现限制时对其进行“缩小”。例如

x: Union[A, B]
if isinstance(x, A):
    reveal_type(x)
else:
    reveal_type(x)

这将打印 A,然后打印 B。它打印 A 因为这是第一个分支中唯一可能存在的类型,然后剩余类型 (B) 必须是第二个分支中的类型。它打印两者是因为它实际上并没有运行代码,只是在进行类型检查,因此 if 语句的两侧都被检查了。

您可以使用 assert 手动强制类型缩小

x: Union[A, B]
assert isinstance(x, A)
reveal_type(x)

这将打印 A 因为您使用 assert 从类型中删除了 B。

协议

MyPy 最好的功能之一是通过协议支持结构化子类型 - 本质上是形式化的鸭子类型。与传统的继承不同,这允许跨库互操作性。以下是它的工作原理

from typing import Protocol


class Duck(Protocol):
    def quack() -> str: ...

现在,任何可以“嘎嘎叫”(并返回字符串)的对象都是鸭子。我们甚至可以添加 @runtime_checkable,它将允许我们(除了类型)在运行时在 isinstance 中检查这一点。因此,现在我们可以像这样设计代码

def pester_duck(a_duck: Duck) -> None:
    print(a_duck.quack())
    print(a_duck.quack())

类型检查器将确保我们只编写对所有 Duck 有效的代码。而且,我们可以编写一个鸭子实现并像这样测试它

class MyDuck:
    def quack() -> str:
        return "quack"

这将通过对 Duck 的检查,例如像这样的检查

import typing

if typing.TYPE_CHECKING:
    _: Duck = typing.cast(MyDuck, None)

请注意此处完全没有依赖关系。我们不需要 MyDuck 来编写 pester_duck,反之亦然。而且,在运行时我们甚至不需要 Duck 来编写这两个中的任何一个!对 pester_duckDuck 依赖完全是类型检查时的依赖(除非我们想使用 runtime_checkable 支持的 isinstance)。

有很多内置协议,其中大多数早于类型检查,并且以抽象基类的形式可用。它们大多数检查一个或多个特殊方法,例如 IterableIterator 等等。

其他功能

静态类型检查具有一些非常棒的功能,值得您查看

  • 联合(Python 3.10 中的新语法)
  • 泛型类型(Python 3.9 中的新语法)
  • 字面量
  • TypedDict
  • 更友好的 NamedTuple 定义(在 Python 3 代码中非常流行)
  • MyPy 使用您要求的 Python 版本进行验证,无论您实际运行的是哪个版本。

完整示例

运行时兼容类型

以下是您需要使用的经典语法,如果您想在运行时访问类型注解,并且您需要支持 Python < 3.10

from typing import Union, List


# Generic types take bracket arguments
def f(x: int) -> List[int]:
    return list(range(x))


# Unions are a list of types that all could be allowed
def g(x: Union[str, int]) -> None:
    # Type narrowing - Unions get narrowed
    if isinstance(x, str):
        print("string", x.lower())
    else:
        print("int", x)

    # Calling x.lower() is invalid here!

类型作为字符串

如果您在运行时不访问类型,或者如果您只使用 Python 3.10+,那么您可以使用更友好的语法。 annotations 未来功能会使注解作为字符串存储,而不是进行评估,这使您可以编写尚未有效的代码,例如 list[int]

from __future__ import annotations


def f(x: int) -> list[int]:
    return list(range(x))


def g(x: str | int) -> None:
    if isinstance(x, str):
        print("string", x.lower())
    else:
        print("int", x)

请注意,没有从 typing 导入!请注意,您不能在非注解位置(例如 isinstance 中的联合)使用“新”语法,除非 Python 在运行时支持它。还有一些库,例如 Typer 和 cattrs,会在运行时使用注解。

如果您手动使用字符串,可以在早期版本的 Python 中使用上述语法,但存在相同的警告。

良好类型的提示

以下是一些准则,可以帮助您编写良好的类型提示。

松散类型与特定类型

当您有一个函数时,您应该采用尽可能通用的类型,并返回尽可能具体的类型。例如

from __future__ import annotations
from collections.abc import Iterable, Mapping


# Bad!!!
def count(x: list[str]) -> Mapping[str, int]:
    result = {}
    for item in x:
        result[x] = result.get(x, 0) + 1
    return result

这将要求用户传递一个列表才能使用此函数,而实际上任何字符串的迭代器都可以工作。然后,对返回值执行有效的字典操作,例如修改操作,将被标记为无效,因为您的类型检查器不知道您有一个实际的字典。将其与以下内容进行比较

# Good
def count(x: Iterable[str]) -> dict[str, int]:
    result = {}
    for item in x:
        result[x] = result.get(x, 0) + 1
    return result

现在所有迭代器都被接受,类型检查器知道您在返回值中有一个实际的字典。通常函数应该接受 Iterable(如果参数只迭代一次)或 Sequence(如果它被多次迭代或与 in 一起使用),或 Mapping,或 Set。很少情况下您可能需要 MutableMappingMutableSet,如果您确实需要它们,将它们包含在类型中可以帮助阅读者知道它将被修改。

如果您的输出类型依赖于输入类型,请尝试传递它,通常使用 TypeVar(或 Python 3.12 中新的泛型语法,但对于旧版本的 Python,无法通过导入获得)。

另外请注意,在现代 Python 中获取这些类型的最佳位置是 collections.abc,但如果您需要在运行时对它们进行下标访问,则需要 Python 3.9+ 或 typing 中的版本。

总结

当与 flake8 这样的良好 linter 一起运行时,这可以在测试或在实际环境中发现问题之前捕获大量问题!它也促进了更 *好的设计*,因为您会考虑类型如何工作以及如何交互。它也更易读,因为如果我提供给您这样的代码

def compute(timestamp): ...

您不知道 “timestamp” 是什么。它是一个整数?一个浮点数?一个对象?使用类型,您将知道我打算给您什么。您可以使用类型别名来这里提供更具表达力的名称!