类型提示与数据模型

本节目标

学完本节,你应能为函数、集合和可空值写类型提示,用 dataclass 表达已校验领域对象,并说明静态类型提示为什么不能替代运行时输入校验。

动态语言为什么还需要类型提示

Python 在运行时仍是动态类型语言。类型提示(type hints)主要帮助读者、编辑器和静态检查器理解契约;解释器通常不会因为参数标注为 str 就自动拒绝整数。

def normalize_query(query: str, limit: int = 200) -> str:
    cleaned = query.strip()
    if not cleaned:
        raise ValueError("query 不能为空")
    if len(cleaned) > limit:
        raise ValueError(f"query 不能超过 {limit} 个字符")
    return cleaned

query: str 是给工具和读者的说明;isinstance、长度与业务规则才构成运行时验证。

先掌握五种常用写法

def find_title(task_id: str) -> str | None: ...
 
def summarize(items: list[str]) -> dict[str, int]: ...
 
def choose(value: str, allowed: set[str]) -> str: ...
 
def pair() -> tuple[str, int]: ...
 
def render(lines: list[str], *, compact: bool = False) -> str: ...
  • str | None 表示可能没有值,不等于空字符串;
  • list[str] 表示可变有序集合;
  • dict[str, int] 说明键和值的预期类型;
  • tuple[str, int] 是固定位置的异构结果;
  • * 后参数必须按名称传递,可减少布尔位置参数误用。

不要为了“类型完整”到处使用 Any。Any 会让静态检查器放弃约束,应只放在真正不可信或动态边界,并尽快缩小为已知类型。

dataclass 表达已校验对象

字典适合解析外部 JSON,但业务核心若到处写 value["status"],字段拼写和状态约束很难追踪。可在边界校验后转成不可变数据类:

from dataclasses import dataclass
from typing import Literal
 
Status = Literal["pending", "running", "done", "failed"]
 
 
@dataclass(frozen=True, slots=True)
class Task:
    task_id: str
    title: str
    status: Status
  • frozen=True 阻止普通字段重新赋值,适合值对象;
  • slots=True 限定实例属性并减少部分开销;
  • Literal 帮助静态检查器理解有限字符串集合。

这些选项都不是安全边界。解析 JSON 时仍要检查类型、空值、未知字段和允许状态;运行时传入任意字符串也不会只因 Literal 自动失败。

外部形状与内部对象分开

推荐数据流:

不可信 dict/list
  ↓ 解析、字段和业务校验
不可变 Task
  ↓ 纯业务函数
报告数据模型
  ↓ 明确序列化
JSON/CLI/API 输出

解析函数的输入可写为 object,迫使实现显式缩小类型:

def parse_task(value: object) -> Task:
    if not isinstance(value, dict):
        raise ValueError("任务必须是对象")
    # 再逐字段验证;不要直接 Task(**value)
    ...

直接 Task(**value) 只会得到部分 Python 参数错误,无法稳定表达未知字段、空字符串、状态集合和面向用户的错误位置。

TypedDict、dataclass 和普通字典

选择适合情况注意
dict[str, object]真正动态、刚进入边界的数据尽快验证并缩小
TypedDict仍以字典传递、但键集合稳定主要是静态描述,不做运行时校验
dataclass内部领域对象、需要属性与不变量需单独解析外部输入

TypedDict 很适合描述框架要求的字典形状;dataclass 更适合业务核心。不要为每个临时两字段字典都建类,也不要让不可信字典穿透整个系统。

Python 3.14 默认采用惰性求值注解。若框架需要在运行时反射注解,不要假定直接读取 __annotations__ 与旧版本语义完全相同;优先使用 typing.get_type_hints(),更底层的注解处理再核对 annotationlib 文档。普通业务代码若不做运行时反射,无需因此增加复杂度。

用枚举还是字符串

Enum 可以集中有限状态并减少拼写错误,但 JSON 最终仍需要字符串。初学项目可以使用字符串 + 明确允许集合;当状态有行为或跨模块复用时再考虑 Enum。选择后要保证解析、序列化、文档和测试一致。

常见错误

  • 把类型提示当运行时校验;
  • 用 dict[str, Any] 让未知数据进入所有函数;
  • 用 None、空字符串和缺失字段表达同一件事却不写规则;
  • 数据类可变,多个步骤无记录地修改同一对象;
  • 输出类型只在代码里,工具 schema 与文档另维护一套并逐渐漂移。

练习

为一个只读 search_notes(query, limit, tags) 工具设计:外部 JSON 形状、内部不可变请求对象和结果对象。明确 tags 缺失、空数组与 null 的区别,并写出运行时校验规则。

自测

  1. 类型提示在运行时会自动拒绝错误值吗?
  2. str | None 与空字符串有什么区别?
  3. 为什么外部 JSON 不应直接展开到数据类?
  4. TypedDict 和 dataclass 的典型边界是什么?
  5. frozen=True 能否证明对象中的所有嵌套值都不可变?

相关概念与下一步

参考资料

获取日期:2026-07-14。