跳转至

调用方还要猜,类型写得再满也只是装饰

上一章里,AI 学习助手已经有了一条基本数据流:外部输入先经过运行时边界, 边界后的 typed core 再依靠 type hints 表达内部协作。

现在轮到核心业务函数 build_plan() 真正返回结果。

它可能成功生成一组学习会话:

周一:Python typing,45 分钟
周三:Python typing,45 分钟
周六:Python typing,60 分钟

也可能发现用户给出的截止日期太近,剩余时间根本装不下计划:

无法生成计划:还缺少 90 分钟

HTTP adapter 或 CLI adapter 调用 build_plan() 后,必须完成两件不同的事:

  1. 成功时遍历学习会话,展示主题和时长;
  2. 时间不足时展示失败原因,不能假装拿到了一份空计划。

先暂停一下。为了正确完成这两件事,调用方至少需要知道什么?

它需要知道结果有几个业务状态,怎样区分这些状态,成功状态包含什么字段,集合 中每个元素是什么,以及失败状态能提供哪些信息。换句话说,类型设计的起点不是 “我会哪些 typing 语法”,而是“调用方必须做哪些正确的事”。

如果签名没有保留这些信息,函数体里即使写满注解,调用方仍然只能靠猜。

运行环境

本章示例使用:

  • Python 3.12;
  • Python 3.12 的 T | U 联合类型与 type 类型别名语法;
  • mypy 2.1.0 验证关键静态检查行为。

可以直接运行静态检查:

uvx mypy --python-version 3.12 --show-error-codes example.py

mypy 的具体诊断措辞可能随版本变化,但本章关注的稳定行为是:Any 会让许多 操作绕过检查,而精确的集合元素和联合类型会保留调用方需要处理的关系。

1. 先看调用方,不要急着写签名

暂时不考虑 Plan 应该用 class、dataclass、Pydantic model 还是其他工具。先用 两段伪代码描述调用方的真实任务。

成功时:

result = build_plan(...)

for session in result.sessions:
    print(session.topic, session.minutes)

时间不足时:

result = build_plan(...)

print(result.reason)
print("还缺少", result.missing_minutes, "分钟")

如果把两段代码硬拼在一起,问题马上出现:调用方什么时候可以访问 sessions, 什么时候应该访问 reason

result = build_plan(...)

if is_failure(result):
    print(result.reason)
else:
    for session in result.sessions:
        print(session.topic, session.minutes)

这个尚未填写的判断条件,揭示了类型契约必须表达的第一件事:build_plan() 不是“返回某个差不多的东西”,而是可能返回两个都有业务意义的状态。

接着再看成功状态。调用方要遍历 sessions,所以只知道“它是一个集合”还不够。 调用方还需要知道:

  • 集合元素是 StudySession,不是任意对象;
  • 每个元素有 topic: str
  • 每个元素有 minutes: int

最后看输入。业务规则说:

  • topic 必填;
  • deadline 可以没有;
  • 没有截止日期和截止日期已知,是两种合法输入状态。

至此,我们还没有写任何正式类型,却已经得到四类类型问题:

业务问题 类型契约要回答什么
调用方必须提供什么 参数类型与参数是否可省略
一个值能否为空 值的类型是否包含 None
集合里装什么 参数化集合的元素类型
操作可能得到哪些有意义结果 返回类型的分支

类型语法只是记录这些答案的工具。业务状态才是答案的来源。

2. dict[str, Any]:看似灵活,实际把问题交给人脑

先看一个很常见的宽泛签名:

from datetime import date
from typing import Any


def build_plan(
    topic: str,
    deadline: date | None = None,
) -> dict[str, Any]:
    ...

它比完全不写类型好一点:至少调用方知道参数大致是什么,返回值是字符串键的 字典。

可真正重要的信息几乎都丢了:

  • 字典有哪些键?
  • "sessions" 一定存在吗?
  • "sessions" 的值是列表、字符串还是 None
  • 列表元素有哪些字段?
  • 失败结果和成功结果怎样区分?

调用方只能把这些约定重新写进自己的脑子:

result = build_plan("Python typing")

for session in result["sessions"]:
    print(session.topic, session.minutes)

