快速导览与学习入口
| 主题分类 | 核心卡片 / 章节入口 | 主题分类 | 核心卡片 / 章节入口 |
|---|---|---|---|
| 核心基石 | Python / CPython · 对象模型 | 函数与调用 | 函数与参数 · 闭包与装饰器 |
| 基础容器 | 数据类型 · List / Dict / Set | 迭代与控制 | 迭代器与生成器 · 短路与循环 |
| 面向对象 | 类与继承 · Python Data Model | 语言进阶 | 异常体系 · Module & Import |
| 类型系统 | Typing 与类型注解 | 系统 IO | 文件与序列化 · 操作系统交互 |
| 并发异步 | 进程与线程 · GIL & Free-threaded | 异步编程 | Asyncio 运行模型 |
| 运行时底层 | 内存与 GC · CPython 字节码与原理 | 网络与数据 | 网络通信 · 数据库调用边界 |
| 工程质量 | 测试与 Mock · 性能分析与优化 | 工程规范 | Packaging & CI · Python 安全 |
| 综合避坑 | 高频陷阱速查 | 配套演练 | 🏋️ 打开复习训练册 |
内容基线:CPython 3.14.7(涉及实现细节时明确标注 CPython 与 Python 语言规范边界)
核心定位:Python 语言核心 + CPython 运行原理 + 标准库核心 + 通用工程实践
适合场景:5~15 分钟碎片化学习 · 查漏补缺 · 面试深度追问复盘 · CPython 机制探究
🏋️ 实战训练:《Python 碎片化复习训练册》(55 个核心知识训练组 · 覆盖变体 / Bug / 面试追问)
📦 离线资源:跳转至文末离线下载区(含 Markdown 原版、调度器脚本、卡片 JSON 与完整 ZIP 包)
使用说明
阅读标记
- L1 核心:日常 Python 开发应该直接掌握。
- L2 深入:理解复杂行为、API 边界和常见陷阱。
- L3 原理:CPython、字节码、内存与运行时实现。
掌握度只使用一套状态:U → M0 → M1 → M2 → M3 → M4。其中 U 表示未学;M1 能定义,M2 能预测,M3 能应用/排错,M4 能迁移并解释边界。卡片正文不重复显示掌握规则。
术语约定
第一次出现时优先使用“中文概念(官方英文术语)”;后续正文优先中文描述,Python API、协议名和类名保留代码/英文形式。Python 表示语言层时不等同于 CPython 实现;涉及实现行为时会显式写“CPython”。
三种使用模式
- 顺序学习:第一次系统学习时,按 Part I → IV 读主知识卡。
- 碎片复习:只看标题并先回答“主动回忆”,答不出再展开正文。
- 专题下钻:沿下面的专题路径连续读 3~7 张卡;Bug/面试训练放在独立训练册。
不使用调度器时,可临时采用 1 → 3 → 7 → 14 天的简化回顾节奏;使用调度器后,以调度器的 S/A/B/C 优先级与 M0~M4 间隔规则为唯一调度来源。只有 5 分钟时,优先解决 1 张到期核心弱项卡,不要为了数量扫 3 张。
配套文件
Python 复习训练册:55 个核心知识训练组,覆盖变体、Bug、面试追问与随机自测。python_cards_3.14.7.json:卡片优先级、前置、回退、目标掌握度。Python_复习调度器_3.14.7.py:按时间预算生成今日复习队列。
专题复习路径
下面的路径适合有 5~15 分钟时连续复习。它们不是新的知识,而是把分散卡片按一个问题重新组织。
| 路径 | 建议时间 | 卡片 | 复习目标 |
|---|---|---|---|
| 对象为什么会“莫名其妙被改了” | 10 分钟 | 4 → 6 → 7 → 8 → 9 | 判断对象共享、原地修改、重新绑定和复制边界 |
| 函数参数到底怎么传 | 10 分钟 | 11 → 13 → 14 → 16 → 18 | 不再用“list 引用传递、int 值传递”解释 Python |
| 一个名字到底去哪里找 | 10 分钟 | 21 → 22 → 23 → 24 → 25 | 从 LEGB 解释闭包、nonlocal 和 late binding |
| 装饰器为什么能工作 | 8 分钟 | 11 → 24 → 26 → 27 | 从函数对象和闭包推导装饰器,而不是背语法糖 |
| 容器选择与复杂度 | 10 分钟 | 34 → 38 → 40 → 166 | 根据访问模式选择 list/dict/set |
for、生成器与惰性 |
10 分钟 | 47 → 52 → 54 → 55 → 56 | 把 for、iterator、generator 连成一个协议模型 |
| Python OOP 的真正底层 | 15 分钟 | 57 → 58 → 61 → 63 → 65 → 66 → 67 | 能解释方法绑定、属性查找、descriptor 和 property |
| Import 为什么会循环 | 8 分钟 | 75 → 78 → 79 → 80 | 理解模块缓存与“半初始化模块” |
| Typing 到底管什么 | 8 分钟 | 82 → 85 → 87 → 91 | 区分运行时语义与静态类型工具 |
| 线程、GIL 与 Free-threaded | 12 分钟 | 106 → 109 → 110 → 113 → 114 | 分开考虑并发、并行、线程安全和 GIL |
| Asyncio 为什么会被阻塞 | 15 分钟 | 115 → 116 → 117 → 118 → 119 → 120 | 建立 event loop、Task、阻塞和取消模型 |
| 对象什么时候真正释放 | 10 分钟 | 123 → 124 → 125 → 126 → 129 | 区分引用计数、循环 GC、弱引用和内存追踪 |
| 外部命令与网络失败 | 10 分钟 | 144 → 148 → 182 | 同时考虑超时、幂等与命令注入 |
| 数据库一次请求的完整边界 | 10 分钟 | 149 → 150 → 151 → 153 | 从连接、事务、池和参数化 SQL 看数据库调用 |
| pytest / Mock 不要测错地方 | 12 分钟 | 154 → 156 → 158 → 159 → 160 | 判断 fixture、test double、patch 位置和 flaky 来源 |
| 性能问题先别猜 | 10 分钟 | 166 → 167 → 168 → 169 → 170 | 从复杂度和 profiling 决定优化方向 |
| 一个包如何变成可安装项目 | 10 分钟 | 171 → 172 → 174 → 175 → 180 | 串起虚拟环境、pyproject.toml、wheel 与 CI |
| Python 安全最常见的三条边界 | 10 分钟 | 181 → 182 → 183 → 185 → 186 | 把“输入是数据还是代码”作为第一判断 |
Part I · Python 语言地基
第一篇 Python 全局心智模型
1. Python、CPython 与“解释型语言”【L1】
30 秒结论
Python 是一门语言;CPython 是最常用的 Python 实现。把 Python 简单称为“解释型语言”不够准确:CPython 会把源代码编译成字节码,再由解释器执行。
最小心智模型
.py 源代码
↓
解析为 AST
↓
编译为 Code Object / Bytecode
↓
CPython Interpreter 执行
关键区别
- Python:语言语法、语义与标准。
- CPython:用 C 为主实现的 Python 运行时。
- PyPy:另一种 Python 实现,包含不同的运行时/JIT 策略。
- MicroPython:面向微控制器和受限环境。
常见误区
“Python 不编译。”
错。CPython 会编译,只是编译目标通常不是直接可执行机器码,而是 Python 字节码。
速记
Python ≠ CPython
Python 代码通常经历:parse → compile → bytecode → execute
什么时候这个区分真的有用
遇到“为什么这个版本的字节码变了”“为什么 PyPy 的性能特征不同”“为什么某个 C 扩展只在 CPython 可用”时,第一步不是继续背 Python 语法,而是先判断问题属于语言语义还是具体实现。例如 dis 输出、GIL、引用计数、sys._is_gil_enabled() 都属于 CPython 实现层;for 的迭代语义、异常传播规则属于 Python 语言层。
防误判: “CPython 当前这样实现”不能自动推出“所有 Python 实现都必须这样”。
主动回忆
- 为什么说‘CPython 是解释器’和‘Python 是语言’不能互换?
- 如果某段代码依赖
dis、GIL 或引用计数,你会把它归到语言保证还是 CPython 实现?为什么?
2. Python 程序从源码到执行【L2】
结论
代码执行不是“解释器一行一行读源码”。CPython 会先生成内部表示,再执行字节码。
观察工具
import ast
import dis
source = "x = 1 + 2"
print(ast.dump(ast.parse(source), indent=2))
dis.dis(compile(source, "<demo>", "exec"))
关键概念
- Token:词法层单位。
- AST:抽象语法树,表达代码结构而非原始文本。
- Code Object:可执行代码的运行时表示之一。
- Bytecode:解释器执行的指令序列。
- Frame:一次代码执行所需的运行时上下文。
边界
字节码属于 CPython 实现细节,同一个 Python 版本的不同实现不必使用相同字节码。
机制与边界:从“阶段”而不是“逐行解释”理解执行
- 机制链:源码先被解析为语法结构,编译为 code object;真正执行时,解释器在 frame 中推进指令。AST、code object、bytecode、frame 分别解决“结构、可执行表示、指令、执行上下文”四个不同问题。
- 错误发生在哪一层:语法错误通常在执行前就被 parser/compiler 拒绝;
NameError、TypeError等则需要真正执行到对应路径才出现。这是区分“编译期能发现什么”和“运行时才能知道什么”的关键。 - 不要依赖:CPython 字节码和优化策略不是 Python 语言稳定 API;升级 micro/minor 版本后,
dis输出变化不意味着语言语义变化。 - 值得下钻时:排查 import/启动成本、研究 traceback/frame、理解 coverage/debugger/profiler,或分析“为什么这段语法最终会这样执行”。
主动回忆
- 一段代码包含合法语法但引用未定义名字:为什么 parser 能通过,而运行到该路径时仍会失败?
- 如果 Python 3.15 改了某个 opcode 名称,但你的程序输出不变,这说明你依赖的是语言语义还是 CPython 实现?
3. .pyc 与 __pycache__【L1】
结论
.pyc 通常缓存模块编译后的字节码,目的是减少后续 import 时重复编译成本;它不是“加密后的源码”,也不保证跨 Python 版本兼容。
观察
导入一个本地模块后,通常能看到:
__pycache__/
module.cpython-314.pyc
常见误区
- 删除
.pyc不等于删除源码。 .pyc不能作为安全保护方案。- Python 会根据缓存有效性规则决定是否重新编译。
开发迁移与误判边界
开发迁移: 部署或排查 import 变慢时,.pyc 值得检查;但容器镜像里预编译字节码只能减少部分启动编译成本,不能把它当成运行时性能优化或源码保护。
误判边界: 把 .pyc 当成可移植构建产物是高频误判:文件名中的实现/版本 tag 已经提示它与解释器版本、缓存失效规则绑定。
主动回忆
- 你把一个 Python 3.14 生成的
__pycache__复制到另一套不同 Python 版本环境,为什么不能假定它会被直接复用? - 服务冷启动慢时,什么证据出现后你才会把排查方向放到字节码编译/缓存,而不是数据库连接、import 副作用或框架初始化?
第二篇 对象、变量与引用
4. Python 对象:identity、type、value【L1】
30 秒结论
Python 对象最核心的三个维度:
identity 对象身份
type 对象类型
value 对象的值/状态
a = [1, 2]
print(id(a))
print(type(a))
print(a)
关键认知
两个对象可以 value 相等,但 identity 不同:
a = [1, 2]
b = [1, 2]
assert a == b
assert a is not b
CPython 下钻
CPython 中大量对象建立在 PyObject 体系上,包含类型信息和引用计数等运行时元数据;这是 CPython 实现,而不是 Python 语言强制的对象布局。
从“看懂”到“会判断”
a = [1, 2]
b = a
c = [1, 2]
不要只记 is / ==。脑中应直接形成:a、b 是两个名字绑定同一个对象;c 是另一个 value 相等的对象。因此修改 b 会被 a 观察到,但不会自动修改 c。
🏋️ 配套实战:进入卡片 4 对应训练(变体 / Bug / 面试题)
先预测
a = [1, 2]
b = [1, 2]
c = a
print(a == b, a is b)
print(a == c, a is c)
不运行代码,预测两行输出,并分别用 identity / value 解释。
揭晓
第一行是 True False;第二行是 True True。a 和 b 是两个 value 相等的 list;c = a 只增加了一个指向同一对象的名字。
再做一个变体
如果类自己实现
__eq__,==的结果还能不能直接推出is的结果?
变体答案
不能。== 可以由类型定义为“业务上的相等”,而 is 始终判断对象 identity。
对象身份不是“内存地址”的跨实现承诺
id() 在对象生命周期内唯一,但 Python 语言只承诺这个 identity 语义;不要把“id() 就是内存地址”写进跨实现逻辑。真正需要长期稳定业务身份时,应使用数据库主键、UUID 等业务标识,而不是对象 identity。
主动回忆
- identity、type、value 分别回答对象的哪三个问题?
- 两个业务上相等的对象为什么仍可能
is为 False?什么时候 identity 才是正确比较维度?
5. 变量不是盒子,而是名字绑定【L1】
结论
a = [1, 2]
更适合想成:
a ─────→ list 对象 [1, 2]
而不是“a 这个盒子里装了列表”。
重新绑定
a = [1, 2]
b = a
a = [3, 4]
结果:
a ─────→ [3, 4]
b ─────→ [1, 2]
a = ... 改变的是 a 的绑定,不会自动修改原对象。
开发场景:重新绑定不是“清空调用方变量”
def reset(config):
config = {}
settings = {"debug": True}
reset(settings)
assert settings == {"debug": True}
函数里的 config = {} 只重绑局部名字。若 API 的目标是清空原 dict,应显式 config.clear();两种写法的副作用契约完全不同。
赋值语句先问“哪个名字被重新绑定”
调试一段状态异常代码时,可以把每个 = 暂时翻译成“让左侧名字绑定到右侧结果”。这样 config = new_config 与 config.update(...) 的差别会立刻显现:前者改变名字绑定,后者可能改变多个调用方共同观察的对象。
主动回忆
- 执行
a = b时,Python 改变的是对象还是名字绑定? config = new与config.update(new)对共享调用方的可观察结果为什么可能不同?
6. 引用与对象共享【L1】
结论
a = [1, 2]
b = a
不会复制列表:
a ─┐
├────→ [1, 2]
b ─┘
因此:
b.append(3)
assert a == [1, 2, 3]
更重要的一步
容器内部保存的也是对象引用:
user = {"name": "Tom"}
items = [user]
user["name"] = "Jack"
assert items == [{"name": "Jack"}]
复杂数据结构可以理解为“对象引用图”。
开发场景:嵌套 payload 的共享
profile = {"roles": ["reader"]}
payload = {"user": profile}
profile["roles"].append("writer")
assert payload["user"]["roles"] == ["reader", "writer"]
真实项目中的配置、请求 payload、缓存对象经常是嵌套引用图。调试“为什么另一处数据也变了”时,第一件事通常不是怀疑 Python 随机改值,而是检查是否共享了同一个可变对象。
🏋️ 配套实战:进入卡片 6 对应训练(变体 / Bug / 面试题)
先预测
user = {"tags": []}
backup = user
backup["tags"].append("vip")
print(user)
预测 user,并说清楚到底是谁“修改了谁”。
揭晓
输出 {'tags': ['vip']}。不是 backup 神秘地修改了变量 user;两个名字引用同一个 dict,而 dict 内部又引用同一个 list,append 修改的是那个共享 list。
再做一个变体
如果改成
backup = user.copy(),随后仍执行backup["tags"].append("vip"),user会变化吗?
变体答案
会。dict.copy() 是浅拷贝,只复制外层 dict;内部 tags list 仍共享。
共享引用是很多“隔空修改”Bug 的真正来源
生产代码里最典型的例子不是 a = b,而是把同一个配置 dict、缓存对象或请求上下文同时放进多个容器。以后看到“另一个模块怎么也变了”,先画对象图,检查是否共享了嵌套可变对象,再考虑并发或框架问题。
主动回忆
b = a为什么通常不会复制对象?- 看到嵌套配置被另一个模块‘隔空修改’,你会先画怎样的对象共享关系?
7. Mutable 与 Immutable【L1】
结论
“可变/不可变”描述的是对象本身能否原地改变,不是变量能不能重新赋值。
常见不可变:
int float bool str tuple frozenset bytes
常见可变:
list dict set bytearray 大多数普通类实例
对比
a = [1]
b = a
a += [2]
assert a is b
list 的 += 通常原地修改。
a = (1,)
b = a
a += (2,)
assert a is not b
tuple 不可变,因此产生新的绑定结果。
边界
a = ([1], 2)
a[0].append(3)
tuple 本身没有改变,但 tuple 引用的 list 发生了变化。
API 设计提醒
函数接收可变对象时,应让调用方容易判断函数是否会原地修改它。list.sort() 原地修改并返回 None;sorted() 返回新 list。好的 API 会尽量让“mutate 还是返回新对象”从名字、文档和类型中可见。
🏋️ 配套实战:进入卡片 7 对应训练(变体 / Bug / 面试题)
先预测
a = [1]
b = a
a += [2]
x = (1,)
y = x
x += (2,)
print(a is b, b)
print(x is y, y)
重点预测两次 is。
揭晓
list 的 += 通常原地修改,所以 a is b 为 True,b 为 [1, 2]。tuple 不可变,x += (2,) 产生新 tuple 并重新绑定 x,所以 x is y 为 False,y 仍是 (1,)。
再做一个变体
“变量可重新赋值”能否证明对象是 mutable?
变体答案
不能。变量重新绑定和对象自身能否原地改变是两件事。
不可变不等于内部所有对象都不可变
tuple 的元素绑定不能被替换,但元素本身可以是可变 list。工程上判断“这个配置安全吗”时,不要只看最外层类型;真正要问的是整个对象图中是否仍暴露可变状态。需要深层不可变时,通常要重新设计数据结构,而不是只套一层 tuple。
主动回忆
- mutable / immutable 描述的是变量还是对象?
- 为什么
([1],)的外层 tuple 不可变,却仍能观察到内部 list 变化?
8. is 与 ==【L1】
结论
== 比较 equality,通常回答“值是否相等”
is 比较 identity,回答“是不是同一个对象”
推荐
if value is None:
...
不要这样做
if count is 10: # 错误用法
...
某些小整数/字符串在 CPython 中可能被缓存或复用,但这是实现优化,不能把 is 当普通值比较。
为什么 == 不是简单比较内存内容
自定义类型可以通过 __eq__() 定义相等性,所以两个不同对象完全可以业务上相等。is 则不走这个业务协议。判断 sentinel(尤其 None)时使用 is,正是因为我们关心“是不是那个唯一对象”,而不是对方怎样重载相等性。
🏋️ 配套实战:进入卡片 8 对应训练(变体 / Bug / 面试题)
先预测
class User:
def __eq__(self, other):
return isinstance(other, User)
a = User()
b = User()
print(a == b)
print(a is b)
预测结果。
揭晓
True、False。这个类把任意两个 User 定义成 equality 相等,但它们仍是两个不同对象。
再做一个变体
为什么判断空值通常写
x is None而不是x == None?
变体答案
因为这里关心的是是否为那个单例 None 对象;同时 == 可能被用户类型重载。
业务相等与对象同一是两类问题
实体对象经常会自定义 __eq__:两个不同 User 实例可能因 user_id 相同而业务相等,但仍不是同一个实例。缓存命中、哨兵对象和 None 判断常关心 identity;金额、字符串、数据对象比较通常关心 equality。先问“我到底在比较身份还是值”,再选 is / ==。
主动回忆
is和==各自比较什么?- 自定义
__eq__后,为什么a == b不能推出a is b?
9. 浅拷贝与深拷贝【L1/L2】
结论
b = a 不复制对象
copy.copy(a) 复制最外层
copy.deepcopy(a) 递归复制对象图
示例
import copy
a = [[1], [2]]
b = copy.copy(a)
c = copy.deepcopy(a)
a[0].append(9)
assert b == [[1, 9], [2]]
assert c == [[1], [2]]
高频陷阱
matrix = [[0] * 3] * 3
matrix[0][0] = 1
三个位置共享同一个内部 list。正确创建独立行:
matrix = [[0] * 3 for _ in range(3)]
设计提醒
deepcopy() 不是默认安全方案。数据库连接、锁、文件句柄、缓存实例等对象往往本来就不应该被递归复制。
开发场景:配置模板
import copy
template = {"http": {"headers": {"X-App": "demo"}}}
a = copy.copy(template)
b = copy.deepcopy(template)
a["http"]["headers"]["X-Trace"] = "1"
assert "X-Trace" in template["http"]["headers"]
assert "X-Trace" not in b["http"]["headers"]
“复制配置”到底要不要共享子对象,是业务语义问题。深拷贝不是更高级的浅拷贝,而是不同的对象图复制策略。
🏋️ 配套实战:进入卡片 9 对应训练(变体 / Bug / 面试题)
先预测
import copy
a = {"user": {"roles": ["reader"]}}
b = copy.copy(a)
c = copy.deepcopy(a)
b["user"]["roles"].append("admin")
print(a["user"]["roles"])
print(c["user"]["roles"])
预测两行输出。
揭晓
a 看到 ['reader', 'admin'],因为浅拷贝的 b 与 a 共享内部对象;c 仍是 ['reader'],因为其内部对象图被递归复制。
再做一个变体
数据库连接、logger、锁这类对象是否应该无脑
deepcopy?
变体答案
通常不应该。深拷贝的边界必须符合语义;外部资源和同步对象往往本来就应该共享或根本不可复制。
复制边界要由业务语义决定
真实 Bug 常见于“复制了配置但嵌套列表仍共享”。不要形成“出问题就 deepcopy”的新反射:数据库连接、锁、logger、文件句柄等资源本来就不应被深拷贝。更好的问题是:哪些子对象应该共享,哪些必须隔离? 如果边界长期说不清,往往说明对象模型需要重构。
主动回忆
- 浅拷贝到底复制了哪一层?
- 一个配置对象里同时有普通 dict、锁和数据库连接时,为什么不能无脑
deepcopy?
10. Hashability 与对象相等【L1/L2】
结论
一个对象能作为 dict key 或 set 元素,需要满足 hash 相关约束。常见可哈希对象包括 int、str,以及成员也可哈希的 tuple。
mapping = {(1, 2): "ok"}
但:
# mapping = {[1, 2]: "bad"} # TypeError
核心约束
若:
a == b
则满足哈希协议的对象必须保证:
hash(a) == hash(b)
反过来不成立:哈希相同不代表对象相等,因为存在哈希碰撞。
为什么可变 list 通常不可哈希
如果一个对象作为 dict key 后还能改变影响相等性的内容,会破坏哈希表定位假设。
设计场景:值对象作为 key
若一个对象的相等性由若干字段决定,又需要作为 dict key,通常应让参与相等性的状态保持不可变。例如 @dataclass(frozen=True) 很适合表示坐标、标识符等值对象。否则对象进入哈希表后再改变相等性相关状态,会破坏查找假设。
开发迁移与误判边界
开发迁移: 设计缓存 key、去重集合或 ORM identity map 时,先问对象的 equality 是否稳定、hash 是否与 equality 一致;可变业务字段参与 hash 会制造“对象明明在 set 里却找不到”的灾难。
误判边界: 可哈希不等于不可变的哲学属性,而是对象在作为哈希键期间必须满足 hash/equality 契约;自定义 __eq__ 后还要重新审视 __hash__。
主动回忆
- 为什么一个包含 list 的 tuple 不能作为 dict key,而只包含 int/str 的 tuple 通常可以?
- 如果
User.__eq__按user_id比较,但__hash__却按可变的email计算,放进 set 后修改 email 会出现什么类型的问题?
第三篇 函数与参数模型
11. 函数本身也是对象【L1】
def add(a, b):
return a + b
f = add
assert f(1, 2) == 3
add 是函数对象的名字;add() 是调用表达式。函数可以被赋值、传参、返回、存入容器,因此高阶函数、callback、闭包和装饰器都能成立。
开发场景:用函数对象做策略表
def json_handler(data):
return "json"
def csv_handler(data):
return "csv"
handlers = {"json": json_handler, "csv": csv_handler}
result = handlers["json"]({})
assert result == "json"
这比在每次调用处堆叠大量 if/elif 更容易扩展,也直接体现“函数是一等对象”。
再深入一步:callable 不只有函数
“函数是一等对象”还要和“可调用对象”区分。实现 __call__() 的实例也可以像函数一样被调用:
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
triple = Multiplier(3)
assert triple(4) == 12
当行为还需要一小组明确状态时,可调用对象有时比闭包更容易检查和扩展。
函数对象让策略可以被数据化
函数是一等对象后,就可以把行为放进 dict、传给排序/重试器、作为回调注入。真实开发中这往往比建一堆只有一个方法的类更直接。但函数作为对象也意味着装饰、缓存和 monkey patch 都是在操作“名字绑定到哪个 callable”,这和后面的闭包、decorator、patch 完全连起来。
主动回忆
- 为什么说函数是一等对象?
- 什么情况下用‘函数作为策略参数’会比建立只有一个方法的策略类更直接?
12. 形参与实参【L1】
def add(a, b): # a、b:parameter
return a + b
add(10, 20) # 10、20:argument
调用时,参数绑定过程会让局部参数名关联到传入对象。
判断重点
“形参/实参”只是语法角色,不表示复制策略。真正发生在调用边界的是实参求值 → 参数匹配 → 局部参数名绑定到对象。因此后面讨论可变对象、副作用时仍然要回到对象绑定模型。
参数绑定阶段的失败
def send(message, *, timeout=5):
return message, timeout
send("hello", timeout=2) # OK
# send("hello", 2) # TypeError:timeout 是 keyword-only
这里的错误发生在调用参数与函数签名匹配时。理解这一点有助于区分“调用协议错误”和“函数体内部业务错误”:前者通常在函数体真正执行前就被拒绝。
形参与实参的价值在“绑定规则”而不是术语
def connect(host, *, timeout=5):
...
connect("db.internal", timeout=2)
调用前存在的是实参对象;进入函数时,解释器按签名规则把它们绑定给形参名字。很多 TypeError: got multiple values、missing required positional argument 本质都是参数绑定阶段失败,函数体甚至还没开始执行。
主动回忆
- 形参和实参分别存在于函数定义还是调用阶段?
- 一个
missing required argument为什么说明错误可能发生在函数体执行之前?
13. Python 到底是值传递还是引用传递【L1/L2】
推荐心智模型
不要背“list 是引用传递、int 是值传递”。Python 的调用模型是统一的:形参名绑定到传入对象。很多资料称之为 call by object sharing 或 pass by assignment。
def mutate(items):
items.append(3)
x = [1, 2]
mutate(x)
assert x == [1, 2, 3]
这里修改的是共享对象。
def rebind(items):
items = [9]
x = [1, 2]
rebind(x)
assert x == [1, 2]
这里只重新绑定了函数局部名字。
一句话
mutate object ≠ rebind name
一个能区分 mutate / rebind 的真实例子
def normalize_in_place(tags):
tags[:] = [t.strip().lower() for t in tags]
def normalize_local(tags):
tags = [t.strip().lower() for t in tags]
raw = [" Python ", "API "]
normalize_local(raw)
assert raw == [" Python ", "API "]
normalize_in_place(raw)
assert raw == ["python", "api"]
两个函数都写了“新列表”,但 slice assignment 修改的是原 list;普通赋值只重绑局部名字。
🏋️ 配套实战:进入卡片 13 对应训练(变体 / Bug / 面试题)
先预测
def f(x):
x.append(2)
x = [9]
a = [1]
f(a)
print(a)
预测结果,并分别指出 append 和 = 做了什么。
揭晓
输出 [1, 2]。append mutate 了调用方与参数共同引用的 list;随后 x = [9] 只重新绑定函数局部名字 x,不会把外部名字 a 一起改绑。
再做一个变体
为什么“list 是引用传递、int 是值传递”不是一个好模型?
变体答案
因为 Python 的参数绑定规则对对象是一致的;表现差异来自对象可变性以及函数执行的是 mutate 还是 rebind。
API 是否修改参数必须成为显式契约
如果函数接受 list/dict,调用方真正关心的不是“Python 是什么传递方式”,而是这个 API 会不会 mutate 输入。公共 API 最好用命名、文档或返回值清晰表达,例如 sorted() 返回新 list,而 list.sort() 原地修改。隐藏的 in-place 修改会制造比参数传递术语更实际的维护成本。
主动回忆
- Python 参数调用统一的名字绑定模型是什么?
- 如何仅通过代码判断某个函数是在 mutate 调用方对象还是 rebind 局部名字?
14. 完整参数语法【L1/L2】
最终要能读懂:
def func(a, /, b=1, *args, c, **kwargs):
...
含义:
a positional-only
b positional-or-keyword,默认值 1
*args 额外位置实参,收集为 tuple
c keyword-only
**kwargs 额外关键字实参,收集为 dict
示例:
def func(a, /, b=1, *args, c, **kwargs):
return a, b, args, c, kwargs
assert func(10, 20, 30, 40, c=50, name="Tom") == (
10, 20, (30, 40), 50, {"name": "Tom"}
)
API 设计案例
def request(url, /, *, timeout=5, retries=0):
...
url 用 positional-only,调用方不依赖内部参数名;timeout / retries 用 keyword-only,让 request(url, 3, 2) 这种难读调用直接失效。参数语法不仅是技巧,也是 API 可读性工具。
🏋️ 配套实战:进入卡片 14 对应训练(变体 / Bug / 面试题)
先预测
def f(a, /, b=2, *args, c, **kwargs):
return a, b, args, c, kwargs
print(f(1, 3, 4, 5, c=6, x=7))
先把每个实参绑定到哪个形参写出来。
揭晓
结果是 (1, 3, (4, 5), 6, {'x': 7})。a 仅位置;b 可位置/关键字;多余位置进入 args;c 是仅关键字;剩余关键字进入 kwargs。
再做一个变体
为什么库 API 有时故意把参数设为 positional-only?
变体答案
这样参数名不成为公开调用契约的一部分,未来可以改内部参数名而不破坏调用方。
参数语法是在设计调用接口
/ 与 * 不只是面试语法。positional-only 可以避免把内部参数名变成兼容性承诺;keyword-only 可以迫使调用点显式写 timeout=...、retries=...,减少多个同类型参数被传反。好的签名是在约束错误调用方式。
主动回忆
/、普通参数、*后参数分别允许怎样传值?- 为什么
timeout、retries等参数适合设计成 keyword-only?
15. 调用时的 * 与 ** 解包【L1】
定义时:
def f(*args, **kwargs):
...
表示“收集”。
调用时:
args = [1, 2]
kwargs = {"c": 3}
f(*args, **kwargs)
表示“展开”。
记忆:
定义:many → collection
调用:collection → many
常见用途:透明转发
装饰器、adapter、proxy 中经常需要:
def wrapper(*args, **kwargs):
return target(*args, **kwargs)
这里前一对 * / ** 是“收集”,后一对是“展开”。但透明转发会隐藏签名,装饰器应配合 functools.wraps,必要时使用更精确的 typing。
开发迁移与误判边界
开发迁移: 包装器、装饰器和配置驱动调用经常需要 *args/**kwargs 转发;真实风险不是语法不会写,而是展开后的名字冲突、参数重复绑定和 API 签名被抹平。
误判边界: 定义端的 *args/**kwargs 是收集,调用端的 *iterable/**mapping 是展开;这两个方向相反,别把“一个 tuple/dict 参数”误认为“多个实参”。
主动回忆
f(1, **{'a': 2})在a已被位置参数绑定时为什么会报错?- 写装饰器时,什么时候应该透明转发
*args, **kwargs,什么时候反而应该显式写出参数以保留类型检查和 API 可读性?
16. 默认参数在定义时求值【L1】
高频陷阱
def add(item, items=[]):
items.append(item)
return items
assert add(1) == [1]
assert add(2) == [1, 2]
默认 list 在函数定义执行时就被创建,后续无参调用复用同一个对象。
推荐写法
def add(item, items=None):
if items is None:
items = []
items.append(item)
return items
不只影响可变对象
from datetime import datetime
def show(now=datetime.now()):
return now
datetime.now() 也是定义时执行,而不是每次调用时执行。
Bug 形态:状态偷偷跨调用
def collect(item, bucket=[]):
bucket.append(item)
return bucket
assert collect("a") == ["a"]
assert collect("b") == ["a", "b"]
危险点不是“list 不能当默认值”,而是默认对象只创建一次。若刻意要跨调用缓存,可以利用这个事实;普通业务 API 则通常用 None sentinel 或 default_factory。
🏋️ 配套实战:进入卡片 16 对应训练(变体 / Bug / 面试题)
先预测
def add(item, bucket=[]):
bucket.append(item)
return bucket
print(add(1))
print(add(2))
预测第二次调用为什么不是 [2]。
揭晓
默认表达式 [] 在函数定义执行时求值一次;两次省略 bucket 的调用复用同一个 list,所以得到 [1]、[1, 2]。
再做一个变体
如果默认参数写成
datetime.now(),即使对象不可变/不会被修改,仍可能有什么问题?
变体答案
时间值同样在定义时冻结;问题不是只有“可变默认参数”,而是默认表达式的求值时机。
默认值的求值时机比“不要写 []”更重要
问题不只发生在可变容器。def f(now=datetime.now()) 也会把定义时刻冻结下来。看到默认参数时要问:这个表达式应该在函数定义时求一次,还是在每次调用时重新求值?如果是后者,通常使用 None/sentinel 在函数体内创建。
主动回忆
- 默认参数表达式在什么时候求值?
- 除了
[],datetime.now()作为默认值为什么也可能是 Bug?
17. 返回值与“多返回值”【L1】
def point():
return 10, 20
实际上返回的是一个 tuple:
result = point()
assert result == (10, 20)
x, y = point()
是“返回 tuple + 解包”,并不存在一个特殊的多返回值运行时机制。
未显式 return 的函数返回 None。
开发迁移与误判边界
开发迁移: 服务层返回多个关联结果时,return a, b 实际返回 tuple;这意味着调用方可以整体保存、解包、切片,也意味着公共 API 增加一个返回项可能破坏固定长度解包。
误判边界: “多返回值”不是多个通道,而是一个返回对象;同样,return 后的表达式求值结束后函数控制流就退出。
主动回忆
- 为什么
x, y = func()的错误可能来自返回 tuple 长度,而不是所谓“第二个返回值不存在”? - 公共函数从返回
(data, error)改为(data, error, meta),哪些调用方式会被直接破坏,哪些可能继续工作?
18. 函数局部名字与对象生命周期【L2】
def create_user():
user = {"name": "Tom"}
return user
result = create_user()
函数结束时局部名字 user 消失,但 dict 仍被 result 引用,因此对象继续存在。
记住:
名字生命周期 ≠ 对象生命周期
这条认知会连接闭包、引用计数和 GC。
生命周期判断法
不要问“函数结束,对象是不是就销毁了”,而要问“函数结束后还有没有可达引用”。返回值、闭包、容器、全局缓存都可能让函数内部创建的对象继续存活。这个问题之后会直接连接到引用计数与 GC。
机制与边界:名字消失不等于对象死亡
- 机制:函数返回后,局部作用域中的名字会消失;对象是否释放取决于是否还有强引用。返回值、全局变量、容器、闭包、traceback 都可能让对象继续存活。
- 典型误判:看到“函数结束了”就推断“大对象释放了”。如果异常 traceback、缓存或闭包仍持有它,对象生命周期会远长于局部变量生命周期。
- 实现边界:默认 CPython 常能通过引用计数较快释放对象,但这是实现特征;free-threaded CPython 还可能延迟某些对象回收,因此不要把“离开作用域立刻析构”当语言保证。
- 值得下钻时:分析内存滞留、闭包捕获、traceback 导致的大对象不释放。
主动回忆
- 函数内部创建 500MB 对象并
return给调用方后,函数 frame 结束了,为什么内存仍可能被占用? - 如果没有返回对象,但异常 traceback 被长期缓存,对象为什么仍可能活着?
19. Lambda、高阶函数、map/filter 与 partial【L1/L2】
短小一次性函数:
key = lambda user: user["age"]
Lambda 只能包含表达式,复杂逻辑应使用 def。
高阶函数把函数当参数或返回值。常见:
sorted(users, key=lambda u: u["age"])
map() / filter() 返回惰性 iterator;简单映射过滤时 comprehension 往往更直观。
from functools import partial
request_with_timeout = partial(request, timeout=5)
partial 用于预绑定部分参数,不等同闭包,但很多简单参数化函数场景都能使用。
选择原则
lambda 最适合很短、一次性的表达式函数,例如 sorted(users, key=lambda u: u.age)。一旦需要多行逻辑、异常处理、文档或调试,命名 def 通常更清楚。map/filter 也不是“更 Pythonic”的同义词;可读的 comprehension 往往更直观。
开发迁移与误判边界
开发迁移: 数据转换管道里,高阶函数能把策略作为对象传入;partial 适合冻结一部分配置,但复杂业务链条中过度堆叠 lambda/map/filter 往往降低可调试性。
误判边界: lambda 只是受限的表达式函数,不是“更高级的函数”;可读性差时 list comprehension 或具名函数通常更好。
主动回忆
- 为什么
sorted(users, key=lambda u: u.age)是高阶函数场景,而lambda本身不是重点? - 当一段
map(filter(lambda...))开始包含异常处理和多步业务规则时,你为什么倾向改成具名函数/普通循环?
20. 递归与递归深度【L1/L2】
递归是函数调用自身或形成递归调用链:
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
必须有终止条件。
Python 不保证 tail-call optimization,因此深递归会增长调用栈并受 recursion limit 限制。树/图算法有时递归最清楚,但未知深度的生产数据可能更适合显式 stack/queue。
开发判断
递归天然适合树、图的递归结构,但 Python 没有通用尾递归优化。面对非常深或可被恶意构造的输入,显式 stack/queue 往往比依赖递归深度更稳健。
开发迁移与误判边界
开发迁移: 树、目录、语法结构天然适合递归;但 Python 没有尾调用优化,深度取决于调用栈,因此用户可控深度的数据必须考虑显式栈/迭代方案。
误判边界: 提高 recursion limit 只是放宽解释器限制,不等于消除 C/Python 栈耗尽风险,也不是算法复杂度优化。
主动回忆
- 遍历一个可能有十万层嵌套的用户 JSON,为什么递归实现即使逻辑正确也可能不是稳健选择?
- 把递归改成显式 stack 后,空间复杂度一定下降吗?真正改变的主要是什么资源边界?
第四篇 名称空间、作用域、闭包与装饰器
21. Namespace 与 Scope【L1】
结论
- namespace:名字到对象的映射。
- scope:代码中某个名字可被直接访问的区域。
可以通过:
print(globals())
观察全局命名空间的一部分。
函数调用通常会建立自己的局部名字环境。
把两个概念分开
namespace 回答“有哪些名字映射到哪些对象”;scope 回答“代码在这里写一个名字时,哪些 namespace 可以直接参与查找”。同一个 namespace 可以被不同机制访问,而 scope 是源代码位置相关的名称可见性规则。
两个概念不要画成同一张图
Namespace:
"name" ──→ object
"count" ─→ object
Scope:
当前这行代码按照什么规则能直接看到哪些名字
module、class、function 都会形成不同形式的 namespace;但 class body 的名称解析规则又不能简单等同于函数局部 scope。学习时先掌握普通函数/模块的 LEGB,再把 class attribute lookup 放到 Data Model 章节处理。
Namespace 是映射,Scope 是可见性规则
这两个词常被混成一个概念。namespace 回答“名字和对象的绑定存在哪里”;scope 回答“当前代码能直接访问哪些 namespace 中的名字”。调试 NameError、patch 失败、closure、module import 时,把这两个问题拆开通常更快。
主动回忆
- namespace 和 scope 分别回答什么问题?
- 遇到
NameError与 patch 失败时,为什么区分‘绑定存在哪’和‘当前能看见哪’有用?
22. LEGB 名称查找【L1】
Python 查找普通名字时可用这条主线理解:
Local
↓
Enclosing
↓
Global
↓
Builtins
示例:
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
return x
return inner()
inner() 中的 x 首先命中 Local。
高频误区
“读取外部变量”和“给名字赋值”不是一回事。函数体内一旦对某名字赋值,若没有 global/nonlocal,编译器通常会把它视为局部名字,因此可能出现 UnboundLocalError。
预测题的关键
LEGB 是“读取名字”时的主要心智模型;赋值还有自己的绑定规则。函数体中只要出现对某名字的普通赋值,编译器通常就把它视作 local,除非使用 global / nonlocal。这正是很多 UnboundLocalError 的来源。
🏋️ 配套实战:进入卡片 22 对应训练(变体 / Bug / 面试题)
先预测
x = "global"
def outer():
x = "enclosing"
def inner():
print(x)
inner()
outer()
inner 中的 x 从哪里找到?
揭晓
从 Enclosing scope 找到 outer 的局部 x,输出 enclosing。LEGB 顺序是 Local → Enclosing → Global → Builtins。
再做一个变体
如果
inner自己先执行x = "local"再print(x),还会访问 enclosing 吗?
变体答案
不会,局部绑定优先。只要该名字被判定为当前函数的 local,查找就不会跳过 local 去取 enclosing。
LEGB 是名字查找路径,不是对象生命周期
局部名字离开 scope,不代表它引用的对象一定消失;闭包或外部容器仍可能持有对象。反过来,LEGB 只描述普通名称解析的核心规则,属性访问 obj.x 走的是另一套 attribute lookup / descriptor 机制,不能混用。
主动回忆
- LEGB 的查找顺序是什么?
- 为什么 LEGB 不能用来解释
obj.attr的 descriptor 优先级?
23. global 与 nonlocal【L1/L2】
global
count = 0
def inc():
global count
count += 1
global count 表示函数中的赋值针对模块级 count。
nonlocal
def outer():
count = 0
def inc():
nonlocal count
count += 1
return count
return inc
nonlocal 针对最近的 enclosing function scope,而不是模块全局。
设计提醒
能用返回值、对象封装或显式状态传递解决时,不要为了省代码大量依赖 global。全局可变状态会增加测试和并发复杂度。
设计提醒
global / nonlocal 都能修改外层状态,因此是显式副作用。少量闭包状态用 nonlocal 很自然;大型业务状态如果需要到处 global,通常是在提示状态边界和依赖设计需要重构。
🏋️ 配套实战:进入卡片 23 对应训练(变体 / Bug / 面试题)
先预测
x = 1
def outer():
x = 2
def inner():
nonlocal x
x += 1
inner()
return x
print(outer(), x)
预测两个值。
揭晓
输出 3 1。nonlocal x 修改最近的 enclosing 绑定;模块级全局 x 没被修改。
再做一个变体
global x和nonlocal x最大的区别是什么?
变体答案
global 指向模块全局命名空间;nonlocal 指向最近的 enclosing function scope,而且要求那里已经存在相应绑定。
global / nonlocal 改的是“赋值落在哪个绑定”
它们不是让变量突然变成可变对象。若函数只是 items.append(...),通常不需要 global/nonlocal;只有当你要执行 items = ... 并让这个赋值作用于外层绑定时,声明才改变编译器如何处理这个名字。
主动回忆
global/nonlocal改变的是对象可变性还是名字绑定目标?- 为什么只调用外层 list 的
append通常不需要nonlocal?
24. 闭包 Closure【L1/L2】
30 秒结论
闭包不是“函数里面再写一个函数”这么简单。核心是:内部函数离开外层函数后,仍然保留对其自由变量的访问能力。
def make_counter():
count = 0
def counter():
nonlocal count
count += 1
return count
return counter
c = make_counter()
assert c() == 1
assert c() == 2
虽然 make_counter() 已经返回,但 count 对闭包仍然可达。
使用场景
- 保存少量私有状态。
- 构造回调。
- 参数化函数。
- 装饰器实现。
何时不用
当状态很多、生命周期复杂、行为需要多个公开方法时,class 往往比闭包更清楚。
开发场景:工厂生成带配置的函数
def make_prefixer(prefix):
def add_prefix(value):
return f"{prefix}:{value}"
return add_prefix
error = make_prefixer("ERROR")
assert error("disk") == "ERROR:disk"
闭包适合携带少量、稳定的配置或状态;若状态演化复杂,class 通常更利于维护和测试。
🏋️ 配套实战:进入卡片 24 对应训练(变体 / Bug / 面试题)
先预测
def make_counter():
n = 0
def count():
nonlocal n
n += 1
return n
return count
c = make_counter()
print(c(), c())
为什么 make_counter() 已结束,n 仍能存在?
揭晓
输出 1 2。返回的函数形成 closure,仍持有对自由变量 n 所在 cell 的引用;函数调用结束不等于其创建过的所有对象和被闭包引用的状态都消失。
再做一个变体
如果你需要十几个字段、复杂生命周期和多种方法,继续用闭包还是改 class?
变体答案
通常 class 更清晰。闭包适合少量私有状态和函数式封装;状态结构复杂时 class 的可读性、可测试性通常更好。
闭包的核心价值是“行为 + 私有状态”
闭包适合轻量策略、工厂函数、回调配置等局部状态场景;当状态字段很多、生命周期复杂、需要多种操作时,class 往往更清晰。判断标准不是“哪个更高级”,而是状态是否值得拥有一个明确的数据模型和接口。
主动回忆
- 闭包保存了什么,使外层函数返回后状态仍可访问?
- 当闭包状态越来越复杂时,什么信号提示你应该改用 class?
25. 闭包的 Late Binding【L1】
经典问题:
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])
通常得到:
[2, 2, 2]
原因不是 lambda 特殊,而是闭包读取的是变量 i,调用发生时循环已结束。
常见修复
利用默认参数在定义时求值:
funcs = [lambda i=i: i for i in range(3)]
assert [f() for f in funcs] == [0, 1, 2]
速记
closure captures variable binding, not a magical snapshot of every value
典型回调 Bug
funcs = [lambda: i for i in range(3)]
assert [f() for f in funcs] == [2, 2, 2]
fixed = [lambda i=i: i for i in range(3)]
assert [f() for f in fixed] == [0, 1, 2]
问题不是 lambda 特有,而是闭包读取变量时发生 late binding。默认参数方案之所以能修复,是因为默认值在函数创建/定义时就被求值并绑定。
🏋️ 配套实战:进入卡片 25 对应训练(变体 / Bug / 面试题)
先预测
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])
预测结果,并解释为什么不是 [0, 1, 2]。
揭晓
通常得到 [2, 2, 2]。闭包捕获的是变量绑定,而不是每次循环时值的快照;调用 lambda 时循环已经结束,i 的最终值是 2。
再做一个变体
怎样只改一处就让每个 lambda 固定住当次
i?
变体答案
可用默认参数立即绑定:lambda i=i: i。这里利用的是默认参数在函数定义时求值。
Late Binding 在异步/回调代码里更隐蔽
循环里注册按钮事件、创建异步任务回调、构造延迟执行函数时,都可能等到循环结束后才真正读取变量。调试这类 Bug 时问一句:值是在创建 callable 时读取,还是在之后调用时读取? 这比死记 lambda i=i 更可靠。
主动回忆
- late binding 是在 callable 创建时还是调用时读取自由变量?
- 异步回调循环里所有任务都打印最后一个 ID,你会优先检查什么?
26. 装饰器的本质【L1】
@decorator
def foo():
...
核心等价关系:
def foo():
...
foo = decorator(foo)
因此装饰器本质上是:
接收对象 → 返回替代对象
最常见的是“函数 → 包装函数”。
from functools import wraps
def log_call(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("calling", func.__name__)
return func(*args, **kwargs)
return wrapper
为什么 wraps 重要
它帮助保留原函数的 __name__、__doc__、__wrapped__ 等元数据,让调试、文档和 introspection 更可靠。
可维护装饰器的最低要求
from functools import wraps
def log_call(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
装饰器引入的是“调用边界上的横切逻辑”。日志、计时、权限检查可以合适;把主要业务流程藏进多层装饰器会让控制流和调试成本迅速上升。
🏋️ 配套实战:进入卡片 26 对应训练(变体 / Bug / 面试题)
先预测
def deco(fn):
def wrapper(*args, **kwargs):
print("before")
return fn(*args, **kwargs)
return wrapper
@deco
def add(a, b):
return a + b
@deco 最核心的等价变换是什么?
揭晓
等价核心是 add = deco(add)。装饰器接收原函数对象,返回另一个对象,再把函数名重新绑定到返回值。
再做一个变体
为什么装饰器常配合
functools.wraps?
变体答案
wrapper 会替代原函数对象;wraps 帮助保留 __name__、__doc__、__wrapped__ 等元数据,让调试、文档和 introspection 更可靠。
装饰器应该增强“横切能力”,而不是隐藏主流程
日志、权限、缓存、重试、指标是典型 decorator 场景,因为它们包围多个函数的共同边界。如果装饰器偷偷改参数语义、吞异常或发起重要业务写操作,调用者很难从函数体看出行为。越是有副作用的装饰器,越应该谨慎。
主动回忆
@decorator最核心的等价变换是什么?- 为什么把重要业务写操作隐藏在 decorator 里会降低可维护性?
27. 带参数装饰器与叠加顺序【L2】
def repeat(times):
def decorator(func):
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
这里有三层:
repeat(3) → decorator
decorator(func) → wrapper
wrapper(...) → 调用原函数
多装饰器
@A
@B
def f():
...
等价:
f = A(B(f))
应用顺序从下往上,实际调用时则由最外层包装开始。
使用边界
装饰器适合横切关注点,例如日志、缓存、鉴权、重试、注册;如果装饰器偷偷改变核心业务语义、参数约定或返回类型,会显著增加认知成本。
机制与边界:把装饰器拆成“生成装饰器”和“应用装饰器”
@retry(3)里的retry(3)会在函数定义阶段先产生一个真正的 decorator;随后 decorator 接收函数并返回新对象。- 叠加时最终绑定关系可按
f = d1(d2(f))推导,所以最靠近函数的装饰器先包住原函数,调用时则最外层 wrapper 先进入。 - 状态放在哪里:装饰器参数、计数器、缓存等常被闭包保存;如果状态应该按实例隔离,类/descriptor 往往比共享闭包更清晰。
- 边界:装饰器会改变名称最终绑定到的对象;若忘记
functools.wraps,签名、名称和 introspection 可能失真。
主动回忆
@auth @cache def f(): ...最终等价于怎样的赋值?调用时哪个 wrapper 最先执行?- 一个带参数装饰器把可变字典保存在闭包里,多个被装饰函数共享它会造成什么状态问题?
第五篇 基础类型与容器
28. int、float、bool 与数值模型【L1/L2】
int
Python int 在可用内存允许范围内支持任意精度整数,不像固定宽度 C 整数那样普通算术后直接溢出。
x = 10 ** 100
float
CPython 的普通 float 通常基于 IEEE 754 双精度浮点表示,因此很多十进制小数不能被二进制精确表达:
0.1 + 0.2 == 0.3 # False
比较近似值时:
import math
assert math.isclose(0.1 + 0.2, 0.3)
涉及十进制定点语义、金额等场景可考虑 decimal.Decimal,但仍要理解上下文精度和舍入规则。
bool
bool 是 int 的子类:
isinstance(True, int) # True
但业务代码不要把这个继承关系当作可读性技巧滥用。
金额案例:不要从二进制 float 再构造 Decimal
from decimal import Decimal
price = Decimal("0.10")
tax = Decimal("0.20")
assert price + tax == Decimal("0.30")
如果业务需要十进制语义,优先从字符串/十进制输入构造 Decimal。Decimal(0.1) 会把 float 已经存在的二进制近似带进来。
数值正确性先看“模型”,再看类型名
金额不应因为 float 写起来方便就直接使用二进制浮点比较;计数适合 int;比例/测量可能接受 float 误差;财务十进制常用 Decimal。类型选择是在选择误差模型和运算语义,不只是选择能不能存这个数字。
主动回忆
- 为什么
0.1 + 0.2能暴露 float 的表示模型? - 金额、计数、科学测量三类数据分别应先考虑怎样的数值语义?
29. Decimal、Fraction 与 complex【L1/L2】
当普通 float 的二进制近似语义不符合需求时,可以选择更合适的数值类型。
from decimal import Decimal
from fractions import Fraction
price = Decimal("19.99")
ratio = Fraction(1, 3)
z = 3 + 4j
选择原则
float:科学计算、工程计算和绝大多数连续数值。Decimal:需要十进制精度/舍入规则的业务。Fraction:精确有理数。complex:复数计算。
Decimal("0.1") 通常比 Decimal(0.1) 更符合“精确输入十进制 0.1”的预期,因为后者已经先经历 float 表示。
开发迁移与误判边界
开发迁移: 金额、汇率等十进制定点语义优先考虑 Decimal;精确有理运算可用 Fraction;complex 服务于复数数学。选择类型应由领域误差模型决定,而不是“哪个更精确”。
误判边界: Decimal('0.1') 与 Decimal(0.1) 语义不同:后者会忠实接收二进制 float 已经产生的近似值。
主动回忆
- 财务代码为什么更推荐从字符串构造
Decimal('19.99'),而不是先写成 float 再传给 Decimal? - 如果业务允许传感器值存在容差,你会优先用 Decimal 还是 float +
isclose?说明依据。
30. NaN 与无穷【L2】
x = float("nan")
NaN 有一个非常重要的性质:
x == x # False
判断 NaN 应使用:
import math
math.isnan(x)
无穷:
float("inf")
float("-inf")
在数据处理、排序、统计和 JSON 互操作时,要特别确认对 NaN/Infinity 的约定。
机制与边界:NaN 不是“一个特殊的普通数字”
- IEEE 754 的 NaN 代表未定义/不可表示的数值结果;其比较语义刻意让
nan == nan为False,所以不能靠普通相等判断检测 NaN,应使用math.isnan()。 inf与-inf可以参与有序比较,但计算传播仍需业务约束;例如统计管道里无穷值可能让均值/范围失去意义。- 真实风险:排序、去重、数据校验如果默认“每个值都等于自己”,遇到 NaN 会破坏这一假设。
- 值得下钻时:数值计算、数据科学、金融/监控指标出现缺失或异常值传播。
主动回忆
- 为什么
x != x有时能暗示 x 是 NaN,但工程代码仍更应该写math.isnan(x)? - 一个校验规则只写
value < max_value,遇到 NaN 时可能产生什么逻辑漏洞?
31. str、Unicode、编码与解码【L1】
核心边界
str 文本 / Unicode 字符序列
bytes 原始字节序列
转换方向:
str --encode--> bytes
bytes --decode--> str
text = "你好"
data = text.encode("utf-8")
assert data.decode("utf-8") == text
常见错误
- 不明确文件/网络边界的编码。
- 把“字符数”和“UTF-8 字节数”混为一谈。
- 对已经是
str的数据再次 decode。
心智模型
编码问题本质上发生在“文本世界”和“字节世界”的边界。
最常用的边界图
程序内部文本(str)
↓ encode
文件 / 网络 / 协议(bytes)
↓ decode
程序内部文本(str)
乱码问题通常不是“字符串坏了”,而是某个边界两端对编码约定不一致。调试时先明确:当前变量到底是 str 还是 bytes,这批 bytes 原来按什么编码产生。
🏋️ 配套实战:进入卡片 31 对应训练(变体 / Bug / 面试题)
先预测
s = "你好"
b = s.encode("utf-8")
print(type(s), type(b), len(s), len(b))
预测 len 是否相同。
揭晓
不同。s 是 str,len(s) 统计 Unicode 字符/code point 数量,此处为 2;UTF-8 中每个汉字通常编码为 3 个字节,所以 len(b) 为 6。
再做一个变体
网络 socket 收到
bytes后,为什么不能“随便 decode 一下”?
变体答案
必须知道协议约定的编码和错误处理方式。bytes 到 str 的转换需要明确编码边界,否则可能解码失败或产生错误文本。
系统边界统一用“文本 ↔ 字节”思考
文件、HTTP、数据库驱动、消息队列最终都会遇到 bytes。推荐把编码/解码集中在边界:内部尽量处理 str,写入网络/磁盘时明确 encode,读取后尽早 decode。乱码问题首先检查“在哪一层把 bytes 当成 str,或使用了错误 encoding”。
主动回忆
str.encode()与bytes.decode()的方向是什么?- 一个 HTTP/文件乱码 Bug 应先检查系统边界上的哪几个 encoding/bytes-str 转换?
32. 字符串格式化、f-string 与 t-string【L1/L2】
现代日常代码优先熟悉 f-string:
name = "Tom"
age = 20
message = f"{name=}, age={age:03d}"
常用能力包括:
- 表达式嵌入。
- 对齐、宽度、小数位。
!r等转换。- 调试形式
{name=}。
Python 3.14 引入 template string literal(t-string)。它使用与 f-string 相近的插值语法,但表达式结果不是普通 str,而是 string.templatelib.Template 对象;静态字符串与 interpolation 会保持为可进一步处理的结构。它适合构造需要自定义插值处理的 API,不应简单理解成“另一种字符串拼接”。
开发迁移与误判边界
开发迁移: 日志、SQL、HTML、命令行参数都涉及“把值放进字符串”,但格式化机制并不会自动提供对应上下文的安全转义。f-string 适合展示;t-string 的价值在于把模板结构与插值值保留下来供后续处理。
误判边界: 格式化便利性与注入安全是两个问题;f'SELECT ... {user}' 仍然可能 SQL injection。
主动回忆
- 为什么 f-string 很适合日志中的普通展示,却不能替代 SQL 参数绑定?
- 如果你需要先获得模板的静态片段和插值对象,再决定如何 escape/渲染,t-string 相比立即变成 str 的 f-string 提供了什么能力?
33. bytes、bytearray、memoryview【L1/L3】
bytes:不可变字节序列。bytearray:可变字节序列。memoryview:通过 buffer protocol 访问底层二进制数据,尽量避免额外复制。
data = bytearray(b"abc")
view = memoryview(data)
view[0] = ord("A")
assert data == bytearray(b"Abc")
memoryview 更常出现在高性能 IO、二进制协议、科学计算和扩展模块边界,普通业务代码无需为了“高级”而使用。
开发迁移与误判边界
开发迁移: 网络协议、压缩、加密、文件格式最终都落到 bytes;bytearray 适合可变缓冲区,memoryview 适合在大 buffer 上切片而避免不必要复制。
误判边界: 不要把 bytes 当“乱码字符串”。编码/解码是文本语义和字节表示之间的明确边界;任意二进制数据未必存在合法文本编码。
主动回忆
- 收到 HTTP body 的 bytes 后,为什么不能无条件
.decode('utf-8')?你需要依据什么元数据或协议规则? - 处理 500MB 二进制缓冲区的子区间时,
memoryview相比普通 bytes 切片可能减少哪类成本?
34. List:动态数组心智模型【L1/L3】
核心 API
items.append(x)
items.extend(xs)
items.insert(i, x)
items.pop()
items.remove(x)
items.sort(key=...)
append vs extend
a = [1]
a.append([2, 3]) # [1, [2, 3]]
b = [1]
b.extend([2, 3]) # [1, 2, 3]
复杂度直觉
CPython list 可以理解为“保存对象引用的动态数组”:
- 末尾
append:均摊 O(1)。 - 按索引访问:O(1)。
- 头部插入/删除:O(n)。
- 查找某值:O(n)。
如果大量从两端 push/pop,考虑 collections.deque。
复杂度直觉要进入日常代码
list 适合末尾 append/pop 和按索引访问;频繁从头部插入/删除会移动大量元素。队列语义通常优先 collections.deque,不要仅因为“list 也能做”就忽略复杂度。
🏋️ 配套实战:进入卡片 34 对应训练(变体 / Bug / 面试题)
先预测
items = []
for i in range(1000):
items.append(i)
为什么 append 不需要每次都重新申请一个刚好更大的数组?
揭晓
CPython list 采用动态数组并预留额外容量,扩容不是每次 append 都发生,因此尾部 append 的摊销复杂度通常是 O(1)。
再做一个变体
如果你的核心操作是频繁从头部
pop(0),list 还是最佳选择吗?
变体答案
通常不是。头部删除需要移动后续元素,常为 O(n);队列场景更适合 collections.deque。
选择 list 时要同时考虑访问模式
list 擅长按索引访问和尾部 append;频繁从头部 pop(0) 会触发元素移动,此时 collections.deque 更合适。性能判断不要停在“list 是动态数组”,而要把数据结构和实际操作频率对应起来。
主动回忆
- 为什么说 CPython list 的主要心智模型是动态数组?
- 大量头部插入/删除为什么提示你考虑 deque 而不是继续优化 list?
35. 排序、key 与稳定排序【L1/L2】
sorted(iterable, key=..., reverse=False)
items.sort(key=...)
区别:
sorted()返回新 list,可接受任意 iterable。list.sort()原地修改 list,返回None。
Python 排序是稳定的:相同 sort key 的元素保持原相对顺序,因此可以安全组合多阶段排序。
优先 key= 提取比较键,而不是手写复杂 comparator。
真实排序案例
users = [
{"team": "b", "score": 90},
{"team": "a", "score": 90},
{"team": "a", "score": 80},
]
result = sorted(users, key=lambda u: (-u["score"], u["team"]))
key 把“如何比较”改成“为每个对象提取一次排序键”,通常比自定义 comparator 更直接,也更符合 Python 排序 API。
开发迁移与误判边界
开发迁移: 报表、分页和优先级队列输出常依赖稳定排序:先按次要键排序再按主要键排序仍可利用稳定性;更常见的是直接用 tuple key 明确多字段顺序。
误判边界: list.sort() 原地修改且返回 None;sorted() 创建新 list。把 x = items.sort() 写进业务代码会把 x 变成 None。
主动回忆
- 要按部门升序、工资降序排序,你会如何设计
key,并避免依赖难读的多次排序? - 为什么稳定排序在‘同分保持原始业务顺序’这类需求里是语义保证,而不只是实现细节?
36. 切片与 Slice Assignment【L1/L2】
items[start:stop:step]
普通 list 切片会创建新的外层 list,因此属于浅层复制语义。
a = [1, 2, 3, 4]
assert a[1:3] == [2, 3]
切片赋值可以原地改变 list 长度:
a[1:3] = [9, 8, 7]
记忆边界
a[:]:新 list,内部对象仍共享。a[::-1]:常见反转副本写法。- 切片对象本身可以通过
slice()创建。
开发迁移与误判边界
开发迁移: 批量替换列表片段时 slice assignment 可以改变长度;它是原地修改,因此所有共享同一 list 的引用都会观察到变化。普通切片读取则产生新的 list。
误判边界: a[:] = new 与 a = new 完全不同:前者保留 list identity,后者只是让名字重新绑定。
主动回忆
- 一个组件持有
config_list的引用,另一处需要整体更新内容;为什么config_list[:] = new_values与config_list = new_values对前者影响不同? a[::2] = values为什么对 values 长度有更严格要求?这和扩展切片的目标槽位数量有什么关系?
37. Tuple、Packing 与 Unpacking【L1】
tuple 的核心不仅是“不可变 list”。它非常适合表达固定结构记录、返回组合值以及解包。
point = 10, 20 # packing
x, y = point # unpacking
扩展解包:
first, *middle, last = [1, 2, 3, 4]
单元素 tuple 必须靠逗号:
x = (1,)
不是括号决定 tuple,而是逗号语义。
开发迁移与误判边界
开发迁移: tuple 很适合表达固定结构和多值协议;unpacking 能让调用方声明期望结构。a, *middle, b = values 是结构匹配式的赋值,不是复制整套数据模型。
误判边界: tuple 自身不可变不代表其成员对象不可变;([1], 2) 里的 list 仍可修改,也会影响 hashability。
主动回忆
- 为什么
point = ([1], 2)是 tuple,却不能据此断言它可哈希或‘内部完全不可变’? - API 返回 tuple 时,什么时候 NamedTuple/dataclass 会比裸 tuple 更适合长期维护?
38. Dict:哈希表与顺序【L1/L2】
核心能力
d["key"]
d.get("key", default)
d.setdefault("key", default)
d.update(other)
d.pop("key")
d.keys(); d.values(); d.items()
现代 Python 的 dict 保持插入顺序,这是语言保证,不应再把 dict 描述成“完全无序”。
查找为什么通常快
dict 通过哈希把 key 映射到内部位置,平均查找接近 O(1),但最坏情况和实际性能还受碰撞、扩容、哈希计算成本等影响。
get vs []
d[key]:不存在时KeyError。d.get(key):不存在返回默认值。
不要机械把所有 [] 都换成 get();有些场景 key 缺失本来就是错误,应尽早暴露。
开发场景:用 dict 建索引
users = [{"id": 10, "name": "A"}, {"id": 20, "name": "B"}]
by_id = {u["id"]: u for u in users}
assert by_id[20]["name"] == "B"
这类“一次 O(n) 建索引,后续多次平均 O(1) 查找”是 dict 最典型的工程价值之一,比反复在线性 list 中搜索更重要。
🏋️ 配套实战:进入卡片 38 对应训练(变体 / Bug / 面试题)
先预测
users = {u["id"]: u for u in records}
如果之后需要按 id 查询 10 万次,这个转换为什么可能值得?
揭晓
把一次 O(n) 构建成本换成大量平均 O(1) 的哈希查找;如果每次都在线性 list 中扫描,整体成本可能远高于提前建立 dict 索引。
再做一个变体
dict 平均 O(1) 是否意味着任何情况下都严格只做一步?
变体答案
不是。它是平均/摊销意义上的复杂度;哈希计算、冲突处理、扩容和恶意碰撞都会影响真实成本。
dict 的 O(1) 是平均复杂度,不是免费午餐
dict 通过 hash 提供平均近 O(1) 查找,但 key 必须保持 hash/equality 语义稳定。把可变业务字段参与 __hash__ 会让对象放进 dict/set 后出现难以定位的查找异常。排序需求也不应误用“dict 保序”来替代显式排序。
主动回忆
- dict key 为什么必须有稳定的 hash/equality 语义?
- 把一个可变字段用于对象
__hash__,对象入 set 后再修改,会产生什么风险?
39. Dict View 与遍历修改【L2】
d.keys()、d.values()、d.items() 返回的是动态 view,而不是普通 list 快照。
d = {"a": 1}
keys = d.keys()
d["b"] = 2
assert "b" in keys
遍历 dict 时修改其大小通常会导致运行时错误:
for key in d:
# del d[key] # 不要这样直接改变大小
...
若确实需要,可遍历快照:
for key in list(d):
...
安全修改模式
需要删除部分 key 时,不要一边遍历同一个 dict 一边改变其大小。常见写法是先遍历快照:
for key in list(mapping):
if should_remove(key):
del mapping[key]
是否需要快照取决于操作是否改变容器结构;修改已有 value 和增删 key 的风险不同。
机制与边界:Dict View 是动态窗口,不是快照
d.keys()/values()/items()返回 view,它们会反映字典之后的变化;这和list(d.items())创建快照完全不同。- 迭代期间改变字典大小通常会触发
RuntimeError;即使只修改已有 key 的 value 不改变大小,也应确认业务语义是否允许“边遍历边变更”。 - 选择原则:需要实时视图就保留 view;需要稳定批次、删除筛选或跨异步边界处理时,先建立快照更安全。
- 性能边界:快照有 O(n) 时间和额外内存成本,所以不要无脑
list(...)。
主动回忆
- 为什么
for k in d: del d[k]危险,而for k in list(d): del d[k]能工作?两者代价分别是什么? - 如果你先保存
keys = d.keys(),之后给 d 新增 key,keys会不会看到它?为什么?
40. Set 与集合运算【L1】
set 适合去重和快速 membership:
seen = {1, 2, 3}
2 in seen
常用集合运算:
a | b # union
a & b # intersection
a - b # difference
a ^ b # symmetric difference
a <= b # subset
frozenset 是不可变集合,可在满足哈希条件时作为 dict key 或 set 元素。
为什么集合运算比手写循环更值得记
假设数据库返回旧 ID,新请求给出目标 ID:
old_ids = {1, 2, 3}
new_ids = {2, 3, 4}
to_add = new_ids - old_ids # {4}
to_remove = old_ids - new_ids # {1}
unchanged = old_ids & new_ids # {2, 3}
这类代码直接表达业务关系,比嵌套循环同时更清晰、更容易获得好的复杂度。
边界
set 不保证你想要的业务排序,也不能装入 unhashable 元素。若“第一次出现顺序”是需求,通常应使用 dict/list 等结构显式保序,而不是指望 set 承担两个职责。
set 最有价值的不是“去重语法”
集合运算可以把很多 O(n²) 的嵌套查找变成接近 O(n) 的 membership 检查。例如权限差集、两批 ID 的增删变化、重复检测都更适合 set。代价是元素必须 hashable,而且集合本身不表达业务顺序。
主动回忆
- set 的 membership 平均复杂度为什么适合去掉嵌套查找?
- 什么业务需求会让 set 不合适,即使它去重很方便?
41. Comprehension【L1/L2】
squares = [x * x for x in range(10) if x % 2 == 0]
lookup = {x: x * x for x in range(5)}
unique = {x.lower() for x in names}
使用原则
短、线性的映射/过滤非常 Pythonic;一旦包含多层嵌套、复杂副作用或大量条件分支,普通循环通常更可读。
作用域提醒
Python 3 中 comprehension 有自己的作用域行为,不要依赖循环变量在 comprehension 外泄露。
可读性边界
comprehension 很适合“一个输入元素 → 一个输出元素/筛选”的表达。若内部需要多层异常处理、状态修改或复杂分支,把它拆成普通循环通常更容易调试。不要把“能写成一行”误认为 Pythonic。
开发迁移与误判边界
开发迁移: comprehension 适合单步映射/过滤,能把‘从 iterable 生成新容器’表达得很紧凑;一旦塞进多重副作用、异常处理或多层条件,普通循环更易调试。
误判边界: comprehension 有自己的作用域行为;同时不要为了‘Pythonic’而把复杂业务塞成一行。
主动回忆
- 把‘过滤有效订单并提取 id’写成 comprehension 很自然;如果还要记录每个无效原因,为什么普通循环通常更好?
- list/set/dict comprehension 和 generator expression 在结果容器、求值时机和内存占用上分别有什么核心差别?
第六篇 表达式与控制流
42. 算术、整除、取模与位运算【L1】
+ - * / // % **
需要特别区分:
5 / 2 # 2.5
5 // 2 # 2
5 % 2 # 1
2 ** 3 # 8
负数的 // 是向负无穷取整,而不是简单截断:
-5 // 2 # -3
位运算:
& | ^ ~ << >>
适合 bit mask、协议字段、权限位等明确二进制语义。普通业务条件不要为了“快”用位运算替代可读布尔逻辑。
开发迁移与误判边界
开发迁移: 分页、分桶、周期索引会频繁用 // 和 %;但负数时 Python 的 floor division 语义可能与某些语言的截断除法不同,跨语言移植要特别检查。
误判边界: 位运算适合掩码和协议字段,不要把它当普通布尔运算;&/| 与 and/or 的求值和返回语义不同。
主动回忆
- 在 Python 中
-3 // 2与很多使用 toward-zero 截断的语言可能不同,为什么? - 权限 bitmask 应使用位运算还是
and/or?两者分别操作什么语义层?
43. 运算符优先级与 Walrus :=【L1/L2】
不要依赖记忆所有优先级;复杂表达式宁可加括号。
Assignment expression:
if (match := pattern.search(text)) is not None:
use(match)
它适合“需要判断,同时后面还要复用结果”的场景。
反模式
如果 := 让一行承担初始化、条件、副作用和复杂计算,普通两行赋值通常更清楚。
开发迁移与误判边界
开发迁移: 复杂条件里依赖运算符优先级会提高审查成本;关键业务条件宁愿加括号。walrus 适合‘需要判断同时还要复用结果’的局部场景,而不是把赋值隐藏进所有表达式。
误判边界: := 不是普通 = 的替代语法,它是表达式;滥用会把状态变化藏进条件,降低可读性。
主动回忆
- 读取流时
while chunk := f.read(8192):为什么是 walrus 的合理场景? - 如果一条 if 同时包含
and/or/not、比较和 walrus,你何时应该拆成命名中间变量而不是继续靠优先级?
44. Truthiness 与短路求值【L1】
常见 falsy:
False
None
数值 0
空 str/list/tuple/dict/set 等
if items:
...
通常比 if len(items) > 0 更直接。
and / or 不一定返回 bool
name = user_input or "anonymous"
or 返回第一个 truthy operand,或最后一个 operand;and 返回第一个 falsy operand,或最后一个 operand。
风险
value or default 会把 0、""、False 也视为需要默认值。若只想处理 None,应显式判断 is None。
开发场景:or default 的语义陷阱
timeout = user_timeout or 30
如果 0 在业务上表示“立即超时/禁用等待”,这行会错误地改成 30。真正只把缺失值 None 替换成默认值时,应显式判断 is None。
🏋️ 配套实战:进入卡片 44 对应训练(变体 / Bug / 面试题)
先预测
value = "" or "default"
flag = [] and expensive_call()
预测 value,并说明 expensive_call() 是否执行。
揭晓
value 是字符串 "default"。空 list 为 falsy,因此 and 在左侧就短路,expensive_call() 不执行。and/or 返回操作数本身,不一定返回 bool。
再做一个变体
为什么
user_input or default可能把合法的0、""也当成“缺失”?
变体答案
因为它依据 truthiness,而不是是否为 None。如果只有 None 表示缺失,应显式判断 is None。
短路表达式返回的是操作数,不一定是 bool
x or default 很方便,但如果 0、""、[] 是合法业务值,它会把“合法 falsy”误当成“缺失”。需要区分缺失与 falsy 时,应该显式使用 is None 或 sentinel。短路技巧只有在 truthiness 与业务语义完全一致时才安全。
主动回忆
and/or为什么不保证返回 bool?- 配置值
0合法时,为什么value or default可能是错误写法?
45. 比较、链式比较与 Membership【L1】
0 < x < 10
是 Python 原生链式比较语法,可以把中间表达式只求值一次,语义比手写重复表达式更准确。
membership:
x in container
x not in container
对于 dict,默认检查 key:
"name" in user
字符串 membership 是子串判断。
开发迁移与误判边界
开发迁移: 成员检查性能依赖容器:大量 membership 查询通常应从 list 转为 set/dict;链式比较则适合表达区间,但中间表达式只求值一次。
误判边界: x in d 检查 dict key,不是 value;a < b < c 也不是简单文本替换成两个完全独立比较。
主动回忆
- 为什么把百万次
user_id in users_list改成 set 可能是算法级优化? 0 < f() < 10中f()会调用几次?这对有副作用的表达式意味着什么?
46. if、条件表达式与 while【L1】
status = "ok" if success else "failed"
条件表达式适合短小二选一,不适合堆积复杂嵌套。
while 适合终止条件不天然由 iterable 表示的循环:
while queue:
item = queue.pop()
无限循环常写:
while True:
...
但必须明确退出、取消、异常或进程终止路径。
开发迁移与误判边界
开发迁移: 条件和循环本身简单,真正的质量差异来自退出条件是否明确、状态变化是否局部。复杂 while True 若没有显式终止/超时,容易形成隐藏死循环。
误判边界: 条件表达式适合简单值选择,不适合塞入大段副作用逻辑;truthiness 也不等于业务‘有效’。
主动回忆
- 轮询任务为什么通常需要
while+ deadline/最大次数,而不是无限while True只依赖某个外部成功事件? value if cond else other何时比普通 if 更清楚,何时会降低可读性?
47. for 的真实基础:迭代协议【L1/L2】
for item in items:
...
概念上近似:
iterator = iter(items)
while True:
try:
item = next(iterator)
except StopIteration:
break
...
所以 for 的本质不是“按索引循环”,而是消费 iterator。
把 for 翻译成协议
概念上:
it = iter(source)
while True:
try:
item = next(it)
except StopIteration:
break
# loop body
理解这一层后,文件对象、generator、数据库 cursor 等为什么能被 for 消费就不再是“特殊语法记忆”。
🏋️ 配套实战:进入卡片 47 对应训练(变体 / Bug / 面试题)
先预测
for x in obj:
print(x)
把这段代码用 iter() / next() / StopIteration 写出等价心智模型。
揭晓
for 先调用 iterator = iter(obj),然后反复 next(iterator);当 next 抛出 StopIteration 时结束循环。真实字节码/实现有优化,但语言层协议可以这样理解。
再做一个变体
一个对象能被
for遍历,是否意味着它本身一定是 iterator?
变体答案
不一定。它只需要是 iterable,能通过 iter(obj) 得到 iterator;list 就是典型 iterable,但不是消耗自身状态的 iterator。
理解迭代协议后,很多 API 会统一起来
for、解包、sum()、list()、许多 comprehension 都消费 iterable。写自定义类型时,如果“它天然是一串元素”,优先考虑实现迭代协议,而不是造一个 get_all_items() 专用 API;这样可以直接进入 Python 生态的通用工具链。
主动回忆
for x in obj背后最核心的协议调用是什么?- 为什么实现 iterator protocol 能让自定义对象直接接入很多内建工具?
48. range、enumerate 与 zip【L1】
range
for i in range(5):
...
range 是惰性的 range object,不会因为 range(10**9) 就立刻创建十亿整数的 list。
enumerate
for index, item in enumerate(items, start=1):
...
优先于手工维护计数器。
zip
for name, score in zip(names, scores):
...
默认按最短 iterable 截止;如果长度不同本身是错误,可使用支持严格检查的 strict=True:
zip(names, scores, strict=True)
日常优先级
需要索引和值时先想 enumerate(),并行遍历多个序列先想 zip(),整数区间先想 range()。它们不是语法糖合集,而是在减少手工维护计数器和索引错位。
开发迁移与误判边界
开发迁移: range 生成惰性的整数序列语义,enumerate 保留元素同时给索引,zip 按并行 iterable 配对。真实代码里它们主要用于避免手工维护索引和错位。
误判边界: 默认 zip 以最短 iterable 截断;当两侧长度必须一致时,静默截断可能隐藏数据丢失,应考虑 strict=True。
主动回忆
- 对两个必须一一对应的 CSV 列表使用
zip(a, b),为什么长度不一致时可能静默产生业务错误? - 遍历列表需要序号时,为什么
enumerate(items)通常优于range(len(items))?哪些场景后者仍合理?
49. break、continue 与 Loop else【L1】
循环 else 最容易被误读。它的核心是:
循环没有通过
break提前终止时执行else。
for item in items:
if match(item):
result = item
break
else:
result = None
这很适合“搜索但没找到”的逻辑。
开发迁移与误判边界
开发迁移: 搜索/验证循环中 loop else 能表达‘没有 break 才执行’,例如遍历所有候选仍未找到;但团队不熟悉时显式函数返回可能更易读。
误判边界: else 绑定的是循环正常结束,不是 if;continue 也不会触发 else 跳过。
主动回忆
- 在
for ... else中找到目标后break,为什么 else 不执行?如果循环因为 iterable 为空结束呢? - 当 loop else 让代码评审者频繁误读时,你会如何用函数/早返回重构而保持语义?
50. assert、pass 与 del:简单语句的真实语义【L1/L2】
assert
assert condition, "message"
它是调试断言,不是永久业务校验。优化模式 python -O 下,当前 CPython 不为 assert 生成正常断言代码。
pass
if condition:
pass
pass 是语法上的空操作,占位用;它不会“跳过本轮循环”(那是 continue),也不会返回函数(那是 return)。
del
a = []
b = a
del a
del a 删除名字 a 的绑定,不等于“强制释放 list 对象”。只要 b 或其他引用仍存在,对象就继续存活。
del obj.attr 与 del mapping[key] 则分别触发属性/元素删除语义,可能进一步调用对象协议。
一眼复习
assert → 调试断言,可被 -O 移除
a pass → 空操作占位
del → 删除绑定/属性/元素,不等于强制 GC
官方:https://docs.python.org/3.14/reference/simple_stmts.html
开发迁移与误判边界
开发迁移: assert 适合开发期不变量,不适合鉴权、参数校验等必须在生产执行的逻辑,因为优化模式可能移除断言;del name 删除绑定,不等于强制释放对象。
误判边界: pass 是语法占位,不代表‘忽略异常’;del container[i] 与 del name 也处在不同语义层。
主动回忆
- 为什么不能写
assert user.is_admin作为安全检查? - 执行
del x后对象一定立刻析构吗?请结合其他引用和 CPython/实现边界解释。
51. match/case 结构化模式匹配【L1/L2】
match 不只是 switch:它可以匹配数据结构。
match event:
case {"type": "click", "x": x, "y": y}:
handle_click(x, y)
case {"type": "quit"}:
shutdown()
case _:
ignore()
可用模式包括 literal、sequence、mapping、class、OR pattern 和 guard。
常见误区
case name: 通常是 capture pattern,而不是“比较变量 name 的值”。写复杂 match 前要真正理解 pattern 语义。
开发迁移与误判边界
开发迁移: 结构化模式匹配适合解析有明确形状的命令、AST、协议消息;它比 switch 更像‘解构 + 条件’,并可能绑定变量。
误判边界: bare name pattern 通常是 capture,不是拿同名常量做比较;常量匹配需要按模式规则写可识别的 value pattern。
主动回忆
- 处理
{'type': 'login', 'user': name}这类消息时,match 相比层层if key in...的优势是什么? - 为什么
case SOME_NAME:可能不是你以为的‘与常量 SOME_NAME 比较’?
第七篇 迭代器与生成器
52. Iterable 与 Iterator【L1】
本卡视角: 正式区分“能产生 iterator 的对象”和“保存迭代状态的 iterator”;
for如何消费它们见卡 47。
区别
Iterable:可以请求 iterator
Iterator:可以不断 next() 产生元素
items = [1, 2, 3]
it = iter(items)
assert next(it) == 1
iterator 通常是有状态的一次性消费对象:
list(it) # 消耗剩余元素
list(it) # 通常已为空
判断思维
不要问“这个对象能不能 for”;更深一步问“它实现了怎样的迭代协议、返回的 iterator 是否可重复创建”。
最小实验
values = [10, 20]
it = iter(values)
assert next(it) == 10
assert next(it) == 20
list 是 iterable,但不是 iterator;iter(list) 会产生保存当前位置的 iterator。判断一个对象“能不能 for”与“自己是不是一次性迭代状态”要分开。
🏋️ 配套实战:进入卡片 52 对应训练(变体 / Bug / 面试题)
先预测
it = iter([10, 20])
print(next(it))
print(list(it))
print(list(it))
预测三次输出。
揭晓
依次是 10、[20]、[]。iterator 持有遍历状态,会被消费;第一次 list(it) 已把剩余元素全部取完。
再做一个变体
为什么把同一个 generator 传给两个消费者可能产生隐蔽 bug?
变体答案
因为它们共享同一个消耗进度;第一个消费者取走的数据,第二个就再也看不到。
Iterable 与 Iterator 的关键差异是“状态”
list 可以多次 iter(list) 得到新的 iterator;iterator 自己维护当前位置,通常被消费后不能自动复位。遇到“第二次循环为什么是空的”,先检查拿到的是可重复迭代容器,还是已经被消费的 iterator/generator。
主动回忆
- Iterable 和 Iterator 的核心差异是什么?
- 同一个 generator 第二次循环为空,你如何解释而不是把它当随机 Bug?
53. 自定义 Iterator【L2】
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
value = self.current
self.current -= 1
return value
iterator 自身的 __iter__() 通常返回 self。
实际开发中,能用 generator 清晰表达时,generator 往往比手写 iterator class 更简洁。
机制与边界:可复用 Iterable 与一次性 Iterator 要分开设计
一个标准 iterator 自己实现 __iter__() 并返回 self,再由 __next__() 推进状态并在结束时抛 StopIteration。这意味着 iterator 往往是有状态、一次性消费的。
如果你的对象需要支持多次独立遍历,更好的结构通常是:容器实现 __iter__(),每次返回一个新的 iterator,而不是让容器自己承担迭代状态。
边界:StopIteration 是协议终止信号,不应该被用作普通业务错误;generator 内部还受 PEP 479 行为影响。
主动回忆
- 为什么
iter(iterator) is iterator通常成立,而iter(container)可以每次返回不同对象? - 一个自定义集合把迭代索引存到自身,导致第二次 for 循环直接结束,你会如何重构?
54. Generator 与 yield【L1/L2】
包含 yield 的函数调用后不会立即执行完函数体,而是返回 generator object。
def countdown(n):
while n > 0:
yield n
n -= 1
g = countdown(3)
assert next(g) == 3
assert next(g) == 2
yield 会暂停函数执行状态;下一次恢复时从暂停点继续。
价值
- 惰性计算。
- 流式数据。
- 避免一次性构造巨大 list。
- 自然表达状态机式序列。
开发场景:逐行处理大文件
def errors(lines):
for line in lines:
if "ERROR" in line:
yield line
调用 errors(file) 不会先把所有结果装进 list,而是在消费时逐步执行。generator 的价值不只是省内存,还包括把“生产速度”和“消费速度”连接成流水线。
🏋️ 配套实战:进入卡片 54 对应训练(变体 / Bug / 面试题)
先预测
def numbers():
print("start")
yield 1
print("middle")
yield 2
it = numbers()
print("created")
print(next(it))
写出打印顺序。
揭晓
先打印 created。调用 generator function 只创建 generator object,不立即执行函数体;第一次 next(it) 才打印 start,然后产生 1。middle 尚未执行。
再做一个变体
generator 的“省内存”真正来自什么?
变体答案
来自惰性地产生元素并保存暂停状态,而不是一次性把全部结果 materialize 到容器中;是否更快则取决于具体工作负载。
Generator 的价值是控制“何时生产”
它不仅省内存,还能表示流式、无限、按需或昂贵计算。代价是结果通常一次性消费、异常可能延迟到迭代阶段才出现、资源生命周期也可能跟着 generator 延长。API 返回 generator 前要确认调用者能接受这些语义。
主动回忆
- 调用 generator function 会立即执行函数体吗?
- 返回 generator 的 API 在异常时机、资源生命周期和重复消费上有什么额外契约?
55. Generator Expression 与惰性【L1】
squares = (x * x for x in range(1_000_000))
与 list comprehension:
squares = [x * x for x in range(1_000_000)]
前者惰性,后者立即创建完整 list。
不要机械认为 generator 总更好
当数据很小、需要多次遍历、需要随机访问、或后续本来就要全部 materialize 时,list 往往更简单。
开发迁移与误判边界
开发迁移: 大数据流水线中 generator expression 能避免一次性物化中间列表;但惰性意味着异常、IO 和源数据读取可能延迟到消费阶段才发生。
误判边界: ‘更省内存’不等于‘总是更快’,而且生成器一次消费后通常无法自动重放。
主动回忆
sum(x*x for x in data)为什么不需要先构造平方列表?- 如果你需要遍历结果两次、随机访问或立即捕获源数据异常,generator expression 可能带来哪些额外复杂度?
56. yield from【L2】
def flatten(groups):
for group in groups:
yield from group
它不仅是 for x in group: yield x 的语法糖;在 generator delegation 里还涉及 send()、throw()、close() 和子生成器返回值传递。
日常业务先掌握“委托迭代”即可,复杂 generator 协议属于 L3 深挖内容。
机制与边界:yield from 是委托协议,不只是少写一个 for
yield from subiter 会把值产出委托给子迭代器/生成器;对子生成器而言,还能转发 send()、throw()、close(),并接收子生成器 return value 作为整个 yield from 表达式的值。
def child():
yield 1
return 99
def parent():
result = yield from child()
print(result) # 99
何时有价值:组合生成器状态机、递归遍历、把一个大 generator 拆成多个可组合阶段。普通同步数据管道只需要展平时,直接 for 往往更直观。
主动回忆
- 子生成器执行
return 42时,42 去了哪里?为什么它不是下一次yield出去的普通元素? - 什么时候
yield from xs与手写for x in xs: yield x几乎等价,什么时候又不等价?
Part II · 对象模型、模块与数据边界
第八篇 面向对象与 Python Data Model
57. Class、Instance 与 Attribute【L1】
class User:
species = "human"
def __init__(self, name):
self.name = name
User:类对象。User("Tom"):实例。species:类属性。name:通常是实例属性。
实例属性查找不仅是“看 obj.__dict__”,还会涉及类、基类和 descriptor 协议。
属性遮蔽要会预测
class User:
role = "reader"
a = User()
b = User()
a.role = "admin"
assert a.role == "admin"
assert b.role == "reader"
assert User.role == "reader"
给 a.role 赋值通常是在实例上创建同名属性,并没有改类属性。descriptor 是这个简化规则的重要例外,后面会展开。
🏋️ 配套实战:进入卡片 57 对应训练(变体 / Bug / 面试题)
先预测
class User:
role = "reader"
u = User()
print(u.role)
u.role = "admin"
print(User.role, u.role)
预测最后一行。
揭晓
输出 reader admin。第一次 u.role 可通过类属性查找得到;赋值 u.role = ... 通常在实例上建立同名属性,遮蔽类属性,并没有改 User.role。
再做一个变体
如果
role是一个 data descriptor/property,实例字典中的同名值还能优先覆盖它吗?
变体答案
通常不能。data descriptor 在属性查找优先级中高于实例 __dict__。
类首先是“共享行为的数据模型”
不要因为有三个函数就自动建 class。适合 class 的信号是:一组状态需要维持不变量,并且多个操作围绕这些状态展开。若没有持续状态,只是把几个无关函数塞到一个 namespace,module 往往更自然。
主动回忆
- class 与 instance 分别是什么运行时对象?
- 一组无状态工具函数什么时候不值得为了‘组织代码’而强行做成 class?
58. self、实例方法、classmethod、staticmethod【L1】
实例方法
class User:
def greet(self):
return self.name
obj.greet() 会通过 descriptor/bound method 机制把实例绑定为第一个参数。
classmethod
@classmethod
def from_json(cls, data):
return cls(data["name"])
适合需要知道“当前类”的替代构造器等场景。
staticmethod
没有自动的 self/cls 绑定,适合逻辑上属于类命名空间、但不依赖实例或类状态的函数。若关联性很弱,模块级函数往往更直接。
什么时候选哪一个
- 实例方法:行为依赖某个实例状态。
classmethod:行为依赖类,常用于 alternative constructor。staticmethod:逻辑与实例/类状态都无关,但概念上属于该类型。
如果 staticmethod 与类也没有明显概念关系,普通模块函数往往更简单。
🏋️ 配套实战:进入卡片 58 对应训练(变体 / Bug / 面试题)
先预测
class A:
def f(self, x):
return x
a = A()
print(a.f)
print(A.f)
为什么 a.f(1) 不需要手动传 a?
揭晓
普通函数放在类中会通过 descriptor 协议在实例访问时形成 bound method,把实例绑定为 self;A.f 仍是未绑定到特定实例的函数对象。
再做一个变体
staticmethod与普通实例方法最本质的差异是什么?
变体答案
staticmethod 不进行实例/类的自动绑定;它只是放在类命名空间中的普通可调用对象。
三种方法的选择看“需要绑定什么”
实例方法需要具体实例状态;classmethod 需要类本身,常用于替代构造器或多态工厂;staticmethod 不需要实例也不需要类,只是逻辑上归属于该类。若 staticmethod 越来越多,反而要问它们是否应该回到 module-level function。
主动回忆
- 实例方法、classmethod、staticmethod 各自自动绑定什么?
- 一个类出现大量 staticmethod 时,你会重新审视什么设计问题?
59. __new__ 与 __init__【L2】
简化理解:
__new__ 创建/返回实例
__init__ 初始化已经创建的实例
大多数普通 class 只需实现 __init__。
__new__ 常出现在:
- immutable subclass。
- 实例创建控制。
- metaclass/框架级机制。
不要把 __init__ 叫“构造函数”后就误以为它负责真正分配对象。
两阶段构造要区分
可以把普通实例创建粗略看成:__new__ 决定/返回“哪个实例对象”,__init__ 对已经得到的实例做初始化。绝大多数业务类只需要 __init__;不可变类型子类、单例/缓存实例、元编程等场景才更常触碰 __new__。重写 __new__ 时如果返回的不是当前类实例,后续 __init__ 调用规则也会不同,因此不要把它当“更早的 init”。
机制与边界:创建对象和初始化对象是两个阶段
__new__(cls, ...) 负责返回实例对象;只有当它返回的是 cls 的实例(或适当子类实例)时,Python 才会继续调用 __init__() 对该实例初始化。__init__() 不能通过返回另一个对象替换实例。
这解释了为什么不可变类型子类(如 tuple、str)常需要在 __new__() 阶段决定真正的值:等到 __init__() 时对象值已经创建完成。
不要滥用:普通业务类几乎总是只需要 __init__();只有对象创建本身需要控制时才下钻 __new__()。
主动回忆
- 为什么给
tuple子类定制内容时通常要改__new__(),而不是只在__init__()里赋值? - 如果
__new__()返回了一个完全不同类型的对象,本类的__init__()还会按正常方式执行吗?
60. 继承、多态与 Duck Typing【L1】
继承
表达“is-a”关系,但实际设计中不能仅因为“想复用代码”就继承。
多态 / Duck Typing
Python 常关注对象“能做什么”,而不是必须来自某个具体继承树:
def dump(writer, data):
writer.write(data)
只要对象满足需要的协议即可。
本卡边界
这里重点讲继承、多态和 duck typing 的语言机制。组合与继承该如何做工程选择,集中放在 卡 192,避免同一设计规则维护两遍。
开发迁移与误判边界
开发迁移: 多态的关键问题是“调用方依赖什么能力/契约”,而不是“是不是继承同一个基类”。需要做 composition vs inheritance 的设计选择时转到卡 192。
误判边界: 多态不等于必须继承同一基类;Python 的 duck typing/Protocol 允许基于能力协作。
主动回忆
- 为什么 Python 的多态不要求所有对象继承同一个具体基类?
- 什么条件成立时继承关系可以满足可替换性,而不是仅为了复用几行代码?
61. MRO 与 super()【L2/L3】
多继承时,Python 使用 Method Resolution Order 决定属性/方法查找顺序。
Class.__mro__
Class.mro()
CPython/Python 的现代 class MRO 基于 C3 linearization。
super() 的关键误区
super() 不是简单“调用父类”。它沿当前类的 MRO 从指定位置继续查找,因此在 cooperative multiple inheritance 中非常关键。
所有参与 cooperative inheritance 的方法签名和 super() 调用约定需要彼此兼容。
super() 的关键不是“父类”
多继承中 super() 沿当前类的 MRO 继续查找,因此合作式多继承要求各层方法签名和 super() 调用方式彼此兼容。直接把 super() 理解成“调用我的父类”会在 diamond inheritance 中产生错误心智模型。
🏋️ 配套实战:进入卡片 61 对应训练(变体 / Bug / 面试题)
先预测
class A:
def f(self): return "A"
class B(A):
def f(self): return "B" + super().f()
print(B().f())
预测结果,并说明 super() 是否等于“父类对象”。
揭晓
输出 BA。super() 是依据当前类与实例的 MRO 继续查找的代理,不是简单“拿直接父类”;在多继承中这一点尤其关键。
再做一个变体
为什么协作式多继承要求链上的方法尽量都使用兼容签名并调用
super()?
变体答案
因为每个类都只是沿 MRO 把控制权交给下一站;某一层硬编码父类或中断 super() 链,会让后续类被跳过。
super() 不是“调用父类名字”
super() 按当前 MRO 继续查找,真正价值体现在 cooperative multiple inheritance。直接写 Parent.method(self) 会跳过 MRO 的协作链,在 mixin/菱形继承里可能导致方法重复调用或漏调用。使用多继承时,各层签名与 super() 调用必须彼此协作。
主动回忆
super()是按哪个顺序继续方法查找?- 多继承里直接调用
Parent.method(self)为什么可能破坏 cooperative MRO?
62. Dataclass【L1/L2】
from dataclasses import dataclass, field
@dataclass
class User:
name: str
tags: list[str] = field(default_factory=list)
default_factory 避免共享一个 mutable default。
常见选项:
frozen=Trueorder=Trueslots=Truekw_only=True
边界
Dataclass 适合“以数据为中心的对象”,不是所有 class 都要 dataclass。复杂不变量、生命周期和行为仍需要正常设计。
高频陷阱:字段默认值
from dataclasses import dataclass, field
@dataclass
class Job:
tags: list[str] = field(default_factory=list)
可变字段应使用 default_factory,原因和 mutable default argument 相同:每个实例通常需要独立对象。
开发迁移与误判边界
开发迁移: dataclass 适合‘数据字段为主、行为相对简单’的对象,自动生成 init/repr/eq;但可变默认值仍应通过 default_factory,并且领域不变量仍需要明确验证。
误判边界: frozen=True 提供的是 dataclass 层面的赋值限制,不等于整个对象图深度不可变。
主动回忆
- 为什么
items: list = field(default_factory=list)优于共享一个 list 默认值? - 一个实体包含复杂生命周期、数据库操作和大量行为时,为什么‘能用 dataclass’不等于‘应该把它建成纯数据对象’?
63. Special Method:Python 语法背后的协议【L1/L2】
很多语法最终映射到对象协议:
len(x) → __len__
repr(x) → __repr__
str(x) → __str__
x == y → comparison protocol
x + y → arithmetic protocol
for x in y → iteration protocol
x[key] → subscription protocol
with x → context manager protocol
x(...) → __call__
重要边界
不要机械地把 len(x) 改写成 x.__len__()。Python 内建操作有自己的特殊方法查找规则,而且直接调用 dunder 通常降低抽象层级。
学习方法
看到 Python 语法时,尝试问“它对应哪个协议?”:len(x) → __len__,x[y] → __getitem__,with x → context manager protocol,for x in y → iterator protocol。高级 Python 的很多“魔法”会因此变成普通对象协议。
开发迁移与误判边界
开发迁移: len、迭代、比较、上下文管理等语法通过 special methods 接入数据模型。设计自定义容器时,优先实现协议让它自然融入 Python,而不是发明 get_length() 这类平行 API。
误判边界: 不要随意直接调用 dunder;内建操作可能有额外分派规则、反射操作和 NotImplemented 协作。
主动回忆
- 让自定义集合支持
len(obj)应实现哪个协议?为什么通常比要求用户调用obj.get_size()更 Pythonic? a + b为什么不应简单理解为永远只调用a.__add__(b)一次?
64. __repr__ 与 __str__【L1】
repr(obj):面向开发/调试,强调明确性。str(obj):面向用户可读表示。
class Money:
def __repr__(self):
return "Money(...)"
def __str__(self):
return "$10"
如果没有合适的 __str__,Python 可以退回到 __repr__ 表示。
不要让 __repr__ 执行昂贵 IO、修改状态或泄露密钥。
开发迁移与误判边界
开发迁移: 日志和调试对象时,__repr__ 应偏向无歧义、诊断友好;__str__ 偏向用户展示。repr 如果泄露 token/password,同样会成为生产安全问题。
误判边界: 不是所有 repr 都必须能 eval 回对象;那只是常见理想,不是普遍契约。
主动回忆
- 一个 API Credential 对象的
__repr__为什么应该主动脱敏,即使 repr 主要给开发者看? - 当只实现
__repr__而没有__str__时,str(obj)通常如何退化?这对调试有什么帮助?
65. Attribute Lookup、__getattr__ 与 __getattribute__【L2/L3】
obj.attr
背后不是单纯 obj.__dict__["attr"]。
__getattribute__:几乎所有实例属性访问都会经过它。__getattr__:正常查找失败后才作为后备。
class Config:
def __getattr__(self, name):
raise AttributeError(name)
风险
重写 __getattribute__ 非常容易无限递归,应使用 object.__getattribute__(self, name) 等方式绕回基础实现。
为什么不要轻易重写 __getattribute__
它拦截几乎所有实例属性读取,一旦实现里再次普通访问属性,很容易递归。很多“属性不存在时动态提供值”的需求只需 __getattr__;只有真正需要全面接管查找流程时才考虑 __getattribute__。
🏋️ 配套实战:进入卡片 65 对应训练(变体 / Bug / 面试题)
先预测
class A:
x = 1
def __getattribute__(self, name):
print("lookup", name)
return super().__getattribute__(name)
a = A()
print(a.x)
访问 a.x 为什么连类属性也会先经过 __getattribute__?
揭晓
实例的普通属性访问入口就是 __getattribute__;它内部再执行 descriptor、实例字典、类层级等默认查找逻辑。__getattr__ 只在正常查找失败后作为后备。
再做一个变体
为什么随意重写
__getattribute__很容易递归爆炸?
变体答案
如果实现内部再次使用 self.attr 读取属性,会再次进入自己;通常需要委托给 object.__getattribute__ / super().__getattribute__。
obj.attr 是一条协议链,不是简单查 dict
调试“为什么这个属性值不是 __dict__ 里的值”时,要想到 descriptor、class attribute、__getattribute__、__getattr__。尤其 ORM field、property、cached_property 等框架能力都建立在属性协议上;绕过协议直接操作 __dict__ 可能破坏框架不变量。
主动回忆
- 实例属性查找为什么不只是查
__dict__? - ORM/property 返回值和
obj.__dict__不一致时,你会检查哪些协议层?
66. Descriptor【L2/L3】
30 秒结论
Descriptor 是属性访问协议。定义 __get__()、__set__()、__delete__() 中任一方法的对象都可以参与 descriptor 机制;property、普通方法绑定、classmethod 等都建立在这一协议之上。
class Positive:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, owner=None):
if obj is None:
return self
return obj.__dict__[self.name]
def __set__(self, obj, value):
if value <= 0:
raise ValueError("must be positive")
obj.__dict__[self.name] = value
Data descriptor 与 non-data descriptor
- 定义
__set__()或__delete__():data descriptor。 - 只有
__get__():non-data descriptor。
实例属性查找的核心优先级可以记成:
Data descriptor
↓
instance __dict__
↓
Non-data descriptor
↓
class variable
↓
__getattr__(前面都没找到时)
因此:
property是 data descriptor,同名实例字典项不能简单压过它;- 普通 Python 函数作为类属性时是 non-data descriptor,所以实例属性可以遮蔽同名普通方法;
obj.method自动绑定self,本质也与函数对象的__get__()有关。
边界
上面的顺序描述的是实例属性访问的核心路径。类属性访问、super()、自定义 __getattribute__() 等还有各自细节。
官方:https://docs.python.org/3.14/howto/descriptor.html
先预测
class D:
def __get__(self, obj, owner):
return "descriptor"
class A:
x = D()
a = A()
a.__dict__["x"] = "instance"
print(a.x)
如果 D 只有 __get__,预测输出。
揭晓
输出 instance。只有 __get__ 的是 non-data descriptor,实例 __dict__ 优先级更高。如果再定义 __set__ / __delete__,成为 data descriptor 后通常会压过实例字典。
再做一个变体
普通实例方法为什么也是 descriptor 的实际应用?
变体答案
类里的函数对象实现了 __get__;通过实例访问时返回 bound method,从而自动绑定 self。
Descriptor 是框架作者的能力,业务代码不必滥用
它非常适合 reusable field、验证器、ORM 映射等“很多类属性共享同一访问规则”的场景。单个属性只需要 getter/setter 时,property 往往更直观。掌握 descriptor 的目标首先是能读懂 property、method binding、ORM,不是每个项目都自己造 descriptor。
主动回忆
- data descriptor 与 non-data descriptor 的优先级差异是什么?
- 为什么普通方法可被实例同名属性遮蔽,而 property 通常不会?
🏋️ 配套实战:进入卡片 66 对应训练(变体 / Bug / 面试题)
查找优先级必须会背后的原因
简化顺序:
data descriptor
→ instance __dict__
→ non-data descriptor / class attribute
→ __getattr__ fallback
property 之所以能阻止实例字典随意覆盖它,普通 method 又能被实例同名属性遮蔽,核心就在 data / non-data descriptor 的优先级差异。
67. property 与受控属性【L1/L2】
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return 3.14159 * self.radius ** 2
调用方使用属性语法:
circle.area
但内部执行逻辑。
设计价值
可以先暴露普通属性,未来在不改变调用语法的情况下加入计算/校验。但 property 不应隐藏昂贵网络调用或大量副作用,否则调用者会被“看起来像字段”的语法误导。
设计边界
property 适合“看起来仍是属性,但需要校验/计算/兼容旧 API”的场景。若一次访问会做网络 IO、数据库查询或昂贵副作用,伪装成普通属性会让调用成本不可见,通常应改成显式方法。
开发迁移与误判边界
开发迁移: property 适合保持属性式 API 的同时加入校验、计算或兼容层;它还能让内部表示变化而不立刻破坏调用方。
误判边界: property 会把一次看似普通的属性访问变成执行代码,因此不要在 getter 中偷偷做昂贵网络 IO。
主动回忆
- 把
user.age从公开字段演进为需要校验的属性时,property 如何减少 API 破坏? - 为什么一个访问数据库的 getter 更适合显式方法名,而不是 property?
68. __slots__【L2】
class Point:
__slots__ = ("x", "y")
它可以限制实例属性布局,并在大量实例时减少某些内存开销。
不要误解
- 它不是安全访问控制。
- 它不是“让 Python class 变成 struct”的简单开关。
- 继承、weakref、dataclass 等组合时有额外规则。
只有在实例量大、内存确实经过测量成为问题时,才值得专门优化。
机制与边界:__slots__ 是实例布局约束,不是“性能魔法”
__slots__ 可以让某些类实例不再为任意属性维护普通 __dict__,从而在大量小对象时降低内存开销,并限制可设置的属性集合。
但它有重要边界:基类/子类如果带 __dict__,实例仍可能拥有字典;需要弱引用时通常还要考虑 __weakref__;复杂多继承下 slots 组合也有约束。
不要误用:它不是私有属性机制,也不应为了单个对象的微小性能收益牺牲可扩展性。先 profile/measure,再决定是否值得。
主动回忆
- 为什么声明了
__slots__的类,某些子类实例仍可能拥有__dict__? - 你有 500 万个结构固定的小节点对象和 200 个普通业务对象,哪一类更值得考虑 slots?为什么?
第九篇 异常与资源管理
69. Python 异常模型【L1】
异常用于表示正常控制流无法继续的情况:
try:
value = int(text)
except ValueError:
...
基本结构
try:
...
except SomeError:
...
else:
... # try 没有异常时
finally:
... # 无论是否异常都执行清理
设计原则
只捕获你能处理的异常。不要为了“不报错”写:
try:
...
except Exception:
pass
这会同时吞掉真实 bug、配置错误和不可恢复状态。
异常处理的真正问题
不要先问“这里能不能 try/except”,而要问:这一层知道怎么恢复吗? 如果只能打印后继续,往往是在吞错。捕获应尽量靠近能增加上下文、重试、降级或转成领域错误的边界。
异常处理的三个层次
发现错误 → raise 合适异常
传播错误 → 不处理就沿调用栈上抛
边界处决策 → retry / 转换 / 记录 / 返回错误响应
越靠近底层,越应该保留具体原因;越靠近应用边界,越适合把底层异常转换成领域/API 可理解的错误。捕获异常后若要保留因果关系,可使用 raise ... from ...。
try:
value = int(raw)
except ValueError as exc:
raise ConfigError("invalid port") from exc
异常类型是 API 契约的一部分
好的异常设计让调用者能区分“参数非法”“资源不存在”“外部依赖超时”“内部程序错误”。不要用一个 except Exception: return None 把所有失败压扁;这会丢掉可观测性,也让上层无法决定重试、降级还是直接失败。
主动回忆
- 异常对象怎样沿调用栈传播?
- 为什么
except Exception: return None会同时破坏错误语义和可观测性?
70. 异常层级、BaseException 与 Exception【L1/L2】
大多数业务异常继承 Exception。BaseException 还覆盖如 KeyboardInterrupt、SystemExit 等通常不希望普通业务代码吞掉的异常。
因此日常兜底通常写:
try:
risky_operation()
except Exception as exc:
handle(exc)
而不是:
try:
risky_operation()
except BaseException:
handle_everything()
自定义异常:
class ConfigurationError(Exception):
pass
异常类型名称应表达失败语义,而不只是“发生错误”。
开发迁移与误判边界
开发迁移: 库代码通常捕获 Exception 范围内的可恢复错误,并让 KeyboardInterrupt/SystemExit 等控制流异常继续传播;异常层级决定了你到底吞掉什么。
误判边界: BaseException 不是‘更全面所以更好’,捕获过宽可能让进程无法正常终止。
主动回忆
- 为什么服务代码常写
except Exception:做边界日志,却通常不写except BaseException:? - 设计自定义异常时,为什么让同一领域异常继承公共基类有助于调用方做分层处理?
71. raise from 与异常链【L2】
低层异常常需要转换为更有业务意义的异常,同时保留根因:
try:
load_config()
except OSError as exc:
raise ConfigurationError("cannot load config") from exc
这样 traceback 能展示:
底层 OSError
↓ caused by
上层 ConfigurationError
如果有意隐藏上下文:
raise NewError(...) from None
应谨慎使用,因为它会减少调试信息。
机制与边界:异常链是在保留“原因”,不是堆更多 traceback
当低层异常需要转换成领域异常时,raise DomainError(...) from exc 会把原异常放进 __cause__,清楚表达“新异常由它直接导致”。如果只是想隐藏无关底层上下文,可使用 from None 抑制展示。
try:
parse_config()
except ValueError as exc:
raise ConfigError("配置非法") from exc
设计原则:对外抽象异常时保留可诊断原因;不要把所有异常都包一层新类型,也不要无条件 from None 抹掉真正线索。
主动回忆
- 为什么
raise ConfigError(...) from exc比raise ConfigError(...)更适合明确的异常转换? - 什么时候
from None是改善错误信息,什么时候会让生产问题更难诊断?
72. ExceptionGroup 与 except*【L2】
并发任务可能同时产生多个错误。ExceptionGroup 可以把多个异常作为一个结构化异常传播。
try:
...
except* ValueError as group:
...
except* OSError as group:
...
except* 会按异常类型从异常组中拆分匹配,而不是传统 except 那样“只处理一个当前异常对象”。这与 asyncio.TaskGroup 等结构化并发场景关系密切。
机制与边界:ExceptionGroup 解决“一次有多个失败”
普通异常模型假设控制流上只有一个当前失败;并发/批处理任务却可能同时产生多个独立异常。ExceptionGroup 把它们作为一个结构化异常树传播,except* 会按异常类型拆分匹配子组,因此多个 except* 分支都可能处理同一次 group 的不同部分。
它和普通 except 的思考方式不同:你不是“选中一个异常”,而是在对一组失败做分区。
典型来源:asyncio.TaskGroup 中多个子任务失败。
主动回忆
- 一个 ExceptionGroup 同时含
ValueError和TypeError,两个不同的except*分支能否都执行?为什么? - 为什么把多个并发失败强行只保留“第一个异常”会损失诊断信息?
73. Context Manager 与 with【L1/L2】
with open("data.txt", encoding="utf-8") as f:
data = f.read()
核心价值:把资源获取与释放绑定在一个结构化作用域中,即使中途抛异常也能执行清理。
对象协议:
__enter__
__exit__
自定义
from contextlib import contextmanager
@contextmanager
def managed():
acquire()
try:
yield resource
finally:
release()
常见资源
文件、锁、数据库事务、临时目录、trace/span、patch 环境等。
自定义资源也能进入 with
context manager 的核心不是“文件语法”,而是把 acquisition / cleanup 绑定到一个词法作用域。锁、数据库 transaction、临时环境设置都可以用这个协议,让清理不依赖每个 return/exception 分支手写。
🏋️ 配套实战:进入卡片 73 对应训练(变体 / Bug / 面试题)
先预测
with open("demo.txt", "w") as f:
f.write("x")
即使 write 后面抛异常,with 最重要的保证是什么?
揭晓
离开上下文时会调用 context manager 的退出逻辑,因此文件有机会被可靠关闭。它把“获取资源 → 使用 → 无论成功失败都释放”绑定成结构化控制流。
再做一个变体
数据库 transaction、thread lock 为什么也适合 context manager?
变体答案
它们都有明确的成对生命周期:进入时获取/开始,退出时释放/提交/回滚;with 可以把清理路径集中表达,减少漏释放。
Context Manager 管的是“成对生命周期”
数据库事务、锁、临时目录、文件、trace span 都有“进入 → 使用 → 无论成功失败都退出”的结构。with 的价值不是少写 finally,而是把资源生命周期封装成协议,让调用者很难忘记清理。
主动回忆
with解决的核心问题是什么?- 事务、锁、临时目录为什么都适合 context manager,而不只是文件?
74. ExitStack【L2】
当资源数量是动态的,嵌套多个 with 不方便:
from contextlib import ExitStack
with ExitStack() as stack:
files = [stack.enter_context(open(path)) for path in paths]
ExitStack 可以注册多个清理动作,并以栈顺序退出,非常适合“运行时决定资源数量”的场景。
机制与边界:ExitStack 把“资源数量动态”变成可组合清理栈
静态数量资源最清晰的写法仍是普通 with。当资源数量、类型或清理动作由运行时决定时,ExitStack 可以逐个 enter_context() 或注册 callback,并按 LIFO 逆序清理;中途获取资源失败时,已经进入的资源也会正确退出。
pop_all() 可以把当前清理责任转移出去,所以它也能表达“先注册回滚,成功后取消回滚”的事务式模式。
边界:ExitStack 自身被 GC 并不会隐式执行回调,必须显式关闭或放进 with。
主动回忆
- 循环打开第 4 个文件时失败,为什么 ExitStack 能保证前 3 个已打开文件被关闭?
- 一个操作先注册 rollback,全部成功后不想执行 rollback,应利用 ExitStack 的哪个思路?
第十篇 模块、包与 Import System
75. Module 是什么【L1】
一个 .py 文件被导入后通常对应一个 module object。模块不是简单“把另一个文件复制进来”,而是有自己的命名空间和初始化过程。
import math
print(type(math))
模块级代码通常在首次实际加载时执行。
Import 会执行模块顶层代码
# config.py
print("loading config")
VALUE = 1
第一次成功 import 通常会执行这些顶层语句并创建 module object。因此模块顶层最好避免不可控的网络调用、重量初始化和环境副作用。
import 一个 module 时可以发生执行
# settings.py
print("loading settings")
TIMEOUT = 3
第一次 import settings 不只是“读取常量”,还会执行 module 顶层语句,然后把结果留在 module namespace。这个事实解释了为什么 module-level registration 很常见,也解释了为什么顶层做数据库连接、网络请求等重副作用会增加启动和测试风险。
Module 同时是代码单元和运行时对象
第一次 import 一个模块时,顶层代码会执行并创建 module namespace;以后通常通过 sys.modules 复用同一 module object。把网络请求、昂贵初始化、不可控副作用放在 module 顶层,会让 import 变成难测试、难控制的执行动作。
主动回忆
- module 第一次 import 时发生了什么?
- 为什么在 module 顶层发网络请求会让 import、测试和启动过程都变脆弱?
76. __name__ 与 __main__【L1】
if __name__ == "__main__":
main()
表示:只有当前模块作为程序入口直接执行时运行 main();作为模块 import 时不运行该入口逻辑。
不要把所有业务初始化都塞在 if __name__ ... 里。最好让入口尽量薄,真实逻辑放进可测试函数。
为什么这个判断常用于 CLI 入口
当文件被直接执行时,模块的 __name__ 通常是 "__main__";被 import 时则是模块名。因此常见结构是把可复用逻辑放进函数,只在入口保护内做命令行启动:
def main():
...
if __name__ == "__main__":
main()
这样 import 模块不会顺便启动程序。
开发迁移与误判边界
开发迁移: 模块既可以被 import 也可以被直接执行;if __name__ == '__main__' 用于把‘可导入定义’与‘脚本入口副作用’分开。
误判边界: 它不是包管理器或 CLI 框架;复杂入口最好把业务放函数里,再由 main 做参数解析和退出码。
主动回忆
- 为什么把数据库迁移逻辑直接写在模块顶层会让测试 import 这个模块时也产生副作用?
python -m package.module与直接执行文件时__name__有什么共同点,为什么 main guard 有用?
77. Package 与 __init__.py【L1/L2】
传统 package 通常是包含 __init__.py 的目录:
app/
__init__.py
service.py
models.py
Python 也支持 namespace package,因此“没有 __init__.py 就一定不是 package”并不始终成立。
Relative import
包内部可以使用显式相对导入:
from . import service
from ..models import User
. 表示当前 package,.. 表示父 package。相对导入依赖 package 上下文,因此“把包内模块直接当脚本文件运行”和“通过 package 导入/-m 运行”可能表现不同。
__all__
__all__ = ["foo", "bar"]
它主要声明模块/package 希望暴露给 from module import * 的名字集合,也可作为公开 API 意图的一部分;它不是访问控制或安全边界。
__init__.py 适合组织 package 初始化和稳定公开 API,但不要在 import package 时偷偷执行昂贵网络 IO 或复杂副作用。
开发迁移与误判边界
开发迁移: package 是组织 import namespace 的方式,不等于发行包 distribution。__init__.py 可建立包并暴露公共 API,但在里面做重 IO/注册副作用会拖慢所有子模块 import。
误判边界: 现代 Python 还有 namespace package;所以‘目录必须有 __init__.py 才可能是包’不是无条件真理。
主动回忆
- 为什么把数据库连接放进
package/__init__.py通常是坏主意? - import package 与 PyPI 上安装的 distribution package 是同一个概念吗?如何区分?
78. sys.path 与模块查找【L1/L2】
import 会使用 import system 和搜索路径寻找模块。常见观察:
import sys
print(sys.path)
高频问题
本地文件若命名为:
json.py
logging.py
random.py
可能意外遮蔽标准库模块。
不要通过到处 sys.path.append(...) 修复项目结构问题;应优先正确 packaging / installation。
开发迁移与误判边界
开发迁移: ModuleNotFoundError 排查应先看正在运行的解释器、工作目录、安装方式和 sys.path,而不是第一反应 sys.path.append(...)。
误判边界: 运行时手改 sys.path 常掩盖 packaging/启动方式问题,并造成测试能跑、部署失败。
主动回忆
- 本地脚本能 import,CI 不能 import 时,你会先检查哪几项环境事实?
- 为什么 editable install 往往比在代码开头硬编码项目根目录进
sys.path更稳健?
79. sys.modules 与 Import Cache【L2】
已经加载的模块通常会记录在:
sys.modules
再次 import 时通常复用现有 module object,而不是每次重新执行模块代码。
这解释:
- 模块级单例状态为何会共享。
- import 不是普通“执行文件”函数。
- reload 行为为什么需要谨慎理解。
为什么“改了模块全局变量,另一个 import 也能看到”
同一解释器里后续 import 通常复用 sys.modules 中的同一个 module object,而不是每次创建一份模块副本。模块因此天然可以承载共享状态——也正因为如此,全局可变状态需要谨慎。
🏋️ 配套实战:进入卡片 79 对应训练(变体 / Bug / 面试题)
先预测
import mymodule
import mymodule
为什么模块顶层代码通常不会因为第二次普通 import 再完整执行一遍?
揭晓
import 系统会先查 sys.modules。第一次加载时模块对象会进入缓存;后续普通 import 通常复用已有模块对象,而不是重新执行。
再做一个变体
这是否意味着 import 天然是“永远不会重新加载”?
变体答案
不是。可以显式使用 importlib.reload(),开发工具也可能有自己的 reload 机制;但普通 import 的核心缓存来自 sys.modules。
Import Cache 缓存的是 module object
sys.modules 让“同一个模块导入多次”通常不重复执行顶层代码,但它不是文件内容缓存。测试中随意删 sys.modules 或 reload() 可能制造多份类对象、单例、注册表状态,导致 isinstance 等行为变得反直觉。
主动回忆
sys.modules缓存的是什么?- 为什么 reload/删除缓存可能导致类身份、注册表或单例出现反直觉行为?
80. Circular Import【L1/L2】
a.py imports b.py
b.py imports a.py
问题不只是“循环”两个字,而是一个模块可能在初始化尚未完成时被另一个模块访问。
常见解决方向
- 重构依赖方向。
- 提取共同抽象到第三模块。
- 减少模块级副作用。
- 必要时延迟局部 import,但不要把它当长期架构补丁。
Circular import 往往是模块职责/依赖层次设计问题的信号。
不要只用“把 import 移进函数”掩盖问题
局部 import 可以打破初始化时序死结,但 circular import 经常说明两个模块职责互相依赖。长期修复更常见的是抽取第三个稳定模块、反转依赖或把共享协议放到更低层。
🏋️ 配套实战:进入卡片 80 对应训练(变体 / Bug / 面试题)
先预测
模块 A 顶层 import B,B 顶层又 from A import x,但 A 还没执行到定义 x。为什么会报“partially initialized module”一类错误?
揭晓
A 在执行完成前已经作为模块对象放进 sys.modules;B 回头看到的是这个“正在初始化中的 A”,其中 x 尚不存在。循环 import 的根因通常是初始化顺序与依赖方向。
再做一个变体
最优先的修复思路是把 import 移进函数,还是重新设计依赖?
变体答案
长期优先重新设计依赖边界/抽取共同模块;局部 import 可以打破初始化环,但更多是战术手段,不应掩盖架构循环。
Circular Import 往往暴露模块职责耦合
循环导入的直接机制是:模块 A 尚未初始化完成时,B 已拿到 A 的半初始化 module object。局部 import 可以临时绕过时机问题,但如果领域模块长期互相依赖,更根本的修复通常是抽取共享层、反转依赖或重新划分职责。
主动回忆
- 循环导入为什么会拿到‘半初始化模块’?
- 局部 import 能止痛但什么时候应该重新划分模块职责?
81. importlib、Finder 与 Loader【L3】
import system 可扩展。粗略流程:
import name
↓
检查 sys.modules
↓
寻找 module spec
↓
Finder 找到模块
↓
Loader 创建/执行 module
↓
缓存到 sys.modules
importlib 暴露了大量导入系统接口。Plugin system、动态加载和自定义 importer 会接触这些能力,但普通业务项目通常无需重写 import machinery。
原理下钻:Import 是 Finder → ModuleSpec → Loader 的协议
导入系统不是“按路径打开 .py 文件”这么简单。sys.meta_path 上的 finder 决定自己能否找到目标模块并返回 ModuleSpec;loader 再负责创建/执行模块。标准文件导入只是这个协议的一种实现,zip、冻结模块、扩展模块和自定义虚拟模块都能接入同一框架。
导入过程中模块会与 sys.modules 缓存协同,以支持复用并处理递归导入;因此自定义 loader 必须遵守 import machinery 的生命周期,而不是自行拼一个 module object 就算完成。
不要依赖:私有 finder/loader 类型和内部细节可能变化;优先使用公开 importlib API。
主动回忆
- 如果你想让
import settings_prod从数据库而不是文件系统加载配置,概念上应该扩展 Finder 还是修改 Python 语法? - 为什么自定义 Loader 仍必须尊重
sys.modules和 ModuleSpec,而不能只执行一段字符串?
第十一篇 类型注解与静态类型
82. Type Hint 不改变 Python 的动态类型本质【L1】
def add(a: int, b: int) -> int:
return a + b
默认情况下,Python 运行时不会因为调用:
add("a", "b")
就自动执行静态类型检查。
Type hints 主要服务:
- IDE。
- 静态 type checker。
- 文档与 API 表达。
- 框架 runtime introspection。
运行时实验
def add(x: int, y: int) -> int:
return x + y
assert add("a", "b") == "ab"
注解没有自动阻止这次调用。静态类型工具能提前指出问题,但若系统边界需要 runtime validation,仍需显式校验或使用相应库。
🏋️ 配套实战:进入卡片 82 对应训练(变体 / Bug / 面试题)
先预测
def add(x: int, y: int) -> int:
return x + y
print(add("a", "b"))
在没有额外 runtime validator 时,会因为注解自动拒绝字符串吗?
揭晓
不会,正常 Python 调用仍执行并得到 ab。Type hints 主要供静态分析、IDE、文档和框架读取;Python 运行时不会自动按注解做普遍强制类型检查。
再做一个变体
那为什么类型提示仍有价值?
变体答案
它把接口契约变得机器可分析,使错误更早在编辑器/CI 中暴露,并改善重构与 API 可读性;价值不依赖运行时强制。
Type Hint 的主要消费者是人和工具
默认 Python 运行时不会因为 x: int 就拒绝字符串。真正价值来自 IDE、type checker、review 和 API 表达。若业务确实需要运行时校验,应显式使用验证逻辑/库,不要误以为加了 annotation 就获得输入安全。
主动回忆
- Type Hint 默认会不会在运行时拒绝错误类型?
- 若 API 需要真正的运行时输入校验,为什么 annotation 本身不够?
83. 常用类型表达【L1】
str | None
list[str]
dict[str, int]
tuple[int, str]
Callable[[str], int]
Literal["open", "closed"]
现代 Python 优先使用内建泛型语法:
list[str]
而不是旧代码常见:
from typing import List
List[str]
阅读老代码时仍要识别旧语法。
开发迁移与误判边界
开发迁移: 类型表达的价值是把容器、联合、可选、Callable 等接口契约写出来,让 checker/IDE 帮你发现组合错误;重点是表达语义而不是追求最花哨的类型。
误判边界: X | None 表示允许 None,不表示参数可以省略;‘可选值’和‘默认参数’是两个维度。
主动回忆
def f(x: str | None)为什么调用f()仍可能报缺参数?- 接口接受任何
Sequence[str]时,为什么签成list[str]可能不必要地限制调用方?
84. Any 不是“所有类型的父类”【L1/L2】
Any 的核心作用是告诉静态类型系统:这里放弃/弱化类型约束。
def load() -> Any:
...
它和 object 不一样:
object:你知道它是某个 Python 对象,但要安全使用具体操作需要 narrowing。Any:type checker 基本允许任意操作,错误更可能传播。
大面积 Any 会让静态类型检查失去价值。
开发迁移与误判边界
开发迁移: Any 是静态类型系统里的逃生口:它允许检查器放弃大量约束,适合不确定边界逐步迁移,但应尽量缩小传播范围。
误判边界: Any 不是 object 的同义词。object 表示‘我不知道具体类型,但仍需安全缩窄后操作’,Any 则基本允许任意操作。
主动回忆
- 参数类型从
Any改成object后,为什么直接调用.foo()会被 checker 阻止? - 接入无类型第三方库时,如何把 Any 限制在适配层,而不是让它污染整个业务层?
85. Protocol 与 Structural Typing【L2】
from typing import Protocol
class Writer(Protocol):
def write(self, data: str) -> int: ...
def dump(writer: Writer, data: str) -> None:
writer.write(data)
对象无需显式继承 Writer,只要结构满足所需协议,静态类型系统即可接受。
它把 Python 的 duck typing 思想转化为可检查的静态契约。
为什么 Protocol 适合依赖倒置
业务函数可以声明“我需要一个具有 send() 的对象”,而不要求实现类继承某个具体基类。这样真实 SMTP client、测试 Fake、内存实现都可以按结构满足协议,减少对基础设施类层次的耦合。
机制与边界:Protocol 把“能做什么”从继承树里抽出来
Structural typing 关注对象是否具备所需接口,而不是是否继承某个共同基类。Protocol 让这种 duck typing 关系能够被静态类型检查器表达,因此第三方类即使没有显式继承 Protocol,也可以满足协议。
这适合定义边界能力:Readable、Clock、Repository 等;调用方依赖最小接口,测试 fake 也更容易替换。
运行时边界:@runtime_checkable 的检查能力有限,主要判断属性存在,不等于完整验证方法签名和静态类型约束。
主动回忆
- 为什么一个第三方类没有继承
Protocol,仍可能被静态检查器认为满足它? - 为什么
isinstance(obj, RuntimeProtocol)通过,不代表 obj 的方法参数类型一定完全满足 Protocol?
86. TypedDict【L1/L2】
用于描述“仍然是普通 dict,但 key 结构已知”的数据:
from typing import TypedDict
class UserData(TypedDict):
name: str
age: int
它不会在 runtime 自动把 dict 变成新类实例;主要是静态结构描述。
若数据需要行为、验证、方法或复杂不变量,可考虑 dataclass、普通 class 或验证模型,而不只是 TypedDict。
开发迁移与误判边界
开发迁移: TypedDict 用于描述 dict 形状,特别适合 JSON/旧接口边界;它主要服务静态检查,运行时值仍是普通 dict,不自动做 schema 验证。
误判边界: TypedDict 不是 dataclass,也不会给你属性访问、构造校验或运行时字段限制。
主动回忆
- 为什么从 HTTP JSON 得到的 dict 即使标成
UserPayload,仍不能因此假定外部数据运行时一定合法? - 什么时候 TypedDict 比创建 dataclass/Pydantic 类更轻量,什么时候需要真正运行时验证模型?
87. Generics 与 Type Parameter【L2】
现代语法可表达泛型:
def first[T](items: list[T]) -> T:
return items[0]
T 表示“输入元素类型与返回类型之间存在关系”,不是随便写一个占位符。
泛型最有价值的地方是保留类型信息:
list[str] 输入 → str 输出
list[int] 输入 → int 输出
Python 3.14 写泛型时的建议
新代码优先认识 PEP 695 的 type parameter syntax;维护旧代码时仍会大量看到 TypeVar。两种写法解决的是同一类“保持类型关系”问题,但运行时表示和声明方式并不完全相同。
机制与边界:Generic 描述“类型之间的关系”,不是创建另一套运行时对象模型
泛型真正有价值的地方是保留关联:Box[T] -> T、Repository[T] -> list[T],让检查器知道输入/成员/返回值属于同一类型变量。现代语法可以写:
class Box[T]:
def __init__(self, value: T):
self.value = value
def get(self) -> T:
return self.value
运行时仍然是普通 Python 类/对象;静态工具利用类型参数推断关系。复杂协变/逆变属于下一层,不应和“泛型基本用途”混在一起。
主动回忆
- 为什么把返回类型写成
object会丢掉T -> T的关系,而 Generic 能保留? Box[int]的类型参数主要服务运行时数据隔离还是静态分析?
88. type 语句与现代类型别名【L2】
Python 3.12+ 可以用 type 语句声明现代类型别名:
type UserId = int
type Pair[T] = tuple[T, T]
type 在这里是 soft keyword;生成的别名是 typing.TypeAliasType。
和普通赋值不要混为一谈
Vector = list[float]
仍可作为传统 type alias 写法,但 type Vector = ... 更明确表达“这是类型别名声明”,并支持新的 generic alias syntax。
类型别名的 value 在 annotation scope 中惰性求值,这使它可以引用定义稍后的名称。
官方:https://docs.python.org/3.14/reference/simple_stmts.html#the-type-statement
机制与边界:type 语句不是简单的赋值别名
现代写法:
type UserId = int
type Pair[T] = tuple[T, T]
type 是专门的类型别名声明,运行时会创建 TypeAliasType;它还能自然声明泛型 alias,并具有延迟求值能力,因此比传统 UserId: TypeAlias = int 更清晰地表达“这是类型层 API”。
边界:类型别名仍不是新的运行时封装类型;UserId 不会像 NewType 或自定义类那样提供真正的数据隔离。
主动回忆
type UserId = int是否会让UserId(1)成为不同于 int 的新运行时类型?为什么?- 泛型类型别名为什么比在每个函数签名里重复写复杂
tuple[...]更利于维护?
89. Self、overload、Type Narrowing【L2】
常见高级工具:
Self:返回当前类/子类实例类型。@overload:描述不同参数组合对应的不同返回类型。TypeGuard/TypeIs:帮助 type checker 根据运行时检查缩窄类型。ParamSpec:保留可调用对象参数签名,装饰器 typing 很常见。
原则:类型注解应该提高 API 明确度;如果为了“让 checker 闭嘴”写出比业务逻辑更复杂的类型体操,需要重新评估抽象。
机制与边界:这三个工具分别解决“自身类型、多个签名、控制流收窄”
Self:表达返回/参数类型与当前类或子类保持一致,特别适合 fluent API 和可继承构造器。@overload:给静态检查器声明“不同输入对应不同输出”;运行时仍必须有一个真正实现。- Type narrowing:通过
isinstance、TypeGuard、TypeIs等让检查器在某条控制流中缩小联合类型。
不要把它们当成运行时 dispatch。需要按运行时类型选择实现时,应使用普通分支、singledispatch 等机制。
主动回忆
- 为什么一组
@overload声明本身不能承担运行时函数实现? - 一个基类方法返回
Self时,子类调用为什么比写死基类名更精确?
90. Variance、ParamSpec 与 TypeVarTuple【L3】
高级泛型会遇到:
covariance
contravariance
invariance
它描述 Container[Sub] 与 Container[Base] 的子类型关系如何随类型参数变化。
ParamSpec 用于保留 callable 的参数签名,尤其适合类型安全装饰器;TypeVarTuple 用于可变数量类型参数。
如果项目没有复杂泛型 API,不需要为了“类型完整”主动引入这些机制;先保证普通 annotation 清晰。
原理下钻:Variance、ParamSpec、TypeVarTuple 都是在表达“高阶类型关系”
- Variance 回答:如果
Cat <: Animal,那么Container[Cat]与Container[Animal]是什么关系。可变容器通常需要更保守的 invariant 语义,因为读写同时存在。 - ParamSpec 保存一整个 callable 的参数签名,最典型用途是写不丢签名信息的 decorator。
- TypeVarTuple 表达可变长度的类型参数序列,例如形状/tuple 维度。
这些主要服务静态类型系统;如果项目没有复杂 library API,过度引入会显著增加注解认知成本。
主动回忆
- 为什么允许把
list[Cat]当成list[Animal]可能在 append 时破坏类型安全? - 写一个透明装饰器时,为什么
Callable[..., R]往往不如ParamSpec精确?
91. Python 3.14 延迟求值注解【L2】
30 秒结论
Python 3.14 默认采用 PEP 649/749 的延迟求值注解:注解通常到被访问时才求值。但如果源码显式使用:
from __future__ import annotations
仍使用此前的字符串化注解语义。
三种模型要分清
Python 3.0~3.13 默认
→ eager / stock semantics(遇到注解时求值)
from __future__ import annotations(3.7+)
→ stringified annotations(以字符串形式存储)
Python 3.14+ 默认
→ deferred evaluation(访问时延迟求值)
Python 3.14 新增 annotationlib。常见读取格式:
Format.VALUE → 尽量求值得到真实值
Format.FORWARDREF → 无法解析的名称保留为 ForwardRef
Format.STRING → 以字符串形式读取
from annotationlib import Format, get_annotations
annotations = get_annotations(func, format=Format.FORWARDREF)
边界与安全
“源代码里的 annotation”“运行时 introspection 结果”“静态 type checker 的推断”是三个不同层次。注解求值/内省还可能执行代码,不应把不可信输入直接交给相关求值接口。
官方:https://docs.python.org/3.14/library/annotationlib.html
机制与边界:3.14 的注解变成“需要时再求值”
Python 3.14 默认采用 PEP 649/749:函数、类和模块注解不再在定义时立即求值,而是保存可在需要时执行的 annotate 逻辑。这让前向引用通常不必手写字符串,也减少 import 阶段副作用。
运行时内省建议认识 annotationlib.get_annotations():可按 VALUE、FORWARDREF、STRING 等格式读取,处理尚未定义的名称更稳健。
关键例外:存在 from __future__ import annotations 时仍采用旧的字符串化语义,而不是 3.14 默认 deferred semantics。
主动回忆
- Python 3.14 中函数定义时引用一个稍后才定义的类,为什么通常不再立即
NameError? - 为什么框架读取注解时应考虑
annotationlib,而不是假定__annotations__永远已经是最终运行时对象?
第十二篇 文件、IO 与序列化
92. open() 与文本/二进制边界【L1】
with open("data.txt", "r", encoding="utf-8") as f:
text = f.read()
文本模式处理 str,二进制模式处理 bytes:
with open("image.bin", "rb") as f:
data = f.read()
原则
文本文件尽量显式给 encoding,尤其跨平台数据、协议和长期存储。
开发默认写法
from pathlib import Path
text = Path("config.txt").read_text(encoding="utf-8")
文本 IO 最重要的边界是:磁盘保存 bytes,程序通常处理 str。显式编码让同一代码在不同 locale/机器上更可预测。二进制协议、图片、压缩文件则不要经过文本编码层。
文本与二进制要在边界一次说清
from pathlib import Path
Path("note.txt").write_text("你好", encoding="utf-8")
raw = Path("note.txt").read_bytes()
assert raw == "你好".encode("utf-8")
str 是 Unicode 文本抽象,bytes 是具体字节序列。文件扩展名不会替你决定编码;文本模式只是帮你在 IO 边界执行 encode/decode。协议格式不明确时,不要靠“看起来能读”猜编码。
打开文件时同时决定“数据模型 + 编码策略”
文本模式得到 str,二进制模式得到 bytes。处理协议、图片、压缩包时优先 binary;处理明确编码的文本时显式指定 encoding。跨平台程序不要依赖系统默认编码,否则同一份文件可能只在某台机器上正常。
主动回忆
- 文本模式和二进制模式分别返回什么类型?
- 跨平台文本文件为什么应显式指定 encoding,而不是依赖系统默认值?
93. Buffering、seek() 与流【L2】
文件对象是流式接口,不等于“整个文件已经在内存”。
f.read(size)
f.tell()
f.seek(offset)
Buffering 让用户态程序不必每个字节都直接触发系统调用。
读取大文件
优先按行/块流式处理:
with open(path, encoding="utf-8") as f:
for line in f:
process(line)
不要默认 f.read() 把未知大小文件全部读入内存。
机制与边界:File Object 上面还有 buffering,下面还有 OS 文件位置
文本文件常见链路是:Python 文本层 → 编码/解码 → 缓冲二进制层 → 原始文件描述符。flush() 主要把 Python 缓冲推向下层,不等于保证磁盘已经持久化;需要 durability 时还要考虑 OS 层 fsync() 等语义。
seek() 在二进制流中按字节位置最直观;文本流受编码和 newline 转换影响,任意“字符偏移 = 字节偏移”的假设并不可靠。
何时下钻:大文件随机访问、日志刷新、崩溃后持久性、协议/二进制格式处理。
主动回忆
- 为什么
file.flush()成功后,突然断电仍不能绝对保证数据已经进入物理介质? - UTF-8 文本中为什么不能简单用“第 100 个字符”推导
seek(100)的正确字节位置?
94. pathlib【L1】
from pathlib import Path
path = Path("data") / "users.json"
if path.exists():
text = path.read_text(encoding="utf-8")
Path 用对象化 API 表达路径操作,避免大量手动字符串拼接和平台分隔符问题。
但路径操作仍受当前 working directory、权限、符号链接和 TOCTOU 等操作系统语义影响。
开发迁移与误判边界
开发迁移: 路径拼接、后缀、父目录、遍历用 pathlib 能减少字符串路径拼接错误,并更清楚表达 filesystem 语义。
误判边界: Path 对象解决路径表示,不自动解决权限、竞态和 path traversal;用户输入仍需安全边界。
主动回忆
- 为什么
base / user_input的写法更清晰,却仍不能自动防止../../etc/passwd? - 跨平台代码里 pathlib 相比手工
'/'拼接最直接减少了哪类问题?
95. JSON、CSV、TOML【L1】
JSON
import json
json.dumps(obj)
json.loads(text)
JSON 类型系统和 Python 不完全相同,例如 tuple 会失去 tuple 类型语义。
CSV
CSV 看似简单,但 quoting、delimiter、newline、encoding 都可能影响正确性,优先使用 csv 模块而不是手写 split(',')。
TOML
Python 标准库 tomllib 支持读取 TOML,适合配置;它不提供通用 TOML 写入器。
格式选择不是 API 偏好
- JSON:跨语言数据交换,类型集合较窄。
- CSV:二维表格交换,但类型和 dialect 需要约定。
- TOML:人可读配置,适合项目配置文件。
先根据数据模型和互操作边界选格式,再记具体 json/csv/tomllib API。
开发迁移与误判边界
开发迁移: JSON 适合互操作,CSV 适合表格交换,TOML 常用于人可读配置;选择格式要看数据模型、类型损失、流式处理和生态,而不是统一‘都序列化成 JSON’。
误判边界: JSON 不保存 tuple、datetime、Decimal 等 Python 专属语义;反序列化后要明确恢复/验证领域类型。
主动回忆
- 把 Decimal 金额直接转 JSON 时你需要决定什么编码策略,为什么不能假定 round-trip 后类型不变?
- 配置文件更偏人维护时,TOML 相比 JSON 可能有哪些可读性优势?
96. Pickle 与反序列化安全【L1/L2】
Pickle 可以序列化大量 Python 对象,但它不是不可信数据格式。
不要:
pickle.loads(untrusted_data)
因为恶意 pickle 可以在反序列化过程中触发危险代码执行。
选择思路
- 跨语言/外部 API:JSON 等显式格式。
- Python 内部受信环境:pickle 可能方便,但仍要考虑版本、类定义和安全边界。
开发迁移与误判边界
开发迁移: pickle 适合可信 Python 环境里的对象图持久化/进程通信等有限场景;面对外部上传、缓存污染或供应链边界时应视为代码执行风险。
误判边界: 签名/加密能改善完整性或保密性,但只有在密钥和验证链可靠时才能决定是否信任;核心规则仍是不要 unpickle 不可信数据。
主动回忆
- 为什么‘我只是在反序列化数据’这句话不足以描述 pickle 的安全性质?
- 需要跨语言交换不可信业务数据时,你会为什么优先 JSON/显式 schema 而不是 pickle?
第十三篇 标准库能力地图
97. collections【L1】
deque
适合两端 O(1) append/pop:
from collections import deque
queue = deque()
queue.append(x)
queue.popleft()
Counter
频次统计:
from collections import Counter
Counter("banana")
defaultdict
当“缺失 key 自动创建默认容器”是清晰业务语义时很好用。
ChainMap
把多个 mapping 作为分层查找视图,适合配置覆盖等场景。
从问题反推容器
- 频繁两端 push/pop →
deque。 - 计数频率 →
Counter。 - 缺失 key 自动生成容器 →
defaultdict。
标准库容器的价值在于把意图直接编码进数据结构,而不是只少写几行代码。
开发迁移与误判边界
开发迁移: deque、Counter、defaultdict、ChainMap 等解决的是特定数据结构语义。选它们的理由应是操作模式匹配,而不是‘标准库更高级’。
误判边界: 例如 deque 两端 O(1) 操作优势不代表随机索引也像 list 一样合适;defaultdict 的自动创建也可能在只读访问时产生状态。
主动回忆
- 大量从队头 pop 的任务队列为什么更适合 deque,而不是 list.pop(0)?
- 什么时候
Counter比手写 dict 计数更清楚?它对不存在键的语义有什么特点?
98. heapq 与 bisect【L1/L2】
heapq 提供最小堆能力:
import heapq
heapq.heappush(heap, item)
heapq.heappop(heap)
适合 Top-K、优先队列算法等。
bisect 在已排序序列上做二分定位/插入位置。
边界
频繁在 list 中间插入仍需 O(n) 移动元素;二分只解决“找位置”的 O(log n),不消除插入成本。
选择数据结构而不是背函数
heapq 适合持续维护“当前最小/最大若干元素、优先队列”等场景;bisect 适合在已排序 list 中用二分找到位置。要注意:bisect 查位置是 O(log n),但向 list 中间插入仍需要移动元素,整体插入通常是 O(n)。数据量和更新模式决定是否该换别的数据结构。
开发迁移与误判边界
开发迁移: top-K、任务优先级用 heapq;有序序列上的二分定位用 bisect。它们依赖不同不变量:heap 只保证堆序,不保证整体排序;bisect 假设序列已经按同一规则有序。
误判边界: 把 heap list 打印出来看到不是全排序,并不代表堆坏了。
主动回忆
- 持续维护前 100 个最大值时,为什么 heap 往往比每次全量 sort 更合适?
- 在未排序列表上调用 bisect 为什么语法上能运行,却没有你想要的查找语义?
99. functools【L1/L2】
重点:
wraps:装饰器元数据。partial:预绑定部分参数。cache/lru_cache:缓存纯度较高的函数结果。reduce:折叠序列,复杂业务中往往普通循环更清晰。singledispatch:按第一个参数类型做通用函数分派。
缓存警告
缓存会把“时间换空间”,也会延长对象生命周期。缓存 key 必须可哈希,还要考虑失效、内存和依赖外部状态的问题。
常见工具各解决什么
wraps:装饰器保留函数元数据。partial:预绑定部分参数得到新 callable。cache/lru_cache:缓存纯函数式调用结果。singledispatch:按第一个参数类型做单分派。
不要因为都在 functools 就把它们当成一类“函数式技巧”;它们分别解决元数据、参数绑定、缓存和分派问题。
开发迁移与误判边界
开发迁移: lru_cache/cache、partial、wraps、singledispatch 分别解决缓存、参数绑定、元数据保留和按类型分派。使用前先确认函数副作用、key 可哈希和缓存生命周期。
误判边界: 缓存一个依赖当前时间/数据库隐式状态的函数可能返回陈旧结果;wraps 不是装饰器的装饰品,而是保留 introspection/调试元数据。
主动回忆
- 为什么给读取数据库实时余额的函数直接加
@cache可能是业务 bug? - 装饰器没有
@wraps时,调试、签名检查和框架 introspection 可能看到什么错误信息?
100. itertools【L1/L2】
itertools 提供高效 iterator building blocks:
chain
islice
count
cycle
repeat
product
permutations
combinations
groupby
适合构建惰性数据管道,但不要为了函数式炫技把简单循环改造成难读的 iterator 嵌套。
groupby 只聚合相邻相同 key,常常需要先排序;它不是 SQL GROUP BY 的自动等价物。
itertools 的核心价值是流式组合
from itertools import chain, islice
stream = chain(range(3), range(100, 103))
assert list(islice(stream, 4)) == [0, 1, 2, 100]
很多 itertools 返回 iterator,因此具备惰性和一次性消费语义。组合很大的排列/组合时尤其要警惕结果数量本身可能指数/阶乘增长;“惰性”并不能让无限大的业务结果变便宜。
开发迁移与误判边界
开发迁移: itertools 适合构建惰性迭代流水线,能组合无限序列、分组、切片、笛卡尔积等;强项在‘按需产生’,不是让所有循环神奇变快。
误判边界: groupby 只分组相邻相同 key;若想把全体同 key 聚合,通常要先按 key 排序或换其他结构。
主动回忆
- 日志已按 user_id 排序时
groupby为什么好用?未排序时会发生什么? - 处理无限序列时,为什么惰性 itertools 可以工作,而先
list(...)可能永远结束不了?
101. 日期、时间与时区【L1/L2】
最危险的不是 datetime API 本身,而是时区语义不明确。
需要区分:
naive datetime
aware datetime
UTC
local timezone
DST
Unix timestamp
现代跨系统数据通常优先存储/传输明确的 UTC 或带 offset 时间,并在展示边界转换到用户时区。
zoneinfo 提供 IANA time zone 支持。
不要手写“UTC+8 永远加 8 小时”的逻辑处理有 DST 的地区。
最小时间原则
跨系统传递“一个确定时刻”时优先使用 timezone-aware datetime,并明确 UTC/时区转换。naive datetime 没有时区信息,不能仅凭数值判断它表示北京时间、洛杉矶时间还是 UTC。日历时间与持续时长也不要混成一个概念。
开发迁移与误判边界
开发迁移: 生产系统应明确 instant、timezone、calendar time 三者。存储通常倾向 UTC aware datetime,展示时转换到用户时区;调度还要考虑 DST 的不存在/重复本地时间。
误判边界: naive datetime 并不自动代表 UTC;把它解释成哪个时区往往依赖上下文,跨系统最危险。
主动回忆
- 为什么数据库中保存 UTC 时间戳、展示时按用户 zoneinfo 转换通常更稳健?
- ‘每天当地时间 02:30 执行’遇到 DST 切换时可能出现什么问题?
102. os、sys、shutil、tempfile【L1】
os:操作系统接口、环境变量、进程/文件系统基础。sys:解释器运行时信息、argv、path、stdio 等。shutil:高层文件/目录复制移动。tempfile:安全创建临时文件/目录。
原则:标准库已有可靠抽象时,不要用 shell command 重新实现普通文件操作。
按抽象层选择模块
os:环境变量、进程/文件系统等 OS 接口。sys:解释器、argv、path、stdin/out 等运行时接口。shutil:文件/目录的高层复制、移动、归档。tempfile:安全创建临时文件/目录。
如果只是路径拼接和遍历,优先 pathlib 往往比混写大量字符串路径更可读。
开发迁移与误判边界
开发迁移: 文件移动、临时目录、进程环境等系统操作优先使用对应标准库抽象。tempfile 的价值还包括安全创建临时资源,避免手工猜文件名造成竞态。
误判边界: os 很强但不是‘所有系统操作都用 os.path’;现代路径优先 pathlib,目录树复制/移动常用 shutil。
主动回忆
- 为什么
open('/tmp/myapp.tmp','w')的固定临时文件名可能存在竞态/安全问题,而 tempfile 更合适? - 处理路径时你如何在 pathlib、os、shutil 之间按职责选择?
103. 正则表达式 re【L1/L2】
import re
pattern = re.compile(r"\d+")
match = pattern.search(text)
重点区分:
match 从开头匹配
search 搜索任意位置
fullmatch 整个字符串匹配
findall 收集匹配
sub 替换
Raw string r"..." 只是减少 Python 字符串转义干扰,不是“regex 专用字符串类型”。
复杂嵌套文本、HTML/XML、完整编程语言等通常不应靠一个巨大 regex 解决。
开发迁移与误判边界
开发迁移: 正则适合局部文本模式,不适合替代完整 parser。生产正则还要关注灾难性回溯、边界条件和输入规模。
误判边界: match/search/fullmatch 语义不同;想验证整个字符串却只用 search,可能接受包含合法片段的非法输入。
主动回忆
- 校验整个 ID 格式时为什么
fullmatch往往比search更贴合意图? - 遇到嵌套语法或复杂转义规则时,什么时候应该停止继续堆 regex,改用 parser?
104. compression.zstd【L1/L2】
Python 3.14 标准库新增 Zstandard 支持模块 compression.zstd。这意味着在 3.14+ 环境中,一些原本依赖第三方 zstd 库的常见压缩/解压场景可以直接使用标准库能力。
选择压缩算法时仍要依据:
压缩率
速度
内存
互操作性
目标系统支持
“标准库有了”不意味着所有现有第三方库都应立即移除。
开发迁移与误判边界
开发迁移: zstd 适合高吞吐压缩场景,但压缩级别、CPU、内存、延迟和兼容性需要一起测。Python 3.14 的 compression.zstd 让常见场景无需额外依赖。
误判边界: ‘压缩率最高’不等于系统最优;实时 API、日志归档、离线备份的目标不同。
主动回忆
- 在线响应压缩为什么可能选择较低 level,而离线归档接受更高 CPU 成本?
- 引入标准库 zstd 后,为什么仍要确认对端/文件格式生态是否支持,而不是只看 Python 能不能压?
105. 标准库补充能力地图【L1】
除了前面重点模块,还应该“知道去哪里找”:
数学: math, decimal, fractions, statistics
随机/安全: random, secrets
函数/操作: operator
枚举: enum
标识: uuid
哈希/消息: hashlib, hmac
编码: base64, binascii
数据结构: array
命令行: argparse
配置/解析: configparser
URL: urllib.parse
并发: concurrent.futures
测试: unittest, unittest.mock
目标不是背 API,而是建立标准库检索意识:遇到通用问题先检查标准库是否已经提供成熟抽象,再决定引入第三方依赖。
检索能力也是 Python 能力
长期开发不可能背完标准库。更重要的是建立“问题 → 模块类别”的映射:需要队列先想 collections.deque,需要安全 token 先想 secrets,需要临时文件先想 tempfile。知道标准库存在什么能力,比背十几个冷门函数签名更可迁移。
开发迁移与误判边界
开发迁移: 标准库能力地图的目标是建立‘先想到标准库类别,再决定是否引第三方依赖’的检索习惯,而不是背模块清单。
误判边界: 标准库不自动等于最佳方案;需求超出能力、跨平台细节复杂或生态标准已有成熟第三方库时,应基于维护成本选择。
主动回忆
- 需要临时文件、优先队列、时区、压缩、子进程时,你能先分别想到哪些标准库入口?
- 什么时候你会明确放弃标准库实现,选择第三方库?请从功能、维护、安全和生态兼容说明。
Part III · 并发与 CPython Runtime
第十四篇 并发、并行与异步
106. Concurrency 与 Parallelism【L1】
30 秒结论
Concurrency 并发:多个任务在时间上交错推进
Parallelism 并行:多个任务在同一时刻真正同时执行
还要区分:
synchronous / asynchronous
blocking / non-blocking
CPU-bound / IO-bound
这些词描述不同维度,不能互相替代。
选择入口
- CPU 密集:优先考虑多进程、free-threaded 适配后的线程、多解释器、原生扩展等。
- IO 密集:线程和 asyncio 都可能合适。
- 先测量瓶颈,再选择模型。
用时间线区分两个概念
Concurrency: A 做一会 → 等待 → B 做一会 → A 继续
Parallelism: A 和 B 在同一时间真正由多个执行资源推进
高并发 HTTP 服务的目标通常先是“等待期间不要闲着”;CPU 密集计算的目标才更直接涉及并行算力。
🏋️ 配套实战:进入卡片 106 对应训练(变体 / Bug / 面试题)
先预测
某服务同时处理 100 个 HTTP 请求,但任何一个瞬间 CPU 可能只执行少量任务。这更接近 concurrency 还是 parallelism?
揭晓
首先是 concurrency:多个任务生命周期重叠、交替推进。只有多个执行单元在同一时刻真正同时做计算,才涉及 parallelism。两者可以同时存在,但不是同义词。
再做一个变体
单核 CPU 上能不能有 concurrency?
变体答案
能。通过时间片或协作式调度,多个任务可以交替推进,只是不能在同一核上真正并行执行 CPU 指令。
先判断问题需要并发还是并行
并发解决“多个任务如何交错推进”,并行解决“是否同时占用多个执行核心”。IO-bound 服务可能通过 asyncio/thread 提高吞吐而没有 CPU 并行;CPU-bound 任务则要进一步考虑进程、free-threaded build、释放 GIL 的 native code 等方案。
主动回忆
- concurrency 与 parallelism 的区别是什么?
- 一个 IO-bound Web 服务和一个纯 Python CPU 计算任务,为什么可能选择不同并发模型?
107. Process【L1/L2】
进程拥有独立的虚拟地址空间和操作系统资源上下文。多个进程默认不共享普通 Python 对象。
from concurrent.futures import ProcessPoolExecutor
优点
- 隔离强;
- 可以利用多个 CPU 核心;
- 普通 Python 对象不会像同进程线程那样天然共享。
成本
- 创建/切换成本更高;
- 数据通信需要 IPC、序列化或共享内存;
- 大对象在进程间移动可能很贵。
Python 3.14 的 start method 必须记住
multiprocessing 的主要启动方式有 spawn、fork、forkserver。Python 3.14 起:
Windows / macOS 默认:spawn
支持 forkserver 的 POSIX 平台默认:forkserver
fork:不再是任何平台的默认 start method
需要 fork 的代码应显式选择,而不要依赖旧版本/Linux 经验。spawn / forkserver 还意味着目标 callable、参数和模块入口设计必须适合子进程重新导入/序列化。
官方:https://docs.python.org/3.14/library/multiprocessing.html#contexts-and-start-methods
开发迁移与误判边界
开发迁移: CPU 密集任务可考虑进程绕开单解释器 GIL 限制并利用多核,但进程隔离意味着参数/结果传输、启动和内存成本。
误判边界: Process 不是 Thread 的‘更快版’。小任务大量分发时,序列化和 IPC 开销可能吞掉收益。
主动回忆
- 对每个只有 0.5ms 的计算任务启动/提交进程为什么可能比单进程还慢?
- CPU-heavy 图像处理和共享大量可变状态的低延迟任务,分别要权衡哪些 Process 成本?
108. 多进程通信:Queue、Pipe、Shared Memory【L2】
进程默认地址空间隔离,通信需要显式机制。
常见方式:
multiprocessing.Queue
Pipe
shared_memory
Manager(更高层代理)
Queue 适合消息传递;shared memory 避免大块数据反复复制,但需要更严格的同步和生命周期管理。
设计优先级通常是:
少共享
→ 消息传递
→ 确实需要时再共享内存
IPC 的代价模型
多进程默认不共享普通 Python 对象。Queue/Pipe 常通过序列化传递消息,简单但有复制/序列化成本;shared memory 避免部分复制,却把同步、一致性和生命周期责任交给你。先选择最简单、边界清晰的消息传递,只有测到数据复制成为瓶颈再考虑共享内存。
机制与边界:IPC 的核心权衡是复制、序列化还是显式共享
Queue:易用、适合消息传递,数据通常需要序列化/复制,边界清晰。Pipe:点对点通信更直接,但拓扑和协议由你管理。SharedMemory:减少大数据复制,但同步、一致性、生命周期清理全部需要显式设计。
因此“共享内存最快”不是足够的决策理由。大量小消息可能更适合 Queue;大数组跨进程反复复制才可能值得 shared memory。
版本/平台边界:进程启动方式影响可继承状态与资源行为,不能把 fork 经验直接套到所有平台。
主动回忆
- 两个进程每秒交换 20 条小任务消息,你为什么通常先选 Queue 而不是 SharedMemory?
- 把 2GB 数组每秒复制给 worker 成为瓶颈时,共享内存解决了什么,又新增了哪些同步风险?
109. Thread【L1】
同一进程的线程共享大部分内存,因此通信方便,但共享可变状态会产生竞争。
from concurrent.futures import ThreadPoolExecutor
适合许多阻塞 IO:文件、网络、第三方同步客户端等。
关键问题
shared state
race condition
critical section
synchronization
线程安全不是“程序没有崩溃”,而是并发执行仍满足预期不变量。
Thread 的主要工程特征
同一进程内线程天然共享 Python 对象和内存,因此通信成本低,但同步成本也随之出现。线程很适合大量会释放 GIL/等待 IO 的阻塞库;共享可变状态越多,race condition 与生命周期管理越难。
Thread 最适合解决什么
Thread 很适合大量时间花在等待且底层调用能释放 GIL 的任务,例如若干网络/文件 IO。它也适合必须与只提供同步 API 的库集成。
一个 Process
├─ Thread A ─┐
├─ Thread B ─┼─→ 共享 heap / Python objects
└─ Thread C ─┘
共享内存让通信便宜,也让隔离更弱。线程方案的成本经常不是“怎么创建线程”,而是共享状态、取消、异常、关闭顺序怎样设计。
Thread 的最大工程成本是共享状态
线程共享进程地址空间,所以传递对象方便,但也让竞态、锁和生命周期更复杂。使用 ThreadPoolExecutor 时仍要明确任务是否会共同修改同一对象;“用了线程池”并不会自动提供线程安全。
主动回忆
- 线程之间默认共享哪些进程资源?
- ThreadPoolExecutor 为什么不会自动消除共享 dict 的竞态?
110. Race Condition 与 Lock【L1/L2】
counter += 1
不要看到一行 Python 就自动认为“它是原子的”。一个高级表达式可能对应多个字节码步骤、对象方法和运行时操作。
使用锁保护共享不变量:
from threading import Lock
lock = Lock()
with lock:
update_shared_state()
常见同步工具
LockRLockSemaphoreEventCondition
原则
锁保护的是不变量和临界区,不是“看到共享变量就随便加一个锁”。锁粒度太大影响并发度,粒度太小则可能仍然破坏业务原子性。
业务不变量才是加锁单位
with lock:
if balance >= amount:
balance -= amount
不要只问“这一行是不是原子”。真正需要保护的是“检查余额 + 扣款”这个跨多步的不变量。即使单个操作偶尔看似原子,也不代表整个业务序列线程安全。
🏋️ 配套实战:进入卡片 110 对应训练(变体 / Bug / 面试题)
先预测
counter = 0
def inc():
global counter
counter += 1
两个线程各调用很多次。为什么“有 GIL”仍不能把 counter += 1 当成业务级原子操作?
揭晓
高层表达式可能涉及读取、计算、写回等多个步骤;线程调度和扩展代码都可能让操作交错。GIL 不等于你的复合不变量受到保护。共享可变状态仍需要合适同步。
再做一个变体
什么时候 Lock 保护的应该不是“一行代码”,而是多步事务式不变量?
变体答案
当正确性要求多个读写动作整体不可被其他线程观察到中间状态时,临界区应覆盖整个不变量维护过程,而不是机械锁某一行。
Race Condition 的单位通常不是“一行代码”
if key not in cache: cache[key] = build() 是典型 check-then-act,多步业务不变量即使每个 dict 操作单独安全也可能竞争。锁应该保护的是必须作为整体成立的不变量,而不是机械地给每个变量套锁。
主动回忆
- race condition 为什么常发生在多步不变量而不是单条语句?
if key not in cache: cache[key] = build()需要保护的临界区是什么?
111. Thread-local 与同步工具补充【L2】
import threading
local = threading.local()
local.request_id = "..."
Thread-local 给每个线程提供独立属性视图,适合某些遗留框架上下文,但在 async 环境中不能代替 contextvars。
同步工具补充:
Semaphore:限制同时进入资源的数量。Event:一次状态通知。Condition:等待某个受锁保护的条件成立。
不要用 thread-local 隐藏大量依赖,否则函数行为会依赖隐式线程环境。
机制与边界:Thread-local 解决“每线程状态”,同步原语解决“线程之间协调”
threading.local() 为每个 OS thread 提供各自视角的数据,适合传统线程池中的线程级上下文;它不能防止共享对象竞争,也不是 Lock 的替代品。
Event:一次状态通知/开关;Condition:围绕共享状态等待条件变化;Semaphore:限制并发进入数量;RLock:同一线程需要重入同一锁时使用。
异步协程上下文通常更适合 contextvars,因为多个 Task 可以运行在同一个线程。
主动回忆
- 为什么在 asyncio 应用里用
threading.local()保存 request_id 可能把多个 Task 混在一起? - Semaphore 限制同时访问资源的数量,它是否自动保护资源内部的复合状态不产生 race condition?
112. Deadlock、Starvation 与 Lock Ordering【L2】
死锁经典条件之一:线程 A 持有锁 1 等锁 2,线程 B 持有锁 2 等锁 1。
降低风险的手段:
- 固定锁获取顺序。
- 缩短持锁时间。
- 避免持锁期间执行未知 callback/网络 IO。
- 必要时 timeout。
- 尽量减少共享可变状态。
Starvation 则是某任务长期得不到资源,即使系统整体还在运行。
最实用的预防:固定加锁顺序
如果两个线程可能同时需要锁 A、B,一个始终 A→B、另一个 B→A 就可能形成循环等待。团队代码中给多锁场景定义统一顺序,比“发生死锁再加 timeout”更可靠。timeout 能帮助系统逃离永久等待,但不能自动修复已经破坏的业务原子性。
机制与边界:Deadlock 是“永远等”,Starvation 是“总轮不到”
经典 deadlock:线程 A 持有 L1 等 L2,线程 B 持有 L2 等 L1。最有效的工程策略之一是建立全局 lock ordering:所有路径都按固定顺序获取多把锁,破坏循环等待条件。
Starvation 则可能系统一直有进展,但某个线程长期得不到资源;公平性、长时间持锁和调度策略都可能导致它。
排错重点:线程 dump / stack 显示每个线程卡在哪把锁;不要看到“请求不动了”就默认是 GIL。
主动回忆
- A 按 L1→L2 加锁,B 按 L2→L1 加锁,最危险的交错是什么?
- 系统吞吐仍在增长但某个 worker 几分钟拿不到锁,这更像 deadlock 还是 starvation?
113. 传统 GIL 心智模型【L1/L2】
传统 CPython 构建中,GIL(Global Interpreter Lock)限制同一解释器内多个线程同时执行 Python 字节码的方式,因此 CPU 密集纯 Python 线程通常不能简单获得多核线性加速。
但要避免三个错误结论
有 GIL = 没有线程 错
有 GIL = 没有 race 错
有 GIL = 所有操作原子 错
线程仍然可以在阻塞 IO、释放 GIL 的 C 扩展等场景并发工作。
GIL 与对象安全
历史上 GIL 也简化了 CPython 对象引用计数、C API 和大量内部状态的同步问题,但它不是一个“专门用来保护某一个 Python 对象”的普通 mutex。
最容易说错的一句话
不要说“有 GIL,所以 Python 没有线程竞争”。GIL 保护的是 CPython 执行器/对象运行时的一些内部约束,不会自动让你的 check → update 业务序列原子化;而且 C 扩展、IO 等路径还可能释放 GIL。
🏋️ 配套实战:进入卡片 113 对应训练(变体 / Bug / 面试题)
先预测
一个传统 GIL-enabled CPython 进程启动 8 个 Python thread 做纯 Python CPU 密集循环。能否据此期待 8 核线性加速?
揭晓
通常不能。传统 GIL 模式下,同一解释器中执行 Python bytecode 的线程受到 GIL 约束;I/O 或释放 GIL 的 C 扩展是另一回事。
再做一个变体
“GIL 让 dict/list 自动线程安全,所以无需 Lock”为什么危险?
变体答案
即使某些单个底层操作在特定实现中表现为原子,也不能把业务上的多步操作、不变量和未来 free-threaded 行为建立在这种偶然保证上。
不要把 GIL 当成性能或线程安全的万能解释
分析性能时先区分纯 Python CPU、阻塞 IO、会释放 GIL 的 C 扩展;分析正确性时则看共享状态的不变量。一个问题是否“与 GIL 有关”需要具体到执行路径,不能只凭“用了 thread”就下结论。
主动回忆
- 传统 GIL 限制的是什么执行层?
- 为什么既不能用 GIL 证明线程安全,也不能仅凭 GIL 判断所有线程性能?
114. Free-threaded CPython 3.14【L2/L3】
Python 3.13 引入 free-threaded 构建;Python 3.14 中它成为官方支持但仍可选的构建方式。这不意味着所有默认 Python 3.14 都已经没有 GIL。
必须记住
Python 3.14 默认/GIL-enabled build
≠
free-threaded build
Free-threaded 模式允许多个线程并行执行 Python 代码,但代价包括新的同步机制、扩展兼容性、单线程/内存开销等权衡。
“安装了 3.14t”也不代表当前进程一定无 GIL
Free-threaded build 仍可以显式启用 GIL;而导入未声明支持 free threading 的 C extension 时,运行时可能发出警告并重新启用 GIL。
CPython 可用下面的实现细节接口观察当前状态:
import sys
print(sys._is_gil_enabled())
sys._is_gil_enabled() 是 CPython implementation detail,不应写成跨实现保证。
对开发者最大的变化
不要把“过去在 GIL 下恰好没暴露的数据竞争”当成线程安全设计。即使 free-threaded build 给 dict/list/set 等内建类型增加内部同步,也应优先用明确的 Lock 等同步原语表达跨线程不变量。
官方:https://docs.python.org/3.14/howto/free-threading-python.html
先预测
Free-threaded CPython 的目标是允许 Python 线程更充分并行。是否意味着删掉 GIL 后所有代码都无需重新审视线程安全?
揭晓
恰恰相反。没有传统全局锁的隐式串行化后,共享可变状态的竞态更值得显式审视;CPython 通过细粒度同步、引用计数与内存管理变化维持运行时安全,但不会替你维护业务不变量。
再做一个变体
为什么这张卡需要区分“Python 语言语义”和“CPython 3.14 free-threaded 实现”?
变体答案
GIL、biased/deferred reference counting、mimalloc 等是 CPython 实现机制,不是 Python 语言规范对所有实现的普遍要求。
3.14.7 下真正需要检查的运行时事实
Free-threaded 在 3.14 已是官方支持但仍可选的构建。迁移时依次确认:解释器是否为 free-threaded build、当前 GIL 是否实际启用、第三方 C extension 是否兼容、共享状态是否显式同步、性能是否真的受益。CPython 3.14.7 的 free-threaded 实现还会使用 biased/deferred/per-thread reference counting,并使用 mimalloc;这些都属于 CPython 实现细节。
主动回忆
- Python 3.14 free-threaded build 是默认且永远无 GIL 吗?
- 评估 3.14 多线程并行收益前,你会依次确认哪四类条件?
🏋️ 配套实战:进入卡片 114 对应训练(变体 / Bug / 面试题)
迁移到 free-threaded 时的判断顺序
先确认解释器是不是 free-threaded build,再确认 GIL 当前是否启用,然后检查依赖的 C extension 是否兼容,最后才谈多核线程扩展。把“Python 3.14 支持 free-threaded”简化成“3.14 默认无 GIL”是错误结论。
115. Asyncio 核心模型【L1】
Asyncio 的重点不是“多线程换了语法”,而是协作式并发:任务在 await 等明确挂起点让出执行权,由 event loop 调度其他任务。
import asyncio
async def fetch():
await asyncio.sleep(1)
return 42
asyncio.run(fetch())
基础对象
coroutine
Task
Future
event loop
日常代码优先把握 coroutine / Task / await,Future 更多是底层协调抽象。
Event Loop 心智模型
asyncio 不是“自动多线程”。一个 event loop 通常在一个 OS 线程里不断推进多个愿意协作让出控制权的 Task。任务遇到可等待 IO 时 await,loop 才能去推进别的任务。
🏋️ 配套实战:进入卡片 115 对应训练(变体 / Bug / 面试题)
先预测
async def main():
await io_a()
await io_b()
这两次 await 是否天然并发执行?
揭晓
不一定。按这种顺序写通常是先等待 io_a() 完成,再执行 io_b()。要让独立操作并发推进,需要创建多个 Task / 使用 TaskGroup、gather 等组织并发。
再做一个变体
event loop 的核心价值是“让 CPU 算得更快”吗?
变体答案
不是。它擅长在大量任务等待 I/O 时切换到其他可运行任务,提高单线程处理大量 I/O 并发的利用率。
Asyncio 的并发来自“同时存在多个可推进任务”
单纯连续 await a(); await b() 往往仍是串行等待。要并发,需要多个 Task 同时存在,并且这些任务在等待 IO 时会真正把控制权交回 event loop。判断 asyncio 代码时画出“有哪些 Task、每个 Task 在哪里 await、哪个调用会阻塞线程”比盯着 async 关键字更有效。
主动回忆
- event loop 如何在一个线程里推进多个 Task?
- 连续两个
await为什么不自动意味着两个操作并发?
116. async def、Coroutine 与 await【L1】
调用普通函数:
result = f()
通常立即进入函数体。
调用 async function:
coro = async_func()
得到 coroutine object;需要 await、创建 Task 或由事件循环管理才能真正推进执行。
高频错误
async_func() # 创建后忘记 await
可能产生 “coroutine was never awaited” 警告。
Coroutine object 与 Task 不要混为一谈
async def load():
return 42
coro = load() # 只是 coroutine object
# result = await coro # 在 async context 中推进它
Task 则把 coroutine 注册到 event loop,使它能够被调度推进。await 的对象也不只 coroutine;更一般地说是 awaitable。日常应用代码不必先钻 Future 底层,但要能区分“创建 coroutine”“创建 Task”“等待结果”三个动作。
调用 async function 只创建 coroutine,不会自动执行完
async def load():
return 1
coro = load()
此时 coro 是 coroutine object;必须被 await、包装成 Task,或由其他异步机制驱动。常见 RuntimeWarning: coroutine was never awaited 本质就是创建了 coroutine 却没人推进它。
主动回忆
- 调用
async def函数返回什么? coroutine was never awaited的根本原因是什么?
117. Task、TaskGroup 与结构化并发【L1/L2】
async def run_jobs():
async with asyncio.TaskGroup() as tg:
tg.create_task(job1())
tg.create_task(job2())
TaskGroup 把并发任务生命周期绑定在一个结构化作用域:退出上下文前,子任务完成或按规则取消/传播异常。
比“到处 create_task() 然后忘记保存引用”更容易管理失败、取消和清理。
为什么结构化并发更容易收尾
TaskGroup 把子任务限定在明确作用域:离开 async with 前,子任务要么完成,要么失败/取消被统一处理。它减少“创建了后台 Task 但忘记等待、异常没人取、函数已经返回”的悬空生命周期。
🏋️ 配套实战:进入卡片 117 对应训练(变体 / Bug / 面试题)
先预测
如果你启动 3 个互相关联的子任务,其中一个失败,为什么 TaskGroup 往往比“散落地 create_task 后忘记管理”更可靠?
揭晓
TaskGroup 把子任务生命周期纳入一个结构化作用域,退出时统一等待,并在失败时按规则取消/汇总相关任务异常,减少 orphan task 和遗漏异常。
再做一个变体
结构化并发的核心不是某个 API 名称,而是什么原则?
变体答案
并发任务应有清晰的父子生命周期边界:谁创建、谁等待、谁负责取消与收集失败。
结构化并发的重点是“生命周期归属”
TaskGroup 让一组子任务被一个明确作用域管理:退出作用域前等待它们结束,失败时有一致的取消/异常传播策略。与“随手 create_task 然后忘掉引用”相比,它减少 orphan task、静默异常和关闭阶段任务泄漏。
主动回忆
- TaskGroup 的‘结构化’体现在哪里?
- 为什么随手
create_task()后丢掉引用比 TaskGroup 更容易产生生命周期 Bug?
118. asyncio.gather()、to_thread() 与并发边界【L1/L2】
async def run_all():
return await asyncio.gather(job1(), job2())
gather() 适合聚合一组 awaitable 结果,但错误传播、取消和生命周期语义要根据具体需求理解。新代码若需要明确的结构化任务生命周期,TaskGroup 往往更适合。
同步阻塞函数桥接:
async def bridge(arg):
return await asyncio.to_thread(blocking_func, arg)
它把工作放到线程,不会把 CPU 密集 Python 代码神奇变成无限并行,也不能修复线程不安全的第三方库。
开发迁移与误判边界
开发迁移: gather 适合并发等待多个 awaitable 并按输入顺序收集结果;to_thread 用于把会阻塞 event loop 的同步 IO 暂时移到线程。它们不是 CPU 并行的统一方案。
误判边界: 把 CPU-heavy Python 函数丢进 to_thread 通常不会 magically 获得多核 Python 字节码并行;异常/取消语义也要明确。
主动回忆
- 同步文件/旧 SDK 调用卡住 asyncio 服务时,
to_thread解决的核心问题是什么? - 需要结构化并发和‘一个失败取消兄弟任务’语义时,为什么 TaskGroup 常比裸 gather 更清楚?
119. Blocking Code 会卡 Event Loop【L1】
async def bad():
time.sleep(10)
虽然函数写了 async def,time.sleep() 仍是同步阻塞,event loop 线程在这期间无法调度其他 coroutine。
解决方向
- 使用真正异步的 IO API。
- 必须调用同步阻塞函数时,可考虑
asyncio.to_thread()等边界桥接。 - CPU 密集工作考虑进程/其他并行方案。
“加 async”不会自动把阻塞库变成异步库。
典型错误
async def handler():
time.sleep(5) # 阻塞 event-loop 线程
若第三方库只有阻塞 API,可以评估 asyncio.to_thread();若是 CPU 密集计算,则线程转移未必解决吞吐问题,要结合 GIL/free-threaded/process 模型判断。
🏋️ 配套实战:进入卡片 119 对应训练(变体 / Bug / 面试题)
先预测
async def handler():
time.sleep(5)
return "ok"
如果这个 coroutine 跑在 event loop 线程中,5 秒内其他协程会怎样?
揭晓
time.sleep 是同步阻塞调用,会占住 event loop 所在线程,其他协程无法在这段时间正常获得调度。应使用异步 I/O、asyncio.sleep,或把必须的阻塞工作放到线程/进程边界。
再做一个变体
把所有同步函数都扔进
to_thread()是好设计吗?
变体答案
不是。它适合兼容无法异步化的阻塞 I/O;CPU 密集任务、无限制线程膨胀和需要显式资源控制的工作仍要选择更合适边界。
异步代码最危险的不是慢,而是“独占 event-loop 线程”
time.sleep()、同步 HTTP 客户端、重 CPU 计算都可能让 loop 不能推进其他 Task。修复策略取决于阻塞来源:异步库替换同步 IO;短暂同步调用可考虑 asyncio.to_thread();CPU-heavy 工作通常要考虑进程/native code,而不是把一切丢给线程。
主动回忆
- 什么叫 blocking code 卡住 event loop?
- 同步 HTTP、
time.sleep、CPU 密集计算三类阻塞分别可能怎样处理?
120. Cancellation 与 Timeout【L2】
取消不是“强杀一段 Python 代码”。Asyncio cancellation 会在任务可响应的挂起点注入取消语义,代码需要正确清理资源。
try:
...
finally:
cleanup()
Timeout 本质上也和取消机制紧密相关。
不要随意吞掉 CancelledError 后继续无限运行,否则会破坏上层结构化并发预期。
Cancellation 是控制流,不是普通失败
async 任务取消通常会在可取消点注入取消异常,任务需要让它继续传播,同时在 finally / async context manager 中完成清理。吞掉 cancellation 可能让上层以为任务已经停止,实际上资源仍在运行。timeout 本质上也经常通过 cancellation 驱动,所以超时后的清理路径必须可测试。
机制与边界:Cancellation 是协作式控制流,不是强杀线程
Task.cancel() 会安排在下一次合适机会向协程注入 asyncio.CancelledError。协程应使用 try/finally 清理资源;如果显式捕获 CancelledError,通常要清理后重新抛出,否则会破坏 TaskGroup、asyncio.timeout() 等结构化并发机制。
asyncio.timeout() 内部使用 cancellation 实现超时边界,并在 context 外表现为 TimeoutError。所以“超时”和“取消”在实现上有关联,但业务语义并不完全相同。
原则:取消路径必须和成功/异常路径一样被测试。
主动回忆
- 为什么
except BaseException: pass出现在 coroutine 中可能让 TaskGroup 行为异常? - 超时后数据库写入已经发送到远端:本地 Task 被取消是否等于远端操作一定没有发生?
121. Async Iterator 与 Async Context Manager【L2】
异步迭代:
async def consume(stream):
async for item in stream:
process(item)
对应异步迭代协议。
异步上下文:
async def use_session(session):
async with session:
await do_work(session)
用于异步获取/释放资源,如连接、session、异步锁。
它们不是普通 for/with 的“漂亮写法”,而是允许进入/退出/取下一个元素的过程本身发生 await。
机制与边界:异步协议的本质是“进入/下一项/退出本身也需要 await”
Async iterator 使用 __aiter__() 与异步 __anext__(),后者通过 StopAsyncIteration 结束;它适合每获取一项都可能等待 IO 的数据源。Async context manager 的 __aenter__() / __aexit__() 则允许资源获取和释放本身异步化。
典型场景:数据库连接、异步 HTTP stream、消息队列消费者。
不要误用:纯内存迭代不需要为了“现代”改成 async;异步协议解决的是等待问题,不会自动让 CPU 计算并行。
主动回忆
- 一个分页 API 每翻一页都要网络等待,为什么 async iterator 比先一次性拉完所有页面更自然?
async with能让 CPU 密集的资源初始化自动并行吗?为什么?
122. 多解释器【L2】
Python 3.14 标准库新增 concurrent.interpreters,并提供 concurrent.futures.InterpreterPoolExecutor。
先区分两件事
concurrent.interpreters
→ 管理/切换隔离的 interpreter execution context
→ 本身不会自动创建并发线程
InterpreterPoolExecutor
→ ThreadPoolExecutor 子类
→ 每个 worker thread 使用自己的 interpreter
→ 可以获得真正的多核并行
心智模型:
一个 OS process
├── thread A → interpreter A
└── thread B → interpreter B
解释器彼此拥有独立运行时状态,例如模块、sys、builtins、__main__ 都不是普通意义上的共享对象。可变对象不能像普通线程那样随意跨 interpreter 共享,通信/复制边界必须显式设计。
所以要记住:
多解释器首先提供隔离;并发来自你如何把解释器与线程等执行资源组合。
官方:
- https://docs.python.org/3.14/library/concurrent.interpreters.html
- https://docs.python.org/3.14/library/concurrent.futures.html#interpreterpoolexecutor
机制与边界:多解释器首先提供隔离,再与线程组合获得并行
Python 3.14 的 concurrent.interpreters 给 Python 层提供多解释器 API。每个 interpreter 有独立的 import 状态、builtins 等运行时状态;interpreter 本身不会自动创建并发,要在不同 interpreter 中同时运行仍需要线程等执行载体。
InterpreterPoolExecutor 把线程和多个 interpreter 组合起来;在传统 GIL-enabled CPython 中,每个 interpreter 有自己的 GIL,因此可以实现真正多核 Python 执行。
边界:同一进程内不是安全沙箱;扩展模块可能破坏隔离,并且不是所有第三方包都已兼容多解释器。
主动回忆
- 为什么创建两个 interpreter 后顺序在同一个线程里调用它们,并不会自动获得并行?
- 多解释器和多进程都能隔离状态,但为什么不能把 interpreter 当安全沙箱使用?
第十五篇 内存管理与垃圾回收
123. Python 对象与内存【L1/L3】
Python 层看到的是对象;CPython 底层需要为对象分配内存并维护元数据。
可以用:
import sys
sys.getsizeof(obj)
观察对象本体的某些内存大小,但不要把它误认为“整个对象图总内存”。容器引用的其他对象通常需要单独计算。
getsizeof() 只看一层
import sys
x = [bytearray(1_000_000)]
print(sys.getsizeof(x))
这个数字主要是 list 对象自身的浅层大小,不会自动递归统计它引用的百万字节对象。分析内存时要区分“对象本体大小”“对象图保留大小”“进程 RSS”,三者不是一个指标。
内存问题至少分四层
Python 对象关系
↓
Python allocator
↓
C runtime / native extension
↓
OS 看到的 RSS / virtual memory
对象已经不可达,不代表 RSS 必须立刻下降:allocator 可能保留内存供后续复用;native library 也可能持有自己的 buffer。因此排查内存增长要结合 tracemalloc、对象数量、GC/引用图以及进程级指标,而不是只盯一个工具。
Python 对象内存不能只看 getsizeof()
容器对象往往只持有对子对象的引用,sys.getsizeof(container) 通常不是整个对象图的总大小。定位内存问题应区分对象数量、引用关系、allocator 保留、RSS、Python-tracked allocation 等层次;单个数字无法解释全部内存行为。
主动回忆
- Python 对象内存为什么不能只看一个
getsizeof()? - RSS 不下降时,为什么不能立刻断定还有 Python 对象泄漏?
124. Reference Counting【L2】
CPython 仍以引用计数作为核心对象生命周期机制之一,但在 Python 3.14 必须区分默认构建和 free-threaded 构建。
默认 GIL-enabled CPython
典型心智模型:
新增强引用 → refcount 增加
释放强引用 → refcount 减少
refcount 到 0 → 通常立即触发对象释放
a = []
b = a
这里 a、b 都会贡献强引用关系。
Free-threaded CPython
为了降低多线程共享对象上的引用计数竞争,3.14 free-threaded build 还使用:
biased reference counting
+ deferred reference counting
+ per-thread reference counting
+ immortal objects
因此某些对象即使内部计数状态达到可释放条件,也可能延迟到 safe point 或后续 GC 才真正回收。
sys.getrefcount() 不要当真理
它自身观察对象也会影响引用关系;另外 immortal object 的 refcount 可能是一个很大的特殊值,并不等于真实引用数量。在 free-threaded build 中,仅凭 refcount 为 1 也不足以推断“没有其他线程访问”。
引用计数属于 CPython 实现机制,不应推广成所有 Python 实现的语言语义。
官方:
- https://docs.python.org/3.14/c-api/refcounting.html
- https://docs.python.org/3.14/howto/free-threading-python.html
先预测
a = []
b = a
del a
执行 del a 是否等于“删除 list 对象”?
揭晓
不是。del a 删除名称绑定;只要 b 仍引用该 list,对象仍然可达。引用计数只是 CPython 管理对象生命周期的一部分实现机制。
再做一个变体
为什么在 free-threaded 3.14 中不能把“引用计数一到 0 就立即在当前线程析构”当成绝对模型?
变体答案
free-threaded CPython 引入 biased/deferred 等引用计数策略与不同回收时机;教学模型必须标明这是实现相关且可能延迟。
引用计数模型要区分默认构建与 free-threaded
默认 GIL-enabled CPython 中,“引用计数归零通常立即析构”是很有用的近似模型;但 3.14.7 free-threaded build 为降低跨线程争用,存在 biased、deferred、per-thread reference counting,对象释放可能延迟。写业务代码不要依赖“某一行之后析构器一定立刻执行”的时机假设。
主动回忆
- 默认 GIL-enabled CPython 的 refcount=0 心智模型是什么?
- 为什么 free-threaded 3.14.7 中不能把‘归零立即析构’当绝对时序保证?
🏋️ 配套实战:进入卡片 124 对应训练(变体 / Bug / 面试题)
不要把 refcount 当成 Python 语言语义
“最后一个普通引用消失后对象常常立刻释放”是默认 CPython 中非常实用的经验,但不是 Python 语言保证;free-threaded CPython 的引用计数策略也更复杂。编写正确程序不要依赖精确析构时刻,资源释放使用 with。
125. 循环引用与 Cyclic GC【L2】
引用计数无法单独解决循环引用:
a = []
a.append(a)
对象构成循环后,即使外部名字删除,单靠局部引用计数信息也不足以发现整张对象图已经不可达。CPython 的 cyclic GC 用于检测这类不可达循环对象图。
import gc
CPython 3.14.7 的 generation 状态
这是版本敏感细节:
- Python 3.14.0 曾移除 generation 1;
- 3.14.5 又重新引入 generation 1,以维持 3.13 风格的 GC 行为;
- 因此本手册基线 3.14.7 的
gc接口按三代统计/分类理解。
stats = gc.get_stats() # 当前返回三个 generation 的统计字典
原则
大多数代码不需要频繁手工 gc.collect()。如果进程内存持续增长,应依次排查:
对象真的仍可达?
缓存?
循环引用?
free-threaded 的 deferred/per-thread refcount 或 QSBR 延迟?
allocator 尚未归还 OS?
C extension?
官方:https://docs.python.org/3.14/library/gc.html
先预测
a = []
b = []
a.append(b)
b.append(a)
del a, b
为什么单靠简单引用计数难以判断这两个 list 可回收?
揭晓
两个对象互相引用,计数不会因为外部名字删除而自然降到 0;cyclic GC 需要识别“虽然内部互相引用,但整体已不可从程序根部到达”的对象图。
再做一个变体
循环 GC 的存在是否意味着 Python 不再使用引用计数?
变体答案
在 CPython 中不是二选一:引用计数负责大量对象生命周期,循环 GC 补充处理引用环等情况。
GC 是引用计数的补充,不是所有对象的唯一回收器
循环引用使每个对象的 refcount 都大于零,因此需要 cyclic GC 判断整个对象图是否不可达。CPython 3.14.7 当前又有 0/1/2 三代(3.14.5 重新引入 generation 1);这类代数和阈值属于版本敏感实现细节,应用层更应该理解“为什么循环引用不能只靠 refcount”。
主动回忆
- 引用计数为什么无法单独回收循环引用?
- 应用代码应掌握 GC 的哪条稳定模型,哪些 generation 细节属于版本敏感实现?
🏋️ 配套实战:进入卡片 125 对应训练(变体 / Bug / 面试题)
为什么还需要 GC
a = []
b = [a]
a.append(b)
即使外部名字被删掉,两个容器仍互相引用。单看“引用计数是否为 0”无法识别这组对象已经从程序根部不可达,因此 CPython 还需要 cyclic GC 检测这类引用环。
126. weakref【L2】
弱引用允许“观察/缓存某对象,但不因为这份引用阻止它被回收”。
适合:
- 某些 cache。
- 对象 registry。
- 观察者关系。
不是所有内建对象都支持 weak reference。
弱引用访问时对象可能已经不存在,因此必须接受“目标消失”这个语义。
典型用途:不延长对象寿命的关联
缓存、观察者表、对象注册表有时希望“对象活着就能找到它,但注册表本身不要让它永远活着”,这正是 weak reference 的价值。不是所有对象都天然支持弱引用,自定义类型与 __slots__ 也可能需要相应配置。weakref 解决的是生命周期耦合,不是普通内存优化按钮。
机制与边界:Weak Reference 是“观察对象但不阻止它死亡”
普通引用会延长对象生命周期;weakref.ref(obj) 不提供这种强所有权。当对象没有强引用后,weak reference 会失效,可用于缓存、对象注册表、父子图结构等“不应该因为登记本身就让对象永久存活”的场景。
WeakKeyDictionary / WeakValueDictionary 能让条目随对象生命周期自动消失。
边界:并非所有类型都天然支持 weak reference;slots 类还需要正确保留 weakref 能力。弱引用也不能替代明确资源释放。
主动回忆
- 一个全局 cache 用普通 dict 把对象作为 value 永久保存,为什么可能造成逻辑内存泄漏?WeakValueDictionary 改变了什么所有权关系?
- weakref 失效后为什么必须考虑目标已经不存在,而不能假定
.ref()永远返回对象?
127. __del__ 与 Finalization【L2】
__del__ 不是普通资源管理的首选工具。对象何时最终释放可能受到循环引用、解释器退出、实现差异等因素影响。
需要确定性资源释放时优先:
with / context manager
try/finally
显式 close
不要把数据库事务提交、关键文件 flush 等正确性逻辑仅寄托在 __del__。
资源清理不要交给 __del__
析构时机受实现、引用环、解释器退出等因素影响;finalizer 代码还可能因对象复活、依赖模块已清理等情况变得难推理。文件、锁、数据库连接等确定性资源使用 context manager;__del__ 更适合作为最后防线,而不是正常控制流。
机制与边界:__del__ 是 finalizer hook,不是可靠资源管理协议
__del__() 的执行时机受对象生命周期、循环引用、解释器关闭和具体实现影响;finalizer 中抛出的异常也无法像普通调用栈一样正常传播。更麻烦的是 finalizer 可能通过把 self 保存到外部实现对象 resurrection,使生命周期更加复杂。
文件、锁、事务、网络连接应优先用 context manager 明确作用域;需要“对象死亡时尽力回调”时可评估 weakref.finalize()。
原则:正确性不能依赖“程序退出前 __del__ 一定按某顺序运行”。
主动回忆
- 为什么数据库连接不能只依赖
__del__来 commit/close? - 如果
__del__把self添加到全局列表,对象生命周期发生了什么特殊变化?
128. CPython Allocator:pymalloc 与 mimalloc【L3】
Python 3.14 不能用一条 pymalloc → Arena / Pool / Block 覆盖所有 CPython 构建。
默认 GIL-enabled build
小对象通常走 pymalloc,可以粗略理解为:
Python small objects
↓
pymalloc
↓
Arena → Pool → Block
pymalloc 主要优化不大于 512 bytes 的小对象分配。
Free-threaded build
Free-threaded CPython 不使用 pymalloc 分配 Python 对象,默认且要求相关 Python memory/object domain 使用 mimalloc:
Python objects
↓
mimalloc
↓
per-thread heaps / pages
↓
部分回收路径还会受到 QSBR 延迟
为什么对象释放了 RSS 仍可能不降
两种构建都可能出现:
对象生命周期结束
≠
allocator 立刻把页归还 OS
≠
进程 RSS 立刻下降
原因可能包括 allocator 缓存/分片、mimalloc 延迟 purge、QSBR 安全回收延迟以及系统 allocator 行为。因此“RSS 没降”不能单独证明 Python 对象仍泄漏。
官方:
- https://docs.python.org/3.14/c-api/memory.html
- https://docs.python.org/3.14/howto/free-threading-python.html
原理下钻:3.14 必须区分默认构建和 free-threaded 构建的 allocator
默认 CPython 常用 pymalloc 服务小型 Python 对象;其经典组织可理解为 block → pool → arena,并通过缓存/分层减少频繁向 OS 申请小块内存。释放 Python 对象不等于对应 RSS 马上下降,因为 arena/pool 可能继续留给解释器复用。
Python 3.14 的 free-threaded build 则不使用 pymalloc 分配 Python 对象,而使用 mimalloc。它还使用多个 heap,并可通过 QSBR 等机制延迟释放 backing pages,因此内存回收到 OS 的时机又不同。
不要依赖:具体 arena 大小、内部结构或 allocator 行为不是 Python 语言保证。做内存诊断时先确认你运行的是哪种 CPython build。
主动回忆
- 对象已经
del并被回收,为什么进程 RSS 仍可能几乎不下降? - 在 Python 3.14 free-threaded build 中,为什么用纯 pymalloc 的 Arena/Pool/Block 模型解释全部对象分配是错误的?
129. tracemalloc【L2】
import tracemalloc
tracemalloc.start()
它可以跟踪 Python 内存分配并比较 snapshot,帮助定位“哪些 Python 代码路径在持续分配”。
它并不等同操作系统级完整内存 profiler,也不能自动解释所有 C extension / native memory。
诊断时要区分 Python heap、native heap、mmap、共享内存、文件 cache 等来源。
机制与边界:tracemalloc 回答“Python 分配从哪里增长”,不是“系统所有内存去哪了”
tracemalloc 跟踪 Python 内存分配的 traceback,可对两个 snapshot 做 diff,定位某条代码路径持续增加了多少分配。这非常适合查 Python 对象层的泄漏/缓存增长。
import tracemalloc
tracemalloc.start()
# workload...
s1 = tracemalloc.take_snapshot()
# more workload...
s2 = tracemalloc.take_snapshot()
for stat in s2.compare_to(s1, 'lineno')[:5]:
print(stat)
边界:第三方 C 库、GPU、mmap 或 OS/native allocator 的全部内存不一定都能由 tracemalloc解释。
主动回忆
- RSS 每小时涨 1GB,但 tracemalloc snapshot 几乎不变,你首先应该怀疑“Python 没有泄漏”还是“增长可能在 tracemalloc 覆盖范围之外”?
- 为什么单个 snapshot 往往不如两个时间点做 diff 对定位持续增长有用?
第十六篇 CPython 执行原理
130. Code Object【L3】
函数对象和 code object 不是同一个东西。
def add(a, b):
return a + b
print(add.__code__)
函数对象还携带 globals、defaults、closure 等运行时绑定,而 code object 更接近已编译代码信息。
理解这个区别有助于解释:同一段代码逻辑如何与不同 closure/default/global 环境组合成函数对象。
原理下钻:Code Object 是“编译结果”,Function 才是“可调用对象”
一个 code object 保存字节码、常量表、名字、参数/局部变量元数据和源码位置信息等,但它本身没有某次调用的局部运行状态。函数对象则把 code object 与 globals、defaults、closure 等环境绑定起来。
因此同一个 code object 可以被多次调用,每次调用产生不同 frame;也能由不同函数对象在不同环境中复用。
边界:code object 字段和 bytecode 细节高度 CPython/版本相关,适合 debugger、instrumentation、解释器研究,不适合业务代码硬编码依赖。
主动回忆
- 为什么“函数 = code object”这个说法不完整?至少还缺哪些运行环境信息?
- 同一个函数被递归调用 20 次时,是产生 20 个 code object 还是 20 个执行 frame?
131. Frame 与调用栈【L2/L3】
函数调用需要执行上下文:局部状态、指令位置、调用关系等,这些由 frame 相关机制承载。
main frame
↓ calls
foo frame
↓ calls
bar frame
异常 traceback 本质上会沿调用 frame 链展示失败路径。
递归不断创建新的调用层,因此存在 recursion limit;Python 不保证尾递归优化。
调试时 Frame 为什么重要
每次函数/代码执行都有相关 frame,保存局部名字、代码位置、调用关系等上下文;traceback 本质上沿失败时的 frame 链展示“从哪里一路调用到这里”。因此调试器能查看某一层局部变量,也正是建立在 frame/introspection 能力上。生产代码一般不要长期持有 frame 引用,它可能连带保留大量局部对象。
原理下钻:Frame 是一次执行的上下文快照
frame 把 code、globals/builtins、局部状态、当前执行位置以及与调用链相关的信息联系起来。每次函数调用、生成器/协程恢复都需要相应执行上下文;traceback 正是沿 frame 记录调用路径。
这也解释一个内存陷阱:长期保存 traceback/frame 会间接保存 frame 中引用的局部对象,导致大对象无法释放。
版本边界:CPython 3.11+ 对内部 frame 表示做过显著优化;应依赖 inspect、traceback 等公开 API,而不是假设 C 层 PyFrame 内部布局稳定。
主动回忆
- 为什么把异常 traceback 放进全局列表可能让本应结束的请求对象一直活着?
- 递归函数共享同一个 code object,为什么每一层仍能拥有各自不同的局部变量?
132. Bytecode 与 dis【L2/L3】
import dis
def f(x):
return x + 1
dis.dis(f)
dis 适合:
- 理解高级语法执行步骤。
- 研究性能/解释器。
- 验证某些“是否原子”的错误猜测。
边界
不要把某个 micro version 的 opcode 名称当长期稳定 API。CPython 字节码是实现细节,会随版本变化。
dis 适合解释,不适合当稳定 API
它很适合回答“这个表达式大致拆成了哪些解释器指令”“为什么一行代码不是一个原子动作”。但 opcode、specialization 和字节码格式会随 CPython 版本变化;不要把某个版本的具体指令序列写成业务正确性的前提。
原理下钻:dis 是观察解释器指令的窗口,不是稳定 ABI
CPython 编译 code object 后会产生解释器指令。dis 可以查看逻辑指令、源码位置,也可通过 show_caches=True 观察 inline cache,通过 adaptive=True 查看运行时 specialization 后的字节码。
同一段 Python 语义在不同版本可能生成完全不同的 opcode;优化器也可能折叠常量或改变指令布局。
使用价值:解释名称加载、函数调用、循环成本或 specialization;不要写依赖具体 opcode 序号/偏移的普通业务逻辑。
主动回忆
- 为什么升级 CPython 后
dis输出大变,但单元测试全部通过并不矛盾? adaptive=True看到的指令为什么可能和最初编译得到的逻辑 bytecode 不完全一样?
133. Adaptive / Specializing Interpreter【L3】
现代 CPython 会根据运行时观察对某些常见操作进行 specialization,使热点字节码采用更具体、更快的执行路径。
心智模型:
通用操作
↓ 观察运行时类型/模式
specialized execution
这意味着“Python 解释器只是永远执行同一套最通用 opcode”已经过时。
但业务代码优化仍应从算法、IO、数据结构和 profiler 开始,而不是手工猜 specialization。
原理下钻:Specialization 是“根据运行时反馈替换更具体的快路径”
Adaptive interpreter 会收集某些操作的类型/目标等运行时信息,把通用指令 specialization 成适合当前模式的版本,并使用 inline cache 保存辅助数据;条件失效时又可以 deopt 回更通用路径。
这是一种语义透明优化:正确 Python 代码不应该依赖“当前是否已经 specialized”。它与实验性 JIT 是不同层面的优化能力,不要混为一谈。
何时值得下钻:解释 warm-up、microbenchmark 波动、研究解释器性能;日常业务优化仍应先 profile 算法和 IO。
主动回忆
- 为什么一个属性访问循环跑久后可能出现不同的 specialized 指令,但结果必须保持一致?
- Specializing interpreter 与 JIT 都是性能机制,为什么不能简单说它们是一回事?
134. Python 调用栈、OS 线程与 CPU 的关系【L2】
需要把不同层次分开:
Python function/frame
↓
CPython interpreter
↓
OS thread
↓
OS scheduler
↓
CPU core
Python frame 不是 OS thread;coroutine 也不是 OS thread;进程更不是“一个 Python 函数”。
把这些层次分开后,并发问题会清晰很多。
机制与边界:把 Process、Interpreter、Thread、Frame、CPU 五层分开
可用这条链理解一次同步 Python 调用:
OS Process
└─ CPython Interpreter
└─ OS Thread
└─ Python Frame / eval loop
└─ CPU 执行机器指令
一个进程可以有多线程;3.14 还可以有多个 interpreter;一个线程在调用栈上可关联多个 frame,但某一时刻实际推进的是当前执行 frame。asyncio 的多个 Task 也通常在同一个 OS thread 上轮流推进,并不等于创建多个线程。
这张图用于避免把“协程切换、线程调度、GIL、CPU core”混成一个概念。
主动回忆
- 一个 asyncio 程序有 1 个进程、1 个线程、1000 个 Task:是否意味着同时有 1000 个 OS thread?
- 递归深度增加的是 frame 数量、thread 数量还是 process 数量?
可选高级专题:元编程、反射与动态能力
这一篇属于可选高级专题。先掌握对象模型、属性查找、Descriptor、Import 和 CPython 执行模型,再下钻这些机制。
135. getattr、setattr、hasattr、dir、vars【L1/L2】
Python 可以通过名字动态访问对象:
value = getattr(obj, "name")
setattr(obj, "name", "Tom")
hasattr(obj, name) 本质上会尝试属性访问并根据 AttributeError 判断;如果属性访问本身有复杂 descriptor/动态逻辑,需要意识到它可能执行代码。
dir(obj) 适合交互式探索,但不是稳定的“对象所有字段清单”协议。vars(obj) 通常尝试访问对象的 __dict__;使用 __slots__ 的实例可能没有普通实例 __dict__。
原则
动态属性访问适合框架、序列化、插件和适配层;普通业务流程能用显式属性时,显式通常更易重构和静态分析。
开发迁移与误判边界
开发迁移: 反射 API 适合框架、序列化、插件和调试:getattr/setattr 动态访问,dir/vars introspection。但业务代码如果所有字段都靠字符串拼接,会失去显式接口和静态检查。
误判边界: hasattr 会尝试 getattr,并只把 AttributeError 视为不存在;属性 getter 中的异常语义需要谨慎。
主动回忆
- 配置驱动调用插件方法时
getattr(plugin, name)为什么合理,但为什么必须限制允许的 name? vars(obj)和dir(obj)分别更接近什么信息?为什么 dir 不是简单等于__dict__.keys()?
136. Introspection 与 inspect【L2】
Introspection 是“程序在运行时检查自身对象信息”。
import inspect
sig = inspect.signature(func)
print(inspect.iscoroutinefunction(func))
常见能力:
函数签名
source / module 信息
class/member 检查
coroutine/generator 判断
frame / stack 检查
边界
反射信息不是免费的稳定业务 API。依赖私有属性、frame 局部变量或某个 CPython 实现细节的代码容易随版本变化。
机制与边界:Introspection 是“问运行时对象它是什么”,不是随意窥探内部实现
inspect.signature()、inspect.getmembers()、inspect.iscoroutinefunction()、getattr() 等公开工具能让框架根据函数签名、注解、方法类别动态工作。依赖注入框架、CLI 自动生成、序列化和测试工具大量使用这类能力。
代价:越依赖运行时反射,控制流越隐式,静态搜索和重构越困难;读取私有属性、frame 内部结构还会产生版本耦合。
原则是:先使用明确接口,只有“框架必须处理未知用户对象”时才把 introspection 当核心能力。
主动回忆
- 一个 CLI 框架根据函数 signature 自动生成参数,这属于 introspection 还是 metaprogramming?
- 为什么业务代码用
inspect猜第三方对象私有字段,升级后比调用公开方法更脆弱?
137. type、动态创建类与 Metaclass【L2/L3】
普通情况下:
class User:
pass
创建出来的 User 本身也是对象,默认通常由 metaclass type 创建:
assert type(User) is type
type 也能动态创建类:
User = type("User", (), {"kind": "human"})
Metaclass 可以介入类对象的创建过程,例如修改 namespace、注册类、验证类定义。
什么时候不要用
如果 class decorator、__init_subclass__、descriptor 或普通工厂已经足够,metaclass 往往是更重、更隐式的机制。它适合框架级 class construction protocol,而不是普通业务复用技巧。
原理下钻:类定义最终也会经历“准备 namespace → 执行类体 → 创建类对象”
普通类默认由 type 创建。metaclass 可以介入类对象创建过程,用 __prepare__ 提供类体 namespace、用 __new__/__init__ 检查或改写类定义。因此 metaclass 操作的是类的创建规则,不是普通实例生命周期。
动态 type(name, bases, namespace) 可以直接构造类,但多数“注册/校验子类”的需求其实用 __init_subclass__ 或 class decorator 更简单。
原则:只有当规则必须在整个类族创建阶段统一介入时,再考虑 metaclass。
主动回忆
class User(metaclass=M): ...中,M 管的是 User 实例创建还是 User 这个类对象的创建?- 如果需求只是“每个子类定义时自动登记到 registry”,为什么
__init_subclass__往往比 metaclass 更轻?
138. __init_subclass__、Class Decorator 与注册机制【L2】
很多过去使用 metaclass 的简单注册场景,可以使用:
class Plugin:
registry = {}
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
Plugin.registry[cls.__name__] = cls
或者 class decorator:
def register(cls):
registry[cls.__name__] = cls
return cls
选择顺序
普通函数/显式注册
→ class decorator / __init_subclass__
→ metaclass(确实需要控制类创建协议时)
优先选择最小魔法层级。
机制与边界:三种类扩展机制按“介入范围”选择
__init_subclass__:基类希望在子类创建完成时验证、注册或填默认配置;天然沿继承层级传播。- Class decorator:显式对某个类做一次转换/注册,局部、可读,不要求共享基类。
- Metaclass:统一控制一个类族的创建协议,能力最强也最隐式。
插件 registry、ORM model 校验等经常可以先从前两者开始。不要因为“自动注册”就直接使用 metaclass。
边界:多个框架 metaclass 组合可能产生 metaclass conflict,这也是保持机制简单的重要理由。
主动回忆
- 要求所有
Plugin子类自动注册且它们都有共同基类,你会先考虑哪种机制? - 两个第三方基类拥有不兼容 metaclass 时,多继承为什么可能直接在类创建阶段失败?
139. Monkey Patching 与动态执行边界【L2】
运行时替换属性:
module.func = fake_func
属于 monkey patching 的一种形式。测试 patch、兼容层和临时 instrumentation 会用到,但生产代码大量依赖它会让真实行为取决于 import/执行顺序。
更强的动态能力还有 eval()、exec()、动态 import 等。它们不是禁用特性,但要明确:
可读性成本
静态分析损失
安全边界
调试难度
能用数据驱动、registry、Protocol 或显式 dependency injection 表达时,通常比 runtime 改写对象更稳。
机制与边界:Monkey Patch 改的是运行时绑定,因此风险来自“全局、时序、导入位置”
Monkey patch 本质是在运行时重新绑定模块/类属性。它对临时测试替换很有用,但生产代码长期 patch 第三方实现会把行为藏在初始化顺序里:先 import 还是先 patch、谁持有旧引用、并发线程何时看到变化,都可能影响结果。
eval/exec 则更进一步,把数据变成代码执行;面对不可信输入时属于安全边界而不是“动态技巧”。
原则:测试 patch 要作用于“被测代码实际查找的名字”;生产扩展优先公开 hook、dependency injection、plugin API。
主动回忆
- 模块 A 已经
from lib import f,此后只改lib.f,A 里的旧名字一定跟着变化吗?这和 patch 位置有什么关系? - 为什么 monkey patch 在单测隔离里常可接受,而作为生产系统长期扩展机制通常脆弱?
Part IV · Python 工程实践
第十七篇 操作系统、网络与数据库
140. Program 与 Process:运行实例和 OS 资源容器【L1】
Program 磁盘上的程序/代码
Process 一次正在运行的程序实例,是 OS 资源与地址空间的边界
同一个 program 可以同时启动多个 process。一个 Python process 内通常至少有一个执行线程,也可以再创建线程、socket、文件描述符等;线程本身的并发/共享状态模型见 卡 109。
进程隔离改变了共享模型
Program: app.py
↓ 启动两次
Process A Process B
├─ memory A ├─ memory B
└─ threads └─ threads
两个进程即使执行同一份代码,普通 Python 对象也默认存在于各自地址空间。需要交换数据时必须通过 IPC、socket、shared memory 等明确机制。这也是多进程往往比多线程隔离更强、通信成本更高的原因。
本卡边界
本卡只负责 program → process → OS 资源边界。Thread 的同步、race condition、GIL 等机制统一回到并发篇,不在这里重复教学。
主动回忆
- program 与 process 的区别是什么?为什么同一份程序可以对应多个 process?
- 为什么两个 process 默认不共享同一个普通 Python 对象?需要共享时必须跨过什么边界?
141. Environment、Working Directory 与 Signal【L1/L2】
环境变量:
import os
value = os.environ.get("APP_ENV")
当前工作目录:
from pathlib import Path
Path.cwd()
相对路径是相对于 working directory 解释的,不是自动相对于当前 .py 文件。
Signal 是 OS 向进程传递异步通知的一种机制。Python signal 能处理部分信号,但平台差异明显,而且 handler 中应避免复杂不安全操作。
开发迁移与误判边界
开发迁移: 环境变量和 cwd 是进程运行上下文,部署、IDE、cron、systemd 之间可能不同;相对路径和配置读取不要偷偷假定启动目录。signal 则是 OS 与进程协作的异步通知机制。
误判边界: 环境变量不是天然安全 secret store,cwd 也不是项目根目录保证。
主动回忆
- 同一脚本在 IDE 正常、cron 找不到配置文件,为什么 cwd 是第一批要检查的事实?
- 优雅关闭服务时,signal handler 更适合做什么,不适合在里面执行哪些复杂阻塞工作?
142. stdin、stdout、stderr 与 Exit Code【L1】
传统进程接口:
stdin 标准输入
stdout 正常输出
stderr 错误/诊断输出
Python:
import sys
sys.stdin
sys.stdout
sys.stderr
命令行程序还应通过 exit code 表达成功/失败,而不是只打印“失败了”。
CLI 的正确输出契约
成功结果写 stdout,诊断/错误通常写 stderr,并用 exit code 表示成功或失败。这样 shell 管道可以只把数据流传给下一个程序,同时监控系统仍能单独收集错误:
import sys
print("invalid config", file=sys.stderr)
raise SystemExit(2)
命令行工具的输出流与退出码属于对外 API。
开发迁移与误判边界
开发迁移: CLI/子进程协议不仅看 stdout 文本,还要看 stderr 和 exit code。稳定工具应把正常机器可消费输出与诊断信息分开,并用非零退出码表达失败。
误判边界: ‘打印了 ERROR’不等于调用方知道失败;如果 exit code 仍是 0,自动化可能把它当成功。
主动回忆
- CI 调用脚本时,为什么 exit code 比终端上红色日志更可靠?
- 设计 CLI 时,哪些内容应该进 stdout,哪些应进 stderr?
143. File Descriptor【L2】
在 POSIX 心智模型中,打开文件、pipe、socket 等内核资源通常可以通过 file descriptor 引用。
经典约定:
0 stdin
1 stdout
2 stderr
Python 高层 file object 会进一步处理 buffering、encoding 等。
“Python 文件对象”和“底层 FD”不是同一个抽象层次。
高层 file object 与 FD 的关系
open() 返回的 Python 文件对象通常在底层持有/包装一个 OS file descriptor,并额外处理 buffering、text encoding 等策略。你可以通过 file.fileno() 观察 FD,但不要绕过高层缓冲后又随意混用底层读写,否则容易造成位置和缓冲状态不一致。
机制与边界:Python 文件对象是对 OS handle/fd 的更高层封装
在 POSIX 心智模型中,file descriptor 是进程内的小整数,引用内核维护的打开文件/pipe/socket 等资源;Python open() 返回的对象通常还叠加 buffering、文本编码等高级能力。
因此“关闭 file object”和“fd ownership”需要明确:如果通过 os.fdopen()、socket.makefile()、dup 等组合 API,共享/转移底层描述符所有权时尤其容易 double-close 或 leak。
平台边界:Windows 的底层 handle/socket 模型并不完全等同 POSIX fd,不要把所有 OS 细节写死成 0/1/2 之外的 Unix 假设。
主动回忆
- 为什么 Python
TextIOWrapper的关闭可能最终影响一个底层 fd,而它本身又不只是 fd? - 使用
os.dup()后得到的新 fd 与原 fd 在资源生命周期上是什么关系?
144. subprocess【L1/L2】
import subprocess
result = subprocess.run(
["git", "status", "--short"],
capture_output=True,
text=True,
check=True,
)
优先传 argument list,而不是把不可信字符串拼成 shell command。
shell=True 风险
如果用户输入进入 shell 字符串,可能造成 command injection。只有确实需要 shell 语法时才启用,并明确转义/信任边界。
安全且可测试的调用形式
上面的 argument list 避免不必要的 shell 解析层;check=True 让非零退出码进入明确错误路径,capture_output=True, text=True 则让调用方显式处理文本输出。只有确实需要 shell 管道/重定向语义时才引入 shell,并严格控制输入。
🏋️ 配套实战:进入卡片 144 对应训练(变体 / Bug / 面试题)
先预测
subprocess.run(["git", "status"], check=True, capture_output=True, text=True)
为什么参数列表通常比拼接成 "git status ..." 再 shell=True 更安全、更可控?
揭晓
参数列表直接表达可执行程序和 argv,不需要 shell 再解析引号、通配符和命令分隔符,减少注入与转义问题;check=True 还让非零 exit code 进入明确异常路径。
再做一个变体
外部命令调用还应默认考虑哪两个运行边界?
变体答案
超时与输出/错误处理。生产代码通常还要考虑工作目录、环境变量、字符编码和子进程生命周期。
subprocess 的接口边界首先是 argv,而不是 shell 字符串
能用 subprocess.run(["git", "status"], check=True) 就优先使用参数列表。只有确实需要 shell 语法(管道、重定向、通配等)才考虑 shell=True,并严格控制输入。安全和跨平台问题大多来自把用户数据拼进 shell command string。
主动回忆
subprocess为什么优先传 argv list?- 什么情况下你真的需要
shell=True,又要承担什么输入安全责任?
145. Socket、IP 与 Port【L1】
网络编程基础:
IP 定位网络主机/接口
Port 定位主机上的逻辑服务端点
Socket 进程使用网络协议进行通信的编程接口
客户端通常:
resolve → connect → send/receive → close
服务端通常:
socket → bind → listen → accept → receive/send
高层 HTTP 库最终仍建立在类似的网络和 OS 能力之上。
开发迁移与误判边界
开发迁移: socket 是通信端点抽象;服务通常绑定本地 IP/port,客户端连接目标 IP/port。排障要区分监听地址、NAT、防火墙和端口是否真正可达。
误判边界: 127.0.0.1 监听只对本机,不等于‘服务器有公网 IP 所以外部能连’。
主动回忆
- Web 服务只 bind
127.0.0.1:8000时,远端为什么通常访问不到? 0.0.0.0是监听语义还是一个正常的远端目标地址?解释区别。
146. DNS、URL 与连接目标【L1】
用户访问:
https://example.com:443/path?q=1
至少可以拆成:
scheme https
host example.com
port 443
path /path
query q=1
DNS 把域名解析为网络地址;解析成功不代表 TCP/TLS/HTTP 一定成功。
from urllib.parse import urlparse
urlparse(url)
不要自己用 split(':') 解析完整 URL,IPv6、认证信息、转义等边界会迅速变复杂。
开发迁移与误判边界
开发迁移: DNS 把名字解析成地址,但 URL 还包含 scheme、port、path 等语义;连接失败要判断卡在解析、TCP connect、TLS 还是应用协议。
误判边界: DNS 成功只证明得到地址,不证明目标端口可连、更不证明 HTTPS 证书/HTTP 服务正常。
主动回忆
curl https://api.example.com报错时,如何用分层思路区分 DNS、TCP、TLS、HTTP 四类问题?- 为什么修改
/etc/hosts能影响域名解析,却不会自动改变证书里的 hostname 校验?
147. TCP、UDP、HTTP、HTTPS【L1】
TCP
面向连接、可靠字节流,但“可靠”不等于你的应用 request 一定成功;超时、断连、半关闭、重试仍需要应用处理。
UDP
无连接数据报,不保证可靠到达/顺序。
HTTP
应用层 request/response 协议。
HTTPS
HTTP + TLS 安全传输等机制。不要把 HTTPS 简化成“HTTP 端口换成 443”。
按层排查网络问题
DNS: 名字解析到地址
TCP/UDP: 端到端传输
TLS: 加密与身份验证(HTTPS 中)
HTTP: 请求/响应应用协议
“HTTP 请求失败”可能根本还没到 HTTP 层:DNS 解析失败、TCP connect timeout、TLS certificate 错误都发生得更早。分层心智模型能直接改善排障。
开发迁移与误判边界
开发迁移: TCP 提供有序可靠字节流,UDP 提供数据报;HTTP 是应用层协议,HTTPS 是 HTTP + TLS 保护。选协议时看可靠性、延迟、连接模型和生态。
误判边界: TCP 的‘可靠’不代表应用请求一定执行一次;断线重试仍可能产生重复业务,需要幂等设计。
主动回忆
- 支付请求在 TCP 超时后客户端不知道服务端是否已处理,为什么不能仅凭‘TCP 可靠’直接重发?
- HTTPS 主要提供哪些安全属性?它为什么不能自动防止你的应用层 SQL injection?
148. Timeout、Retry 与 Idempotency【L1/L2】
网络调用必须考虑 timeout。没有 timeout 的“简单请求”可能永久占用 worker/线程。
重试前必须问:
失败是否可重试?
请求是否幂等?
服务器是否可能已成功处理但响应丢失?
是否应指数退避+jitter?
POST 重试、支付、创建资源等场景尤其需要 idempotency key 或业务去重机制。
Retry 之前先问两个问题
- 失败是瞬时的吗,重试真的可能恢复吗?
- 操作可重试吗,重复执行会不会造成二次扣款/重复创建?
因此生产 retry 往往还需要 timeout、退避、jitter、最大次数,以及幂等 key/业务去重,而不是简单 for range(3)。
🏋️ 配套实战:进入卡片 148 对应训练(变体 / Bug / 面试题)
先预测
一个“创建订单”HTTP 请求超时了,你不知道服务端到底有没有成功创建。可以立即无脑 retry 吗?
揭晓
不能只看“网络失败”。如果操作非幂等,重试可能创建重复订单。需要幂等键、请求去重或可查询结果等机制,让 retry 在业务语义上安全。
再做一个变体
为什么 timeout 和 retry 必须一起设计,而不是分别加两个参数?
变体答案
timeout 决定你何时放弃当前尝试,retry 决定失败后是否再做;二者共同影响总延迟、重复副作用和下游压力。
Retry 必须和 Timeout、Idempotency 一起设计
没有 timeout 的 retry 可能每次都无限等;没有 idempotency 的 retry 可能重复扣款、重复创建订单。面对外部调用时应先定义单次超时、总截止时间、哪些错误可重试、退避策略以及重复请求是否安全,再决定“重试 3 次”这样的数字。
主动回忆
- Timeout、Retry、Idempotency 为什么必须一起考虑?
- 一个支付请求超时后直接重试,最危险的业务后果是什么?
149. DB-API 基本模型【L1】
Python DB-API 风格驱动常见抽象:
connection
cursor
execute
fetch
commit
rollback
更通用的事务骨架应显式表达提交/回滚,而不要假定所有 DB-API connection 都实现相同的 context manager 协议:
cursor = connection.cursor()
try:
cursor.execute("SELECT ... WHERE id = ?", (user_id,))
connection.commit()
except Exception:
connection.rollback()
raise
finally:
cursor.close()
边界
- 参数占位符(
?、%s、:name等)取决于具体驱动; - 某些驱动(例如特定
sqlite3使用方式)支持 connection context manager,但这不是 DB-API 2.0 对所有驱动的统一要求; SELECT是否需要事务提交、autocommit 默认值等也依赖驱动/数据库语义。
DB-API 是驱动之间的共同抽象,不是 ORM
核心模型是 connection 管会话/事务,cursor 执行语句与读取结果,参数绑定交给驱动完成。不同数据库/驱动在 placeholder、context manager、autocommit 等细节上可能不同,因此公共教程应区分 DB-API 规范与具体驱动扩展。
主动回忆
- DB-API 中 connection 与 cursor 的职责怎样区分?
- 为什么 context manager/autocommit 等行为不能假设所有 DB-API 驱动都完全一致?
DB-API 心智模型
connection → transaction/session boundary
cursor → execute/fetch interface
parameters → 交给 driver 绑定,不手工拼 SQL
具体 placeholder 样式、autocommit、context manager 支持由驱动决定;“DB-API 统一接口”不等于所有数据库驱动行为完全一致。
150. Transaction【L1/L2】
事务是一个原子业务工作单元,不只是“最后记得 commit”。
需要理解:
begin
read/write
commit / rollback
isolation
concurrent transactions
实际数据库还涉及隔离级别、锁、MVCC、死锁等。
应用代码要让事务边界与业务不变量一致;事务太大容易放大锁/资源问题,太碎则破坏原子性。
事务边界应该跟业务不变量走
“扣 A 余额 + 加 B 余额”如果业务上必须一起成功,就应处于同一可保证原子性的事务边界。不要按照“每条 SQL 一个 transaction”机械划分;transaction 是数据一致性设计的一部分。
🏋️ 配套实战:进入卡片 150 对应训练(变体 / Bug / 面试题)
先预测
转账逻辑分两步:A 扣 100,B 加 100。第一步成功、第二步失败。没有 transaction 会发生什么?
揭晓
系统会处于业务不一致状态。事务把一组数据库操作定义为一个原子提交边界:要么一起 commit,要么失败后 rollback。
再做一个变体
事务边界越大越安全吗?
变体答案
不一定。长事务会延长锁持有和资源占用,增加冲突;边界应覆盖需要共同保持一致性的最小业务单元。
事务保护的是业务不变量
“从 A 扣款 + 给 B 加款”必须作为一个原子业务单元提交或回滚;事务边界如果只围住其中一条 SQL,就没有保护真正的不变量。长事务又会增加锁持有时间和冲突,因此边界应尽量小但完整。
主动回忆
- 事务应该围绕 SQL 条数还是业务不变量划边界?
- 转账的扣款和加款为什么必须处在同一原子单元?
151. Connection Pool【L1/L2】
数据库连接创建昂贵,因此服务器应用通常复用连接池。
必须理解:
pool size
checkout
return
connection lifetime
health check
timeout
transaction cleanup
一个请求结束前如果没有正确 rollback/return,污染的事务状态可能被下一个请求复用。
Pool 不是“连接越多越快”
连接池减少反复建连成本并限制并发连接数量,但容量过小会排队/超时,过大又可能压垮数据库。更危险的是借出连接后事务未 commit/rollback 或未归还,导致状态泄漏到下一位使用者。pool sizing 应结合数据库上限、请求并发和查询时长测量。
开发迁移与误判边界
开发迁移: 连接池复用昂贵数据库连接,并控制并发上限;池大小过大可能把压力推给数据库,过小会形成排队。要结合 DB 限制、请求并发和事务时长调优。
误判边界: connection pool 不等于 query cache;拿到连接后仍是正常数据库会话,事务泄漏/未归还连接会耗尽池。
主动回忆
- Web 服务突然大量请求卡在‘获取连接’,你会检查哪些 pool 指标和事务行为?
- 为什么把 pool size 从 20 一口气改成 500 可能让数据库整体更糟?
152. ORM 的抽象位置【L1/L2】
ORM 把对象/查询 API 映射到关系数据库操作,但不会消除数据库本身的事务、索引、锁、N+1、隔离级别和 SQL 性能问题。
更准确的层次:
Python domain code
↓
ORM / query layer
↓
DB driver / DB-API
↓
SQL + 数据库 wire protocol
↓
Database server
SQL 是查询语言,不是所有数据库共享的单一“网络协议”。不同数据库/驱动使用自己的 wire protocol 和连接实现。
优秀 ORM 使用者仍需要理解:生成了什么 SQL、事务边界在哪里、lazy load 什么时候发生、连接池如何管理、索引是否能支持查询形状。
开发迁移与误判边界
开发迁移: ORM 把对象/查询表达映射到数据库,但不会消除 SQL、索引、事务和 N+1。真正性能排查仍要看到生成 SQL 和执行计划。
误判边界: ORM 的 lazy loading 很方便,却可能在循环中隐式发起大量查询;对象模型直观不代表 IO 成本可见。
主动回忆
- 遍历 100 个 user 并访问
user.orders产生 101 次查询,这是什么问题,通常有哪些修复方向? - 为什么高级 ORM 使用者反而更需要理解 SQL 和 transaction,而不是更少?
153. SQL Injection 与参数化 SQL【L1】
不要:
sql = f"SELECT * FROM users WHERE name = '{name}'"
应使用驱动参数绑定:
cursor.execute(
"SELECT * FROM users WHERE name = ?",
(name,),
)
具体 placeholder 按驱动。
参数化主要解决数据值安全编码;动态表名、列名、ORDER BY 等标识符不能简单当普通参数,需要白名单/安全构造。
参数化不是字符串转义技巧
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
参数由数据库 driver 按协议绑定,SQL 结构和数据值保持分离。不要用 f-string、% 或 .format() 自己给用户输入加引号;那是在重新实现一个极难正确的 parser/escaping 层。
开发迁移与误判边界
开发迁移: 参数化 SQL 的核心是让 SQL 结构与数据值分离,由驱动按协议绑定;不要用 f-string/% 拼接用户输入。标识符动态化则需要白名单/专门 API。
误判边界: 参数化通常保护 value,不是把任意 table/column name 也当普通参数占位符。
主动回忆
- 为什么
cursor.execute('... WHERE id = %s', (user_id,))比 f-string 安全? - 用户可选择排序列
sort_by时,为什么不能简单把它放进普通 value placeholder?你会怎么做?
第十八篇 测试、调试与可观测性
154. 测试层级【L1】
常见层级:
unit test
integration test
functional test
end-to-end test
不是层级越高越“高级”。越靠近 E2E,通常越真实但也越慢、越脆弱、定位成本越高。
实践原则
- 单元测试验证局部行为和边界。
- 集成测试验证组件真实协作。
- E2E 验证关键用户链路。
- 不要用大量 E2E 代替所有低层测试。
一个功能应怎样分层验证
以“创建订单”为例:
unit: 金额/状态规则
integration: repository + real DB transaction
E2E: 用户从页面提交到看到成功结果
不是每个规则都需要浏览器 E2E。越靠外层的测试更真实,也通常更慢、更难定位失败。
一个功能通常需要多层测试共同回答不同问题
以“创建用户”为例:
Unit:密码规则/字段转换是否正确?
Integration:Repository 与真实数据库交互是否正确?
E2E:用户从 HTTP 请求到持久化的关键路径是否成功?
如果 unit test 已经能快速证明一个纯函数,就没必要通过浏览器才能验证;如果真正风险在 SQL schema/事务,只 mock repository 的 unit test 又证明不了它。测试层级应跟风险位置对齐。
测试层级由“你想证明什么”决定
Unit test 适合快速验证局部逻辑;integration test 验证多个真实组件如何协作;E2E 验证用户路径。不是层级越高越真实就越好:越靠上反馈越慢、定位越难、环境依赖越多。好的测试组合让不同风险由最便宜且足够可信的层级覆盖。
主动回忆
- unit、integration、E2E 各自最适合证明什么?
- 为什么‘所有东西都用 E2E 测最真实’反而会降低反馈质量?
155. assert、unittest 与 pytest【L1】
Python 内建断言:
assert actual == expected
pytest 常直接使用普通 assert 并提供更丰富的失败展示;unittest 是标准库测试框架,提供 TestCase、setup/teardown、mock 等能力。
assert 的关键边界
在 CPython 当前实现中,使用优化模式:
python -O app.py
会让 __debug__ 为 False,编译器不会为 assert 生成正常断言代码。因此:
不要把 assert 当作生产输入校验、鉴权、安全边界或必须执行的业务规则。
测试里的 assert 是合理的;生产必要条件应显式 if ...: raise ...。
学习重点不是“站队框架”,而是:测试隔离、可重复、断言清晰、失败可定位。
官方:https://docs.python.org/3.14/reference/simple_stmts.html#the-assert-statement
语言语义补充:参见 卡 50。
开发迁移与误判边界
开发迁移: pytest 的优势在简洁 assert、fixture、参数化和插件生态;unittest 是标准库框架。无论工具,测试要验证行为而不是复刻实现。
误判边界: Python assert 可在 -O 下移除,但 pytest 会对测试 assert 做自己的重写;不要因此把生产校验也写成 assert。
主动回忆
- 为什么生产输入验证应显式 raise,而不是依赖 assert?
- 一个测试大量断言私有方法调用次数,却没验证输出行为,重构时为什么会特别脆弱?
156. Fixture【L1】
Fixture 用来提供测试前置资源和清理:
import pytest
@pytest.fixture
def user():
return {"name": "Tom"}
好的 fixture:
- 责任单一。
- 依赖明确。
- 不隐藏过多业务动作。
- 清理可靠。
巨大“万能 fixture”会让测试依赖关系变得难以理解。
Fixture 的核心不是“setup 函数”
fixture 表达测试依赖及其生命周期:数据库、临时目录、登录用户、app client 都可以是依赖图节点。好的 fixture 关注一个清晰资源,不把十几个无关初始化动作塞进一个巨型“万能环境”。
🏋️ 配套实战:进入卡片 156 对应训练(变体 / Bug / 面试题)
先预测
pytest fixture 同时负责“创建数据库、塞 30 条数据、启动服务、mock 时间、断言结果”。这有什么味道?
揭晓
fixture 职责过大,隐藏了大量测试前置条件,复用和失败定位都会变差。fixture 应聚焦资源/状态准备,测试本身仍要清晰表达行为与断言。
再做一个变体
fixture scope 选得越大越快,为什么不一定越好?
变体答案
更大的共享生命周期会增加测试间状态泄漏和顺序依赖风险;性能与隔离需要权衡。
Fixture 的价值是“生命周期 + 依赖”,不是共享代码片段
数据库连接、临时目录、测试用户等资源有明确 setup/teardown 边界,fixture 很合适;把大量业务步骤藏进层层 fixture 会让测试读起来像谜题。fixture 应提供测试所需的环境/依赖,而不是替测试正文执行主要断言逻辑。
主动回忆
- fixture 最适合管理哪类测试依赖?
- 什么时候 fixture 层层嵌套会让测试可读性变差?
157. Parameterization【L1】
一组逻辑、多个输入边界:
@pytest.mark.parametrize(
("value", "expected"),
[(0, False), (1, True), (-1, True)],
)
def test_truth(value, expected):
assert bool(value) is expected
参数化适合减少重复,但每个 case 仍应有可读的失败上下文。
开发迁移与误判边界
开发迁移: 参数化适合用同一行为规则覆盖多个输入/边界,减少复制测试;参数集应该有诊断性 id,并避免把完全不同逻辑硬塞进一个巨型测试。
误判边界: ‘循环测试数据’与 pytest 参数化在报告粒度不同:参数化通常让每组输入成为独立 case。
主动回忆
- 为什么边界值
0,1,max,invalid做参数化比在一个 test 里 for 循环更容易定位失败? - 什么时候参数化表已经复杂到应该拆成多个语义明确的测试?
158. Dummy、Stub、Spy、Mock、Fake【L1/L2】
这些 Test Double 不应全部统称“mock”。
Dummy 只为填参数,不真正使用
Stub 提供预设响应
Spy 记录实际调用信息
Mock 通过预期调用验证交互
Fake 有简化但可工作的实现
设计提醒
Mock 太多常意味着测试绑定实现细节。优先 mock 外部边界,而不是把一个业务函数内部每个函数调用都 mock 掉。
用目的区分 Test Double
- Stub:给调用者预设返回数据。
- Spy:记录调用,之后断言。
- Mock:预先声明交互期待。
- Fake:有可工作的简化实现,例如内存仓库。
不要纠结术语比赛;真正重要的是测试到底在验证结果还是验证交互,以及替身是否让测试过度耦合实现细节。
🏋️ 配套实战:进入卡片 158 对应训练(变体 / Bug / 面试题)
先预测
你测试“订单服务是否在支付失败时返回错误”,但把整个支付客户端换成一个只返回固定值的对象。它更接近 Stub 还是 Mock?
揭晓
如果它只是提供预设返回结果,主要是 Stub;如果测试还验证“是否以指定参数调用了 pay 一次”,则进入 Mock/Spy 式交互验证。Test double 分类看你用它承担什么测试职责。
再做一个变体
为什么不要为了“单元测试纯净”把所有依赖都 Mock 掉?
变体答案
过度 mock 会让测试只验证自己编排的假世界,丢失组件协作契约,并导致实现细节一重构测试就碎。
Test Double 要按“替代目的”选择
Stub 给固定响应;Spy 记录调用;Mock 预先声明交互期望;Fake 提供简化但可工作的实现。真正问题不是术语考试,而是你是否为了隔离一个外部边界而替换依赖。若把内部实现细节全 mock 掉,重构代码可能让测试大量破碎但业务行为完全没变。
主动回忆
- Stub、Spy、Mock、Fake 的替代目的有什么差异?
- 为什么过度 mock 内部实现会让正常重构造成大量无意义测试失败?
159. Patch 的位置【L2】
经典规则:
patch 名字被使用的位置,而不是对象最初定义的位置。
如果:
# service.py
from client import request
def load():
return request()
测试通常需要 patch service.request,因为 load() 查找的是这个绑定。
这个问题本质上再次回到:
import → name binding → namespace
Patch 使用位置,不是定义位置
如果 service.py 执行了 from api import request,测试通常要 patch service.request,因为被测代码运行时从 service namespace 取这个名字。记忆:patch where looked up。
🏋️ 配套实战:进入卡片 159 对应训练(变体 / Bug / 面试题)
先预测
service.py 中写了 from client import send,函数里调用 send()。测试时你应该 patch client.send 还是 service.send?
揭晓
通常 patch service.send,也就是被测代码查找该名字的位置。导入后 service 命名空间已有自己的绑定;只 patch 原始定义位置可能不会影响该绑定。
再做一个变体
“patch where looked up”解决的本质是什么?
变体答案
Python 名称绑定问题:运行时调用表达式从哪个命名空间取对象,就需要替换那个命名空间中的绑定。
Patch 错位置其实是名称解析错误
把它和 LEGB/import 联起来:from client import send 在 service namespace 建立了自己的 send 绑定,函数运行时读取的是这个绑定。patch 的核心不是“mock 库怎么规定”,而是被测表达式运行时从哪里取得那个对象。
主动回忆
- patch where looked up 的‘looked up’指什么?
from client import send后为什么通常 patchservice.send而不是client.send?
160. Deterministic Test 与 Flaky Test【L1】
Flaky test 指同样代码/环境下测试结果不稳定。
常见来源:
- 时间。
- 随机数。
- 并发 race。
- 外部网络。
- 共享状态。
- 测试顺序依赖。
- 未清理数据库/文件。
不要靠“失败就重跑 3 次”把根因长期隐藏。
Flaky 的排查方向
常见来源:共享全局状态、测试顺序依赖、真实时间、随机数、未等待异步任务、端口/网络、并发 race、数据库未隔离。不要把“失败后自动重跑”当修复;重跑只能暂时降低可见失败率。
开发迁移与误判边界
开发迁移: flaky test 的根因常是时间、随机数、并发、外部依赖、共享状态和顺序依赖。修复目标是控制不确定性,而不是自动 rerun 把红灯变绿。
误判边界: 重复跑通过不能证明测试稳定;rerun 最多是缓解发布阻塞,不能替代根因分析。
主动回忆
- 一个测试只在整套测试一起跑时失败,单独运行通过,你优先怀疑哪些共享状态/顺序问题?
- 涉及当前时间的测试如何通过注入 clock/fake time 提高确定性?
161. Coverage 的正确理解【L1】
Coverage 回答的是“哪些代码路径被执行”,不是“测试质量有多高”。
100% line coverage
≠
100% correctness
更重要的是关键业务分支、失败路径、边界条件和不变量是否得到验证。
Coverage 的正确用途
coverage 很适合发现“哪些路径根本没执行到”,却不能证明断言正确、边界充分或行为被验证。100% coverage + 没有有意义断言 完全可能存在。因此把 coverage 当盲区探测器,不是质量分数。
开发迁移与误判边界
开发迁移: Coverage 回答‘哪些代码执行过’,不回答断言是否正确、边界是否完整。它适合发现明显未覆盖区域,不适合设一个百分比当质量真相。
误判边界: 100% line coverage 仍可能完全没测异常分支含义、数据组合和业务不变量。
主动回忆
- 为什么一个只有
func()没有任何有效断言的测试也能提高 coverage? - 你会如何结合 branch coverage、mutation/property tests 或风险分析,让 coverage 变成信号而不是 KPI?
162. Property-based Testing 与 Contract Testing【L2】
Property-based Testing
不是枚举几个固定 case,而是描述应始终成立的性质,并自动生成大量输入验证,例如:
decode(encode(x)) == x
sort(sort(xs)) == sort(xs)
常用第三方工具如 Hypothesis。
Contract Testing
验证服务/组件之间接口契约是否兼容,特别适合分布式系统生产者/消费者独立演进场景。
两者都不是 unit/integration/E2E 的简单替代,而是补充不同失败空间。
机制与边界:Property-based Testing 测“不变量”,Contract Testing 测“边界承诺”
Property-based testing 不依赖你手写少数例子,而是声明性质,例如“encode 后 decode 应还原”“排序结果单调且元素集合不变”,由工具生成大量输入并在失败时尝试 shrink 到更小反例。
Contract testing 则针对服务/模块边界:请求字段、响应 schema、状态码、消息格式或消费者期望是否仍兼容。
两者解决的问题不同:前者扩展输入空间,后者防止协作边界漂移。它们都不是用来替代普通 example-based unit test。
主动回忆
- 测试
decode(encode(x)) == x更像 example test 还是 property?为什么? - 服务 A 单测全绿,但升级后删了 B 依赖的 JSON 字段,哪类测试更直接暴露这个兼容性问题?
163. Traceback 与 Debugging【L1】
遇到异常先从 traceback 最底层实际异常类型/消息和调用链理解问题,不要只看最后一句中文搜索答案。
breakpoint()
会进入调试器环境。
需要掌握:
step
next
continue
变量查看
调用栈
条件断点(IDE)
调试的目标不是“让报错消失”,而是建立可证伪的根因假设。
Traceback 是故障路径,不只是报错文本
读 traceback 时先从异常类型/消息理解“发生了什么”,再沿调用栈找“哪一层首次进入了错误状态”。重新抛出当前异常时优先裸 raise 保留原 traceback;随意 raise exc 会改变 traceback 形态,异常转换则用 raise NewError(...) from exc 明确因果链。
开发迁移与误判边界
开发迁移: traceback 是失败路径的调用栈证据。调试优先找到‘第一个属于自己代码且解释状态变化的位置’,再结合 locals、输入和最小复现,而不是只盯最后一行错误。
误判边界: 异常最后一行告诉你类型和消息,不一定告诉你根因;包装异常、异步边界和回调都会让根因在更早 frame。
主动回忆
- 第三方库抛 KeyError 时,为什么你仍应沿 traceback 找到自己传入非法数据的那个 frame?
- 什么情况下你会用
raise ... from ...保留因果链,帮助后续调试?
164. Logging【L1/L2】
print() 适合临时交互;长期服务通常需要 logging:
import logging
logger = logging.getLogger(__name__)
user_id = 42
logger.info(
"user loaded",
extra={"user_id": user_id},
)
extra 必须提供 logging 可当作 mapping 合并到 LogRecord 的键值,而不是 {...} 这样的 set。
核心对象:
Logger
Handler
Formatter
Filter
级别:
DEBUG INFO WARNING ERROR CRITICAL
原则
日志应帮助回答“发生了什么、在哪个上下文、为什么可能失败”,同时避免泄露密码、token、身份证件等敏感数据。结构化日志也不等于“把任意对象全部 dump 出去”。
开发迁移与误判边界
开发迁移: 生产日志应是可查询事件:包含稳定上下文字段、级别和异常栈;不要把日志当字符串垃圾桶。结构化 logging 有利于按 request_id/user_id 聚合。
误判边界: 敏感信息、超大 payload 和高频循环日志都会带来安全/成本问题;级别也不是越高越重要。
主动回忆
- 为什么
logger.exception('failed')在 except 中通常比只记录str(exc)更有诊断价值? - API 请求日志里哪些上下文适合字段化,哪些数据应该脱敏或根本不记录?
165. Metrics、Tracing 与 Logging【L1/L2】
三者用途不同:
Logs 离散事件详情
Metrics 聚合数值趋势
Traces 请求跨组件调用路径
例如接口慢:
- metrics 看 P95/P99 是否整体变差。
- trace 找慢在哪个 span。
- log 查看具体错误上下文。
不要试图用海量文本日志替代全部可观测性能力。
开发迁移与误判边界
开发迁移: Logs 记录离散事件,metrics 聚合数值趋势,traces 串联一次请求跨服务路径。排障通常三者互补:指标发现异常、trace 定位慢在哪、日志解释局部细节。
误判边界: 不要把每个用户 ID 都做 metric label,高基数会让时序系统成本爆炸;这类维度更适合日志/trace 属性。
主动回忆
- ‘过去 5 分钟错误率是否升高’最适合 metrics 还是 logs?为什么?
- 追踪一次请求跨三个服务的耗时,trace 相比在日志里 grep request_id 有什么结构化优势?
第十九篇 性能分析与优化
166. Big O 与 Python 容器复杂度【L1】
需要有数量级直觉:
O(1)
O(log n)
O(n)
O(n log n)
O(n²)
典型:
- list 按索引:O(1)。
- list membership:O(n)。
- dict/set 平均 membership:O(1)。
- 排序:通常 O(n log n)。
Big O 描述增长趋势,不包含所有常数、cache locality、hash 成本和实际数据分布。
先算调用次数,再谈微优化
一段 O(n) 代码里即使每一步慢一点,通常也比无意写出的 O(n²) 更可控。性能 review 时先看数据规模和嵌套循环/查找次数,再看 Python 层面的局部写法差异。
🏋️ 配套实战:进入卡片 166 对应训练(变体 / Bug / 面试题)
先预测
有一个 list,循环 10 万次执行 if x in items;如果 items 也有 10 万元素,这段结构可能是什么数量级?
揭晓
list membership 是线性查找,外层再做 10 万次可能接近 O(n²)。如果语义允许,把被查询集合预先转成 set,可把大量 membership 平均降为 O(1)。
再做一个变体
Big O 能不能直接告诉你两个 n 很小的实现谁更快?
变体答案
不能。它描述增长趋势,不包含常数、缓存、分配等实际因素;小规模真实性能仍应 benchmark。
复杂度要结合 n 的含义和常数成本
把 list membership 改成 set membership 可能从 O(n²) 降到接近 O(n),通常是结构性收益;把一个 20 项小列表的循环改成晦涩技巧,即使快 5%,可能毫无价值。Big O 用于判断增长趋势,真实性能仍要结合输入规模、内存和实际 profile。
主动回忆
- Big O 描述的是输入规模增长时的什么趋势?
- 何时从 list membership 换到 set 是结构性优化,而不是微优化?
167. Benchmark 与 Profiling【L1】
Benchmark 回答“这段操作多快”;profiling 回答“时间花在哪里”。
import timeit
timeit.timeit("sum(range(100))", number=10000)
程序级 profiler:
python -m cProfile app.py
原则:
不先测量,就不知道你优化的是瓶颈还是心理安慰。
两个工具回答不同问题
- benchmark:这段操作整体需要多久?
- profiler:时间主要花在哪些函数/调用路径?
先 profiler 找热点,再对候选优化做可靠 benchmark,比凭直觉修改更容易验证收益。
典型性能排查顺序
1. 定义慢在哪里/慢到什么程度
2. profile 找 hot path
3. 提出修改
4. benchmark 修改前后
5. 在真实负载/指标中确认没有副作用
timeit 很适合比较两个小实现,但它不会告诉你生产接口的数据库、锁等待、网络延迟占了多少。反过来,profile 显示某函数占 60% 也不自动说明它“写得差”,还要看它是否本来就在做绝大多数必要工作。
Benchmark 回答“多快”,Profiling 回答“时间花在哪”
如果你还不知道瓶颈在哪,先 profile;如果已经针对某个实现做了优化,再用 benchmark 验证变化。微基准要控制 warm-up、输入、重复次数和环境噪声;生产慢请求还要结合真实 tracing/metrics,不能只靠 timeit。
主动回忆
- benchmark 与 profiling 分别回答哪两个问题?
- 一个接口慢但不知道慢在哪时,为什么先跑 microbenchmark 往往顺序不对?
168. 算法优化优先于 Micro Optimization【L1】
把 O(n²) 改成 O(n) 往往比纠结局部语法快得多。
错误优化顺序:
先换写法省几十纳秒
后面才发现数据库 N+1 查询
推荐顺序:
确定目标 → 测量 → 找热点 → 判断根因 → 最小优化 → 再测
一个数量级案例
把循环里反复 x in list 换成预先构建 set,可能把整体从 O(n²) 降到平均 O(n);这类收益通常远大于把局部函数调用改成内联表达式。微优化只有在 profiler 已证明热点且算法/IO 边界已经合理后才值得做。
开发迁移与误判边界
开发迁移: 性能优化首先换算法/数据结构、减少 IO 次数和批量化,再考虑局部语法微优化。10 倍的数据规模会放大 O(n²) 问题,却几乎不会在意少一次函数调用。
误判边界: micro benchmark 的微小差异在真实系统里可能被网络、数据库和缓存噪声淹没。
主动回忆
- 把 list membership 改成 set 为什么常比把 for 循环里的局部变量绑定技巧更值得做?
- 什么证据出现后你才会投入 micro optimization,而不是继续优化算法/IO?
169. Cache【L1/L2】
from functools import lru_cache
@lru_cache(maxsize=128)
def parse(value):
...
缓存适用于:
- 输入决定输出。
- 计算昂贵。
- 重复调用多。
- 可接受内存和陈旧数据风险。
需要同时设计:
cache key
size
TTL/失效
并发
一致性
内存
缓存不是“让函数更快”的无成本装饰器。
Cache 是“用空间和一致性复杂度换时间”
引入 cache 后必须同时思考 key、失效条件、容量、并发、冷启动和陈旧数据。@lru_cache 能让纯函数式查询非常方便,但如果参数隐含外部状态,缓存结果可能已经不再正确。
🏋️ 配套实战:进入卡片 169 对应训练(变体 / Bug / 面试题)
先预测
一个函数 get_user(user_id) 结果缓存 10 分钟。如果用户资料更新了,最大风险是什么?
揭晓
缓存不是“免费加速”,而是在性能与一致性之间做交换;如果没有失效策略,调用方可能读到陈旧数据。
再做一个变体
什么情况下
functools.lru_cache不适合直接套上?
变体答案
结果依赖外部可变状态、需要严格时效、参数不可 hash、缓存对象过大或多进程/分布式一致性要求复杂时,都需要更明确的缓存设计。
Cache 是用一致性和内存换时间
缓存设计至少要回答 key、value、TTL/失效、容量、并发、错误缓存以及数据源更新后的同步策略。@lru_cache 很适合纯函数式重复计算,但不适合把带权限、时间、外部状态的结果无脑永久缓存。
主动回忆
- cache 用什么资源/一致性代价换取速度?
- 给一个结果不适合永久
lru_cache的场景,并说明失效问题在哪里。
170. N+1、批处理与 IO 性能【L1】
大量 Python 服务性能瓶颈不是解释器,而是外部 IO 模式:
for user in users:
query_database(user.id)
可能形成 N+1 请求。
优先考虑:
- batch query。
- 减少 round trip。
- 并发但有上限。
- 连接池。
- 合理缓存。
不要看到接口慢就先研究 bytecode。
N+1 的本质是跨边界调用次数爆炸
1 次查询订单列表
+ 对每个订单再查 1 次用户
= N + 1 次数据库往返
优化重点通常不是“把 Python for 写快一点”,而是 batch/join/prefetch,把昂贵的 IO 边界往返次数降下来。
🏋️ 配套实战:进入卡片 170 对应训练(变体 / Bug / 面试题)
先预测
页面展示 100 个订单,代码先查订单列表,再对每个订单单独查一次用户。为什么这往往比“优化某个 Python for 循环”更值得先处理?
揭晓
这产生 N+1 IO:一次主查询加 100 次额外数据库往返。网络/数据库延迟通常远大于 Python 循环的微观成本;批量查询/join/preload 往往带来数量级改善。
再做一个变体
性能优化为什么要优先找“边界次数”而不是只看 CPU 指令?
变体答案
数据库、网络、磁盘、进程切换等跨边界成本通常高;减少往返次数和算法复杂度常比微调语法更有效。
N+1 的本质是“在循环里跨边界”
数据库只是最典型案例;循环里逐个 HTTP 请求、RPC、磁盘读取同样会把固定网络/IO 延迟放大 N 次。优化时优先寻找 batch、join/prefetch、并发窗口或数据布局调整,而不是先优化 Python 循环本身。
主动回忆
- N+1 的共同结构是什么?
- 循环里做 100 次 HTTP 请求和 100 次 SQL 查询为什么属于同类性能问题?
第二十篇 项目工程化与 Packaging
171. Packaging 术语边界:Script、Module、Package、Distribution【L1】
这些词不要混:
script 作为程序入口执行的代码
module Python 模块
import package 可 import 的包结构
distribution 可安装/发布的项目制品
一个 PyPI distribution 名称和 import package 名称甚至可以不同。
四个名词对应不同问题
- script:作为入口直接运行的代码。
- module:可 import 的单个模块对象/源文件单元。
- package:组织多个 module 的 import namespace。
- distribution:可以构建、发布、安装的项目产物。
“pip 安装名”和“import 名”甚至可以不同,所以遇到 packaging 问题时先确认你正在讨论哪一层。
从源码到安装的关系
项目源码目录
↓ build
Distribution artifact
├─ wheel
└─ sdist
↓ install
环境中出现 import package / module
“package”这个词在讨论中常有歧义:import package 是 Python namespace 结构,distribution package 是发布/安装单位。工程沟通中最好明确自己说的是哪一层。
四个词分别处在不同层
script 是执行入口;module 是可导入的 Python 文件/对象;package 组织 module namespace;distribution 是可构建、发布、安装的项目产物/元数据单位。pip install foo 的 distribution 名和 import foo 的 import package 名甚至可能不同。
主动回忆
- script/module/package/distribution 各自属于哪一层概念?
- 为什么
pip install名称和import名称不必一致?
172. Virtual Environment【L1】
python -m venv .venv
虚拟环境主要隔离:
解释器环境引用
site-packages
命令入口
它不是容器,也不是操作系统级安全沙箱。
不要依赖“我全局 pip 里正好装过”运行项目;项目依赖应显式管理。
Virtualenv 解决什么、不解决什么
它主要隔离 Python package 安装位置和解释器环境,让两个项目可以依赖不同版本。它不是容器、权限隔离或安全 sandbox;进程仍然拥有当前 OS 用户能访问的文件、网络和系统资源。
开发迁移与误判边界
开发迁移: venv 隔离的是某个 Python 环境下的安装包集合和脚本入口,避免项目互相污染;它不是容器,也不隔离 OS 库、内核和网络。
误判边界: 激活 venv 主要是调整 PATH 等 shell 环境,真正关键是你最终运行的 python/pip 指向哪一个解释器。
主动回忆
- 为什么排查‘明明 pip install 了却 import 不到’时应比较
which python和python -m pip? - venv 与 Docker 分别解决什么层次的隔离问题?
173. pip、依赖与 Transitive Dependency【L1】
直接依赖 你明确选择的库
传递依赖 你的依赖进一步依赖的库
问题常来自:
version conflict
unbounded dependency
platform wheel
native build
private index
安装成功不代表依赖管理完成。可复现项目还需要版本策略/lock、构建元数据和环境约束。
直接依赖与传递依赖
你的项目只声明 A,但 A 依赖 B、B 又依赖 C,这些都是 transitive dependencies。升级一个顶层库可能因此改变整棵解析树。生产环境要关注可重复解析/锁定策略,同时不要把“锁得很死”误当成安全:漏洞修复仍需要主动升级和测试。
开发迁移与误判边界
开发迁移: pip 解析并安装直接依赖及传递依赖;生产可重复构建要关心约束/锁定、index 来源和 wheel 可用性,而不仅是 requirements 里写了顶层包。
误判边界: pip install foo 今天成功不保证半年后得到同一依赖图;松散版本范围会让 transitive 更新改变行为。
主动回忆
- 为什么只 pin 你的直接依赖仍可能无法完全复现环境?
- 排查依赖冲突时,为什么先理解是哪两个包对同一 transitive dependency 给了不兼容约束?
174. pyproject.toml【L1/L2】
现代 Python 项目通常把构建系统和项目元数据放在 pyproject.toml 中。
概念上需要区分:
project metadata
build-system
build backend
tool configuration
pyproject.toml 是统一配置载体,不代表所有工具共享完全相同配置语义。
pyproject.toml 的角色
它是现代 Python 项目声明 build system、项目 metadata,以及大量工具配置的统一入口。不要把它理解成“pip 的 requirements 新格式”;依赖锁定、环境解析仍可能由不同工具采用不同机制。
🏋️ 配套实战:进入卡片 174 对应训练(变体 / Bug / 面试题)
先预测
pyproject.toml 同时可能描述 build system、project metadata、工具配置。为什么“它就是 requirements.txt 的替代品”不准确?
揭晓
它是现代 Python 项目的统一配置入口之一,职责远超依赖列表;项目元数据、构建后端和多个工具配置都可以放在其中。lock file 与依赖解析又是另一层问题。
再做一个变体
为什么库项目通常不应该像应用一样把所有传递依赖完全锁死在发布元数据中?
变体答案
库需要与调用方生态共同解析兼容版本;过度锁死会制造依赖冲突。应用部署则更强调可复现环境,通常需要 lock/冻结策略。
pyproject.toml 是项目协议入口,不是单一工具配置
至少分清 [build-system]、[project] 与 [tool.*]。构建前端根据 build-system 调用 backend;项目 metadata 描述 distribution;各工具读取自己的配置。依赖锁定是否以及如何记录,仍取决于具体工作流/工具,不能简单等同于 pyproject.toml 本身。
主动回忆
pyproject.toml中 build-system、project、tool 配置分别服务谁?- 为什么说它不是简单的
requirements.txt替代品?
175. Wheel 与 Source Distribution【L1/L2】
Wheel
预构建 distribution 格式,安装通常不需要现场执行完整构建流程。
sdist
源代码分发,安装时通常需要构建 wheel/制品。
带 native extension 的库是否有匹配平台/Python ABI 的 wheel,会显著影响安装体验。
安装路径差异
wheel 是已构建的 distribution 格式,安装时通常无需在本机执行完整构建流程;sdist 是源码分发,安装端可能需要合适的 build backend、编译器和系统依赖才能先构建 wheel。排查“我机器能装、生产装不了”时,这个区别非常关键。
🏋️ 配套实战:进入卡片 175 对应训练(变体 / Bug / 面试题)
先预测
用户执行 pip install somepkg 时,为什么 wheel 往往比 sdist 安装更快、更稳定?
揭晓
wheel 是预构建分发格式,通常不需要在用户机器上再次完成完整 build;sdist 包含源代码,安装时需要本地构建环境,并可能遇到编译器/系统依赖差异。
再做一个变体
“有 wheel”是否意味着一定跨所有 OS/Python 版本通用?
变体答案
不是。纯 Python wheel 可较通用;含原生扩展的 wheel 带有平台、ABI、Python tag,只适用于匹配环境。
优先安装 Wheel,因为它是构建好的分发产物
wheel 通常无需在用户机器重新运行完整 build 流程,因此安装更快、环境依赖更少;sdist 是源代码分发,安装时通常需要构建。发布库时同时提供合适 wheel 与 sdist,可以覆盖更多平台/构建需求。
主动回忆
- wheel 与 sdist 的关键区别是什么?
- 为什么用户机器上优先安装 wheel 通常更快、更少依赖本地构建环境?
176. Editable Install、Console Scripts 与版本策略【L1/L2】
开发本地 package 时常见 editable install:
python -m pip install -e .
它让环境中的 distribution 指向开发源码,便于修改后立即生效,但具体机制由现代 build backend/标准实现决定,不应依赖旧式 setup.py develop 心智模型。
Package 还可以通过项目元数据暴露 console script entry point,让安装后生成命令行入口。
版本号策略需要明确兼容性承诺;Semantic Versioning 是常见思想,但 Python 项目并非都必须完全采用 SemVer。
开发迁移与误判边界
开发迁移: editable install 适合本地开发,让源码修改直接反映到环境;console scripts 把包中的函数暴露为 CLI;版本策略则属于发布兼容契约。
误判边界: editable 并不等于最终 wheel 的安装行为完全相同,发布前仍应构建并测试真实 artifact。
主动回忆
- 为什么开发期
pip install -e .正常仍不足以证明 wheel 发布后资源文件都正确? - console script entry point 相比要求用户
python path/to/script.py有哪些 packaging 优势?
177. 项目目录设计【L1】
一个通用库项目可能类似:
project/
├── pyproject.toml
├── README.md
├── src/
│ └── mypackage/
│ └── __init__.py
└── tests/
src layout 可以减少“因为当前工作目录刚好可 import”导致的测试错觉。
目录设计目标是明确包边界、入口、测试和配置,不是追求某个唯一模板。
目录结构的目标是消除模糊边界
常见 src/ layout 能减少“测试时意外从仓库根目录 import 未安装源码”的情况,让开发环境更接近真实安装后的 import 行为。目录方案不是教条;核心是明确 package、tests、配置、脚本和生成物的边界,并让工具链能一致发现它们。
开发迁移与误判边界
开发迁移: 目录结构应让 import 边界、领域职责和测试位置清楚;src/ layout 常用于避免测试意外从仓库根直接 import 未安装源码。
误判边界: 不要按‘每个技术名词一个目录’过度分层;目录是依赖边界工具,不是视觉整齐比赛。
主动回忆
- src layout 为什么能暴露‘忘记安装包但测试仍从当前目录碰巧 import 成功’的问题?
- 什么时候按 feature/domain 组织会比 controller/service/repository 的纯技术分层更容易维护?
178. argparse 与命令行程序边界【L1】
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8000)
args = parser.parse_args()
命令行程序至少要明确:
arguments/options
stdout/stderr
exit code
configuration precedence
signal/termination
不要自己用 sys.argv[1] 大量手搓复杂 parser;标准库已处理 help、类型、必填项和错误展示等基础能力。
开发迁移与误判边界
开发迁移: argparse 负责 CLI 参数解析、帮助和基本校验;业务逻辑应放普通函数/服务,便于测试和被其他入口复用。
误判边界: 不要让 import 模块时就执行 parse_args(),否则测试或其他程序 import 会读取对方 argv。
主动回忆
- 为什么通常把
args = parser.parse_args()放进main()而不是模块顶层? - CLI 返回失败时,你会如何把业务异常映射成稳定的 stderr 与 exit code?
179. Formatter、Linter、Type Checker【L1】
三个概念不同:
Formatter 自动统一格式
Linter 静态检查代码问题/规则
Type checker 静态检查类型关系
工具可以组合,但不要把“通过 formatter”当“代码正确”,也不要把 type checker 当 runtime test。
三种工具抓三类问题
formatter → 代码长什么样
linter → 可疑/不规范代码
type checker→ 静态类型关系
它们有重叠但目标不同。把 formatter 配到很严格也替代不了类型检查,把 type checker 打开也不会自动发现所有未使用变量或复杂度问题。
开发迁移与误判边界
开发迁移: formatter 统一风格,linter 找可疑模式/规范问题,type checker 做静态类型分析。三者信号不同,应组合而不是期待一个工具包办代码质量。
误判边界: 通过所有静态工具不代表逻辑正确;它们也不替代测试、安全审查和运行时观测。
主动回忆
- 一个变量类型完全正确但 SQL 逻辑写反,哪类工具可能发现,哪类通常发现不了?
- 为什么团队应尽量自动化 formatter,而不是把代码评审时间消耗在空格和换行争论?
180. Pre-commit 与 CI【L1】
Pre-commit 适合在提交前快速执行格式化、lint、简单静态检查。
CI 是团队/仓库级可信检查环境:
install
lint
type check
test
build
security scan
本地 hook 可以绕过,因此关键质量门禁仍应由 CI 强制执行。
本地钩子不是最终质量门
pre-commit 让格式化、lint、轻量检查尽早在开发者机器执行,反馈快;CI 才是团队共享、不可绕过的最终自动检查环境。关键规则最好两边使用同一配置,避免“本地绿、CI 红”来自版本/参数漂移。
开发迁移与误判边界
开发迁移: pre-commit 把廉价快速检查前移到提交前,CI 则在独立环境执行权威验证。二者应复用相同配置,避免本地绿、CI 因规则不同而红。
误判边界: pre-commit 可被跳过,因此不能作为唯一安全/质量门;最终合并门仍应在 CI/服务端。
主动回忆
- 为什么 secret scan 只放 pre-commit 不够?
- 哪些检查适合本地快速 hook,哪些昂贵集成/E2E 更适合 CI?
第二十一篇 Python 安全
181. eval() 与 exec()【L1】
永远不要对不可信输入直接:
eval(user_input)
exec(user_input)
它们执行 Python 代码,攻击面远高于普通解析。
如果只是读取数据,使用 JSON、ast.literal_eval(仍需考虑资源消耗)、显式 parser 等更窄的能力。
安全原则:使用满足需求的最小解释能力。
更窄的解释器更安全
如果输入只是数字、列表、字典等 Python literal,ast.literal_eval() 比 eval() 能力窄得多;如果输入来自跨系统接口,JSON 通常又比 Python literal 更清晰。安全设计的方向是不断缩小“输入能够表达/触发什么”。
🏋️ 配套实战:进入卡片 181 对应训练(变体 / Bug / 面试题)
先预测
expr = input("expr: ")
result = eval(expr)
如果输入来自不可信用户,这段代码的风险为什么不是“算错结果”这么简单?
揭晓
eval 把输入当 Python 表达式执行,边界从“数据”变成“代码”,可能访问对象、调用函数并造成任意副作用。安全设计首先要避免让不可信输入进入代码执行解释器。
再做一个变体
如果需求只是解析 JSON、数字或一个有限规则表达式,第一选择应该是什么?
变体答案
使用对应的数据解析器或受限语法 parser,而不是通用 eval/exec。原则是让数据保持数据。
eval/exec 的问题是把数据提升成代码权限
用户输入一旦进入 eval/exec,攻击面不只是“读一个变量”,而可能触达进程拥有的文件、网络、secret 等能力。需要解析结构化输入时优先 JSON/TOML/专用 parser;需要表达式 DSL 时也应限制语法和能力,而不是直接执行 Python。
主动回忆
eval/exec把输入提升成了什么权限?- 只是想读 JSON/配置时,为什么执行 Python 表达式是错误的抽象?
182. Command Injection【L1】
危险:
subprocess.run(f"grep {user_input} file", shell=True)
更安全的基础做法是把命令和参数分开:
subprocess.run(["grep", user_input, "file"], check=True)
argument list 避免 shell 把 ;、&&、$() 等解释成 shell 语法。
但这还不等于“任何输入都安全”
某些 CLI 会把以 - 开头的用户输入解释成目标程序自己的 option,这属于 argument/option injection,而不是 shell injection。对于支持 -- 终止 option parsing 的命令,可按该命令的规范写:
subprocess.run(["grep", "--", user_input, "file"], check=True)
-- 是否有效取决于目标 CLI,不能机械套给所有程序。真正的边界是:同时理解 shell 解析层和目标程序参数解析层。
先预测
name = request.args["name"]
os.system(f"grep {name} data.txt")
为什么即使你“本来只想传一个名字”,这里也可能执行额外命令?
揭晓
字符串被 shell 重新解释,用户可注入分号、管道、命令替换等 shell 语法。应避免拼接命令字符串,优先使用 argv 列表和不经过 shell 的 API,并对业务输入做约束。
再做一个变体
参数列表是否意味着可以完全不做输入校验?
变体答案
不是。它主要消除 shell 语法注入这一层;业务层仍需校验文件路径、选项注入、权限和数据合法性。
Command Injection 的修复不是“多加几层引号”
最可靠策略是尽量不经过 shell,让参数作为 argv 独立传递。若业务必须调用 shell,要把可变输入限制为枚举/严格验证,并明确 shell 的 quoting 规则。字符串拼接命令 + shell=True 是最应该警惕的组合。
主动回忆
- 为什么 argv list 比拼 shell command string 更安全?
- 如果必须
shell=True,哪些输入必须严格限制?
🏋️ 配套实战:进入卡片 182 对应训练(变体 / Bug / 面试题)
边界原则
用户输入应该作为数据参数进入子进程,而不是参与生成命令语言。["grep", pattern, file] 与 f"grep {pattern} {file}" 的差异,本质是“结构化参数”与“重新解析一段命令文本”的差异。
183. Path Traversal【L1/L2】
用户提供文件名:
../../etc/passwd
如果直接拼路径,可能越出允许目录。
安全处理需要:
- 明确允许根目录。
- 规范化/resolve 后验证边界。
- 防符号链接竞态。
- 对上传文件使用服务端生成名。
仅做 filename.replace("..", "") 不是可靠防御。
路径安全不是简单删掉 ..
可靠做法通常是确定允许的根目录、规范化/解析候选路径,再验证最终目标仍位于允许根目录内,同时考虑 symlink 和平台路径语义。黑名单替换字符串很容易遗漏编码和路径变体。
开发迁移与误判边界
开发迁移: path traversal 防护的关键是把用户输入限制在允许根目录/资源 ID 空间内,并在规范化解析后验证边界。仅删除 '../' 很容易被编码、绝对路径、符号链接等绕过。
误判边界: Path.resolve() 本身不是完整授权策略;还要确认解析结果是否仍位于允许 base 下,并考虑 symlink/TOCTOU。
主动回忆
- 下载接口直接
base / request.args['file']再 open,攻击者可能如何越界? - 比起让用户提交任意路径,为什么映射受控 file_id → 服务端路径通常更安全?
184. 临时文件与 TOCTOU【L2】
不要手工:
path = "/tmp/my-fixed-name"
然后先检查“不存在”再创建;检查与使用之间可能发生竞态(Time Of Check To Time Of Use)。
优先使用:
import tempfile
让系统/标准库以安全方式创建唯一临时资源。
更广义地说,任何“先检查权限/存在性,再稍后执行”的文件系统逻辑都要意识到状态可能在两步之间改变。
机制与边界:TOCTOU 的根因是“检查”和“使用”不是同一个原子操作
危险模式:先 exists() 判断一个路径安全/不存在,再稍后 open();两个动作之间,另一个进程可能替换路径、创建 symlink 或抢先创建文件,于是你使用的对象已经不是刚才检查的对象。
创建临时文件应优先使用 tempfile 等提供安全创建语义的 API;涉及安全权限时应使用 OS 提供的原子 open/create flags,而不是自己拼随机文件名 + exists()。
边界:TOCTOU 不只属于文件系统;任何“先检查共享状态,后执行”且中间可被并发修改的流程都有同类风险。
主动回忆
- 为什么
if not path.exists(): path.write_text(...)不能证明你最终写入的是你刚检查的同一安全目标? - 余额检查后再扣款之间没有锁/事务,这是否也是一种广义 TOCTOU?
185. random vs secrets【L1】
普通模拟/随机抽样:
import random
安全 token、密码重置链接、CSRF nonce:
import secrets
secrets.token_urlsafe()
random 的目标是统计随机和可复现,不是密码学不可预测性。
选择标准
模拟、抽样、游戏随机等非安全用途可以使用 random;密码重置 token、session secret 等需要不可预测性的值使用 secrets。这里的区别不是“哪个更随机”,而是是否以攻击者预测为威胁模型。
🏋️ 配套实战:进入卡片 185 对应训练(变体 / Bug / 面试题)
先预测
登录验证码、密码重置 token、抽奖随机排序分别应该优先考虑 secrets 还是 random?
揭晓
安全 token/验证码应使用 secrets;仿真、游戏、非安全随机等可使用 random。random 的设计目标是可重复的伪随机,不是密码学不可预测性。
再做一个变体
设置一个很复杂的
random.seed(...)能把它变成安全 token 生成器吗?
变体答案
不能。算法本身不是为密码学安全设计,状态可能被推断;安全随机应直接使用系统级 CSPRNG 接口,例如 secrets。
随机性的需求分两类
模拟、抽样、测试数据可以使用 random,甚至为了可复现主动设置 seed;token、密码重置码、API secret 等安全用途要使用 secrets/系统安全随机源。判断标准不是“结果看起来够乱”,而是攻击者是否能预测。
主动回忆
random与secrets的设计目标分别是什么?- 测试需要可复现和密码重置 token 两个场景各应该选择哪个?
186. Secret Management【L1】
不要把:
API key
数据库密码
私钥
access token
硬编码进源代码或提交到 Git。
环境变量只是常见注入方式之一,不等于完整 secret management。生产环境还应考虑 secret store、权限、轮换、审计和日志脱敏。
Secret 最容易从“旁路”泄漏
不要只盯源码仓库。secret 还可能进入日志、异常 traceback、shell history、CI 输出、监控标签、测试 fixture 和构建产物。环境变量只是传递 secret 的一种方式,并不天然安全;更重要的是最小权限、短生命周期、轮换、避免输出以及使用专门 secret store 的边界。
开发迁移与误判边界
开发迁移: secret 应由专门配置/secret manager 注入,避免进入源码、日志、镜像层和异常信息;还要支持轮换和最小权限。
误判边界: 环境变量只是传递手段,不是完整 secret management:它仍可能被进程环境、诊断工具或错误日志泄露。
主动回忆
- 发现 API key 已提交 Git 后,为什么仅删除最新 commit 中的字符串还不够?
- 一个成熟 secret 生命周期通常还需要哪些能力:存储、访问控制、审计、轮换、撤销分别解决什么问题?
187. Dependency Security【L1/L2】
风险包括:
- 已知漏洞版本。
- typosquatting package。
- dependency confusion。
- 被接管的维护者/发布链路。
- 恶意 build script。
基本措施:
可信来源
版本/lock
审计工具
最小依赖
及时更新
CI 构建隔离
“来自 PyPI”不自动等于安全。
开发迁移与误判边界
开发迁移: 依赖安全要管理来源、版本、已知漏洞和供应链完整性。升级不能只看 CVE 数量,还要验证兼容性和实际可利用条件。
误判边界: 锁文件提高可重复性但不会自动让依赖安全;锁住一个有漏洞版本只会稳定复现漏洞。
主动回忆
- 为什么 dependency scanner 报一个 CVE 后仍需要判断你的版本、调用路径和部署暴露面?
- 私有/公共 package source 混用时,dependency confusion 风险来自哪里?
第二十二篇 Pythonic 与设计思想
188. Pythonic 的真正含义【L1】
Pythonic 不是“用最多 Python 语法糖”,而是在 Python 语义、惯例和标准协议下写出清晰、直接、可维护的代码。
常见原则:
Readability counts.
Explicit is better than implicit.
Simple is better than complex.
不要为了“一行写完”牺牲可读性。
Pythonic 不是“最短代码”
真正目标是让熟悉 Python 的读者快速预测行为:利用协议、标准库和清晰惯用法,但不隐藏成本和副作用。一条嵌套三层 comprehension 即使少 8 行,也可能比普通循环更不 Pythonic。可读性、明确边界和可维护性优先于炫技。
一个可操作的 Pythonic 判断清单
当你在两种写法之间犹豫时,按顺序问:
- 哪个更直接表达意图?
- 哪个更符合 Python 已有协议/标准库?
- 副作用和失败路径哪个更明显?
- 下一位维护者能否快速改动?
- 性能若真重要,有没有测量证据?
例如 enumerate(items) 通常比手工维护 i += 1 更 Pythonic,不是因为少一行,而是它准确表达“遍历时同时需要索引”这一意图。
Pythonic 是“可预测的惯用设计”,不是语法炫技
# 更短,但未必更清晰
result = [f(x) for x in xs if p(x) and q(x) if r(x)]
如果一行代码需要读者反复拆解,它即使大量使用 comprehension 也不一定 Pythonic。真正的判断标准是:熟悉 Python 的人能否快速理解行为、错误边界和副作用;标准协议/库是否减少了自造约定;代码是否为未来修改保留了清晰空间。
主动回忆
- Pythonic 的目标为什么不是代码最短?
- 一个三层嵌套 comprehension 与清晰普通循环之间,你会用哪些标准做选择?
189. EAFP 与 LBYL【L1/L2】
LBYL
if key in mapping:
value = mapping[key]
EAFP
try:
value = mapping[key]
except KeyError:
...
Python 常鼓励 EAFP,但不是教条。
当检查本身便宜且表达业务规则时,LBYL 很自然;当“检查后状态可能马上变化”或异常就是 API 正常缺失语义时,EAFP 更合适。
不要把 EAFP 理解成“到处 try”
EAFP 适合操作本身就是最可靠的能力检查,例如直接打开文件并处理 FileNotFoundError,避免“先 exists、后 open”之间状态变化。若检查本身便宜、稳定且能清楚表达业务规则,LBYL 也完全合理。
🏋️ 配套实战:进入卡片 189 对应训练(变体 / Bug / 面试题)
先预测
def get_value(cache, key):
if key in cache:
return cache[key]
和
def get_value(cache, key):
try:
return cache[key]
except KeyError:
...
哪个一定更 Pythonic?
揭晓
没有脱离场景的固定答案。EAFP 适合“成功是常态且检查与操作可能竞态”的场景;LBYL 适合检查廉价、失败常见、或需要给出不同分支语义时。Pythonic 不是机械套 EAFP。
再做一个变体
为什么先
if path.exists()再打开文件仍可能失败?
变体答案
检查和使用之间状态可能改变(TOCTOU)。真正打开文件仍必须处理异常,因此很多资源操作适合直接尝试并处理失败。
EAFP/LBYL 取决于竞态和失败成本
if path.exists(): path.open() 仍可能在检查后文件被删除,这是 TOCTOU;直接尝试 open() 并处理目标异常更可靠。但如果失败异常非常昂贵、输入检查纯内存且无竞态,LBYL 也可能更清晰。不要把 EAFP 变成“所有异常都 catch”。
主动回忆
- EAFP 与 LBYL 各自强调什么?
- 为什么先
exists()再open()仍然可能有 TOCTOU 竞态?
190. Pure Function 与 Side Effect【L1】
Pure-ish function:相同输入产生相同输出,不修改外部可见状态。
Side effect 包括:
写文件
改数据库
发网络请求
修改共享对象
打印/日志
读当前时间/随机数(从可预测性角度)
不是“副作用都不好”,而是要把副作用边界显式化,让核心逻辑更容易测试和推理。
为什么纯函数更容易测试
同样输入产生同样输出且不修改外部状态时,测试只需要构造输入和断言结果。副作用并不是坏事——系统必须写数据库、发请求——更好的设计是把计算规则与副作用边界分开,让核心逻辑保持可预测。
🏋️ 配套实战:进入卡片 190 对应训练(变体 / Bug / 面试题)
先预测
函数 calculate_total(items) 除了返回总价,还偷偷写数据库、改全局缓存、发送通知。为什么这会让测试和复用变难?
揭晓
调用者无法仅从输入/返回值推断行为,副作用边界被隐藏;测试需要准备更多外部状态,重复调用也可能不等价。将计算与副作用分离通常更易理解和验证。
再做一个变体
Pure function 是否意味着“任何代码都不应该有副作用”?
变体答案
不是。真实系统必须 IO、写库、发消息;设计目标是把副作用集中在明确边界,让核心逻辑尽量可预测。
Side Effect 的问题是“隐藏依赖”
纯函数只由输入决定输出,更容易测试、缓存和并发;实际系统当然必须写数据库、日志、网络。好的设计不是消灭副作用,而是把副作用推到明确边界,让核心决策逻辑尽量保持可预测。
主动回忆
- pure function 与 side effect 的核心差异是什么?
- 真实系统无法消灭副作用时,怎样把它们隔离到清晰边界?
191. Function vs Class【L1】
优先问:是否真的有需要长期维护的状态与行为组合?
单纯转换:
def normalize(data): ...
往往比:
class Normalizer:
def normalize(self, data):
...
更简单。
当存在生命周期、共享配置、多种行为和明确对象概念时,class 更合理。
一个实用判断
如果只是“输入 → 计算 → 输出”,函数通常足够;当你需要维护明确状态、不变量和多组围绕同一状态的行为时,class 开始有价值。不要为了“面向对象”把无状态工具强行包装成只有 staticmethod 的类。
开发迁移与误判边界
开发迁移: 没有共享状态、生命周期和可替换协作者时,函数往往更简单;类适合封装状态、不变量和一组围绕同一对象的行为。
误判边界: ‘未来可能扩展’不是把所有函数塞进 class 的充分理由;无状态 Utils 类在 Python 中经常只是模块函数的复杂包装。
主动回忆
- 一个纯字符串转换集合为什么模块函数可能比
StringUtils类更自然? - 什么情况下把相关函数升级成有状态对象能真正改善依赖管理和不变量维护?
192. 设计决策:Composition vs Inheritance【L1】
继承会把类型关系、实现复用和 override 行为耦合在一起。
组合更像:
class OrderService:
def __init__(self, payment_gateway):
self.payment_gateway = payment_gateway
依赖可以替换,更适合测试和演进。
不是“永远不用继承”,而是只有真实 subtype / protocol 关系成立时再使用。
🏋️ 配套实战:进入卡片 192 对应训练(变体 / Bug / 面试题)
先预测
Car 需要一个 Engine。如果业务关系是“汽车拥有/使用发动机”,为什么 composition 往往比让 Car(Engine) 继承 Engine 更自然?
揭晓
继承表达 is-a / 可替换关系,组合表达 has-a / 使用关系。把“使用某能力”误建模为继承会扩大耦合和 MRO/重写负担。
再做一个变体
什么时候 inheritance 仍是合理选择?
变体答案
当子类型确实满足父类型契约、调用方可以把它当父类型替换使用,并且共享抽象行为具有稳定语义时。
Composition 默认更灵活,但不是“继承永远错误”
继承适合真正稳定的 is-a 关系和需要多态替换的抽象;composition 适合组合可替换能力,并避免脆弱基类耦合。判断时问:子类是否真的能在不破坏契约的前提下替代基类?如果只是“想复用几行代码”,优先 composition/普通函数。
主动回忆
- composition 与 inheritance 分别表达什么关系?
- 如果只是为了复用实现代码而继承,为什么通常应该重新考虑 composition/函数?
193. Cohesion、Coupling 与依赖方向【L1】
- 高内聚:一个模块中的内容围绕明确职责。
- 低耦合:模块之间依赖少且稳定。
坏味道:
utils.py 3000 行
任何模块都 import 它
它又 import 所有人
好的边界让依赖方向更接近:
业务规则
↓ 依赖抽象
基础设施实现
而不是业务规则直接散落在数据库/HTTP/CLI 细节中。
Review 时问依赖方向
高内聚/低耦合不是追求模块越小越好,而是让变化原因集中、依赖稳定。若业务规则为了测试必须启动数据库、HTTP client 和框架上下文,往往说明基础设施细节已经反向侵入核心逻辑。
开发迁移与误判边界
开发迁移: 高内聚让同一模块围绕一个稳定职责变化,低耦合减少对具体实现/全局状态的依赖;依赖方向应让核心业务少依赖易变基础设施。
误判边界: 低耦合不等于接口越多越好。无意义抽象层会增加跳转和维护成本。
主动回忆
- 业务 service 直接 import 具体数据库客户端并在内部创建连接,为什么测试和替换会更难?
- 什么时候增加一个抽象接口是在降低耦合,什么时候只是在制造 ceremony?
Part V · 复习与参考
高频陷阱速查
这里是错误视角的复习入口,不是新的知识卡,不进入掌握度与调度系统。每个陷阱都跳回唯一主知识卡。
Mutable Default Argument
def f(items=[]): ...
默认对象定义时创建并跨调用共享。除非你明确需要共享状态,否则用 None + 函数体内创建。
主知识卡:卡 16。
最小复现
def collect(item, bucket=[]):
bucket.append(item)
return bucket
print(collect('A')) # ['A']
print(collect('B')) # ['A', 'B']
如果业务语义要求每次调用独立,默认值应使用 None/sentinel,并在函数体里创建新容器。
排查提示: 真实 Bug 常见于请求处理函数:默认 list/dict 跨请求积累状态。修复不是‘每次 copy 一下’的口诀,而是理解默认表达式只在函数定义时求值,并在调用期显式创建新对象。
Late Binding Closure
funcs = [lambda: i for i in range(3)]
调用时都读取最终 i。需要快照可:
lambda i=i: i
主知识卡:卡 25。
最小复现
callbacks = [lambda: i for i in range(3)]
print([f() for f in callbacks]) # [2, 2, 2]
callbacks = [lambda i=i: i for i in range(3)]
print([f() for f in callbacks]) # [0, 1, 2]
第一组闭包共享同一个自由变量读取关系;第二组利用默认参数在定义时求值保存了当次值。
排查提示: 事件回调/异步任务在循环后才执行时,late binding 特别常见:闭包读取的是变量单元的当前值,不是创建函数时自动拍快照。
[[]] * n
多个元素引用同一个内部 list。独立行使用 comprehension。
主知识卡:卡 9。
最小复现
rows = [[0, 0]] * 3
rows[0][0] = 1
print(rows) # [[1, 0], [1, 0], [1, 0]]
rows = [[0, 0] for _ in range(3)]
第二种写法每轮真正创建一个新的内部 list。
排查提示: [obj] * n 复制的是引用槽位,不会深复制 obj。二维列表初始化只是最典型表现;任何可变对象重复引用都会共享状态。
is 比较普通值
is 比 identity,不要用来替代字符串、整数等普通 equality。
主知识卡:卡 8。
最小复现
def is_ten(value):
return value == 10
普通值语义用 equality。不要根据某次 REPL 中整数/字符串对象恰好被复用,就把实现优化写进业务逻辑。
排查提示: is 用于 identity,普通数值/字符串相等应使用 ==。小整数/部分字符串可能因实现缓存恰好 identity 相同,这正是最危险的‘本机测试通过’。
浅拷贝误当深拷贝
list.copy()、dict.copy()、a[:] 只复制最外层容器。
主知识卡:卡 9。
最小复现
DEFAULT = {"headers": {"x-app": "demo"}}
config = DEFAULT.copy()
config["headers"]["trace"] = "123"
print(DEFAULT) # 内层 headers 也被修改
这类问题在默认配置、pytest fixture 和请求模板里尤其常见。
排查提示: 配置模板、嵌套 payload 和测试 fixture 经常因 shallow copy 共享内部 list/dict 而互相污染。修复前先画对象图,确定真正需要复制的边界。
遍历时修改容器
删除/新增元素会改变遍历结构,可能跳元素或触发错误。需要修改时考虑快照、构造新容器或明确索引策略。
主知识卡:卡 39。
最小复现
items = [1, 2, 2, 3]
for x in items:
if x == 2:
items.remove(x)
print(items) # 仍可能留下 2
过滤通常更清楚地写成 items = [x for x in items if x != 2];若必须保持原对象 identity,再明确选择 slice assignment。
排查提示: 遍历中结构性修改 list/dict/set 容易跳元素或触发 RuntimeError。常见策略是遍历快照、构造新容器,或先收集待删除项再处理。
浮点数直接相等
涉及计算误差时优先 math.isclose() 或合适的 Decimal 语义。
主知识卡:卡 28。
最小复现
import math
result = 0.1 + 0.2
print(result == 0.3)
print(math.isclose(result, 0.3, rel_tol=1e-9, abs_tol=0.0))
容差不是通用常数,应由数量级和业务误差模型决定。
排查提示: float 比较通常应基于领域容差;math.isclose 提供相对/绝对容差组合。金额等需要十进制精确语义的领域则应从类型选择层解决。
Naive Datetime
跨时区系统中没有 timezone 信息的 datetime 很容易造成歧义;明确时间语义。
主知识卡:卡 101。
最小复现
from datetime import datetime, timezone
created_at = datetime.now(timezone.utc) # 明确是 UTC aware datetime
跨服务传输时,真正要保存的是明确的时间语义,而不只是一个看起来像日期时间的字符串。
排查提示: naive datetime 缺少明确时区语义,跨服务、DST 和用户时区转换时很危险。生产事件时间优先传递 aware datetime/UTC instant,并在展示边界转换。
Circular Import
局部 import 可以止血,但长期应检查模块职责和依赖方向。
主知识卡:卡 80。
排查提示: circular import 通常说明模块初始化阶段互相需要尚未定义的名字,根因可能是依赖方向混乱。局部 import 能缓解时序,但不是自动修复架构。
Bare except / 吞异常
try:
risky_operation()
except:
pass
会隐藏失败。捕获具体异常并保留必要上下文。
主知识卡:卡 69。
最小复现
try:
process_record()
except Exception:
logger.exception("record failed")
raise
如果调用层无法恢复,记录后重新抛出通常比 pass 更诚实;真正允许逐条跳过的批处理,则应显式累计失败结果。
排查提示: 异常边界要区分‘能处理’和‘只想记录’。bare except 会连 KeyboardInterrupt/SystemExit 等都捕获,吞异常则让系统带着错误状态继续运行。
finally 中控制流
在 finally 中 return、break、continue 可能覆盖原本的 return/exception 控制流,极易隐藏问题。Python 3.14 中,如果 return、break 或 continue 跳出 finally 代码块,编译器会发出 SyntaxWarning(PEP 765)。代码设计上应尽量避免这种会覆盖或隐藏原控制流的写法。
主知识卡:卡 69。
Shadow Builtins
list = []
id = 1
str = "x"
会遮蔽内建名字,使后续代码和调试混乱。局部偶发名字不一定灾难,但公共/长作用域中应避免。
主知识卡:卡 22。
最小复现
list = [1, 2, 3]
# list("abc") # 此处 list 已经不是内建类型
这类 bug 常不是在命名那一行报错,而是在很远的后续代码调用 builtin 时才暴露。
排查提示: 把变量命名为 list/id/type/str 会遮蔽 builtins,错误往往在后面调用同名内建时才出现。IDE/linter 能帮助提前发现。
Wildcard Import
from module import *
让名字来源不明确,静态分析和重构困难。除特殊交互式/明确 API 场景外,优先显式 import。
主知识卡:卡 77。
最小复现
# 不推荐:名字来源在当前文件不可见
# from module_a import *
# from module_b import *
from module_a import parse as parse_a
from module_b import parse as parse_b
显式 import 让代码评审、IDE 和静态分析都能看到符号来源。
排查提示: wildcard import 隐藏名字来源,容易冲突并破坏静态分析;公共包若要控制 from x import * 的集合可用 __all__,但业务代码通常仍应显式 import。
Blocking Code in Asyncio
async def 内部调用同步阻塞函数仍会卡 event loop。需要异步 API 或线程/进程边界。
主知识卡:卡 119。
最小复现
import asyncio
import time
async def bad():
time.sleep(1) # 阻塞 event loop
async def better():
await asyncio.to_thread(time.sleep, 1)
to_thread 这里解决的是同步阻塞 IO/等待代码占住事件循环的问题,不应被理解成通用 CPU 并行器。
排查提示: asyncio 服务中调用同步 time.sleep、requests 或慢文件/SDK 会阻塞 event loop,使其他协程也无法推进。修复是使用异步 API、to_thread 或进程/专用执行器。
Iterator Exhaustion
iterator/generator 消费后不会自动重置:
g = (x for x in range(3))
list(g)
list(g) # []
需要多次遍历就保存原 iterable 或 materialize 为 list。
主知识卡:卡 52。
最小复现
g = (x * 2 for x in range(3))
preview = list(g)
actual = list(g)
print(preview) # [0, 2, 4]
print(actual) # []
调试打印、any()、next() 都可能改变 iterator 后续状态。
排查提示: iterator 是有状态消费对象,日志预览、list(iterator)、any/all 都可能提前消耗它。API 如果需要多遍,应接受 iterable 后明确物化,或要求可重迭代容器。
shell=True + 用户输入
典型 command injection 风险。优先 argument list。
主知识卡:卡 182。
排查提示: shell=True 会让字符串进入 shell 语法解析;用户输入可注入 ;, $(), 重定向等。优先 subprocess.run([cmd, arg], shell=False) 直接传 argv。
不可信 Pickle
不要反序列化不可信 pickle。它不是安全数据交换格式。
主知识卡:卡 96。
排查提示: 不可信 pickle 与卡 96 是同一安全根因在事故视角的速查:攻击者可构造在反序列化时执行任意 callable 的 payload。
隐藏的 O(n²)
典型:循环里做线性 membership:
for x in big_list:
if x in another_big_list:
...
若允许集合语义,可先转 set 把大量 membership 从 O(n) 降为平均 O(1)。
主知识卡:卡 166。
最小复现
def common(a, b):
return [x for x in a if x in b]
# 如果 b 很大且查询很多:
def common_faster(a, b):
b_set = set(b)
return [x for x in a if x in b_set]
优化是否成立还要算上构建 set 的成本和复用次数;不要脱离数据规模只背复杂度标签。
排查提示: 隐藏 O(n²) 常来自循环里做线性 membership、重复拼接/查找或嵌套扫描。先按数据规模推导操作次数,再用 profiler 证实。
Python 版本演进速记
194. Python 3.8~3.10【L1】
3.8
- Assignment expression
:=。 - Positional-only parameter syntax
/。
3.9
list[str]等内建泛型。- dict union
|/|=。
3.10
- Structural Pattern Matching。
X | Yunion type syntax。
阅读旧代码时,需要知道这些语法为什么可能不存在。
开发迁移与误判边界
开发迁移: 3.8~3.10 的版本知识主要用于读旧代码和设最低支持版本:walrus/positional-only、built-in generics、dict union、pattern matching、X|Y typing 等会影响语法兼容。
误判边界: 不要背发布日期;真正重要的是‘项目最低 Python 版本决定哪些语法能直接使用’。
主动回忆
- 库声明支持 Python 3.9 时,为什么不能直接在源码使用
match/case? - 升级最低版本后,你会优先清理哪些兼容写法,让代码利用现代 typing/语法?
195. Python 3.11~3.13【L1/L2】
3.11
- 显著解释器性能改进。
ExceptionGroup/except*。asyncio.TaskGroup。
3.12
- PEP 695 新 type parameter syntax 等 typing 改进。
3.13
- Free-threaded 构建进入实验阶段。
- JIT 等实验性运行时工作继续推进。
版本演进应该服务于“理解现代代码和旧代码差异”,而不是背 release note。
开发迁移与误判边界
开发迁移: 3.11~3.13 带来异常组/TaskGroup、typing/解释器性能、3.13 free-threaded 实验等变化。阅读资料时要区分‘某版本首次实验’与‘后续版本正式支持’。
误判边界: 性能提升是 workload 相关的;不能因为 release note 写更快就承诺所有程序固定提升某个百分比。
主动回忆
- 为什么介绍 free-threaded 时要说明 3.13 的实验阶段与 3.14 的支持状态不同?
- 维护兼容 3.11~3.13 的库时,你如何用 CI matrix 防止只在当前解释器测试导致语法/API 漏洞?
196. Python 3.14【L1/L2】
本手册基线版本的重要变化:
- Free-threaded Python officially supported:仍是可选构建,不是所有 3.14 默认都无 GIL;
- Deferred evaluation of annotations(PEP 649/749);
- Template string literals(t-string)(PEP 750);
- Multiple interpreters in the standard library;
compression.zstd;- 实验性 JIT introspection:CPython 3.14 增加
sys._jit,是否可用取决于构建,JIT 仍属于实验性实现细节; - Asyncio introspection、错误信息、REPL 等方面继续改进。
import sys
if hasattr(sys, "_jit"):
print(sys._jit.is_available())
任何“Python 3.14 已彻底删除 GIL”或“3.14 默认启用 JIT”的表述都不准确。
官方:
- https://docs.python.org/3.14/whatsnew/3.14.html
- https://docs.python.org/3.14/library/sys.html#sys._jit
开发迁移与误判边界
开发迁移: Python 3.14 卡用于掌握当前基线的重要差异:deferred annotations、t-string、free-threaded 支持、多解释器、zstd、GC/运行时变化等;需要特别区分默认构建和 free-threaded 构建。
误判边界: micro 版本也可能修正实现细节,例如 GC 行为;因此版本敏感结论应标明 3.14.x 具体文档,而不是只写‘Python 3.14 一定这样’。
主动回忆
- 为什么‘Python 3.14 已无 GIL’是错误表述?
- 阅读一篇基于 3.14.0 的 GC 文章时,你为什么还要核对 3.14.7 当前文档?
综合知识连接图
以下 8 张属于综合图(优先级 R),用于连接已学知识,不是新的独立概念。
197. 对象链【复习图】
Name
↓ binding
Object
├── identity
├── type
└── value
↓
Reference Sharing
├── mutable → mutate
└── immutable → rebind/new object
↓
Copy
↓
Function Arguments
↓
Reference Count
↓
GC / Lifetime
198. 函数链【复习图】
Function Object
↓
Arguments / Parameters
↓
Namespace
↓
LEGB
↓
Closure
↓
Higher-order Function
↓
Decorator
199. 面向对象链【复习图】
Object
↓
Class / Instance
↓
Attribute Lookup
↓
Descriptor
↓
Bound Method / property
↓
Inheritance
↓
MRO / super
↓
Metaclass
200. 迭代与异步链【复习图】
Iterable
↓ iter()
Iterator
↓ next()
Generator
↓ yield / suspension
Coroutine
↓ await
Task
↓
Event Loop
Generator 与 coroutine 不是同一个概念;这张图表达的是“暂停/恢复与协议抽象逐步演化的认知连接”。
201. 执行链【复习图】
.py source
↓
Parser
↓
AST
↓
Compiler
↓
Code Object / Bytecode
↓
Frame
↓
CPython Interpreter
↓
OS Thread
↓
Scheduler / CPU
202. 并发链【复习图】
Workload
├── CPU-bound
│ ├── Process
│ ├── Free-threaded threads(合适且兼容时)
│ └── Multiple interpreters / native code
│
└── IO-bound
├── Thread
└── Asyncio
Shared Mutable State
↓
Race Condition
↓
Synchronization / Isolation
203. IO 链【复习图】
Python str/object
↓ encode / serialize
bytes
↓
buffer
↓
file / socket
↓
file descriptor
↓
kernel
↓
disk / network
204. 工程链【复习图】
Source Code
↓
Import Package
↓
pyproject.toml
↓
Build Backend
↓
Wheel / sdist
↓
Package Index
↓
pip
↓
Virtual Environment
↓
Runtime
官方资料入口
本手册有意避免绑定大量第三方教程,版本相关事实以 Python 官方资料为准。建议长期保留以下入口:
- Python 3.14 官方文档:https://docs.python.org/3.14/
- Python 3.14 What’s New:https://docs.python.org/3.14/whatsnew/3.14.html
- Python Language Reference:https://docs.python.org/3.14/reference/
- Python Standard Library:https://docs.python.org/3.14/library/
- Python HOWTO:https://docs.python.org/3.14/howto/
- Python 3.14.7 Release:https://www.python.org/downloads/release/python-3147/
- Packaging User Guide:https://packaging.python.org/
离线与配套资源
本系统所有源码、题目、调度脚本与结构化元数据均已打包,支持离线或导入个人笔记系统复习:
- 📄 下载主手册 Markdown(CPython 3.14.7)
- 📝 下载训练册 Markdown(CPython 3.14.7)
- ⚙️ 下载 Python 复习调度器(Python 调度脚本)
- 📊 下载知识卡 Metadata(JSON 数据文件)
- 📦 下载完整离线包(ZIP 包含全套复习资料)
维护与版本审计
本复习系统的技术审查报告、卡号演进与收敛记录归档于仓库工程文档:
docs/dev/python-review/Python_碎片化复习手册_CHANGELOG.mddocs/dev/python-review/Python_碎片化复习手册_正式收敛QA.mddocs/dev/python-review/Python_碎片化复习系统_使用导航.md