静态类型检查
基础
目前 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 检查。它还使您在类型方面保持(过于)诚实。
添加类型
有三种方法可以添加类型。
- 它们可以作为注解内联。通常最适合 Python 3 代码。
- 它们可以放在特殊的“类型注释”中。最初是为 Python 2 代码设计的,仍然需要正确的导入。
- 它们可以放在一个单独的文件中,与原始文件同名,但扩展名为
.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_duck
的 Duck
依赖完全是类型检查时的依赖(除非我们想使用 runtime_checkable
支持的 isinstance
)。
有很多内置协议,其中大多数早于类型检查,并且以抽象基类的形式可用。它们大多数检查一个或多个特殊方法,例如 Iterable
、Iterator
等等。
其他功能
静态类型检查具有一些非常棒的功能,值得您查看
- 联合(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
。很少情况下您可能需要 MutableMapping
或 MutableSet
,如果您确实需要它们,将它们包含在类型中可以帮助阅读者知道它将被修改。
如果您的输出类型依赖于输入类型,请尝试传递它,通常使用 TypeVar(或 Python 3.12 中新的泛型语法,但对于旧版本的 Python,无法通过导入获得)。
另外请注意,在现代 Python 中获取这些类型的最佳位置是 collections.abc
,但如果您需要在运行时对它们进行下标访问,则需要 Python 3.9+ 或 typing
中的版本。
总结
当与 flake8 这样的良好 linter 一起运行时,这可以在测试或在实际环境中发现问题之前捕获大量问题!它也促进了更 *好的设计*,因为您会考虑类型如何工作以及如何交互。它也更易读,因为如果我提供给您这样的代码
def compute(timestamp): ...
您不知道 “timestamp” 是什么。它是一个整数?一个浮点数?一个对象?使用类型,您将知道我打算给您什么。您可以使用类型别名来这里提供更具表达力的名称!