更麻烦的是,Any 不只是表示“这里暂时不知道”。对于静态检查器,它还意味着 大量操作被允许继续传播。

下面故意把 minutes 拼成 minuts

def render_plan() -> None:
    result = build_plan("Python typing")
    print(result["sessions"][0].minuts)

对这一段执行 mypy,检查器不会报告字段拼写错误。原因不是它确认了 minuts 存在,而是 result["sessions"] 已经是 Any,索引后的元素仍然是 Any, 继续访问 .minuts 也被放行。

这是一种很危险的“无错误”:工具没有足够信息提出反对意见,不代表代码正确。

可以把 Any 想成静态检查道路上的一段浓雾。车当然还能开,但车道、路口和悬崖 边缘都看不见了。若某个边界确实无法静态确定,短暂进入浓雾可能有理由;若核心 业务接口长期生活在里面,类型检查器就只能坐在副驾驶上保持沉默。

Any 并非永远禁止。上一章的原始外部 JSON 在校验前可以诚实地表示未知内容。 但数据已经进入 typed core 后,若业务状态明明已知,继续返回 dict[str, Any] 就是在主动丢弃可检查信息。

3. 让返回类型保留成功与失败

现在把业务事实写成最小结构。下面选择 dataclass 只是为了让示例简洁,不表示 项目必须使用 dataclass。

from dataclasses import dataclass
from datetime import date


@dataclass(frozen=True)
class StudySession:
    topic: str
    minutes: int


@dataclass(frozen=True)
class Plan:
    sessions: list[StudySession]


@dataclass(frozen=True)
class PlanFailure:
    reason: str
    missing_minutes: int


type PlanResult = Plan | PlanFailure


def build_plan(
    topic: str,
    deadline: date | None = None,
) -> PlanResult:
    ...

这个签名没有描述算法细节,却保留了调用方完成任务所需的信息:

  • topic 是字符串;
  • deadline 的值可以是 dateNone
  • 成功结果是 Plan
  • 失败结果是 PlanFailure
  • 成功结果中的 sessionsStudySession 列表。

Plan | PlanFailure 称为联合类型。它表达的是:一次调用返回的具体对象会是其中 一种,但只看调用点时,调用方必须为两种可能性负责。

如果调用方不处理失败状态,直接访问 sessions

def render_plan() -> None:
    result = build_plan("Python typing")
    print(result.sessions)

mypy 2.1.0 会指出:

Item "PlanFailure" of "Plan | PlanFailure" has no attribute "sessions"

这不是类型检查器故意增加仪式感。它是在复述业务事实:时间不足时,根本没有 可展示的会话集合。

调用方需要先缩小结果类型:

def render_plan() -> None:
    result = build_plan("Python typing")

    if isinstance(result, PlanFailure):
        print(result.reason)
        print("还缺少", result.missing_minutes, "分钟")
        return

    for session in result.sessions:
        print(session.topic, session.minutes)

进入 if 分支后,检查器知道 resultPlanFailure。提前 return 后, 剩余代码中的 result 只能是 Plan,于是 sessions 可以安全访问。

此时再把 minutes 拼错:

print(result.sessions[0].minuts)

mypy 会报告:

"StudySession" has no attribute "minuts"; maybe "minutes"?

同样一处拼写错误,在 dict[str, Any] 版本中悄悄通过,在精确版本中被定位到 具体字段。差别不在代码有没有“类型味道”,而在签名是否保留了调用关系。

类型别名负责命名关系,不会创造新业务对象

这一行:

type PlanResult = Plan | PlanFailure

给重复出现的类型关系起了一个业务化名称。Python 3.12 的类型检查器会把 PlanResultPlan | PlanFailure 视为等价类型。

别名适合在一个组合会多次出现,或名字能明显提升可读性时使用。它不会自动生成 新 class,也不会让两个分支多出运行时行为。

如果类型只出现一次,而且完整写法更清楚,直接写:

def build_plan(...) -> Plan | PlanFailure:
    ...

也完全合理。类型别名是为了降低阅读成本,不是为了给每一行类型都办一张新的 身份证。

4. 从四条业务规则推导类型

现在把规则和类型逐项对应起来。

规则一:主题必填

