浏览知识库目录

Python

类型注解、dataclass 与结构化数据

使用类型注解、数据类、TypedDict 和 Protocol 表达领域模型与接口契约。

类型注解、dataclass 与结构化数据

类型注解不会自动把 Python 变成静态语言,也不会替代运行时校验。它的价值是让接口、领域模型和重构意图可以被编辑器、类型检查器和读者共同验证。


一、学习目标

  • 为函数、容器和可空值编写注解
  • 理解运行时对象与静态类型的边界
  • 使用 dataclass 表达任务实体
  • ProtocolTypedDict 描述接口
  • 理解 Python 3.14 注解延迟求值

二、函数和容器注解

def normalize_title(value: str, *, maximum: int = 200) -> str:
    title = value.strip()
    if not title:
        raise ValueError("任务标题不能为空")
    if len(title) > maximum:
        raise ValueError("任务标题过长")
    return title
def titles_by_id(tasks: list["Task"]) -> dict[int, str]:
    return {task.id: task.title for task in tasks}

现代 Python 使用 list[str]dict[str, int]str | None。只有确实接受任意类型时才使用 Any,否则它会让检查在该位置失效。


三、类型注解不是输入校验

def double(value: int) -> int:
    return value * 2


double("a")  # 运行时得到 "aa"

解释器默认不会根据注解拒绝字符串。来自命令行、JSON、数据库和网络的数据仍需运行时解析与校验。类型注解描述的是完成验证后的内部契约。


四、使用 dataclass 建模

from dataclasses import dataclass
from datetime import UTC, datetime
from enum import StrEnum


class TaskStatus(StrEnum):
    TODO = "todo"
    DONE = "done"


@dataclass(frozen=True, slots=True)
class Task:
    id: int
    title: str
    status: TaskStatus
    created_at: datetime
    updated_at: datetime

    @classmethod
    def new(cls, title: str) -> "Task":
        clean = title.strip()
        if not clean:
            raise ValueError("任务标题不能为空")
        now = datetime.now(UTC)
        return cls(0, clean, TaskStatus.TODO, now, now)
  • frozen=True 阻止普通属性重新赋值,使状态变化更明确。
  • slots=True 减少实例属性存储并阻止随意添加新属性。
  • 自动生成 repr 和按字段比较。

“冻结”不代表深度不可变;字段内部若包含列表,列表仍可修改。


五、不可变更新

from dataclasses import replace
from datetime import UTC, datetime

completed = replace(
    task,
    status=TaskStatus.DONE,
    updated_at=datetime.now(UTC),
)

返回新对象让状态变化更容易追踪,特别适合测试和并发读取。数据库层仍负责使用同一个主键更新持久化记录。


六、TypedDict 描述字典形状

from typing import TypedDict


class TaskPayload(TypedDict):
    id: int
    title: str
    status: str


def serialize(task: Task) -> TaskPayload:
    return {
        "id": task.id,
        "title": task.title,
        "status": task.status.value,
    }

TypedDict 只服务静态检查,运行时仍是普通字典。外部 JSON 必须检查键和类型后,才能安全地视为 TaskPayload


七、Protocol 描述行为

from typing import Protocol


class TaskRepository(Protocol):
    def add(self, task: Task) -> Task: ...
    def list(self, *, status: TaskStatus | None = None) -> list[Task]: ...

服务只依赖行为协议,测试可以传入内存实现,生产使用 SQLite 实现。

class TaskService:
    def __init__(self, repository: TaskRepository) -> None:
        self._repository = repository

八、泛型

from collections.abc import Iterable
from typing import TypeVar

T = TypeVar("T")


def first_or_none(values: Iterable[T]) -> T | None:
    return next(iter(values), None)

泛型保留输入和输出类型之间的关系。若使用 object,调用方只能得到 object | None;使用类型变量后,传入 Iterable[Task] 会得到 Task | None


九、Python 3.14 的注解求值

Python 3.14 默认延迟求值注解,前向引用通常无需写成字符串,也减少定义时求值带来的导入问题。需要在运行时读取注解时,应使用官方支持的 annotationlibtyping.get_type_hints,不要直接依赖类命名空间中的内部表示。

静态类型检查与运行时注解反射是两个不同需求。大多数业务代码只需让检查器读取注解。


十、常见错误

  • 为了让 mypy 安静而到处写 Any# type: ignore
  • 认为 cast(Task, value) 会在运行时转换或验证对象。
  • 把数据库行、JSON 字典直接声明为已验证领域对象。
  • 一个函数既可能返回值、None、字符串错误,又可能抛异常。

十一、练习与自测

  1. 将字典任务替换为冻结的 Task 数据类。
  2. 为仓储写协议,并实现内存版本。
  3. 编写解析函数,把未知 JSON 安全转换为 TaskPayload

自测:

  • 类型注解为什么不能替代运行时校验?
  • TypedDictdataclass 在运行时有什么区别?
  • Python 3.14 延迟注解对前向引用有何影响?

十二、官方资料

上一篇:迭代器、生成器与装饰器 | 下一篇:文件系统、序列化、正则与时间