调用方还要猜,类型写得再满也只是装饰¶
上一章里,AI 学习助手已经有了一条基本数据流:外部输入先经过运行时边界, 边界后的 typed core 再依靠 type hints 表达内部协作。
现在轮到核心业务函数 build_plan() 真正返回结果。
它可能成功生成一组学习会话:
也可能发现用户给出的截止日期太近,剩余时间根本装不下计划:
HTTP adapter 或 CLI adapter 调用 build_plan() 后,必须完成两件不同的事:
- 成功时遍历学习会话,展示主题和时长;
- 时间不足时展示失败原因,不能假装拿到了一份空计划。
先暂停一下。为了正确完成这两件事,调用方至少需要知道什么?
它需要知道结果有几个业务状态,怎样区分这些状态,成功状态包含什么字段,集合 中每个元素是什么,以及失败状态能提供哪些信息。换句话说,类型设计的起点不是 “我会哪些 typing 语法”,而是“调用方必须做哪些正确的事”。
如果签名没有保留这些信息,函数体里即使写满注解,调用方仍然只能靠猜。
运行环境¶
本章示例使用:
- Python 3.12;
- Python 3.12 的
T | U联合类型与type类型别名语法; - mypy 2.1.0 验证关键静态检查行为。
可以直接运行静态检查:
mypy 的具体诊断措辞可能随版本变化,但本章关注的稳定行为是:Any 会让许多
操作绕过检查,而精确的集合元素和联合类型会保留调用方需要处理的关系。
1. 先看调用方,不要急着写签名¶
暂时不考虑 Plan 应该用 class、dataclass、Pydantic model 还是其他工具。先用
两段伪代码描述调用方的真实任务。
成功时:
时间不足时:
如果把两段代码硬拼在一起,问题马上出现:调用方什么时候可以访问 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:
对这一段执行 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的值可以是date或None;- 成功结果是
Plan; - 失败结果是
PlanFailure; - 成功结果中的
sessions是StudySession列表。
Plan | PlanFailure 称为联合类型。它表达的是:一次调用返回的具体对象会是其中
一种,但只看调用点时,调用方必须为两种可能性负责。
如果调用方不处理失败状态,直接访问 sessions:
mypy 2.1.0 会指出:
这不是类型检查器故意增加仪式感。它是在复述业务事实:时间不足时,根本没有 可展示的会话集合。
调用方需要先缩小结果类型:
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 分支后,检查器知道 result 是 PlanFailure。提前 return 后,
剩余代码中的 result 只能是 Plan,于是 sessions 可以安全访问。
此时再把 minutes 拼错:
mypy 会报告:
同样一处拼写错误,在 dict[str, Any] 版本中悄悄通过,在精确版本中被定位到
具体字段。差别不在代码有没有“类型味道”,而在签名是否保留了调用关系。
类型别名负责命名关系,不会创造新业务对象¶
这一行:
给重复出现的类型关系起了一个业务化名称。Python 3.12 的类型检查器会把
PlanResult 与 Plan | PlanFailure 视为等价类型。
别名适合在一个组合会多次出现,或名字能明显提升可读性时使用。它不会自动生成 新 class,也不会让两个分支多出运行时行为。
如果类型只出现一次,而且完整写法更清楚,直接写:
也完全合理。类型别名是为了降低阅读成本,不是为了给每一行类型都办一张新的 身份证。
4. 从四条业务规则推导类型¶
现在把规则和类型逐项对应起来。
规则一:主题必填¶
主题是创建计划的必要输入,没有“未提供主题也照常生成”的业务状态:
这里的 str 表达值的类型。函数没有为 topic 提供默认值,所以调用方也必须
传入这个参数。
注意,str 只表示字符串,并不自动表达“非空字符串”“合法主题”或“主题必须
存在于课程目录”。这些是运行时约束或业务规则,需要其他机制验证。本章只讨论
类型能准确表达的这一层。
规则二:截止日期允许没有¶
业务允许“尚未设置截止日期”,所以值域包含 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,因此使用:
如果业务规则改成“调用方必须明确说明有没有截止日期”,则可以保留
date | None,但去掉默认值。类型设计应跟着调用约定变化,而不是看到
“可能没有”就条件反射地写同一种签名。
规则三:计划包含多个学习会话¶
计划不是“某个 list”,而是 StudySession 的集合:
参数化集合把元素类型继续传给调用方。于是:
检查器知道 session 是 StudySession,可以检查属性名和属性类型。
若只写裸 list:
“这里有一个列表”仍然没有回答“列表里有什么”。对业务接口而言,集合容器和元素 类型通常缺一不可。
这里选择 list,是因为示例把会话表示为一组有顺序、可遍历的具体结果。如果
业务只承诺“可以按顺序读取”,而不承诺调用方能够修改,未来也可能选择更抽象的
只读接口。那属于具体项目的设计取舍,不影响本章的核心判断:公开集合必须把
调用方依赖的元素关系表达出来。
规则四:生成可能成功,也可能时间不足¶
这条规则落在返回分支:
为什么不直接返回 Plan | None?
如果失败时调用方只需要知道“没有计划”,None 可能足够。但本例还要显示原因和
缺少的分钟数,None 无法携带这些信息。用它会迫使调用方重新猜测失败原因,
或去别处寻找隐藏状态。
为什么不一定抛异常?
“截止日期太近,时间不足”是当前业务允许出现、调用方需要正常展示的状态。异常 是否更合适,要结合项目的错误策略决定。本章不规定联合类型、异常或某种 result 对象中谁永远最佳,只要求公开签名不要隐藏调用方必须处理的业务状态。
5. 类型越复杂,不等于契约越专业¶
学会精确表达后,另一个方向的误区会出现:既然类型能编码信息,那就尽可能把 所有信息都塞进去。
例如,为一个只有两个字段的最小接口同时引入多层泛型、多个 Protocol、复杂 Literal 组合和一串嵌套别名。它也许能通过类型检查,却可能让阅读者花更多时间 解码类型,而不是理解业务。
判断精确度是否合适,可以问三个问题:
- 调用方需要根据这项信息采取不同动作吗?
- 检查器能否据此发现真实的错误调用?
- 新增复杂度是否比它消除的猜测更少?
在本例中:
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. 五个常见误区¶
误区一:只标参数,不标返回值¶
输入看起来很整齐,最重要的成功与失败关系却仍然藏在函数体里。公开函数的返回值 通常与参数同样重要,尤其当调用方需要分支处理时。
误区二:看到“可以没有”,就一律写 T | None¶
“参数可省略”“值可以为 None”“查询正常但没有结果”“操作失败”不是同一个
状态。
默认值、联合类型和返回分支分别承担不同职责。先写业务句子,再决定它落在哪个 维度。
误区三:集合写了 list 就算完整¶
裸集合只声明了容器,没有声明元素。调用方仍然不知道能访问什么,检查器也无法 追踪元素字段。
比 list 多出来的不是装饰,而是下一段代码真正依赖的关系。
误区四:用 Any 消除诊断¶
把一段难以通过检查的代码改成 Any,确实可能让红线消失。与此同时,错误调用
也可能一起从检查范围里消失。
只有在类型确实无法静态确定,而且边界与理由可以说明时,Any 才是诚实选择。
如果业务状态本来就明确,Any 通常是在删除信息,不是在解决问题。
误区五:类型越长越专业¶
复杂类型只有在表达真实差异时才有价值。若调用方不需要区分,检查器也不能因此 发现更多真实错误,多一层抽象可能只是多一层阅读成本。
专业感不来自尖括号和竖线的数量,而来自每个类型选择都能指回一条业务事实。
本章小结¶
设计业务类型时,先站到调用方的位置:
- 它必须提供哪些输入?
- 哪些参数可以省略?
- 哪些值本身允许为
None? - 集合里究竟是什么元素?
- 操作有哪些需要正常处理的返回状态?
dict[str, Any] 会让字段、元素和分支信息在调用边界上消失。精确的业务对象、
参数化集合与联合类型,则能把这些关系交给 IDE 和类型检查器继续追踪。
但精确不等于复杂。Plan | PlanFailure、list[StudySession] 和
date | None 都有明确业务依据;没有依据的复杂层次只会增加维护成本。
如果只带走一句话,可以带走这句:
先列出调用方必须正确处理的业务状态,再选择刚好能表达这些状态的类型。
下一章会继续使用这些公开类型,观察它们怎样成为模块之间的协作面,以及 为什么模块边界不能靠“多拆几个文件”自动产生。
练习¶
练习一:从规则推导签名¶
build_plan() 有以下规则:
topic必须提供;deadline可以省略,也允许显式传None;- 成功结果包含多个
StudySession; - 时间不足时返回原因和缺少的分钟数。
请写出最小公开类型,并说明每一项选择对应哪条业务规则。对象可以使用 dataclass,也可以只写结构草图。
练习二:找出宽泛签名丢失的信息¶
请回答:
- 调用方无法从签名确认哪些输入事实?
- 调用方无法从签名确认哪些输出事实?
- 哪些错误可能因为
Any而逃过静态检查? - 如果
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: ...
待判断调用:
练习四:迁移到目标查询¶
新增一个函数:根据 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 |
这道题的关键不是记表格,而是分开两个问题:调用语法是否允许不传,以及传入后 这个值允许属于哪些类型。
练习四解析¶
最小契约可以是:
这里 None 表示一次正常查询完成了,但该用户尚未创建学习目标。调用方应显式
检查:
数据库连接失败或程序缺陷不是“正常不存在”。如果把所有异常都捕获并返回
None,调用方会把系统故障误报成用户没有数据,真正的问题也失去诊断信息。
至于项目最终使用异常、独立失败对象还是其他结果模型,应由更完整的错误契约
决定;不能仅凭本题宣布唯一答案。
四个检查维度是:
- 参数及输入类型;
- 集合元素类型;
- 值的可空性与参数是否可省略;
- 操作可能返回的有意义分支。