主题是创建计划的必要输入,没有“未提供主题也照常生成”的业务状态:

def build_plan(topic: str, ...) -> PlanResult:
    ...

这里的 str 表达值的类型。函数没有为 topic 提供默认值,所以调用方也必须 传入这个参数。

注意,str 只表示字符串,并不自动表达“非空字符串”“合法主题”或“主题必须 存在于课程目录”。这些是运行时约束或业务规则,需要其他机制验证。本章只讨论 类型能准确表达的这一层。

规则二:截止日期允许没有

业务允许“尚未设置截止日期”,所以值域包含 None

deadline: date | None

但“值可以为 None”和“调用时可以不传参数”是两件事。

看四个签名:

from datetime import date


DEFAULT_DEADLINE = date(2026, 9, 1)


def case_a(deadline: date) -> None:
    ...


def case_b(deadline: date = DEFAULT_DEADLINE) -> None:
    ...


def case_c(deadline: date | None) -> None:
    ...


def case_d(deadline: date | None = None) -> None:
    ...

它们分别表达:

签名 调用时可省略 可以显式传 None
deadline: date
deadline: date = DEFAULT_DEADLINE
deadline: date | None
deadline: date | None = None

默认值决定参数能否省略;date | None 决定值域里有没有 None。两者经常同时 出现,所以容易被误认为一件事。

本例希望调用方既可以完全不传截止日期,也可以明确传入 None,因此使用:

def build_plan(
    topic: str,
    deadline: date | None = None,
) -> PlanResult:
    ...

如果业务规则改成“调用方必须明确说明有没有截止日期”,则可以保留 date | None,但去掉默认值。类型设计应跟着调用约定变化,而不是看到 “可能没有”就条件反射地写同一种签名。

规则三:计划包含多个学习会话

计划不是“某个 list”,而是 StudySession 的集合:

@dataclass(frozen=True)
class Plan:
    sessions: list[StudySession]

参数化集合把元素类型继续传给调用方。于是:

for session in plan.sessions:
    print(session.topic, session.minutes)

检查器知道 sessionStudySession,可以检查属性名和属性类型。

若只写裸 list

class Plan:
    sessions: list

“这里有一个列表”仍然没有回答“列表里有什么”。对业务接口而言,集合容器和元素 类型通常缺一不可。

这里选择 list,是因为示例把会话表示为一组有顺序、可遍历的具体结果。如果 业务只承诺“可以按顺序读取”,而不承诺调用方能够修改,未来也可能选择更抽象的 只读接口。那属于具体项目的设计取舍,不影响本章的核心判断:公开集合必须把 调用方依赖的元素关系表达出来。

规则四:生成可能成功,也可能时间不足

这条规则落在返回分支:

type PlanResult = Plan | PlanFailure

为什么不直接返回 Plan | None

如果失败时调用方只需要知道“没有计划”,None 可能足够。但本例还要显示原因和 缺少的分钟数,None 无法携带这些信息。用它会迫使调用方重新猜测失败原因, 或去别处寻找隐藏状态。

为什么不一定抛异常?

“截止日期太近,时间不足”是当前业务允许出现、调用方需要正常展示的状态。异常 是否更合适,要结合项目的错误策略决定。本章不规定联合类型、异常或某种 result 对象中谁永远最佳,只要求公开签名不要隐藏调用方必须处理的业务状态。

5. 类型越复杂,不等于契约越专业

学会精确表达后,另一个方向的误区会出现:既然类型能编码信息,那就尽可能把 所有信息都塞进去。

例如,为一个只有两个字段的最小接口同时引入多层泛型、多个 Protocol、复杂 Literal 组合和一串嵌套别名。它也许能通过类型检查,却可能让阅读者花更多时间 解码类型,而不是理解业务。

判断精确度是否合适,可以问三个问题:

  1. 调用方需要根据这项信息采取不同动作吗?
  2. 检查器能否据此发现真实的错误调用?
  3. 新增复杂度是否比它消除的猜测更少?

在本例中:

  • list[StudySession] 有价值,因为调用方要访问元素字段;
  • Plan | PlanFailure 有价值,因为调用方必须处理两个状态;
  • date | None 有价值,因为没有截止日期是合法输入;
  • 给“Python typing”这个主题单独发明一层复杂泛型,没有当前业务依据。

