标注写着 int,为什么“很多”还是进来了?¶
你正在给 AI 学习助手实现一个很朴素的功能:用户填写想学什么、每周能投入多少 小时,系统据此生成学习计划。
浏览器通常会提交这样的 JSON:
看起来没什么可担心的。topic 是文字,weekly_hours 是数字,计划生成函数只要
把每周时间换算成每天时间就行。
但真实用户不会一直配合我们维持世界和平。某次提交可能是:
人一眼就能看出问题:“很多”是一种态度,不是一个适合参加除法运算的数字。
程序却没有这种生活经验。它不会看着 "很多" 沉思片刻,然后温柔地提醒用户:
“我理解你的热情,但还是请给一个整数。”
它只会照着代码继续走,直到某一步实在算不下去。
这时你可能会想:给函数加上 type hints 不就好了吗?
参数明明写着 int。如果传进来的不是整数,Python 总该在门口拦一下吧?
答案可能比想象中更有 Python 风格:它会看一眼注解,礼貌地点点头,然后照常 执行。
这一章就从这个反差开始。我们要弄清四件事:
- type hints 到底做什么,又明确不做什么;
- 外部 JSON 在哪里、怎样变成核心业务可以信任的数据;
- Pydantic 与 typed core 如何分工,而不是彼此替代;
- 为什么类型正确、输入有效,程序仍然可能把事情做错。
这不是几个术语的辨析题。边界放错以后,错误数据会深入业务逻辑,函数会反复 校验同一份字典,或者整个项目表面上“类型齐全”,实际上谁也不知道数据究竟从 哪一刻开始可信。
我们先不背定义,直接去看事故现场。
运行环境¶
本章代码使用 Python 3.12 和 Pydantic v2。可以在虚拟环境中安装:
不同 Pydantic v2 小版本的错误文字可能略有不同,但本章观察的解析、校验和默认 转换行为一致。
1. 参数写着 int,Python 真的会检查吗?¶
先运行最小版本:
def calculate_daily_minutes(weekly_hours: int) -> int:
print("已经进入函数")
return weekly_hours * 60 // 7
minutes = calculate_daily_minutes(6)
print(minutes)
输出:
每周 6 小时等于 360 分钟,平均到 7 天,每天是 51 分钟。整数除法舍去了一点 余数,但这不是本章的重点。
现在换成浏览器送来的 "很多":
运行时首先打印:
随后才抛出 TypeError。
这行打印非常关键。它证明 Python 并没有因为参数注解是 int,就在函数入口拒绝
字符串。程序已经进入函数体,甚至已经开始运算了。
具体过程还有一点荒诞感:
在 Python 中是合法的,结果是:
所以 "很多" * 60 也不会立即失败。Python 会非常认真地生成一长串“很多”,
然后在字符串参加 // 7 时才发现局面无法收拾。
更值得警惕的是,错误类型不一定会报错。它有时会安静地给出一个错误结果:
输出不是 12,也不是异常,而是:
程序顺利跑完,终端一片祥和,结果却已经偏离业务含义。相比立即报错,这种 “看起来什么都没发生”的错误往往更麻烦。
到这里,我们可以确认两句话并不等价:
- 这个函数声明自己需要一个整数。
- Python 在运行时保证传进来的对象一定是整数。
type hints 做的是第一件事,不是第二件事。Python 官方 typing 文档明确说明,
Python 运行时不会强制执行函数和变量的类型标注。注解可以被 IDE、类型检查器和
linter 使用,但注解本身不会偷偷在函数门口安插一位检查员。
先记住这一点
type hints 会声明预期,却不会自动改造、检查或拒绝运行时对象。
这不意味着 type hints 没有用。恰恰相反,它很有用,只是它干的是另一份工作。
2. type hints 是路线图,不是入口验货¶
先看一个没有类型信息的业务函数:
def build_plan(goal):
daily_minutes = goal.weekly_hours * 60 // 7
return {
"topic": goal.topic,
"daily_minutes": daily_minutes,
}
只看签名,goal 像一个没有标签的纸箱。调用方不知道里面应该装什么:
- 普通字典可以吗?
- 必须是一个带属性的对象吗?
weekly_hours可以是None吗?- 返回值有哪些字段?
- 调用失败时又会发生什么?
维护者只能打开函数,沿着 goal.weekly_hours 和 goal.topic 反向猜测。IDE
同样很为难。它可以提供一些通用帮助,却无法可靠判断调用方传入的对象是否满足
函数需要。
我们把契约写清楚:
from dataclasses import dataclass
@dataclass(frozen=True)
class LearningGoal:
topic: str
weekly_hours: int
@dataclass(frozen=True)
class Plan:
topic: str
daily_minutes: int
def build_plan(goal: LearningGoal) -> Plan:
daily_minutes = goal.weekly_hours * 60 // 7
return Plan(
topic=goal.topic,
daily_minutes=daily_minutes,
)
现在不必钻进函数体,我们已经能读出一条协作关系:
对调用方来说,签名说明该准备什么、会拿回什么。若误传 str、dict 或
None,类型检查器可以在代码运行前指出不一致。
对实现方来说,函数体可以依赖 goal.topic 是 str、goal.weekly_hours 是
int。如果返回了错误对象,或者把可能为空的值直接参加运算,静态检查工具也
更有机会发现问题。
对维护者和 IDE 来说,补全、跳转、重命名和调用链分析终于有了可靠线索。接手 代码的人不必先进行一场“从属性访问猜对象形状”的考古工作。
这就是 type hints 的核心价值:把原本藏在注释、实现细节和开发者脑海里的约定, 变成机器工具与人都能读取的静态类型契约。
可以把它想成一张内部路线图。路线图会告诉团队:
- 这条路预期接收哪种对象;
- 会把什么结果交给下一段代码;
- 哪些调用关系明显走错了方向。
但路线图不会检查刚从外面送来的纸箱。箱子上写着“整数”,里面仍然可能装着
"很多"。要知道里面究竟是什么,必须真的拆箱验货。
浏览器、消息队列、配置文件和第三方 API 都在 Python 静态分析的视野之外。它们 不会因为你的函数签名很工整,就自觉配合类型声明。
于是,第二份工作出现了:谁负责入口验货?
3. 外部 JSON 只是原料,还不是业务对象¶
浏览器发来 JSON 后,程序最初拿到的通常是一份普通字典:
它有两个看起来很熟悉的键,但“长得像”不等于“已经是”。
这份字典还没有回答:
- 必需字段是否都存在;
- 值能否形成声明的类型;
- 失败应该停在哪一层;
- 成功后,核心业务能依赖哪些前提。
给变量换一个更自信的名字也没有帮助:
名字从 raw_payload 变成 goal,数据没有因此经历任何成长。就像把文件夹
命名为 final_final_really_final,并不会让内容自动变得最终。
给它补一个注解也不行:
这行代码没有创建 LearningGoal,也没有检查字典。它只是写下了一条与真实对象
不一致的声明。类型注解不是转换按钮,更不是贴上去就生效的质量认证。
我们需要一个真实发生的动作,把外部数据从“不知道能不能信”变成“核心业务可以 依赖这些前提”:
外部数据跨过的这个位置,就是运行时数据边界。
为什么直到这里才正式给它命名?因为现在这个术语不再是从天而降的定义。我们 已经亲眼看见了它要解决的问题:type hints 能画清内部路线,却不能替外部数据 验货。
4. 让 Pydantic 在入口真正看一眼数据¶
在本课程的工程里,我们使用 Pydantic v2 建立运行时边界:
这里仍然写了类型标注,但接下来发生的事情与普通函数注解不同。调用
model_validate() 时,Pydantic 会在运行时读取真实输入,执行解析与校验。
先让 "很多" 过来试试:
from pydantic import ValidationError
raw_payload = {
"topic": "Python typing",
"weekly_hours": "很多",
}
try:
validated = LearningGoalInput.model_validate(raw_payload)
except ValidationError as exc:
print(exc)
错误信息会定位到 weekly_hours,大意是:
这一次,错误没有继续流入 build_plan(),也没有等到一长串“很多”参加除法时
才被发现。它停在了数据责任最清楚的位置:外部输入进入核心业务之前。
再试合法输入:
raw_payload = {
"topic": "Python typing",
"weekly_hours": 6,
}
validated = LearningGoalInput.model_validate(raw_payload)
print(validated.weekly_hours)
print(type(validated.weekly_hours))
输出:
校验成功后,后续代码可以依赖模型结果中的 weekly_hours 是 int。这就是边界
带来的变化:核心业务不必每走一步都回头怀疑“它到底是不是数字”。
一个小转折:校验不一定是原样放行或拒绝¶
如果输入是字符串 "6" 呢?
raw_payload = {
"topic": "Python typing",
"weekly_hours": "6",
}
validated = LearningGoalInput.model_validate(raw_payload)
print(validated.weekly_hours)
print(type(validated.weekly_hours))
输出仍然是:
Pydantic 默认可能执行合理的类型转换,也就是 coercion。"6" 能解析为整数,
所以被转换成 6;"很多" 无法解析,因此被拒绝。
这提醒我们,不能把“使用了 Pydantic”简化成“所有字符串一律禁止入内”。 Pydantic 更像按明确规则验货,而不是看到字符串就拉响警报。是否允许转换,取决 于数据来源和业务风险。
Pydantic v2 支持 strict mode,也支持字段约束和额外字段策略,不过这些细节先 留到真正设计 API 输入契约时。本章只需要抓住一件事:
运行时边界必须执行明确的输入策略。模型名称不能代替真实校验,使用 Pydantic 也不能代替我们理解它接受、转换和拒绝什么。
5. 一条完整的数据流长什么样¶
现在把零散片段接起来。为了看清职责,示例使用:
LearningGoalInput表达外部输入;LearningGoal表达核心业务接收的数据;Plan表达业务结果。
from dataclasses import dataclass
from typing import Any
from pydantic import BaseModel
class LearningGoalInput(BaseModel):
topic: str
weekly_hours: int
@dataclass(frozen=True)
class LearningGoal:
topic: str
weekly_hours: int
@dataclass(frozen=True)
class Plan:
topic: str
daily_minutes: int
def to_learning_goal(data: LearningGoalInput) -> LearningGoal:
return LearningGoal(
topic=data.topic,
weekly_hours=data.weekly_hours,
)
def build_plan(goal: LearningGoal) -> Plan:
daily_minutes = goal.weekly_hours * 60 // 7
return Plan(
topic=goal.topic,
daily_minutes=daily_minutes,
)
def handle_payload(payload: dict[str, Any]) -> Plan:
validated_input = LearningGoalInput.model_validate(payload)
goal = to_learning_goal(validated_input)
return build_plan(goal)
先别急着讨论“是不是每个项目都要两套对象”。这段代码的用途是把边界画亮,像 在数据流上打开三盏灯。
第一盏灯:边界之前,诚实承认我们还不知道¶
handle_payload() 接收 dict[str, Any]。这里的 Any 不是偷懒,而是在忠实
描述事实:外部 JSON 在校验前确实可能包含各种内容。
危险的不是“边界入口出现了未知类型”,而是未知类型一路进入了业务深处,沿途 所有函数都假装自己收到的是可靠数据。
第二盏灯:边界处,真正建立可信前提¶
这一行是整条数据流的分水岭。
在它之前,payload 只是一份外部声明;在它之后,代码拿到的是按模型策略处理过
的结果。失败时,问题停在边界;成功时,后续逻辑可以少背很多心理包袱。
第三盏灯:边界之后,用类型传播信任¶
转换是显式的,核心函数接收 LearningGoal,返回 Plan。后面的函数和模块通过
type hints 继续表达协作关系,不需要反复猜测字段,也不需要每一层都重新审问同一
份数据。
整条路线可以压缩成:
不可信外部数据
│
│ Pydantic 在运行时解析、校验
▼
可信的边界结果
│
│ 根据项目需要显式转换
▼
typed core
│
│ type hints 让内部协作可检查
▼
明确的业务结果
Pydantic 和 type hints 不是两个争夺同一岗位的候选人。一个负责入口验货,一个 负责内部路线。
- 只有 type hints,外部错误数据仍可能直接进入核心业务;
- 只有 Pydantic,内部函数仍可能靠裸字典和口头约定协作;
- 两者配合,数据怎样变可信、可信数据怎样继续传播,才都看得见。
至于边界模型和业务对象是否复用,没有永恒答案。小项目可以在职责清楚的前提下 复用;当传输格式、校验工具和业务对象需要独立变化时,再显式转换。真正重要的 不是对象数量,而是转换位置能找到、选择理由说得清。
6. 校验很有用,但不要在每个路口都重新验票¶
知道运行时校验有用后,很容易出现一种热情过度的设计:
def build_plan(raw_goal: dict[str, object]) -> Plan:
goal = LearningGoalInput.model_validate(raw_goal)
...
def calculate_schedule(raw_goal: dict[str, object]) -> list[int]:
goal = LearningGoalInput.model_validate(raw_goal)
...
def format_plan(raw_goal: dict[str, object]) -> str:
goal = LearningGoalInput.model_validate(raw_goal)
...
每个函数都亲自校验一次,看上去层层把关,安全感很足。可数据明明已经通过入口, 现在每走十步又被要求重新验明身份。检查次数增加了,边界反而消失了。
这种写法会带来三个问题。
第一,内部函数不再表达自己真正需要什么。它们都接收宽泛字典,再自行恢复数据 形状。调用者只能靠阅读实现猜契约。
第二,输入策略散落在业务流程里。字段规则变化后,开发者必须寻找所有校验点, 确认它们有没有悄悄长出不同版本。
第三,核心业务开始依赖外部传输格式和边界工具。原本只需要计算学习计划的函数, 现在还要知道 Pydantic 和原始字典。
更稳妥的原则是:
- 数据从不可信区域进入可信区域时,执行运行时校验;
- 校验成功后,通过明确类型传播已经建立的前提;
- 数据再次跨越新的系统或信任边界时,再考虑新的校验。
浏览器 JSON 进入服务是边界;另一个服务发来的消息进入消费者也是边界。一个 普通内部函数调用另一个内部函数,通常不是新的边界。
所以“集中校验”不是宣誓整个系统一生只校验一次,而是让校验跟随真实边界, 不要跟随函数数量。
7. 把它放回熟悉的 ETL 世界¶
如果你做过 ETL pipeline,这条关系其实并不陌生。
上游声称某列是整数,并不代表真实数据里不会出现空字符串、特殊符号、旧格式或 一段令人困惑的自由文本。下游 transformation 写着“这里按整数处理”,也不会 自动把脏数据清干净。
通常需要先经历:
在 typed Python 工程里,可以暂时对应成:
Pydantic 边界类似 raw record 进入可信处理区域前的解析与校验。type hints 则 类似可信 pipeline 各阶段之间明确的输入输出 schema。它们让内部协作可检查, 却不会自动清理刚到达系统的原始数据。
这个类比很好用,但别让它承担超出能力范围的工作。Pydantic model 不是完整数据 质量平台,type checker 也不会分析生产数据分布。ETL 中的跨记录一致性、去重、 质量监控和异常隔离,往往不属于一个字段模型。
类比只需要帮我们记住这一句:
原始数据进入可信处理区域时,要在运行时建立前提;可信区域内部仍要用明确 契约继续传递数据。
8. 两道检查都通过,程序还是可能算错¶
现在我们已经有了整齐的类型,也有了运行时边界。是不是可以宣布胜利了?
先看这段代码:
def build_plan(goal: LearningGoal) -> Plan:
daily_minutes = goal.weekly_hours * 60 // 5
return Plan(
topic=goal.topic,
daily_minutes=daily_minutes,
)
参数是 LearningGoal,返回值是 Plan。类型检查没有明显问题。
输入:
也能通过 Pydantic 校验。
程序运行后得到每天 72 分钟。类型很正确,输入很健康,答案却错了。需求是把
每周时间平均到 7 天,代码却除以了 5。正确结果应该是 51。
type checker 不知道一周为什么要按 7 天计算。Pydantic 也不知道学习计划如何 分配才符合产品规则。它们都完成了自己的工作,只是业务公式写错了。
这时,三种“正确”终于分开了:
| 判断层次 | 它真正回答的问题 | 本例中的证据 |
|---|---|---|
| 静态类型正确 | 代码是否按声明的类型协作 | 参数是 LearningGoal,返回 Plan |
| 运行时输入有效 | 外部数据是否满足入口策略 | weekly_hours 能成功形成整数 |
| 业务行为正确 | 实现是否符合真实需求 | 每周分钟数是否按 7 天分配 |
这三位同事彼此合作,但不替对方上班。
- 类型检查通过,不代表外部输入已经校验;
- 输入校验通过,不代表业务规则已经满足;
- 一个业务案例跑通,也不代表所有调用关系都符合类型契约。
后续课程会用测试检查具体业务行为。本章只需要建立一个健康的警觉:看到“有类型” “有 Pydantic”“程序能跑”,都不要急着把它翻译成“程序已经正确”。
9. 四个很有诱惑力的误区¶
误区一:参数有注解,错误对象就进不来¶
这条签名能帮助静态工具发现不合理调用,但 Python 运行时仍可收到其他对象。 如果数据来自 JSON、配置、消息或第三方服务,就要找到那一行真正读取运行时值、 并决定接受、转换或拒绝的代码。
如果找遍调用链只看到注解,没有看到实际校验,那么门牌写得再漂亮,入口也没有 验货。
误区二:用了 Pydantic,内部函数就不需要类型¶
Pydantic 解决的是外部数据怎样进入可信区域。边界之后,多个函数、模块和返回 结果仍然需要协作。
这样的接口仍然让调用方猜字段、元素和返回形状。边界模型不会自动替内部接口补 上契约。Pydantic 负责把数据送进来,type hints 负责让后面的路不至于重新变成 没有路标的空地。
误区三:每层都重新创建模型才最安全¶
重复校验经常不是“防线严密”,而是“不知道防线究竟在哪里”。它增加转换和耦合, 还可能让各层逐渐形成不同输入策略。
真正该问的是:数据刚刚跨越新的信任边界了吗?如果没有,就优先使用明确类型 传播已经建立的前提。
误区四:校验通过就说明业务一定正确¶
Pydantic 可以确认 weekly_hours 是整数,却不知道 0 小时是否符合产品规则,
也不知道计划算法应该除以 5 还是 7。
字段形状、业务状态和算法结果是不同层次。模型创建成功值得高兴,但还没到开 庆功会的时候。
10. 阅读数据流时,沿路问这七个问题¶
以后阅读 typed Python 项目,可以暂时忘掉“它用了哪些流行工具”,沿着数据走 一遍:
- 数据最初来自哪里?浏览器、文件、环境变量、消息队列还是另一个服务?
- 程序最初拿到的真实对象是什么?字符串、字典、字节还是第三方对象?
- 哪一行代码真正执行了解析和运行时校验?
- 校验成功后,后续代码可以依赖哪些前提?
- 这些前提如何通过参数、返回值、属性和集合元素类型继续传播?
- 数据是否在内部又退化成形状不明的字典或
Any? - 眼前的证据证明的是类型关系、输入有效,还是业务行为?
这七个问题比“项目有没有使用 Pydantic”更有判断力。工具名称不能替代数据流。 同样使用 Pydantic,一个项目可能在入口建立清楚边界,另一个项目可能让每个函数 都重新检查同一份字典。
别数模型,也别数注解。先找数据从哪来、在哪变可信、之后怎样继续走。
本章小结¶
回到最初那份输入:
参数标成 int,不会让 Python 自动拒绝 "很多"。type hints 的职责是声明
静态类型契约,让调用方、实现方、IDE 和类型检查器能够检查程序内部的协作关系。
外部 JSON 在运行时才到达系统,因此需要一个真实执行的边界机制。Pydantic v2
的 model_validate() 可以解析和校验输入:成功时产生符合模型结果的数据,
失败时用 ValidationError 把问题留在边界。默认模式还可能执行类型转换,所以
我们必须理解模型实际接受、转换和拒绝什么。
边界之后,数据通过明确业务类型进入 typed core。内部函数通常不必层层重复 校验,除非数据再次跨越新的系统或信任边界。
最后,类型与校验都不能证明业务算法正确。静态类型正确、运行时输入有效和业务 行为正确,需要各自的证据。
如果只带走一句话,可以带走这句:
在入口验货,在内部画清路线,最后再确认货物真的送到了正确目的地。
练习¶
练习一:三份工作分别由谁负责¶
下面四项主要属于静态类型契约、运行时数据边界,还是业务正确性?
- IDE 提示调用方把
str传给参数类型为LearningGoal的函数。 model_validate()拒绝无法解析为整数的"很多"。build_plan()把每周分钟数错误地除以 5。- 核心函数返回
Plan,调用方却把结果当作str使用。
练习二:先下注,再运行¶
先别运行。预测下面三次调用分别会发生什么:
class LearningGoalInput(BaseModel):
topic: str
weekly_hours: int
def double_hours(hours: int) -> int:
return hours * 2
double_hours("6")
LearningGoalInput.model_validate(
{"topic": "Python", "weekly_hours": "6"}
)
LearningGoalInput.model_validate(
{"topic": "Python", "weekly_hours": "很多"}
)
尤其注意:不是每个错误类型都会用异常提醒你。
练习三:把验货点放回入口¶
某程序的数据流如下:
现在三个内部函数都接收 dict[str, object],并各自调用 Pydantic 校验。请判断:
- 运行时边界更适合放在哪里?
- 边界之后的函数参数应表达什么?
- 为什么不建议三个函数各自重新校验同一份字典?
练习四:换一个入口,规则还成立吗?¶
系统以后不只接收浏览器请求,还会从消息队列收到:
消息由另一个服务发送。请判断:
- 消息队列消费者是否仍需要运行时边界?
- HTTP 和消息使用相同字段,是否意味着只能有一个入口函数?
- 两个入口怎样复用同一个 typed core?
练习解析¶
练习一解析¶
- 属于静态类型契约。IDE 根据参数声明检查调用关系。
- 属于运行时数据边界。真实值已经到达程序,Pydantic 正在解析和校验它。
- 属于业务正确性。类型和输入都可以正确,算法仍可能违反需求。
- 属于静态类型契约。返回值声明让工具发现调用方的使用方式与接口不一致。
判断时不要只看工具名称。先问:检查发生在源码分析阶段还是数据到达之后?它在 检查调用关系、输入内容,还是业务结果?
练习二解析¶
double_hours("6") 会正常进入函数。Python 不会因为 hours: int 自动拒绝
字符串。字符串乘以 2 合法,所以结果是 "66"。
这正是它值得被记住的原因。错误类型不一定轰轰烈烈地报错,也可能端端正正地 返回一个错误答案。
第二次调用在 Pydantic 默认模式下会成功。字符串 "6" 可以解析为整数,模型
中的 weekly_hours 最终是整数 6。
第三次调用会产生 ValidationError。"很多" 无法解析为整数,问题停在模型
边界,不会继续进入业务计算。
练习三解析¶
边界应靠近 HTTP JSON 进入 Python 业务系统的位置。route handler 或专门的输入
adapter 收到字典后,先建立 Pydantic 模型,再把验证后的数据或显式转换后的业务
对象交给 build_plan。
边界之后,函数参数应表达它们真正需要的 typed data,例如 LearningGoal、
Plan 或具体集合,而不是继续传递形状不明的字典。
三个函数重复校验会让外部传输格式和 Pydantic 依赖扩散到核心流程,还会增加 重复转换与策略不一致风险。既然数据已经在入口建立可信前提,内部应通过明确 类型传播它。
练习四解析¶
消息队列消费者仍需要运行时边界。消息来自进程或系统之外,即使发送方声称遵循 相同 schema,接收方拿到的仍是运行时外部数据。旧消息、版本差异、错误生产者或 手工注入都可能破坏约定。
HTTP 和消息使用相同字段,不表示必须共享同一个入口函数。HTTP adapter 与消息 consumer 的读取方式和失败处理不同,可以分别负责各自协议。
两个入口在各自边界完成解析与校验,再转换为同一个 LearningGoal,共同调用:
这样复用的是稳定的业务契约与核心规则。我们既没有让消息消费者依赖 HTTP 对象, 也没有复制两份计划算法。