Kodokon kodokon.com

类型提示与 dataclasses:认真地为现代 Python 加上类型

理解注解在运行时究竟做了什么,并借助 frozen、slots 和 default_factory 把 dataclasses 用到极致。

10 分钟 · 3 题

在 Kodokon 中打开本课

类型注解是元数据,而不是约束:解释器把它们存放在 __annotations__ 里,运行时从不检查它们。检查是 mypypyright 这类静态分析器的工作,发生在执行之前。直接的后果是:一个类型写错的程序照样毫无怨言地运行 - 而这正是有意为之的设计。渐进式类型让你能够一点一点地为现有代码库添加注解,而不破坏任何东西。

PYTHON
from typing import get_type_hints

def add(a: int, b: int) -> int:
    return a + b

print(add("py", "thon"))
print(add.__annotations__)
print(get_type_hints(add))
注解拦不住任何东西:这次"不正确"的调用照样成功。

从 Python 3.9 开始,内置类型本身就是泛型:直接写 list[int]dict[str, float],无需导入 typing.List。从 3.10 起,联合类型写作 int | None。要把输入类型和输出类型绑定起来,就使用 TypeVar - Python 3.12 甚至提供了紧凑写法 def first[T](items: list[T]) -> T。有一个常被忽视的点:from __future__ import annotations 会让所有注解变成惰性的(以字符串形式存储),这既允许你引用在后面才定义的类,也降低了导入时的开销。

PYTHON
from collections.abc import Iterable, Iterator
from typing import TypeVar

T = TypeVar("T")

def dedupe(items: Iterable[T]) -> Iterator[T]:
    seen: set[T] = set()
    for item in items:
        if item not in seen:
            seen.add(item)
            yield item

print(list(dedupe([3, 1, 3, 2, 1])))
TypeVar 保证 dedupe(list[int]) 真正产出的是 int。
PYTHON
from dataclasses import dataclass, field

@dataclass(frozen=True, slots=True)
class Point:
    x: float
    y: float

@dataclass
class Basket:
    items: list[str] = field(default_factory=list)
    total: int = 0

    def __post_init__(self) -> None:
        self.total = len(self.items)

point = Point(1.0, 2.0)
print(point, hash(point))
print(Basket(["book", "pen"]))
Point 不可变、可哈希,而且每个实例都没有 __dict__。

有三个参数能把 @dataclass 变成一件生产级的工具。frozen=True 让实例变得不可变,并结合自动生成的相等性判断使它们可哈希 - 可以用作字典的键。slots=True(Python 3.10+)移除了每个实例的 __dict__:内存占用更低,属性访问更快,任何意料之外的属性赋值都会失败。最后,kw_only=True 强制调用时显式使用关键字参数。__post_init__ 则是安放不变式和派生字段的地方。对于一个简单的不可变聚合体,NamedTuple 依然说得通;一旦涉及行为或校验,就优先选择 dataclass。

知识检测

确认你已牢记本课的重点内容。

  1. 当函数被注解为 (a: int, b: int) -> int,而你调用 add('py', 'thon') 时,运行时会发生什么?
    • Python 立刻抛出一个 TypeError
    • 调用正常执行:注解在运行时从不被检查
    • Python 发出一个 RuntimeWarning,然后执行这次调用
    • 模块拒绝被导入
  2. 为什么 items: list[str] = [] 不能作为 dataclass 字段的默认值?
    • 因为 list[str] 不是字段的合法注解
    • 因为 dataclass 只接受没有默认值的字段
    • 因为这个可变的默认值会被所有实例共享:dataclass 会抛出 ValueError,并要求使用 field(default_factory=list)
    • 因为默认值必须在 __post_init__ 中设置
  3. frozen=True 对一个 dataclass 做了什么?
    • 实例变得不可变,并借助自动生成的相等性判断变得可哈希 - 可以用作字典的键
    • 这个类不能再被继承
    • 字段变成共享的类属性
    • 每次赋值时都会检查注解