类型应当精确到足以表达业务允许的状态,但不需要比业务本身更戏剧化。

6. 把它放回熟悉的 ETL 经验

如果你维护过 ETL pipeline,可以把公开函数签名看成 stage 之间的 schema。

假设某一步输出“多条清洗记录”。只写 list,类似只告诉下游“这里会来很多 东西”,却不说明每条记录有哪些列、列值能否为空。只写 dict[str, Any],类似把 schema 退化成“键大概是字符串,值随缘”。

更可检查的 pipeline 会明确:

  • 输入记录必须有哪些字段;
  • 某列是否允许 null;
  • 一批记录中的元素结构;
  • stage 是产出正常批次、空结果,还是带原因的拒绝结果。

typed Python 的参数、None、参数化集合和联合类型,做的是相似工作:把原本 藏在实现和口头约定中的状态,提前放到协作边界上。

这个类比也有边界。类型检查器不会验证真实生产数据是否符合分布,也不会替代 运行时 schema 校验。上一章已经区分了运行时边界与静态契约;本章只是继续追问: 数据跨过边界后,内部 schema 到底有没有把业务状态说清楚。

7. 五个常见误区

误区一:只标参数,不标返回值

def build_plan(topic: str, deadline: date | None = None):
    ...

输入看起来很整齐,最重要的成功与失败关系却仍然藏在函数体里。公开函数的返回值 通常与参数同样重要,尤其当调用方需要分支处理时。

误区二:看到“可以没有”,就一律写 T | None

“参数可省略”“值可以为 None”“查询正常但没有结果”“操作失败”不是同一个 状态。

默认值、联合类型和返回分支分别承担不同职责。先写业务句子,再决定它落在哪个 维度。

误区三:集合写了 list 就算完整

裸集合只声明了容器,没有声明元素。调用方仍然不知道能访问什么,检查器也无法 追踪元素字段。

list[StudySession]

list 多出来的不是装饰,而是下一段代码真正依赖的关系。

误区四:用 Any 消除诊断

把一段难以通过检查的代码改成 Any,确实可能让红线消失。与此同时,错误调用 也可能一起从检查范围里消失。

只有在类型确实无法静态确定,而且边界与理由可以说明时,Any 才是诚实选择。 如果业务状态本来就明确,Any 通常是在删除信息,不是在解决问题。

误区五:类型越长越专业

复杂类型只有在表达真实差异时才有价值。若调用方不需要区分,检查器也不能因此 发现更多真实错误,多一层抽象可能只是多一层阅读成本。

专业感不来自尖括号和竖线的数量,而来自每个类型选择都能指回一条业务事实。

本章小结

设计业务类型时,先站到调用方的位置:

  • 它必须提供哪些输入?
  • 哪些参数可以省略?
  • 哪些值本身允许为 None
  • 集合里究竟是什么元素?
  • 操作有哪些需要正常处理的返回状态?

dict[str, Any] 会让字段、元素和分支信息在调用边界上消失。精确的业务对象、 参数化集合与联合类型,则能把这些关系交给 IDE 和类型检查器继续追踪。

但精确不等于复杂。Plan | PlanFailurelist[StudySession]date | None 都有明确业务依据;没有依据的复杂层次只会增加维护成本。

如果只带走一句话,可以带走这句:

先列出调用方必须正确处理的业务状态,再选择刚好能表达这些状态的类型。

下一章会继续使用这些公开类型,观察它们怎样成为模块之间的协作面,以及 为什么模块边界不能靠“多拆几个文件”自动产生。

练习

练习一:从规则推导签名

build_plan() 有以下规则:

  1. topic 必须提供;
  2. deadline 可以省略,也允许显式传 None
  3. 成功结果包含多个 StudySession
  4. 时间不足时返回原因和缺少的分钟数。

请写出最小公开类型,并说明每一项选择对应哪条业务规则。对象可以使用 dataclass,也可以只写结构草图。

练习二:找出宽泛签名丢失的信息

from typing import Any


def build_plan(payload: dict[str, Any]) -> dict[str, Any]:
    ...

请回答:

  1. 调用方无法从签名确认哪些输入事实?
  2. 调用方无法从签名确认哪些输出事实?
  3. 哪些错误可能因为 Any 而逃过静态检查?
  4. 如果 payload 是刚进入系统的外部 JSON,Any 应该在哪一刻结束传播?

练习三:区分省略与可空

不运行代码,判断以下调用哪些符合各自签名:

def a(deadline: date) -> None: ...
def b(deadline: date = DEFAULT_DEADLINE) -> None: ...
def c(deadline: date | None) -> None: ...
def d(deadline: date | None = None) -> None: ...

待判断调用:

a()
b()
b(None)
c()
c(None)
d()
d(None)

练习四:迁移到目标查询

新增一个函数:根据 user_id 查询学习目标。

业务规则如下:

  • user_id 必须提供;
  • 找到时返回 LearningGoal
  • 用户尚未创建目标时,这是正常的“不存在”状态;
  • 数据库连接失败或程序缺陷不算“正常不存在”。

请设计最小返回契约,并说明为什么程序异常不应被悄悄压成同一个 None

最后,不看正文说出选择业务类型时应检查的四个维度。

练习解析

练习一解析

一种最小设计是:

from dataclasses import dataclass
from datetime import date


@dataclass(frozen=True)
class StudySession:
    topic: str
    minutes: int


@dataclass(frozen=True)
class Plan:
    sessions: list[StudySession]


@dataclass(frozen=True)
class PlanFailure:
    reason: str
    missing_minutes: int


type PlanResult = Plan | PlanFailure


def build_plan(
    topic: str,
    deadline: date | None = None,
) -> PlanResult:
    ...

对应关系如下:

  • topic: str 且没有默认值,对应“主题必须提供”;
  • deadline: date | None = None 同时表达“可省略”和“值允许为空”;
  • list[StudySession] 表达成功结果包含多个有明确元素结构的会话;
  • Plan | PlanFailure 表达调用方必须处理成功与时间不足两个业务状态;
  • PlanFailure 的字段保留了调用方展示失败所需的信息。

dataclass 不是唯一实现。只要所选结构能让公开契约保留这些业务事实,就满足本题 目标。

练习二解析

输入方面,签名只说明 payload 是字符串键字典,却没有说明必填键、各键的值 类型、可空性和允许状态。

输出方面,签名没有说明成功与失败怎样区分、有哪些字段、sessions 是否存在、 集合元素是什么,以及失败时有哪些信息。

一旦索引结果成为 Any,错误键、错误属性、错误方法和不兼容运算都可能继续 传播。例如 .minuts 这样的拼写错误不会因为工具“认为它正确”而通过,只是工具 已经没有信息判断它错误。

payload 是外部 JSON,边界入口可以暂时承认未知内容;运行时模型完成解析与 校验后,应尽快转换或收敛为明确业务类型。Any 不应无依据地穿过整个 core。

练习三解析

结果如下:

调用 是否符合签名 原因
a() 参数没有默认值,不能省略
b() 参数有 date 默认值
b(None) 可以省略不等于可以传 None
c() 值允许为 None,但参数仍无默认值
c(None) 联合类型包含 None
d() 参数有默认值
d(None) 联合类型也包含 None

这道题的关键不是记表格,而是分开两个问题:调用语法是否允许不传,以及传入后 这个值允许属于哪些类型。

练习四解析

最小契约可以是:

def find_learning_goal(user_id: str) -> LearningGoal | None:
    ...

这里 None 表示一次正常查询完成了,但该用户尚未创建学习目标。调用方应显式 检查:

goal = find_learning_goal(user_id)

if goal is None:
    print("尚未创建学习目标")
    return

print(goal.topic)

数据库连接失败或程序缺陷不是“正常不存在”。如果把所有异常都捕获并返回 None,调用方会把系统故障误报成用户没有数据,真正的问题也失去诊断信息。 至于项目最终使用异常、独立失败对象还是其他结果模型,应由更完整的错误契约 决定;不能仅凭本题宣布唯一答案。

四个检查维度是:

  1. 参数及输入类型;
  2. 集合元素类型;
  3. 值的可空性与参数是否可省略;
  4. 操作可能返回的有意义分支。

参考资料