全景:Python 的定位与现状
钻进语法前先答三问:它凭什么流行、生态在哪些领域最强、这一页怎么读。
Python 把宝押在一件事上:代码是写给人读的——语法接近伪代码,五行顶别人二十行;跑得慢的部分,再交给 C 写的库去干。
两大立身之本
- 可读性:用缩进表达结构,读别人的代码像读英语短句。初学者第一天就能写出真正有用的脚本;
- 胶水能力:NumPy、PyTorch 的计算内核都是 C/C++/CUDA 写的,Python 只负责指挥——「慢」的是解释器,不是你调用的那些库。
代价从哪来
- 不声明类型、逐行解释执行:免编译、改一行立刻重跑,写原型的速度无出其右;
- 代价是错误被推迟到运行时才爆——名字拼错要等程序真跑到那一行才知道。
# Python 的气质:接近伪代码,全程标准库
from pathlib import Path
from collections import Counter
words = Path("log.txt").read_text(encoding="utf-8").split()
for word, n in Counter(words).most_common(3):
print(word, n)
# 词频统计:读文件 → 切词 → 计数 → 取前三
# 输出(log.txt 内容为 apple banana apple cherry apple banana):
# apple 3
# banana 2
# cherry 1print "hello" 不带括号)——Python 2 在 2020 年就停止维护了,见到不带括号的 print 直接关掉换一篇。照着跑会得到 SyntaxError。选语言本质是选生态。Python 在几个领域是事实标准,在另一些是主流之一,也有明确不该用它的地方。
版图一览
- 数据分析 / AI(NumPy、pandas、PyTorch):事实标准,大模型时代进一步巩固;
- Web 后端(FastAPI、Django):主流选项之一;脚本与运维:几乎无对手;
- 不该用它:手机 App、浏览器前端、对启动时间敏感的高频 CLI。
为什么 AI 圈选了 Python
- 核心还是胶水能力:科学计算内核用 C/CUDA 写,研究者只想用最省事的语言指挥它们;
- 先有 NumPy 打底,再长出 pandas、PyTorch——生态一旦滚起来,雪球自己会滚。
# 「电池齐全」实感:读写 JSON + 统计,零第三方依赖
import json, statistics
from pathlib import Path
scores = {"语文": [88, 92, 79], "数学": [95, 87, 91]}
Path("scores.json").write_text(json.dumps(scores), encoding="utf-8")
data = json.loads(Path("scores.json").read_text(encoding="utf-8"))
for subject, s in data.items():
print(subject, round(statistics.mean(s), 1))
# 语文 86.3
# 数学 91这页不是从头背到尾的字典,而是一条有主线的路:先把环境跑通,再立对心智模型,剩下的按需推进。
推荐路径
- 01:装 Python、建虚拟环境、跑通第一个脚本、学会读报错——环境是 Python 入门真正的门槛,这一章值得逐卡照做;
- 02:立住「名字绑定,不是变量盒子」这个核心心智,后面的坑一大半从这里来;
- 17 与 11 随时查:踩到诡异行为先翻 17(高频坑合集),想找轮子先看 11(标准库导览),别急着
pip install。
# 版本自检:进入任何一章之前先跑这三行
import sys
print(sys.version) # 3.14.4 (main, ...)
print(sys.version_info >= (3, 12)) # True 才能保证本页示例全可跑上手:装环境,跑通第一个脚本
从零装 Python、认识 venv 与 uv、跑通脚本与 REPL、读懂 traceback。
装 Python 的坑不在下载,而在「装完找不到」和「机器上有好几个互相打架」。选一条路线装到底,装完立刻把虚拟环境也一起办了——它不是进阶技巧,是第一课。
三条路线,挑一条
- python.org 官方安装器最直接、和所有教程一致,初学首选;uv 则一个工具管版本、虚拟环境和依赖(
uv python install 3.14),社区正在收敛到它; - 系统自带的
python3跑小脚本够用,但版本常偏旧——它是给操作系统自己用的,别当主力。装完敲python3 --version验证,不低于 3.12 即可跟完本页。
venv:每个项目一套独立的包目录
- 没有它,第二个项目一定和第一个打架:全局只能装一个版本,先装的必被顶掉;新版 Ubuntu/Debian 干脆禁止往系统环境 pip 装包(PEP 668);
- 流程背下来:
python -m venv .venv→ 激活(Windows 是.venv\Scripts\activate)→ 提示符前多出(.venv)就对了;.venv不进版本控制,依赖清单才进。走 uv 的话uv venv/uv add/uv run连 activate 都省了。
# 装完后的验证(终端里跑,$ 是提示符不用输入)
# Windows:
$ python --version
Python 3.14.4
# macOS / Linux:
$ python3 --version
Python 3.14.4
# uv 路线:装 uv 后一条命令搞定 Python 本体
$ uv python install 3.14
# Ubuntu/Debian 补齐 pip 与 venv(系统自带路线)
$ sudo apt install python3-pip python3-venvpython;重装勾上即可。把五行代码存进文件、让解释器跑起来——这一步走通,剩下的全是语法细节。
hello.py 逐行拆
#开头是注释,解释器直接跳过;name = input("你叫什么名字? ")——先打印提示语,然后暂停等你敲一行字,回车后把内容交给名字name。注意拿到的永远是文字(str),哪怕你敲的是数字,这个坑在 02 章展开;year = 2026——把名字绑到整数上,不用声明类型;print(f"你好,{name}!")——f-string:引号前加f,花括号里的名字会被替换成它的值;print也可以接多个值用逗号隔开。
两种跑法
- 跑文件:存成
hello.py,终端 cd 到目录,python hello.py(macOS/Linux 用python3)。这就是 Python 程序的全部运行方式——解释器从第一行执行到最后一行; - REPL:直接敲
python3不带文件名,出现>>>后敲一行执行一行、立刻回显(1 + 1回车就显示2)。适合当计算器、试一个函数怎么用、验证一个猜想,本页大量示例都能贴进去玩。退出敲exit()或 Ctrl-D。
# 我的第一个 Python 脚本 —— 存成 hello.py
name = input("你叫什么名字? ")
year = 2026
print(f"你好,{name}!")
print("现在是", year, "年")
# 终端里运行:
# $ python3 hello.py
# 你叫什么名字? 小北
# 你好,小北!
# 现在是 2026 年input() 拿到的永远是字符串:想做算术必须先 int(...) 转换,否则 "3" + 1 直接 TypeError。这是新手第一天最常撞的一条。报错不是失败,是 Python 在精确告诉你「停在哪、为什么」。从下往上读,八成的错自己就能修。
三段结构,从下往上读
- 最后一行最重要:
异常类型: 人话描述——先看类型再看描述; - 中间几行是出错的文件、行号和那行代码,下面一排
^^^精确指着位置; - 第一行那句
most recent call last的意思是「调用链按时间排列」——所以事发现场永远在最底部;栈很长时,从下往上找第一个属于你自己文件的行号。
三种真实报错
- 拼错名字:
NameError: name 'mesage' is not defined. Did you mean: 'message'?——3.10 起 Python 还会猜你想打哪个词,顺着改就行; - 类型不配:
TypeError: can only concatenate str (not "int") to str——"明年你就 " + age + 1想拿文字加数字,而age来自input所以是 str。波浪线~~~~^~~标出了是哪个加号出的事; - 缩进错:
IndentationError: expected an indented block after 'if' statement——注意这条没有 Traceback 头,因为它在运行前的语法检查阶段就被查出,程序一行都没执行。
# 错误 1:拼错名字(NameError)
message = "hello"
print(mesage) # 少打一个 s
# 错误 2:str + int(TypeError)
age = input("你的年龄: ") # input 返回的是 str
print("明年你就 " + age + 1) # 文字不能加数字
# 错误 3:if 后忘了缩进(IndentationError)
score = 95
if score >= 60:
print("及格") # 这行必须缩进四格
# 三条的完整报错原文见左侧讲解——都跑出来过^^^。语法基础:变量、字符串与数字
名字绑定而非变量盒子——这一条撑起 Python 大半反直觉。
忘掉「变量是个装值的盒子」——Python 的赋值是把名字贴到对象上,一个对象可以同时贴好几个名字。这是本页最重要的一个心智模型。
赋值到底做了什么
a = [1, 2, 3]:先创建列表对象,再把名字a绑上去;b = a:只是再贴一个名字,没有复制任何东西——id(a) == id(b)为 True(id 返回对象的唯一身份编号);- 于是
b.append(4)之后打印a,得到[1, 2, 3, 4]——不是 b「影响了」a,而是它俩本来就是同一个对象的两个名字。
为什么数字没这个问题
- int、str、tuple 是不可变对象:没有任何方法能原地改它们;
x = 42; y = x之后x = x + 1——这不是「把盒子里的 42 改成 43」,而是造一个新对象 43、把名字 x 改绑过去;y 还贴在原来的 42 上(加一后两者 id 分家,y 仍是 42);- 分清两个动作:重新绑名字(
x = ...,永远安全)和原地改对象(.append()、[k] = v,会被所有名字看见)。后面的可变默认参数、浅拷贝、函数传参之坑(05 17)全是这一条的变奏。
真想要一份副本
c = a.copy()(或list(a)、切片a[:])——c is a为 False、c == a为 True:新对象,值相同;- 注意这是浅拷贝:嵌套的里层对象仍是共享的,深层独立要用
copy.deepcopy(a)(展开见 17)。
a = [1, 2, 3]
b = a # 同一对象贴两个名字
print(id(a) == id(b)) # True
b.append(4)
print(a) # [1, 2, 3, 4] —— a 也「变」了
c = a.copy() # 真正的(浅)副本
print(c is a, c == a) # False True
# 不可变对象:改绑不影响旧名字
x = 42
y = x
x = x + 1 # 造新对象 43,x 改绑过去
print(y) # 42 —— y 仍指着原对象items.append(...),外面的列表跟着变——因为形参只是给同一个对象又贴了个名字。不想影响调用方,函数内先 items = list(items) 复制一份。字符串是你用得最多的类型:f-string 负责把值嵌进文字,方法负责加工——而字符串本身永远不变,所有「修改」都是造新的。
f-string 三板斧
- 嵌值:引号前加
f,花括号里可以放任意表达式——f"{name} 今年 {age} 岁"; - 调试:
f"{age=}"同时输出名字和值(age=18),排查时最省事的写法; - 格式规格:冒号后跟格式——
f"{3.14159:.2f}"得3.14(两位小数)、f"{1234567:,}"得1,234,567(千分位)、f"{0.876:.1%}"得87.6%(百分比)。对齐规格见本章「输入输出」卡。
最常用的方法
| 方法 | 作用 | 例(结果) |
|---|---|---|
strip() | 去两端空白 | " x ".strip() → "x" |
split(",") | 按分隔符切成列表 | "a,b,c".split(",") → ['a','b','c'] |
"-".join(列表) | 用分隔符拼回去 | "-".join(["a","b","c"]) → "a-b-c" |
replace(旧, 新) | 替换(返回新串) | s.replace("World","Python") |
upper() / lower() | 大小写 | "Hi".upper() → "HI" |
startswith / in | 前缀 / 包含判断 | "Wor" in s → True |
len(s) 按码点数(日常可先理解成字符数)——len("你好") 是 2,不是字节数;码点、字节、字素的细账见 10 章。
不可变:所有「修改」都是新对象
s[0] = "h"直接报TypeError: 'str' object does not support item assignment——字符串造出来就定型了;- 所以
replace、strip都是返回新串,原串原样不动——写s.strip()却不接收返回值,等于什么都没做; - 好处:字符串可以放心共享、可做字典键(呼应本章「名字绑定」卡:不可变对象没有共享之忧)。
name, age = "小明", 18
print(f"{name} 今年 {age} 岁") # 小明 今年 18 岁
print(f"{age=}") # age=18(调试)
print(f"{3.14159:.2f}") # 3.14
print(f"{1234567:,}") # 1,234,567
s = "Hello, World"
print(s.replace("World", "Python")) # Hello, Python
print(s) # Hello, World —— 原串没变
print("a,b,c".split(",")) # ['a', 'b', 'c']
print("-".join(["a", "b", "c"])) # a-b-c
s[0] = "h" # TypeError!字符串不可变(报错原文见左)join 要求列表里全是 str:"-".join([1, 2, 3]) 报 TypeError: sequence item 0: expected str instance, int found——先转换:"-".join(str(x) for x in nums)。f"{w["k"]}" 可跑;更早版本得里外换引号。分享代码给别人前想想对方的版本。int 想多大有多大;float 是 64 位二进制小数——一切浮点「怪事」都源自一个事实:二进制精确存不下 0.1。
int:真正的任意精度
2 ** 100直接得到 31 位的准确结果1267650600228229401496703205376——不会溢出,没有「int 最大值」;- 大数字面量可用下划线分组:
1_000_000(就是一百万)。
float:0.1 + 0.2 的真相
0.1 + 0.2打印出0.30000000000000004,== 0.3为 False——0.1 在二进制里是无限循环小数,存进 64 位就带上了微小误差;- 比较浮点用
math.isclose(0.1 + 0.2, 0.3)(True); - 算钱等需要十进制精确的场合用
decimal.Decimal:Decimal("0.1") + Decimal("0.2")恰好是0.3。注意必须传字符串——Decimal(0.1)会把 float 已带的误差原样继承进来(打印出 0.1000000000000000055…)。
除法与幂
/永远返回 float:7 / 2是3.5,连4 / 2都是2.0;//整除向下取整:7 // 2是 3,-7 // 2是 -4(往更小方向,不是砍掉小数);%取余,结果符号跟除数:-7 % 3是 2;**幂:2 ** 10是 1024;它右结合且优先级高于取负——2 ** 3 ** 2是 512(先算 3²),-2 ** 2是 -4(先平方再取负,想要 4 写(-2) ** 2)。
round 是银行家舍入
round(0.5) 得 0、round(1.5) 得 2、round(2.5) 也得 2——.5 时向偶数靠,不是小学的四舍五入(这样统计上不偏大)。再叠加二进制误差:round(2.675, 2) 得 2.67(因为 2.675 实际存的值略小)。显示两位小数用 f"{x:.2f}";真要按十进制规则精确舍入(财务),用 Decimal 的 quantize。
print(2 ** 100) # 1267650600228229401496703205376(不溢出)
print(0.1 + 0.2) # 0.30000000000000004
print(0.1 + 0.2 == 0.3) # False
import math
print(math.isclose(0.1 + 0.2, 0.3)) # True —— 浮点这样比
print(7 / 2, 7 // 2, 7 % 3) # 3.5 3 1
print(-7 // 2, -7 % 3) # -4 2(向下取整;余数跟除数同号)
print(2 ** 3 ** 2, -2 ** 2) # 512 -4(右结合;先幂后负号)
print(round(0.5), round(1.5), round(2.5)) # 0 2 2 银行家舍入
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2")) # 0.3 —— 传字符串!round(2.675, 2) 得到 2.67 而非 2.68)。任何「差一分钱」级别的需求都别用 float + round,直接上 Decimal。int() 转换是向零截断:int(3.99) 得 3、int(-3.99) 得 -3——和 // 的向下取整在负数上不一样,别混用。print 和 input 就是初学阶段程序的全部界面——print 有几个被忽视的参数,input 有一个人人必踩的坑。
print 的 sep 与 end
- print 接多个值时默认拿空格连接、末尾换行——两者都能改;
sep换连接符:print("2026", "07", "22", sep="-")输出2026-07-22,免去手工拼接;end换结尾:print("loading", end="...")后面的输出接在同一行(得到loading...done),做进度提示常用。
input 永远返回 str
- 用户敲了
7,你拿到的是文字'7'不是数字(type是 str); - 于是
n * 3得'777'(文字重复三遍),比大小走字典序——"10" < "9"是 True(逐字符比,'1' 排在 '9' 前); - 先转换再运算:
int(n) * 3得 21。用户乱输时int("abc")报ValueError: invalid literal for int() with base 10: 'abc'(报错原文)——健壮写法是循环加 try/except 重试(见 08)。
f-string 对齐:打个小表格
- 格式规格里
<左对齐、>右对齐、^居中,数字是总宽度:f"{name:<6}{price:>8.2f}"——名字占 6 位左对齐,价格占 8 位右对齐留两位小数; - 对齐输出见右侧代码注释,几行就是一张整齐的对账单;
- 对齐宽度按字符个数算,一个汉字算一个字符但在终端占两格宽——中英混排或字数不齐的中文列会歪(「苹果」和「车厘子」两行就没对齐)。纯中文场景可用全角空格
chr(12288)填充缓解。
print("2026", "07", "22", sep="-") # 2026-07-22
print("loading", end="...")
print("done") # loading...done(同一行)
n = input("数字: ") # 用户敲 7,拿到的是 '7'(str!)
print(n * 3) # 777 —— 文字重复,不是 21
print(int(n) * 3) # 21 —— 先转 int 再算
# f-string 对齐:名字左对齐占 6 位,价格右对齐占 8 位
for name, price in [("苹果", 5.5), ("香蕉", 3.25)]:
print(f"{name:<6}" + f"{price:>8.2f}")
# 苹果 5.50
# 香蕉 3.25a + b 得 "12"——静默错误,比报错更险。所有来自 input 的「数字」落地第一件事就是 int()/float()。print(f"{x=}")——变量名和值一起出,多个变量一行搞定:print(f"{a=} {b=}")。数字、字符串、函数、连类型本身,在 Python 里全是对象;每个对象都有三件套——身份(id)、类型(type)、值。分清比的是哪一件,is 和 == 的一切疑惑就散了。
三件套与自查工具
- 身份:
id(x)给对象唯一编号,is比的就是它——「是不是同一个对象」; - 类型:
type(42)是<class 'int'>,连type(int)都是<class 'type'>(类型的类型);函数也是对象——能给函数挂属性f.note = "...",这正是装饰器的根基(05); - 自查:
dir(obj)列出对象全部属性——dir("abc")有四十多个非下划线方法,忘了方法名就 dir 一下,配合 REPL 里help(str.strip)看用法。
类型判断:isinstance 优于 type ==
isinstance(x, str)是标准写法,它认继承关系;type(x) == str只认精确类型;- 一个冷知识:
isinstance(True, int)是 True——bool 是 int 的子类,所以True + True得 2。
is vs ==:比身份 vs 比值
a = [1, 2]; b = a; c = [1, 2]——a == cTrue(值相等)、a is cFalse(两个对象)、a is bTrue(同一对象的两个名字,呼应本章第一卡);- 规则一句话:比值用 ==;is 只用于 None——
if x is None:是唯一的日常正当用途。
为什么不能拿 is 比数值
- 3.14 写
n is 257直接收到SyntaxWarning: "is" with 'int' literal. Did you mean "=="?——解释器直接提示你改; - 结果还不可靠:CPython 缓存 -5~256 的小整数(跨编译单元
100 is 100True、300 is 300False),而同一文件里的字面量还可能被编译器合并成同一个常量(同文件两个 300 竟然 is True)——同一段比较,换个写法结果就翻转。这是实现细节,不是语言承诺,唯一正确做法就是用 ==。
print(type(42), type("hi")) # <class 'int'> <class 'str'>
print(isinstance(42, int)) # True —— 类型判断标准写法
print(isinstance(True, int)) # True —— bool 是 int 子类
a = [1, 2]
b = a
c = [1, 2]
print(a == c) # True —— 值相等
print(a is c) # False —— 不是同一个对象
print(a is b) # True —— 同一对象的两个名字
x = None
if x is None: # is 的唯一日常用途
print("还没有值")
print(dir("abc")) # 列出 str 的全部方法,忘名字就查它x == None 或 type(x) == str:前者会被自定义 __eq__ 干扰且不地道(标准写法 x is None),后者认不出子类(用 isinstance)。ruff(14 章)对这两条都会报警。dir(对象) + help(对象.方法) 是零成本的离线文档——比搜索引擎快,且保证和你装的版本一致。控制流与真值判断
if/for/while、真值规则、match——Python 的分支与循环惯用法。
Python 的 if 不要求条件是布尔值——任何对象都能放进去,解释器先做「真值测试」,规则一句话:空的、零的、None 的算假,其余都算真。
falsy 全清单(3.14)
- 数字零
0、0.0;空文本"";空容器[]、()、{}、set()、range(0);再加None和False——这十个bool()全返回False,内建对象里其余基本都为真。 - 「真」看的是空不空,不是内容像不像假:
" "(一个空格)、[0](装着 0 的列表)、"False"(内容碰巧是 False 的字符串)全为True。 - 自定义对象默认恒真;类实现
__bool__或__len__后才会参与真值判断(魔术方法见 06)。
「if x:」和「if x is not None:」是两句不同的话
| x 的值 | if x: | if x is not None: |
|---|---|---|
0 | 跳过 | 进入 |
"" | 跳过 | 进入 |
None | 跳过 | 跳过 |
5 | 进入 | 进入 |
差别就在第一、二行:当 0 或空串是合法数据时(数量为零、备注为空),if x: 会把它们错当成「没有值」。「用户到底传没传」这个问题,只有 is not None 答得准。
elif 链与条件表达式
elif自上而下,第一个命中就停,后面的条件不再看;全部落空才走else(可省略)。所以把最特殊的条件放最前面。- 三元写法是
a if cond else b——条件在中间,先写「取什么」再写「什么时候」。适合短小的取值,嵌套两层以上就拆成 if 语句。 - Python 没有 switch:平铺的多路分支用
elif;要按「数据形状」分发,用本章的match卡。
# 十个内建 falsy 值——其余对象基本都为真
for v in [0, 0.0, "", [], {}, set(), (), None, False, range(0)]:
print(bool(v)) # 全部 False
bool(" "), bool([0]), bool("False") # True True True——非空即真
def label(x):
if x: # 真值测试:0 / "" / [] 都进不来
return "有值"
return "空/零/None"
def label2(x):
if x is not None: # 只排除 None:0 和 "" 能进来
return "传了值"
return "没传"
label(0) # '空/零/None' ← 0 被误判成「没有」
label2(0) # '传了值' ← 语义正确
# 条件表达式:先写取什么,再写什么时候
status = "成年" if age >= 18 else "未成年"if result: 在 result 可能为 0 或 "" 时是经典 bug:0 是合法结果,却被当成「没有结果」走了错误分支(label(0) 落进「空/零/None」)。判断「是否为 None」永远写 is None / is not None。if items:,比 len(items) > 0 更 Pythonic;但要区分「没传值」和「传了空值」,唯一可靠的写法是 is (not) None——is 与 == 的分工见 02。Python 的 for 不是「计数循环」而是「遍历循环」:它只做一件事——向可迭代对象要下一个元素,要不到就结束。计数只是遍历 range 的特例。
range:按需生成的等差数列
range(5)产出 0–4,不含终点;range(2, 10, 2)得 2、4、6、8;步长为负可以倒数:range(3, 0, -1)得 3、2、1。- range 是惰性的:
range(1_000_000_000)瞬间建好,len()和in都是算出来的、不真生成十亿个数(999 in r立即返回 True)。这是「按需产出」思想的第一次露面,完整故事在 07。 - 需要下标时别写
range(len(xs))——用enumerate,见下。
enumerate 与 zip:带序号、并行走
enumerate(xs, start=1)产出(序号, 元素)元组,配合解包for i, name in ...直接拆开;list(enumerate("ab"))得[(0, 'a'), (1, 'b')]。zip并行遍历多个序列,长度不等时静默截断到最短:zip([1, 2, 3], "ab")只产出两对。- 3.10+ 传
strict=True,长度不齐立刻抛异常,报错原文:ValueError: zip() argument 2 is shorter than argument 1。 - 顺手一招:
dict(zip(names, ages))把两列配成字典。
for-else:没 break 才执行
else挂在循环上时读作「循环没被 break 打断就执行」——不是「循环没执行」。- 典型用途是搜索:找到就
break,一路没找到自然落进else报告「没有」。省掉一个 found 标志变量。 [2, 4, 6]里找奇数,没找到,else 分支执行;[2, 3, 6]找到 3 触发 break,else 不执行。- 另外注意:直接
for k in d:遍历字典拿到的是键;要键值对用d.items()(详见 04)。
list(range(5)) # [0, 1, 2, 3, 4]——不含终点
list(range(2, 10, 2)) # [2, 4, 6, 8]
for i, name in enumerate(["甲", "乙"], start=1):
print(i, name) # 1 甲 / 2 乙
list(zip([1, 2, 3], "ab")) # [(1,'a'), (2,'b')]——静默截断!
list(zip([1, 2, 3], "ab", strict=True))
# 3.10+:长度不齐直接抛 ValueError(原文见正文)
# for-else:搜索的标准姿势
for n in [2, 4, 6]:
if n % 2:
print("找到奇数", n)
break
else: # 没有 break 才会走到这里
print("一个奇数都没有") # 本例输出这句zip 截断是静默的:两个「本该等长」的列表因为上游 bug 长度不齐时,不报错、只丢数据。数据对齐场景一律 zip(a, b, strict=True)(3.10+),让不齐当场炸出来,而不是下游默默少几行。for 能遍历一切可迭代对象——列表、字符串、文件、生成器(协议本身在 07 展开)。需要序号上 enumerate、需要并行上 zip,几乎永远不需要手写下标。while 负责「不知道要循环几次」的场景;break 和 continue 是两种完全不同的跳法——一个整段离开,一个只跳过本轮。
break、continue 与 do-while 的替代
break:立刻结束整个循环;continue:跳过本轮剩余语句,回到条件判断进入下一轮(n == 1那轮的 print 被跳过)。两者都只作用于最内层循环。- Python 没有 do-while;「至少执行一次」的惯用写法是
while True:循环体末尾if 条件: break——读输入、轮询、重试都是这个形态。 - 3.8+ 海象运算符
:=让「取一个、判一个」并成一行:while (chunk := next(it, "")):,省掉循环前后重复的取值语句。
头号坑:边遍历边删(演示)
nums = [1, 2, 2, 3],在for x in nums:里nums.remove(2),期望删光所有 2——结果是[1, 2, 3],第二个 2 漏网。- 原因:for 在列表上按下标推进。删掉下标 1 的元素后,后面整体前移,第二个 2 从下标 2 滑到下标 1;而迭代器下一步取下标 2——正好从它头上跳了过去。不多不少,就漏相邻的那一个。
- 字典和集合更干脆:遍历中增删键直接抛
RuntimeError: dictionary changed size during iteration(set 报Set changed size during iteration)。列表这种「静默出错」反而更危险。
三种正确姿势
- 首选:推导式建新列表——
nums = [x for x in nums if x != 2],得[1, 3]。声明「留什么」而不是命令「删什么」,正确且最快。 - 必须原地改:遍历副本、修改原表——
for x in nums[:]:,得[1, 3]。 - 手动
while+ 下标:删除后不前进、否则i += 1,得[1, 3]。啰嗦但对增删逻辑复杂的场景最可控。
# continue 跳过本轮:n==1 时不打印
n = 3
while n > 0:
n -= 1
if n == 1:
continue
print(n) # 2 和 0
# 坑:边遍历边删,漏掉相邻元素
nums = [1, 2, 2, 3]
for x in nums:
if x == 2:
nums.remove(x)
print(nums) # [1, 2, 3] ——第二个 2 没删掉!
# 正确:推导式建新列表(首选)
nums = [1, 2, 2, 3]
nums = [x for x in nums if x != 2] # [1, 3]
# 或者:遍历副本,修改原表
for x in nums[:]:
if x == 2:
nums.remove(x)continue 写在循环变量更新语句之前,那一轮就不会更新变量——条件永真,死循环。while 里用 continue 时先确认更新语句在它前面;这也是「优先用 for 遍历、少手管计数器」的理由之一。[x for x in xs if 条件] 不仅保证正确,通常还比原地反复 remove(每次 O(n))快得多。match(3.10+)不是换皮 switch——它做的是「结构匹配 + 解构绑定」:既检查数据长什么形状,又顺手把里面的值拆出来绑给变量。
模式一览
- 字面量:
case 200:;或模式:case 404 | 410:任一命中即可;守卫:case code if code >= 500:——先捕获再验条件,守卫里能直接用刚捕获的变量。 - 序列:
case []:(空表)、case [x]:(恰好一个)、case [x, *rest]:——和解包一样拆列表。 - 映射:
case {"type": t, **extra}:——只要求包含"type"键,值绑给 t,多余键值进 extra(多余的键不会导致失配)。 - 类:
case Point(x=0, y=y):——先做 isinstance 检查,再按属性逐个匹配或捕获;配dataclass(见 06)最顺手。 - 兜底:
case _:通配不绑定;case str() as s:匹配类型的同时把整个对象留在 s 里。 - 自上而下首个命中即止,没有 C 系 switch 的 fallthrough,不需要 break。
头号坑:裸名字是「捕获」,不是「比较」
- 直觉写法
case NOT_FOUND:(此前NOT_FOUND = 404)看着像「等于 404 吗」,实际是捕获模式:匹配任何值,并把它重新绑定给 NOT_FOUND。status = 200时该分支照样命中,事后 NOT_FOUND 变成了 200——常量被悄悄改写。 - 若它后面还有其它 case,编译期直接拒绝(3.10 引入 match 起就如此),报错原文:
SyntaxError: name capture 'NOT_FOUND' makes remaining patterns unreachable。但当它是最后一个 case 时没有任何警告,静默吞掉一切。 - 解法:常量用带点的名字——点号访问是值模式,做的才是相等比较。
case HTTP.NOT_FOUND:对 200 不命中、对 404 命中。所以把状态码收进类或Enum再引用。
什么时候用 match
- 处理「形状多变的结构化数据」时最值:解析 JSON 消息、事件分发、语法树——一个 case 同时完成类型判断、形状检查、字段提取三件事。
- 只是「某个整数等于几」的平铺分支,
elif更直白;match 的价值在解构,不在替代 switch。
def http(status):
match status:
case 200:
return "ok"
case 404 | 410: # 或模式
return "gone"
case code if code >= 500: # 捕获 + 守卫
return f"server error {code}"
case _:
return "other"
def handle(msg):
match msg:
case {"type": "click", "x": x, "y": y}:
return f"点击 ({x}, {y})"
case [first, *rest]: # 序列解构
return f"批量:{first} 加 {len(rest)} 条"
# 坑:裸名字是捕获——200 也命中,NOT_FOUND 被改成 200
NOT_FOUND = 404
match status:
case NOT_FOUND:
...
# 对:带点的名字才是值比较
class HTTP:
NOT_FOUND = 404
match status:
case HTTP.NOT_FOUND:
...case 某个变量名: 是捕获、匹配一切,还会把该名字重新绑定(NOT_FOUND 被改成 200)。只在后面还有别的 case 时才报 SyntaxError 救你;它排最后就静默出错。常量一律收进类或 Enum,用 case HTTP.NOT_FOUND: 这种带点形式。if 守卫,「形状对 + 条件满足」才算命中,守卫里可以直接用刚捕获的变量;映射模式只查「包含这些键」,多余键不影响匹配——特别适合宽容地解析外部 JSON。数据结构:list / dict / set / tuple
四大内置容器的选型、细节与浅拷贝陷阱。
list 是 Python 的默认容器:有序、可变、允许重复、什么类型都能混装。日常操作就四类——增、删、查、排,外加一门独门手艺:切片。
增删查
- 增:
append(x)末尾加一个元素;extend(可迭代)逐个追加;insert(0, x)插到头部(要挪动后面所有元素,O(n),慎用于大表)。注意对比:对[..., 2, 6]再append([2, 6]),结果末尾是整个列表[2, 6]当一个元素躺着。 - 删:
pop()弹出并返回末尾(给下标可弹任意位置);remove(x)删第一个等于 x 的元素;del xs[i]按下标删。 - 查:
x in xs是否存在(逐个比对,O(n)——大表高频查询见本章「选型对照」卡);index(x)找下标;count(x)数出现次数。 - 下标越界抛异常,报错原文:
IndexError: list index out of range。
切片:[start : stop : step],不含 stop
nums = [3, 1, 4, 1, 5]:nums[1:4]得[1, 4, 1](下标 1、2、3,不含 4);nums[-2:]取末两个[1, 5];nums[::2]隔一个取一个;nums[::-1]整表反转。- 负索引从尾巴数:
-1是最后一个;三个参数都可省,省 start 从头、省 stop 到尾。 - 与下标不同,切片越界不报错:
[1, 2][5:10]安静地返回[]。
排序:sort 原地、sorted 复制,key 定规则
xs.sort()原地排序,返回值是None;sorted(xs)返回新列表、原表不动。两者都收reverse=True和key。key是一个函数,对每个元素算出「排序依据」:sorted(words, key=len)按长度排出['fig', 'banana', 'cherry'];sorted(pairs, key=lambda p: p[1])按第二项排(lambda 见 05)。
nums = [3, 1, 4, 1, 5]
nums[1:4] # [1, 4, 1]——含头不含尾
nums[-2:] # [1, 5] 末两个
nums[::-1] # [5, 1, 4, 1, 3] 反转
nums.append(9) # 末尾加一个元素
nums.extend([2, 6]) # 逐个追加 → ..., 2, 6
nums.append([2, 6]) # 整个列表当一个元素塞进去!
nums.pop() # 弹出并返回末尾
nums.remove(1) # 只删第一个 1
r = nums.sort() # 原地排;r 是 None
sorted(nums) # 新列表,原表不动
words = ["banana", "fig", "cherry"]
sorted(words, key=len) # ['fig', 'banana', 'cherry']
pairs = [("bob", 3), ("amy", 1)]
sorted(pairs, key=lambda p: p[1]) # 按第二项排append([6, 7]) 是把整个列表当一个元素塞进去,逐个追加要用 extend(对比见 code);② nums = nums.sort() 把返回值 None 赋回去,列表直接「消失」(nums 变 None)——原地方法不返回自身,这是 Python 的刻意设计。xs[:n] 前 n 个、xs[n:] 第 n 个起、xs[-n:] 末 n 个;xs[:] 复制整表(但只是浅拷贝——见本章「浅拷贝 vs 深拷贝」卡)。dict 按键存取值,底层是哈希表——不管存了多少条,按键取值都是一步到位。它也是 Python 自身的骨架:模块的属性、对象的字段、关键字参数,底下全是 dict。
基本操作与顺序保证
- 3.7+ 起 dict 保持插入顺序:依次写入 b、a、c 三键,遍历顺序就是
['b', 'a', 'c'],不会按字母重排。 - 取不存在的键,
d["z"]抛异常(报错原文KeyError: 'z');d.get("z")返回None,d.get("z", 0)返回给定默认值——三种取法按「缺键算不算错」来选。 d.setdefault(k, 默认):键在就返回现值,不在就先存默认再返回。返回的是字典里那个对象本身,所以可以直接连着改:两次d.setdefault("groups", []).append(...)后,d["groups"]是['admin', 'dev']——第二次没有重置列表。- 合并:
a | b(3.9+)生成新字典,键冲突时右边赢({"a": 1, "b": 2} | {"b": 99}得{'a': 1, 'b': 99})。
三个视图与遍历
d.keys()/d.values()/d.items()返回视图不是快照:拿到ks = d.keys()之后再添新键,"new" in ks立即为 True——视图始终照着字典的现状。- 遍历键值对的标准姿势:
for k, v in d.items():;直接for k in d:只给键。 - 正因为视图是「活」的,遍历中增删键会当场报错,报错原文:
RuntimeError: dictionary changed size during iteration。要边遍历边删,先list(d)拿键的快照。
dict 推导:一行建表
{n: n * n for n in range(4)}得{0: 0, 1: 1, 2: 4, 3: 9}——和列表推导同构,只是写成键: 值对。- 反转映射一行搞定:
{v: k for k, v in d.items()};配zip把两列配成表:dict(zip(names, ages))。 - 计数、分组这类「缺键先初始化」的模式,标准库有现成的
Counter/defaultdict(见 11)。
user = {"name": "Alice", "age": 28}
user["z"] # KeyError: 'z'
user.get("z") # None——缺键不算错时用 get
user.get("z", 0) # 0——带默认值
# setdefault:键不在就先建,返回字典里那个对象
d = {}
d.setdefault("groups", []).append("admin")
d.setdefault("groups", []).append("dev")
d["groups"] # ['admin', 'dev']
for k, v in user.items(): # 遍历键值对的标准姿势
print(k, v)
merged = defaults | overrides # 3.9+ 合并,右边覆盖左边
# dict 推导:一行建表 / 反转
squares = {n: n * n for n in range(4)} # {0:0, 1:1, 2:4, 3:9}
inverted = {v: k for k, v in user.items()}for k in d: 里增删键会立刻抛 RuntimeError: dictionary changed size during iteration。要边遍历边删,先固定键的快照:for k in list(d):——list(d) 复制的是键列表,之后随便删。d[k](让它炸)、缺键正常用 get、缺键要顺手初始化用 setdefault。按值排序:sorted(d.items(), key=lambda kv: kv[1])(key 函数见 05)。set 存「不重复的元素」,两大绝活:秒级判断在不在(哈希定位,不逐个比)和数学集合运算。代价是无序,以及一条硬要求——元素必须可哈希。
去重与集合运算
set([1, 2, 2, 3])得{1, 2, 3}——重复自动消失,去重一行完事。- 四则运算:交
a & b得{3}、并a | b得{1, 2, 3, 4, 5}、差a - b得{1, 2}(a 有 b 没有)、对称差a ^ b得{1, 2, 4, 5}(只在一边)。 - 还能比包含:
{1, 2} <= a判子集(True)。「两份名单谁缺谁多」这类问题,转 set 做差集比双层循环又快又清楚。
硬要求:元素必须可哈希
- list 是可变的、不可哈希,进不了 set。报错原文(3.14):
TypeError: cannot use 'list' as a set element (unhashable type: 'list');当 dict 的键同理:cannot use 'list' as a dict key (unhashable type: 'list')——3.14 的报错会点明「用在哪」,核心都是括号里那句 unhashable。 - 不可变的能进:tuple 可以(
{(1, 2)}成立,但前提是 tuple 里面也全是可哈希的)。 - 「集合套集合」怎么办?用
frozenset:不可变版 set,本身可哈希,能放进另一个 set;对它.add(3)报AttributeError: 'frozenset' object has no attribute 'add'——它根本没有修改类方法。
两个细节
- 空集合是
set():type({})是 dict——花括号字面量被字典占用了,{1, 2}才是 set。 - set 不保证顺序:
list(set(xs))去重后顺序不可依赖。要「保序去重」,用 3.7+ 字典保插入序的特性:list(dict.fromkeys([3, 1, 3, 2, 1]))得[3, 1, 2]——首次出现的顺序被留住了。 - 和 dict 一样,遍历 set 时增删元素抛
RuntimeError: Set changed size during iteration。
set([1, 2, 2, 3]) # {1, 2, 3} 去重
a = {1, 2, 3}
b = {3, 4, 5}
a & b # {3} 交集
a | b # {1, 2, 3, 4, 5} 并集
a - b # {1, 2} 差集:a 有 b 没有
a ^ b # {1, 2, 4, 5} 对称差:只在一边
{[1, 2]} # TypeError:list 不可哈希,进不了 set
{(1, 2)} # ✅ tuple 不可变,可以
fs = frozenset([1, 2]) # 不可变 set,可哈希
{fs, frozenset([3])} # ✅ 集合套集合
type({}) # dict!空集合必须写 set()
# 保序去重:dict 3.7+ 保插入序
list(dict.fromkeys([3, 1, 3, 2, 1])) # [3, 1, 2]{} 是空字典不是空集合(type({}) 为 dict),空集合只能写 set();另外 list(set(xs)) 去重会打乱顺序,保序去重用 list(dict.fromkeys(xs))([3, 1, 3, 2, 1] 得 [3, 1, 2])。x in 列表 逐个比对,x in 集合 哈希直达(对照见本章「选型对照」卡)。判断两组数据的重叠与差异,先转 set 再用 & / -,比手写循环快一个量级也清楚一个量级。tuple 是「不可变的 list」,但真正让它无处不在的是解包:多返回值、变量交换、for 循环里的 k, v——全是 tuple 在底下工作。
不可变,以及两个字面量陷阱
- 改元素直接报错,报错原文:
TypeError: 'tuple' object does not support item assignment。不可变换来两件事:可以当 dict 键、进 set(前提见下),以及「传给别人也不怕被改」。 - 造 tuple 的其实是逗号不是括号:
type((1))是 int(括号只是运算优先级),(1,)才是单元素 tuple;t = 1, 2, 3不写括号也是 tuple。 - 「不可变」只锁一层:tuple 里装着 list 时,
hash((1, [2]))报TypeError: unhashable type: 'list'——tuple 可哈希的前提是每个元素都可哈希。
解包:tuple 的主场
- 基本形:
x, y = (3, 4)按位置拆开;交换变量的名场面a, b = b, a——右边先打包成 tuple,再拆给左边,不需要临时变量。 - 星号解包收走「剩下的」:
first, *mid, last = [1, 2, 3, 4, 5]得first=1、mid=[2, 3, 4](是 list)、last=5。星号只能出现一次,位置随意。 - 多返回值就是「返回 tuple + 调用处解包」:
return min(xs), max(xs),接的时候lo, hi = minmax(...)。语言层面没有多返回值这回事,全是 tuple。
namedtuple:给位置起名字
p[0]这种下标读法过两周就没人看得懂。namedtuple给每个位置起名:Point = namedtuple("Point", ["x", "y"])后,p.x和p[0]都是 3,打印出来还是自带字段名的Point(x=3, y=4)。- 它仍是 tuple:能解包(
px, py = p)、不可变、可哈希。 - 要带类型注解、或字段需要默认值/可变,往前一步用
dataclass(见 06)或typing.NamedTuple(见 12)。
t = (3, 4)
t[0] = 9 # TypeError: 'tuple' object does not support item assignment
type((1)) # int!括号不造元组
type((1,)) # tuple——逗号才是关键
# 解包三连
x, y = t # 按位置拆
a, b = b, a # 交换,无需临时变量
first, *mid, last = [1, 2, 3, 4, 5]
# first=1 mid=[2, 3, 4] last=5
def minmax(xs):
return min(xs), max(xs) # 「多返回值」= 返回 tuple
lo, hi = minmax([3, 1, 5]) # 1 5
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
p.x, p[0] # 3 3——按名按位都行
print(p) # Point(x=3, y=4)(1) 就是整数 1(type 为 int)。函数传参、写配置时想传单元素 tuple 却写成 (x),类型悄悄变了,等到很远的下游才报错。return a, b、调用处 x, y = f()——这是 tuple 解包,不是特殊语法;返回的东西超过两三个、或要按名字取,就升级 namedtuple 或 dataclass(06)。Python 的变量是名字贴在对象上,不是装值的盒子——所以「复制」有三个完全不同的层次:赋值只是加个名字,浅拷贝只复制外层,深拷贝才是彻底分家。
层次一:赋值根本不是拷贝
alias = m只是给同一个对象再贴一个名字:alias is m为 True,通过 alias 追加元素,m 立刻「跟着变」——因为从来就只有一个列表。- 这是理解后面一切的地基,也是 05 里「可变默认参数」坑的同一条根。
层次二:浅拷贝——外层新的,内层共享(演示)
m[:]、list(m)、m.copy()、copy.copy(m)四种写法等价,都是浅拷贝:s = m[:]后,s is m为 False(外层是新列表),但s[0] is m[0]为 True——内层那几个子列表还是同几个对象。- 后果,
m = [[1, 2], [3, 4]],对拷贝改内层s[0].append(99),原表 m 变成[[1, 2, 99], [3, 4]]——穿透了;而改外层s.append([7]),m 纹丝不动。一层是自己的,里面是合租的。 - dict 一样:
conf.copy()后改拷贝里的["opts"]列表,原 conf 跟着变。
层次三:深拷贝——递归复制到底
copy.deepcopy(m)把每一层都复制一份新的:对深拷贝的内层 append,原表 m 完全不受影响。- deepcopy 要递归走遍整个结构,有真实开销;它能处理自引用(对象套自己),但大结构上别在热路径里反复调。
- 选择判据:结构只有一层(元素是数字、字符串这类不可变值)→ 浅拷贝就够;结构有嵌套且要独立修改 → deepcopy;只是想「多一个名字用」→ 直接赋值,但心里清楚这不是副本。
import copy
m = [[1, 2], [3, 4]]
# 赋值:同一对象多一个名字
alias = m
alias is m # True——根本没有第二个列表
# 浅拷贝:外层新,内层共享
s = m[:] # 等价 list(m) / m.copy() / copy.copy(m)
s is m # False 外层是新的
s[0] is m[0] # True 内层还是同一个!
s[0].append(99)
print(m) # [[1, 2, 99], [3, 4]] ——原表被穿透
s.append([7])
print(len(m)) # 2——改外层不影响原表
# 深拷贝:逐层复制,彻底分家
d = copy.deepcopy(m)
d[0].append(111)
print(m[0]) # [1, 2, 99]——不再受影响[:] 拷贝了,怎么原数据还是变了?」——因为嵌套列表的内层是共享的(s[0] is m[0] 为 True,改 s[0] 穿透到 m)。这坑常伪装成「莫名其妙的数据污染」,离出错点很远才发现;更多同族陷阱收在 17。xs[:] 或 .copy() 足够;有、且拷贝后要各改各的——上 copy.deepcopy。函数收到列表参数又要改它时,先拷一份是礼貌也是自保。四个内建容器各管一摊:list 管顺序,dict 管映射,set 管唯一性,tuple 管不变。选错通常不报错,只是慢——而且是数据一多才慢出来的那种。
一张表定性
| 容器 | 有序 | 可变 | 元素可重复 | 典型场景 |
|---|---|---|---|---|
list | 是 | 是 | 是 | 一串东西,顺序有意义:日志行、待办、坐标序列 |
dict | 保插入序(3.7+) | 是 | 键唯一 | 按名字找东西:配置、缓存、JSON 对象 |
set | 否 | 是 | 否 | 去重、成员判断、两组数据算交并差 |
tuple | 是 | 否 | 是 | 定长的一组值:多返回值、坐标点、dict 的复合键 |
复杂度:慢是怎么慢出来的
| 操作 | list | dict / set |
|---|---|---|
x in c 成员判断 | O(n) 逐个比 | O(1) 哈希直达 |
| 按键/下标取值 | O(1)(按下标) | O(1)(按键) |
| 末尾追加 | O(1) 均摊 | O(1)(set.add / d[k]=v) |
| 头部插入/删除 | O(n) 整体挪动 | — |
remove / 按值找 | O(n) | O(1)(set.discard) |
要害在第一行:把「大列表 + 循环里 in 查找」组合起来就是 O(n²)——一万条数据一亿次比较。先 set(xs) 再查,整段降回 O(n)。头部频繁进出则该换 deque(双端 O(1),见 11)。
快速判据(按问题选,不按习惯选)
- 「第 i 个是什么、按顺序处理」→ list;
- 「叫某名字的是什么」→ dict;
- 「有没有、重没重、两堆差在哪」→ set;
- 「这几个值是一体的、不该被改」→ tuple(还能当 dict 键,如
(x, y)坐标做键); - 拿不准就先 list——但只要写出「循环里 in 大列表」,立刻回头换 set/dict。
# 反面教材:循环里查大列表 → O(n²)
allowed = ["alice", "bob", ...] # 一万个用户名的 list
hits = [u for u in requests if u in allowed] # 每次 in 都逐个比
# 正确:先转 set,成员判断 O(1)
allowed_set = set(allowed)
hits = [u for u in requests if u in allowed_set]
# 按名字找 → dict;复合键用 tuple
users = {u["id"]: u for u in user_list} # 建索引,之后 O(1) 取
grid = {(0, 0): "起点", (2, 3): "宝箱"} # tuple 当键
# 「有没有 / 差在哪」→ set
missing = set(required) - set(provided)in 大列表」的 O(n²) 就是这么埋进去的。code 里那对写法功能完全相同,规模一大就是分钟与毫秒的差距(复杂度是语言规定行为,量级判据见上表)。x in xs」或「用两个平行 list 按下标对应」,前者换 set,后者换 dict——数据结构选对,一半循环代码会自己消失。函数:参数、作用域与装饰器
参数种类、LEGB、闭包、装饰器——函数是 Python 的一等公民。
Python 的参数系统只有一条主线:调用时先按位置、再按关键字对号入座,收不完的交给 * 和 **。定义处的顺序是固定的:位置参数 → 默认值参数 → *args → 仅关键字参数 → **kwargs。
五段式定义
def create(name, role="user", *tags, sep="-", **extra):create("Bob", "admin", "vip", "pro", city="BJ")得到role='admin'、tags=('vip', 'pro')(tuple)、sep='-'(没传用默认)、extra={'city': 'BJ'}(dict)。*args收多余的位置参数,**kwargs收多余的关键字参数;夹在*args之后的 sep 只能用关键字传。- 少传必错,报错原文:
TypeError: create() missing 1 required positional argument: 'name'。
「/」和「*」:限定传法的两道闸(/ 为 3.8+)
def div(a, b, /, precision=2, *, verbose=False):/之前只能按位置传,*之后只能按关键字传,中间两可。- 违规报错原文:
div(a=7, b=3)抛TypeError: div() got some positional-only arguments passed as keyword arguments: 'a, b';div(7, 3, 2, True)抛TypeError: div() takes from 2 to 3 positional arguments but 4 were given。 - 为什么要限定?
/让参数名不成为接口的一部分(以后改名不算破坏调用方)——内建函数大量用它;*强迫调用方写出verbose=True这种自解释的调用,防止一串裸 True/False 没人看得懂。
调用端的 * 和 **:反向展开
- 同样的星号在调用处意思反过来——不是收集而是展开:
args = (7, 3)、kw = {"precision": 1},div(*args, **kw)得 2.3。 - 「收集」与「展开」配对使用就是转发:装饰器里
wrapper(*args, **kwargs)原样转给被包函数(见本章「装饰器」卡)。 - 类型层面怎么给 *args/**kwargs 标注,见 12。
def create(name, role="user", *tags, sep="-", **extra):
return name, role, tags, sep, extra
create("Bob", "admin", "vip", "pro", city="BJ")
# ('Bob', 'admin', ('vip', 'pro'), '-', {'city': 'BJ'})——
# / 之前仅位置(3.8+),* 之后仅关键字(3.0 起就有)
def div(a, b, /, precision=2, *, verbose=False):
return round(a / b, precision)
div(7, 3) # 2.33 ✅
div(7, 3, precision=4) # 2.3333 ✅
div(a=7, b=3) # TypeError:a、b 只能按位置传
div(7, 3, 2, True) # TypeError:verbose 只能关键字传
# 调用端展开:星号方向反过来
args = (7, 3)
kw = {"precision": 1}
div(*args, **kw) # 2.3*args 是「收集成 tuple」,调用处 f(*xs) 是「拆开逐个传」。混淆的典型症状是把列表整个传给了第一个参数——f(xs) 与 f(*xs) 是两码事。* 之后强制关键字——resize(img, width=800, keep_ratio=True) 永远比 resize(img, 800, True) 好维护。def f(x, acc=[]): 里那个 [] 只在 def 执行的那一刻创建一次,之后挂在函数对象上被所有调用共享——这是 Python 面试与生产事故的双料常客。
现场还原
def push(x, acc=[]): acc.append(x); return acc——连续调用push(1)、push(2),直觉期望[1]和[2],得到[1, 2]和[1, 2]:两次返回的是同一个越积越长的列表。- 物证:默认值就存在函数对象的属性里,两次调用后
push.__defaults__是([1, 2],)——那个列表全程只有一个。
为什么:def 是运行时执行的语句
def不是编译期声明,而是一条运行时语句:执行到它时创建函数对象,此刻把每个默认值表达式求值一次、存进__defaults__。调用时谁没传参,就把存的那个对象递过去——不会重新求值。- 所以坑只咬可变对象:默认值是
[]、{}、set()时,所有调用共享一个可变体,谁改了大家都看见;默认值是数字、字符串、None这类不可变值时,共享也无妨——反正改不动。 - 这与 04 浅拷贝、本章闭包晚绑定是同一条根:名字和默认值绑的是对象,不是「每次新造」的规则。同族陷阱汇总见 17。
惯用解法:None 哨兵
- 标准写法:默认值用
None占位,函数体第一行判断——if acc is None: acc = []。这样[]在每次调用时新建。修正版push2(1)、push2(2)得[1]、[2]。 - 别偷懒写
acc = acc or []:真值测试会把调用方传进来的空列表也换掉。调用方递入mine = []想收集结果,函数却往一个新列表里写,mine始终是空的、追加结果悄悄丢失(result is mine为 False)。is None只拦「没传」,or连「传了空的」一起拦——语义完全不同(真值规则见 03)。
# 坑:默认值在 def 时求值一次,所有调用共享
def push(x, acc=[]):
acc.append(x)
return acc
r1 = push(1)
r2 = push(2)
print(r1, r2) # [1, 2] [1, 2]——不是 [1] 和 [2]!
r1 is r2 # True:两次返回的就是同一个列表
push.__defaults__ # ([1, 2],) 默认值就挂在函数对象上
# 对:None 哨兵,每次调用新建
def push2(x, acc=None):
if acc is None:
acc = []
acc.append(x)
return acc
push2(1) # [1]
push2(2) # [2] ✅ 互不串门
# 别用 or:传入的空列表会被静默换掉
def bad(items=None):
items = items or [] # [] 也为假,被替换!
items.append("added")
return items
mine = []
bad(mine) # mine 仍是 []——结果写进了别的列表items = items or [] 是换一个坑:调用方传进来的空列表 [] 为假、被静默丢弃,往里 append 的结果调用方永远看不到(mine 保持空)。只有 is None 能精确区分「没传」和「传了个空的」。[]/{}/set() 或任何可变对象时,一律改写成 =None + 函数体首行 if x is None: x = ...。ruff 的 B006 规则能自动逮这个坑(工具链见 14)。Python 找一个名字只走一条固定路线:Local 本函数 → Enclosing 外层函数 → Global 模块 → Built-in 内建,找到即止。读很自由,改则要声明。
读随便,赋值定归属
- 函数里读全局变量不用任何声明:函数体
print(count)直接读到模块级的 10。 - 但只要函数体里任何位置出现对某名字的赋值,编译器就把它定为整个函数的局部变量。于是
count = count + 1炸了:右边要读 count,而 count 已被判定为局部、此刻还没值。报错原文:UnboundLocalError: cannot access local variable 'count' where it is not associated with a value。 - 更反直觉的先
print(count)、后面某行才赋值——print 那行照样抛同一个错。归属是编译期按整个函数定的,不是执行到哪行才定;这正是「运行模型藏在直觉背后」的典型案例。
两个声明关键字
global count:本函数里的 count 就是模块级那个,赋值直接改全局(改后全局变 11)。nonlocal n:改外层函数的变量——闭包计数器的命根子(嵌套函数两次自增,外层的 n 变 2);没有它,内层的n += 1同样抛 UnboundLocalError。- 用度把握:
nonlocal是闭包(下一卡)的正常配件;global则是坏味道信号——要改的状态不如显式传参返回,或收进类里(06)。
遮蔽:内层名字挡住外层
- LEGB 找到即止,意味着内层同名会遮蔽外层:给
len赋值 lambda 后,len([1, 2, 3])返回自定义结果;del len之后内建的又「露出来」返回 3。 - 所以给变量起
list、dict、id、type这类名字不会报错,但从那行起同名内建在当前作用域集体失灵——排查起来极费解。
count = 10
def show():
print(count) # ✅ 只读:沿 LEGB 找到全局的 10
def bump():
count = count + 1 # ❌ UnboundLocalError:函数里有赋值,
# count 整个函数内都算局部,右边读不到
def bump_global():
global count # 声明:用的就是模块级那个
count += 1 # ✅ 全局变 11
def outer():
n = 0
def inc():
nonlocal n # 改外层函数的 n,不是新建局部
n += 1
return n
inc(); inc()
return n # 2
# 遮蔽:同名挡住内建
len = lambda x: "shadowed!"
len([1, 2, 3]) # 'shadowed!'——内建 len 被挡
del len
len([1, 2, 3]) # 3——又露出来了list、dict、id、str 不会报错,但同名内建从此在该作用域失灵(len 被遮蔽后返回自定义值)。症状往往离起名处很远:几十行后 list(x) 突然抛「不可调用」类错误。内建名不做变量名。x = ... 和 +=。要改外层就明说(global/nonlocal),更多时候正确答案是改成传参 + 返回值。闭包 = 内层函数记住了外层函数的变量,外层返回后这些变量依然活着。关键细节:闭包记住的是变量本身,不是定义那一刻的值——由此生出经典的晚绑定坑。
闭包正面用法:带状态的函数
- 计数器:
make_counter里定义inc,用nonlocal(上一卡)改外层的 c——连调三次返回 1、2、3,状态在两次调用之间被闭包保存。 - 物证:这份状态就存在函数对象上,
inc.__closure__[0].cell_contents能看到当前值 3。 - 本章「装饰器」卡能把原函数记在 wrapper 身上,靠的正是闭包。
晚绑定:为什么全是 2
fs = [lambda: i for i in range(3)],逐个调用,直觉是[0, 1, 2],是[2, 2, 2]。- 原因:三个 lambda 捕获的是同一个变量 i,不是三份快照。真正调用它们时循环早已结束、i 停在 2,三个函数去读同一个 i,自然读到同一个 2——「晚绑定」:值在调用时才去查。
- 回调、事件处理器、线程任务里在循环中造函数,几乎必踩这坑;它与可变默认参数并列收在 17。
三种解法(都得到 [0, 1, 2])
- 默认参数
lambda i=i: i——上一卡刚讲过「默认值在定义时求值一次」,在这里反过来当武器用:定义那一刻就把当前的 i 拷进默认值,每个 lambda 各存一份。 functools.partial(f, i)——把参数值绑死进新函数,语义最直白。- 工厂函数:
def make(i): return lambda: i——每次调用 make 产生一个新的外层作用域,各闭包各捕获各的 i。
# 闭包:外层返回后,count 仍被 inc 记着
def make_counter():
c = 0
def inc():
nonlocal c
c += 1
return c
return inc
inc = make_counter()
inc(), inc(), inc() # (1, 2, 3)——状态被保存
# 晚绑定坑:三个 lambda 共享同一个 i
fs = [lambda: i for i in range(3)]
[f() for f in fs] # [2, 2, 2]!调用时 i 早停在 2
# 解法一:默认参数——定义时就把当前值拷走
fs = [lambda i=i: i for i in range(3)]
[f() for f in fs] # [0, 1, 2] ✅
# 解法二:partial 绑死参数
from functools import partial
def ident(i): return i
fs = [partial(ident, i) for i in range(3)] # [0, 1, 2] ✅
# 解法三:工厂函数,一人一个作用域
def make(i):
return lambda: i
fs = [make(i) for i in range(3)] # [0, 1, 2] ✅lambda i=i:、partial、工厂函数;这坑在 17 还有完整病例。i=i 或 partial;要「始终看最新值」那晚绑定反而正是你要的行为。lambda 参数: 表达式 是匿名的一次性小函数——只能装一个表达式,值就是返回值。它的主场不是替代 def,而是给高阶函数当「规则说明书」。
lambda 的边界
- 只能一个表达式:不能有语句,
lambda x: return x直接SyntaxError: invalid syntax——表达式本身就是返回值,写 return 反而错。 - 需要两行以上逻辑、需要名字复用、需要文档——就该用
def。lambda 超过一眼能读懂的长度即是坏味道。
key 参数:排序、最值的规则说明书
sorted(users, key=lambda u: u["age"])按年龄排;max(users, key=...)找最年长者(得到 bob)。key 函数对每个元素算一个「比较依据」,排序按依据、返回原元素。- 现成函数直接传名字不用包 lambda:
sorted(words, key=str.lower)忽略大小写(把 'Fig' 排到按字母序该在的末尾;默认按码点大写全排前);key=operator.itemgetter(1)等价于lambda p: p[1]。 - 多级排序用 tuple:
key=lambda r: (r[1], r[0])——先按第二项、平局按第一项。Python 排序是稳定的:key 相同的元素保持原有相对顺序。
map/filter vs 推导式
[x * x for x in nums if x % 2 == 0]——变换 + 过滤一行写完,不用 lambda,多数场景更可读,社区默认选它。map / filter 在已有现成函数时最顺:map(str.strip, lines) 比 [l.strip() for l in lines] 少一层名字;且返回惰性迭代器,接着喂给别的迭代工具不占内存。list() 得 []——已经耗尽。要反复用先收进 list;惰性求值的全貌见 07。users = [{"name": "bob", "age": 35},
{"name": "amy", "age": 28}]
sorted(users, key=lambda u: u["age"]) # amy 在前
max(users, key=lambda u: u["age"]) # bob
words = ["banana", "Fig", "cherry"]
sorted(words) # ['Fig', 'banana', 'cherry'] 大写排前
sorted(words, key=str.lower) # ['banana', 'cherry', 'Fig'] ✅
# 多级排序:key 返回 tuple
rows = [("amy", 28), ("bob", 28), ("cat", 21)]
sorted(rows, key=lambda r: (r[1], r[0]))
# [('cat', 21), ('amy', 28), ('bob', 28)]
nums = [1, 2, 3, 4]
m = map(lambda x: x * x, nums) # 惰性迭代器,此刻还没算
list(m) # [1, 4, 9, 16]
list(m) # []——耗尽了!
[x * x for x in nums if x % 2 == 0] # [4, 16] 推导式一步到位map/filter/zip 返回的迭代器是一次性的:第二次 list(m) 得到空列表,中间不报任何错。「打印时还有、用时没了」多半是这个原因——要多次使用先 list() 落地(原理见 07)。key=len、key=str.lower);取字段用 operator.itemgetter/attrgetter;再复杂才上 lambda。多条件排序记住「key 返回 tuple」一招就够。装饰器不神秘,它站在一个根上:Python 里函数是对象——能赋值给名字、能当参数传、能被返回。顺着这个根,整套语法都能自己推出来。
推导:三步到装饰器
- 第一步,函数是对象:
alias = greet后alias("amy")照常工作,[f("Mix") for f in [str.upper, str.lower]]得['MIX', 'mix']——函数能存进列表挨个调用。 - 第二步,那就能写「收函数、返回新函数」的函数:
shout(func)内部定义 wrapper(把结果转大写)并返回。loud = shout(greet)后loud("bob")得'HI BOB'——wrapper 靠闭包(上一卡)记住了 func。 - 第三步,
@shout只是语法糖:写在def f上方,完全等价于定义完执行f = shout(f)——原名字被重新绑定到包装后的新函数。就这么多。
@functools.wraps:别弄丢身份证
- 被装饰后,名字指向的是 wrapper——不加处理时
__name__变成'wrapper'、__doc__变成None:调试器、文档工具、序列化全被误导。 - 修法固定一行:wrapper 上再套
@functools.wraps(func),把原函数的元信息拷回来。计时装饰器加了它之后,work.__name__仍是'work'、docstring 完好。写装饰器时当成标配肌肉记忆。
带参数的装饰器:再套一层
@repeat(3)分两拍执行:先调repeat(3)拿到真正的装饰器,再拿它去装饰函数——所以带参装饰器就是「返回装饰器的函数」,三层 def 而已。@repeat(3)的 ping 打印三遍。- 多个装饰器从下往上套:
@deco_a在上、@deco_b在下时结果是A(B(core))——离函数近的先包。 - 读懂这一卡就读懂半个生态:路由注册
@app.get("/")(16)、@pytest.fixture(13)、@dataclass/@property(06)全是同一机制。
import functools, time
def timer(func):
@functools.wraps(func) # 保住 __name__ / __doc__
def wrapper(*args, **kwargs): # 原样转发一切参数
t0 = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} 耗时 {time.perf_counter() - t0:.4f}s")
return result
return wrapper
@timer # 等价于 work = timer(work)
def work():
"""假装干活。"""
time.sleep(0.05)
return 42
work() # 打印耗时,返回 42;__name__ 仍是 'work'
# 带参装饰器:先收参数,返回真正的装饰器
def repeat(n):
def deco(func):
@functools.wraps(func)
def wrapper(*a, **k):
for _ in range(n):
result = func(*a, **k)
return result
return wrapper
return deco
@repeat(3) # ping = repeat(3)(ping)
def ping():
print("ping") # 打印三遍@functools.wraps(func) 的装饰器会「偷走」函数身份:__name__ 变 'wrapper'、__doc__ 变 None——日志里满屏 wrapper、文档消失、按名注册的框架行为错乱。每个 wrapper 头上都套它,无例外。@deco ≡ f = deco(f)、@deco(arg) ≡ f = deco(arg)(f)。看到不认识的装饰器,在心里做这个展开,九成疑惑当场消失。面向对象:类、dataclass 与魔法方法
class 语法之下是一套统一的协议:一切行为都是方法调用。
class 就是把「数据」和「操作这些数据的函数」打成一个包;理解它只需要想清楚两件事——self 是谁、属性挂在谁身上。
self 就是实例本身,显式写出来
- 方法只是「挂在类上的函数」,第一个参数按约定叫
self,接收调用它的那个实例。a.learn("坐下")只是Dog.learn(a, "坐下")的简写——两种写法效果完全相同。别的语言把 this 藏起来,Python 把它摆在参数表里,这就是 def 里「多出来的第一个参数」的全部秘密。 __init__不是构造函数而是「初始化器」:实例已经由 Python 造好递进来,它的职责是往self上挂初始属性。
属性查找——先实例后类
self.name = ...写进实例自己的__dict__,每个实例一份;类体里直接赋值的(如species)是类属性,全体实例共享同一份。- 读属性时先查实例、查不到再沿类走,所以实例属性会「遮蔽」同名类属性。
c1.n += 1是「读类、写实例」:读到类上的 0,加 1 后写进 c1 自己——c1.n变 1,c2.n和C.n仍是 0。
可变类属性是全体共享的
- 把
tricks = []写在类体里,所有实例的self.tricks.append(...)改的都是同一个列表:a 学了「坐下」之后,b.tricks也是['坐下']。 - 根因回到主线——Python 的属性只是名字绑定:
append不换绑定、原地修改的正是那份共享对象;而a.tricks = [...]是赋值,反而会新建实例属性把类属性遮住,b 不受影响。「改」与「重新绑定」是两回事。
class Dog:
species = "犬科" # 类属性:全类共享
tricks = [] # ⚠ 可变类属性——坑
def __init__(self, name):
self.name = name # 实例属性:每只一份
def learn(self, t):
self.tricks.append(t)
a = Dog("阿黄"); b = Dog("小白")
a.learn("坐下")
b.tricks # ['坐下'] —— b 也被改了!
a.name, b.name # ('阿黄', '小白') 各自独立
a.tricks = ["打滚"] # 赋值→新建实例属性,遮蔽类属性
b.tricks # ['坐下'] —— 类属性没变
Dog.learn(a, "握手") # 与 a.learn("握手") 完全等价tags = [] 会被所有实例共享——一个实例 append,其它实例全看得见。这个坑太经典,dataclass 干脆把它做成了报错(见本章「dataclass」卡)。__init__ 里 self.x = ... 初始化;类体里只放真正全类共享的常量(如 species)。要给每个实例各自的列表,就写 self.tricks = []。一个只装数据的类,__init__/__repr__/__eq__ 全是可以从字段声明推出来的样板——@dataclass 替你生成,你只写字段。
自动生成了什么(对照)
- 普通类的 repr 是没信息量的
<__main__.PlainP object at 0x…>,==按「是不是同一个对象」比(两个同值实例是False);加上@dataclass后 repr 变成DataP(x=1),==逐字段比值(True)。 - 还按字段声明顺序生成
__init__;加order=True再生成一套比较方法,实例可以直接sorted([V(3), V(1)]排成[V(x=1), V(x=3)])。
可变默认为什么必须 default_factory
- 类体里的默认值在「类定义那一刻」求值一次——写
tags: list[str] = []就会让所有实例共享同一个列表,和 05 章的可变默认参数是同一个根。 - dataclass 干脆把这条坑做成报错,报错原文:
ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory。 field(default_factory=list)的含义是「每次实例化时调用list()现造一个」——一个实例 append 后,新实例的tags仍是[],互不串。
frozen 与可哈希
@dataclass(frozen=True)生成只读实例,赋值抛FrozenInstanceError: cannot assign to field 'x'。- 普通 dataclass 因为生成了
__eq__,默认不可哈希(TypeError: unhashable type: 'Product');frozen 版会一并生成__hash__,能进 set、能当 dict 键——「不可变才可哈希」这条纪律与 04 章的字典键规则一脉相承。
from dataclasses import dataclass, field
@dataclass
class Product:
name: str
price: float = 0.0
tags: list[str] = field(default_factory=list)
p = Product("book", 9.9)
p # Product(name='book', price=9.9, tags=[])
p == Product("book", 9.9) # True:逐字段比值
p.tags.append("x")
Product("b2").tags # []:factory 每次现造,互不串
# tags: list[str] = [] 会直接报 ValueError(原文见正文)
@dataclass(frozen=True)
class Point:
x: int
y: int
pt = Point(1, 2)
pt.x = 5 # FrozenInstanceError: cannot assign to field 'x'
{pt} # frozen → 可哈希,能进 set / 当 dict 键__eq__ 的类默认 __hash__ 被置空,报 TypeError: unhashable type。要么 frozen=True,要么别拿它当键。__post_init__;要运行时强校验加序列化,升级到 pydantic 的 BaseModel——16 章整章都在用它。继承让子类复用父类;而 super() 的准确含义不是「调父类」,是「沿 MRO 队列找下一个」——单继承时两种理解恰好重合,多继承时只有后者是对的。
单继承的标配动作
- 子类自定义
__init__时,第一件事就是super().__init__(...)把父类的初始化跑掉,否则父类要挂的属性根本不存在。漏写后访问d.name报AttributeError: 'Dog' object has no attribute 'name'——而且是用到那一刻才爆,离案发现场很远(主线:错误在运行时才现身)。 - 子类定义同名方法即覆盖:调用方拿着「某种 Animal」用,实际执行的是子类版本——这就是多态。
MRO——方法到底从哪来
- 多继承时 Python 用 C3 线性化把所有祖先排成一条确定的队列,存在
__mro__里。class D(B, C)(B、C 都继承 A)的 MRO 是D → B → C → A → object,D().who()用的是 B 的版本——按队列先到先得。 - 排不出一致顺序时类都定义不出来:
class X(A, B)(B 是 A 的子类却排在后面)直接报TypeError: Cannot create a consistent method resolution order (MRO) for bases A, B。
菱形继承——super 的真正价值
- 协作式写法:每个类的
__init__都调super().__init__(),菱形结构里每个初始化恰好执行一次——顺序 M → L → R → Base,Base 只跑一遍。 - 换成手写类名
Base.__init__(self),顺序变成 M、L、Base、R、Base——公共祖先被跑了两次。这就是「永远用 super、别硬编码父类名」的原因。
class Animal:
def __init__(self, name):
self.name = name
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name) # 先让父类把 name 挂好
self.breed = breed
def speak(self):
return self.name + ": woof" # 覆盖即多态
# 多继承:方法从 MRO 队列里找
class A:
def who(self): return "A"
class B(A):
def who(self): return "B"
class C(A):
def who(self): return "C"
class D(B, C): ...
[c.__name__ for c in D.__mro__]
# ['D', 'B', 'C', 'A', 'object']
D().who() # 'B' —— 队列里先碰到 B__init__ 却忘了调 super().__init__()——定义时不报错、不警告,直到某处访问父类属性才 AttributeError: 'Dog' object has no attribute 'name',报错点和病灶隔着十万八千里。Cls.__mro__。多继承尽量用「一个主类 + 若干 Mixin」的形态,且所有参与者的 __init__ 统一走 super(),别混用类名直调。print(x)、x == y、x < y、len(x) 没有一个是特殊语法——全部会翻译成双下划线方法调用。实现哪个协议,对象就获得哪种语言能力。
__repr__ 给开发者,__str__ 给用户
- repr 出现在调试器和容器打印里(
[Money(5)]);str 用于print和 f-string(¥5.00)。 - 只写
__repr__就够——没有__str__时str()自动回退到它。默认 repr 是没信息量的<…object at 0x…>。
相等与排序
- 不写
__eq__时==按「是不是同一个对象」比:两个同值实例Plain(1) == Plain(1)为False。写了才按值比;遇到比不了的类型返回NotImplemented(不是抛异常),让 Python 再去问对方——Money(1) == 1安静地得False。 - 一个
__lt__就能让sorted工作,>也能用(Python 自动用反射版本);但>=没定义就报TypeError: '>=' not supported between instances of 'Money' and 'Money'。要全套比较,用functools.total_ordering从__eq__+__lt__补齐其余。
__len__ 与真值
- 实现
__len__就能len(c),还顺带参与真值判断:len 为 0 的对象是 falsy(bool(Empty())为False)——「空容器为假」的规则就是这么来的。 - 这套「协议决定能力」正是 07 章的地基:实现
__iter__/__next__就能被 for 遍历,不需要继承任何基类。
class Money:
def __init__(self, yuan):
self.yuan = yuan
def __repr__(self): # 调试/容器里的显示
return f"Money({self.yuan})"
def __str__(self): # print / f-string 的显示
return f"¥{self.yuan:.2f}"
def __eq__(self, other): # m1 == m2
if not isinstance(other, Money):
return NotImplemented # 让对方再试
return self.yuan == other.yuan
def __lt__(self, other): # 有它就能 sorted
return self.yuan < other.yuan
m = Money(5)
print(m) # ¥5.00(__str__)
[m] # [Money(5)](__repr__)
sorted([Money(3), Money(1)]) # [Money(1), Money(3)]__eq__,Python 就把继承来的 __hash__ 置空——hash(m) 报 TypeError: unhashable type: 'Money',对象进不了 set。要「按值相等且可哈希」,得用参与相等的字段自己实现 __hash__。__repr__——日志、断言失败信息、容器打印全靠它。惯例是写成「看起来能重建对象」的表达式,如 Money(5)。Python 没有 private 关键字——封装靠命名约定;等真需要拦截读写那天,@property 能把方法伪装成属性,调用方一行代码都不用改。
@property——读写皆可拦截
- getter 上加
@property,读a.balance实际执行方法;再配@balance.setter,赋值就会走校验——a.balance = -1抛ValueError: 余额不能为负。 - 不写 setter 就是只读属性:给
c.area赋值报AttributeError: property 'area' of 'Circle' object has no setter。
_ 与 __ ——两级「私有」约定
- 单下划线
_x是纯约定:「内部实现,别依赖」,语言不拦——照读不误。 - 双下划线
__x触发名字改写(name mangling):类体外s.__token报AttributeError: 'Secret' object has no attribute '__token',但按改写后的真名s._Secret__token照样拿得到(__dict__里躺的就是这个名字)。它防的是子类同名冲突,不是保密。
为什么 Python 不写 getter/setter 样板
- 正确姿势是先用公开属性;哪天要加校验、改成惰性计算或只读,再原地升级成 property——外部访问语法一个字不变。这就是 Java 式「先预防性写一堆 getter」在 Python 里被视为反模式的原因:property 让你把这笔成本推迟到真正需要的那天。
class Account:
def __init__(self, balance):
self._balance = balance # 约定:内部实现
@property
def balance(self): # 读 → getter
return self._balance
@balance.setter
def balance(self, value): # 写 → 校验
if value < 0:
raise ValueError("余额不能为负")
self._balance = value
a = Account(100)
a.balance # 100:像属性一样读
a.balance = 50 # 走 setter
a.balance = -1 # ValueError: 余额不能为负
class Secret:
def __init__(self):
self._hint = "约定私有,能直接读"
self.__token = "abc123" # 触发名字改写
s = Secret()
s.__token # AttributeError
s._Secret__token # 'abc123' —— 改写后的真名return self.balance(应为 self._balance)——读属性又触发 getter,无限递归,RecursionError: maximum recursion depth exceeded。property 名与存储名必须错开,惯例是存储名加下划线。Python 判断「你行不行」不看你是什么类,看你有没有那个方法——走起来像鸭子、叫起来像鸭子,就当鸭子用。这叫鸭子类型。
不检查类型,直接调用
make_it_quack(x)只管调x.quack()——Duck 和 Person 毫无继承关系,都能进来;Cat 没有 quack,运行到那一行才报AttributeError: 'Cat' object has no attribute 'quack'。- 这是主线的又一次现身:Python 的类型检查发生在运行时、精确到那一次调用——灵活的代价是错误也推迟到那一刻。
EAFP——先做,出错再道歉
- 与其用
hasattr/isinstance层层预检(LBYL,look before you leap),Python 惯用直接调用、用 try/except 接住失败(EAFP,easier to ask forgiveness than permission)。这套文化在 08 章整章展开。
Protocol——把鸭子类型写成可检查的契约
typing.Protocol声明「有这些方法就算数」的结构化类型:不要求继承,mypy 就能静态检查;加@runtime_checkable后isinstance(Duck(), Quacker)为True(只查方法存在与否)。细节与限制见 12 章。- 内置协议同理:实现
__len__就能len(),实现__iter__就能进 for(07 章)——整门语言都是鸭子类型搭起来的。
class Duck:
def quack(self): return "嘎"
class Person:
def quack(self): return "我会学鸭子叫"
class Cat:
def meow(self): return "喵"
def make_it_quack(x):
return x.quack() # 不查类型,直接调用
make_it_quack(Duck()) # '嘎'
make_it_quack(Person()) # '我会学鸭子叫'——无继承也行
make_it_quack(Cat()) # AttributeError:没有 quack
from typing import Protocol, runtime_checkable
@runtime_checkable
class Quacker(Protocol):
def quack(self) -> str: ...
isinstance(Duck(), Quacker) # True:有方法就算数isinstance 检查参数——按需要的行为直接用;对外接口想要可检查的约束,在签名上标 Protocol(12 章)。迭代器、生成器与推导式
for 背后的协议、yield 的惰性、推导式的边界。
for 循环不是魔法,是三步协议的语法糖——iter() 拿迭代器、next() 逐个取值、抛出 StopIteration 就收工。
手动驱动一遍
iter([10, 20, 30])返回一个list_iterator;连续next依次给 10、20、30;第四次抛StopIteration——注意它是异常,且通常不带消息。next(it, 默认值)在耗尽时返回默认值而不抛异常——「探测还有没有下一个」的安全写法。
可迭代对象 ≠ 迭代器
- list 是「可迭代对象」(能按需产出迭代器),但自己不是迭代器:直接
next(nums)报TypeError: 'list' object is not an iterator。 - 迭代器则要求
iter(it)返回自身(True)——所以迭代器本身也能进 for。 - 这个区别的直接后果是「能不能重复遍历」:列表每次 for 都拿到全新迭代器,可以反复;迭代器是一次性的消耗品。
for 的完整展开
for x in nums:等价于「it = iter(nums),然后 while True 里next(it),接住StopIteration就 break」——展开版与 for 输出完全一致。- 这正是 06 章「协议决定能力」的实例:任何实现
__iter__/__next__的对象都能被 for,无需继承任何基类;文件对象、字典、生成器都是这么接入 for 的。
nums = [10, 20, 30]
it = iter(nums) # ① 向可迭代对象要迭代器
next(it) # 10
next(it) # 20
next(it) # 30
next(it) # ② 耗尽 → StopIteration!
next(it, "没了") # '没了':给默认值就不抛
next(nums) # TypeError:list 不是迭代器
iter(it) is it # True:迭代器的 iter 是自己
# for x in nums: 的完整展开
it = iter(nums)
while True:
try:
x = next(it)
except StopIteration:
break
print(x) # 10 20 30,与 for 一致iter([1,2,3]) 第一次遍历得 [1, 2, 3],第二次直接得 []——静默为空、不报错。要多次遍历,持有原列表或每次重新 iter()。next(iter(xs), None) 是「取第一个元素、空了给默认值」的惯用法,比 xs[0] 多一层不抛 IndexError 的保险。函数体里出现 yield,它就不再是普通函数——调用时一行代码都不执行,只返回一个「要一个才算一个」的生成器。
惰性——next 才推进
countdown(3)创建后函数体一行没跑(第一行的「开始执行」没打印);第一次next才执行到第一个yield并暂停,局部变量全部冻结,下次next从暂停点继续。函数从「一口气跑完」变成「可暂停可恢复」。- 生成器表达式
(x*x for x in ...)是一行版;作为唯一参数时括号可省:sum(x*x for x in range(4))得到 14。
只能消费一次
- 生成器本身就是迭代器(上一卡的协议它全实现了),所以继承了迭代器的一次性:
list(g)得[2, 1]之后再list(g)得[];sum(sq)得 30 之后再求一次得 0——不报错、静默给空,这类 bug 藏得很深。 - 要「一份数据多处用」,先
list()固化成列表再传来传去;生成器的定位是「流」,不是「容器」。
内存量级
- 百万元素的列表,仅列表对象本身就是 MB 级;同样逻辑的生成器固定只有几百字节、与元素数量无关——相差约四个数量级。数据不落地,流过去就完了。
range同理是惰性序列,只存三个数。 yield from把迭代委托给子生成器:递归 flatten 把[1, [2, [3, 4]], 5]摊平成[1, 2, 3, 4, 5]。
def countdown(n):
print("开始执行") # 首次 next 才会打印
while n > 0:
yield n # 暂停点:交出值、冻结状态
n -= 1
g = countdown(3) # 一行都没执行
next(g) # 打印「开始执行」,返回 3
list(g) # [2, 1]
list(g) # []:已耗尽,静默给空
sq = (x*x for x in range(10**6)) # 生成器:几百字节
def flatten(nested):
for item in nested:
if isinstance(item, list):
yield from flatten(item) # 委托子生成器
else:
yield item
list(flatten([1, [2, [3, 4]], 5])) # [1, 2, 3, 4, 5]in 也会消耗生成器:3 in g 得 True 之后,g 里只剩 [4]——3 和它之前的元素全被吃掉了。对生成器做过成员检查,就别再指望完整遍历它。for line in f——文件对象本身就是惰性迭代器,内存里只有当前一行;别 readlines() 一次全载。推导式是「从旧集合映射 + 过滤出新集合」的一行式;括号决定产物——方括号造列表、花括号造字典或集合、圆括号造生成器。
四种形态
| 写法 | 产物 | 结果 |
|---|---|---|
[w.upper() for w in words if len(w)>2] | list | ['HELLO', 'HEY'] |
{w: len(w) for w in words} | dict | {'hi': 2, 'hello': 5, 'hey': 3} |
{len(w) for w in words} | set | {2, 3, 5}(自动去重) |
(x*x for x in range(4)) | 生成器 | sum(...) 得 14 |
推导式变量不泄漏
- 推导式有自己的作用域:
[y for y in range(3)]之后print(y)报NameError: name 'y' is not defined。 - 对比:普通 for 的循环变量会留下——循环结束后
z还是 2。因为 Python 的 for 不引入新作用域,循环变量就是当前作用域里一个普通的名字绑定(主线:名字绑定而非变量盒子)。
可读性边界
- 嵌套推导的 for 从左到右读,等价于同顺序的嵌套循环:
[x for row in matrix for x in row]摊平得[1, 2, 3, 4]。 - 经验边界:两个 for、或一个 for 加一个 if,是一行式的可读上限;再复杂(如转置
[[row[i] for row in matrix] for i in range(2)])就该写回普通循环,或给中间结果起个名字。
words = ["hi", "hello", "hey"]
[w.upper() for w in words if len(w) > 2]
# ['HELLO', 'HEY']:映射 + 过滤
{w: len(w) for w in words} # 字典推导
{len(w) for w in words} # 集合推导:{2, 3, 5}
sum(x*x for x in range(4)) # 14:参数位置可省括号
[y for y in range(3)]
y # NameError:推导式变量不泄漏
for z in range(3): pass
z # 2 —— 普通 for 的变量会留下
matrix = [[1, 2], [3, 4]]
[x for row in matrix for x in row]
# [1, 2, 3, 4]:for 从左到右,同嵌套循环:= 会泄漏到外层作用域:[w2 := w for w in words] 之后 w2 是 'hey'——与「推导式变量不泄漏」正相反,别拿它当局部临时变量。itertools 是一盒迭代器积木——全是惰性的,拼起来就是不建中间列表的数据流水线。
常用件速览
| 工具 | 作用 | |
|---|---|---|
count(10, 2) | 无限等差数列 | 前 5 个:[10, 12, 14, 16, 18] |
cycle("AB") | 无限循环 | 前 5 个:['A','B','A','B','A'] |
chain([1,2], "ab", (3,)) | 串联多个可迭代 | [1, 2, 'a', 'b', 3] |
islice(it, 5) | 迭代器切片 | 不支持下标的都靠它截断 |
pairwise([1,2,3,4]) | 相邻成对(3.10+) | [(1,2), (2,3), (3,4)] |
groupby 只合并「相邻」的相同 key
- 它不是 SQL 的 GROUP BY。不排序直接分组,首字母为 a 的词被拆成两组——
['apple', 'avocado']一组,隔着 b、c 之后['apricot']又自成一组。 - 正确姿势:先按同一个 key 函数
sorted,再groupby——a 组这才聚齐['apple', 'avocado', 'apricot']。「先排序」不是可选优化,是语义前提。
无限迭代器的安全守则
count/cycle是无限的,直接list()会挂死——一律先islice截断,或与有限序列zip让对方先耗尽。- 更多积木(
product、combinations、accumulate……)见 11 章速查。
import itertools as it
list(it.islice(it.count(10, 2), 5))
# [10, 12, 14, 16, 18]:无限数列 + 截断
list(it.chain([1, 2], "ab", (3,)))
# [1, 2, 'a', 'b', 3]
data = ["apple", "avocado", "banana",
"cherry", "apricot"]
first = lambda w: w[0]
# ⚠ 不排序:首字母 a 被拆成两组
[(k, list(g)) for k, g in it.groupby(data, key=first)]
# [('a', ['apple','avocado']), ('b', […]),
# ('c', […]), ('a', ['apricot'])] ← a 出现两次
# 先排序再分组,才是「按 key 聚合」
[(k, list(g))
for k, g in it.groupby(sorted(data, key=first), key=first)]
# [('a', ['apple','avocado','apricot']), …](k, g) 收进列表、再逐个 list(g),得到的全是空列表——每组必须当场消费,或当场 list() 固化。异常处理
EAFP 文化、异常层级、else/finally、异常链。
在 Python 文化里,异常不是「程序出大事了」的红色警报,而是正常控制流的一部分——先做,失败了再接住,这叫 EAFP。
EAFP vs LBYL 怎么选
- EAFP:直接
try d["k"],用 except 接 KeyError优一次查找、无「检查完又变了」的间隙,正常路径零开销心智负担短except 写得太宽会误吞别的错误为何标准库自己就用异常表达「没有」——KeyError、StopIteration、FileNotFoundError 全是;顺着这个文化写最顺。 - LBYL:先
if "k" in d再取优意图直白,适合「缺了很正常」的高频路径短查两次;条件和使用之间状态可能被别人改掉(文件、并发场景尤甚)为何检查与动作不是原子的——「刚检查过存在」不等于「用的时候还存在」。
最小形态
try里只包可能出错的最少语句,except写具体类型、as e拿到异常对象。int("abc")抛ValueError: invalid literal for int() with base 10: 'abc'。
为什么不能裸 except
- 异常树的根不是
Exception而是BaseException:KeyboardInterrupt(Ctrl-C)和SystemExit直接挂在根下——issubclass(KeyboardInterrupt, Exception)是False。 - 所以
except Exception会放行 Ctrl-C(穿透到外层),而裸except:连KeyboardInterrupt都吞掉——用户按 Ctrl-C 程序装死、想退都退不出。层级就是这么设计的:「程序该停」的信号故意绕开常规捕获。
user = {"name": "amy"}
# EAFP:先做,出错再接
try:
age = user["age"]
except KeyError:
age = None
# 最小形态:try 只包会出错的那几行
try:
n = int(text)
except ValueError as e:
print(f"不是整数:{e}")
# ⚠ 层级:Ctrl-C 不是 Exception 的子类
issubclass(KeyboardInterrupt, Exception) # False
issubclass(KeyboardInterrupt, BaseException) # True
try:
main()
except Exception as e: # 兜底上限:放行 Ctrl-C ✅
log.error(e)
# except: ← 裸 except 连 Ctrl-C 都吞 ✘except Exception: pass 是「异常黑洞」——bug 静默消失,只留下无法解释的错误状态,排查时连一行线索都没有。接住了至少 log 一行再继续。except Exception as e 并记录日志;永远不写裸 except:——它和 except BaseException 等价,会拦下 Ctrl-C 和解释器退出。内置异常是一棵继承树,except 某个节点就接住整棵子树——精确捕获的功夫全在「选对节点」。
高频异常速查(触发方式全部)
| 异常 | 什么时候抛 | 触发 |
|---|---|---|
ValueError | 类型对、值不对 | int("abc") |
TypeError | 类型不对 | "a" + 1 |
KeyError | 字典无此键 | {}["k"] |
IndexError | 序列下标越界 | [][0] |
AttributeError | 对象无此属性 | None.x |
ZeroDivisionError | 除以零 | 1/0 |
FileNotFoundError | 文件不存在 | open("/no/such") |
NameError | 名字未定义 | 变量名打错 |
ModuleNotFoundError | 模块找不到 | import nope |
树上的位置决定捕获范围
KeyError.__mro__是 KeyError → LookupError → Exception → BaseException;IndexError 也挂在 LookupError 下——except LookupError就把「按键/按下标没找到」一网打尽。同理 FileNotFoundError 是 OSError 的子类,except OSError接住整族 IO 错误。- AttributeError 最常见的形态是
'NoneType' object has no attribute 'x'——十有八九是上游某个函数返回了 None 而你继续用,真正的病灶在上游,别在这行硬接。
一次接多种与 as e
except (ValueError, TypeError) as e用元组一次接多种,两种异常都落进同一分支,type(e).__name__能区分。- 细节:
as e绑定的名字在 except 块结束时被自动删除——块外print(e)报NameError: name 'e' is not defined。想留住异常对象,块内先赋给别的名字。
# 一次接多种;as e 拿到异常对象
for bad in ["abc", None]:
try:
int(bad)
except (ValueError, TypeError) as e:
print(type(e).__name__, e)
# ValueError invalid literal for int() with base 10: 'abc'
# TypeError int() argument must be a string, ...
# 树上的位置决定范围
KeyError.__mro__
# (KeyError, LookupError, Exception, BaseException, object)
try:
lookup()
except LookupError: # KeyError、IndexError 都接住
...
try:
1/0
except ZeroDivisionError as e:
pass
print(e) # NameError:e 出了 except 块即被删除except Exception 写在最前面,后面所有具体分支全变死代码——Python 不报错、不提示,连测试都难发现这种「顺序错」。except Exception 兜底。try 语句其实有四个坑位——except 接失败,else 接成功,finally 无论如何都要过一遍;裸 raise 则让异常原样继续走。
else——成功分支的正确位置
else只在 try 没抛异常时执行。把「成功之后才做的事」放 else 而不是塞进 try,好处是 except 不会误捕到它抛出的同类异常——try 越小,捕获越精确。parse("42")走 else 返回 42,parse("xx")走 except 返回 None,两条路径 finally 都打印。
finally——连 return 都拦
- finally 在正常结束、抛异常、甚至 try 里
return之后都会执行——它是「清理保证」。 - 但别在 finally 里写 return:try 里
return 1、finally 里return 2,函数返回 2;更意外的是 try 里抛的 ValueError 也被 finally 的 return 静默吞掉,函数「正常」返回 99。 - Python 3.14 起编译器直接对这种写法发警告(PEP 765),报错原文:
SyntaxWarning: 'return' in a 'finally' block——老版本连警告都没有,全靠自觉。
裸 raise——看一眼,原样上抛
- except 块里单独一句
raise,把当前异常原样重抛,traceback 链路不断——外层收到的仍是同一个 ZeroDivisionError。「我只想记个日志、处理不了」就该这么写。
def parse(s):
try:
n = int(s) # try 只包可能出错的这行
except ValueError:
return None
else:
return n # 成功才执行,异常不被误捕
finally:
print("无论如何都执行") # return 也拦不住它运行
parse("42") # 打印后返回 42
parse("xx") # 打印后返回 None
# 裸 raise:记完日志原样上抛
def worker():
try:
1 / 0
except ZeroDivisionError:
log.warning("出错了,我处理不了")
raise # 重抛同一个异常,链路不断
# ⚠ 别在 finally 里 return——吞返回值也吞异常(见正文)return/break 会覆盖返回值并吞掉在途异常——异常凭空消失、函数「正常」返回 99。3.14 起有 SyntaxWarning: 'return' in a 'finally' block 兜底,老版本静默放行。with 语句——退出时自动收尾,异常也照样触发。在 except 里转抛新异常时,Python 会自动把旧异常链在后面——raise ... from e 让你明说因果,排错时两段 traceback 都在。
两种链,两种文案(traceback)
- 显式链
raise ConfigError(...) from e:两段 traceback 之间是「The above exception was the direct cause of the following exception:」,原异常存进__cause__——语义是「B 因 A 而起」。 - 隐式链(except 里直接 raise 新异常、不写 from):中缝文案变成「
During handling of the above exception, another exception occurred:」,原异常在__context__——语义是「处理 A 的过程中又出了 B」。 raise ... from None压制链条,traceback 只剩新异常(__suppress_context__为True,__cause__为None)。
怎么读链式 traceback
- 顺序是「最早的原因在最上面、最终异常在最底下」。排错从最后一行往上读:先看最终异常是什么、在哪抛的,遇到 direct cause 分隔线,再往上就是上一层的根因——一路读到最顶就是第一现场。
自定义异常的惯例
- 继承
Exception(永远别继承 BaseException——那会绕过所有except Exception),类体写...或 pass 就够,消息在 raise 时传给构造器。 - 项目里建一个
AppError根类、具体错误都继承它:调用方except ConfigError精细处理某一种,except AppError一网打尽本项目全部自定义错误——继承树就是分类树,和内置异常树同一套玩法。
class AppError(Exception): ... # 项目错误的根
class ConfigError(AppError): ... # 继承树 = 分类树
def load_port(raw):
try:
return int(raw)
except ValueError as e:
raise ConfigError("端口号必须是整数") from e
load_port("abc")
# ValueError: invalid literal for int() with base 10: 'abc'
# ↓ The above exception was the direct cause of ...
# ConfigError: 端口号必须是整数
try:
load_port("abc")
except ConfigError as ce:
ce.__cause__ # 原 ValueError 就挂在这里
# from None:压制链条,traceback 只剩新异常
raise ConfigError("干净的错误") from Nonefrom e,链虽然还在,但文案是「During handling…」——读 traceback 的人会以为你的异常处理代码本身出了 bug。表达「因它而起」就写 from e。from e——使用者既看到你的语义,也看到底层根因。from None 只在确定内部细节对排错无益时用,慎用。凡是「用完必须收尾」的东西——文件、锁、数据库连接——都不该指望自己记得关。with 把收尾变成协议:进入时调 __enter__,无论正常走完还是中途抛异常,离开时必定调 __exit__。
协议就两个方法
with Ctx() as r:先调__enter__,返回值绑给r;块结束调__exit__(exc_type, exc_val, tb)。正常走完时三个参数都是None。- 块内抛异常时,
__exit__会收到异常信息(exc_type是ValueError)——返回False(默认)异常继续向外抛,返回True则把异常吞掉、程序继续往下走。 - 所以文件版的保证是:
with open(...) as f:块内哪怕中途raise,f.closed也是True——收尾不靠你记性。
自己写一个:contextlib 更省事
- 完整写类要实现两个方法;多数场景用
@contextmanager装饰一个生成器就够——yield之前是进入、之后是收尾,收尾放try/finally里才能保证异常时也执行(<b>/</b>成对打印)。 - 常用现成品:
contextlib.suppress(FileNotFoundError)优雅忽略特定异常、redirect_stdout临时改输出目标。
多资源与新写法
- 一行管多个:
with open(a) as f, open(b) as g:;3.10 起可以加括号换行(通过),资源多时更清爽。 - 退出顺序与进入相反——后开的先关,和栈一样。
with open("data.txt") as f:
text = f.read()
# 离开 with 块(含中途抛异常)文件必定已关
from contextlib import contextmanager
@contextmanager
def timer(name):
import time; t0 = time.perf_counter()
try:
yield # 这里回到 with 块执行
finally: # 异常也会走到,收尾有保证
print(f"{name}: {time.perf_counter() - t0:.3f}s")
with timer("load"):
load_data()__exit__ 返回 True 会静默吞掉块内所有异常(lab 里 RuntimeError 被吞、程序继续跑)——除非你明确要做「异常抑制器」,否则返回 False/不返回。另外 @contextmanager 里收尾代码若不放 try/finally,块内一抛异常收尾就被跳过。with 的直觉:这个对象有没有「关 / 释放 / 归还」这类动作?有就包。自己写清理逻辑时,先想 @contextmanager + try/finally,写类是第二选择。前面几张卡讲的都是「一次出一个错」。可并发跑十个任务时,可能同时失败三个——把它们串成异常链会丢信息,只报第一个又会盖住其余。Python 3.11 给了 ExceptionGroup(一个装着多个异常的异常)和配套的 except* 语法。
except* 按类型分组捕获(3.14.6)
except*后面跟类型,它会从组里挑出所有该类型的异常交给你,而不是匹配整个组。抛ExceptionGroup("两类错", [ValueError("v1"), TypeError("t1"), ValueError("v2")]):except* ValueError收到['v1', 'v2'],except* TypeError收到['t1']。- 多个
except*分支都会执行——这和普通except的「命中一个就结束」完全不同。上例里两个分支各跑了一次。 - 每个分支拿到的
eg仍是一个 ExceptionGroup(只是被筛过),成员在eg.exceptions里。
最大的坑:普通 except 抓不到它
raise ExceptionGroup("g", [ValueError("only")]),用except ValueError:抓不到——落到了后面的except ExceptionGroup分支。组里只有一个 ValueError 也一样。- 原因很直接:
ExceptionGroup自己继承自Exception,不继承成员的类型。所以老代码里那些except ValueError在遇到异常组时会整个失效,错误一路往上飘。 - 这也是 15 章
TaskGroup必须配except*的原因——它抛的就是异常组。两处是同一件事的两面。
什么时候自己抛异常组
- 批处理:校验 100 条记录,想一次报出全部错误而不是让用户改一条跑一遍。把收集到的异常
raise ExceptionGroup("校验失败", errs)。 - 清理阶段的多重失败:关闭若干资源,每个都可能出错,不该因为第一个失败就跳过其余。
- 日常单线程业务代码不需要它——一次只出一个错时,普通异常更简单、报错也更好读。
# except* 按类型分组,多个分支都会执行
try:
raise ExceptionGroup("两类错",
[ValueError("v1"), TypeError("t1"), ValueError("v2")])
except* ValueError as eg:
print([str(x) for x in eg.exceptions]) # ['v1', 'v2']
except* TypeError as eg:
print([str(x) for x in eg.exceptions]) # ['t1']
# ✗ 普通 except 抓不到异常组(哪怕组里只有一个)
try:
raise ExceptionGroup("g", [ValueError("only")])
except ValueError:
print("到不了这里")
except ExceptionGroup as eg:
print("落在这里", eg.exceptions[0])
# 批处理:一次报全部错误
errs = []
for row in rows:
try: validate(row)
except ValueError as e: errs.append(e)
if errs:
raise ExceptionGroup("校验失败", errs)except SomeError 遇到异常组会静默失效——异常不被捕获、一路往上冒,日志里出现的是 ExceptionGroup 而不是你熟悉的那个错误名。把 asyncio.gather 换成 TaskGroup(15 章)时尤其常见:并发逻辑改对了,外层的错误处理却全落空了。改并发写法时,外层 except 要一起改成 except*。except* 和 except 不能在同一个 try 里混用(语法错误)。所以决定用哪种时,看这段代码有没有可能收到异常组:调 TaskGroup、或调用了会抛异常组的库,就整段用 except*;否则保持普通 except,别为了新语法而用。模块、包与导入机制
import 到底找了哪里、__init__.py、相对导入与 __main__。
「import greet」不是把代码「包含」进来,而是:找到 greet.py、从头到尾执行一遍、把执行产生的命名空间包成一个模块对象、绑定到名字 greet 上——理解这一句,缓存、单例、导入顺序的一切行为都有了解释。
模块就是一个对象
- 执行完 greet.py 后,里面定义的所有名字(变量、函数、类)都挂在这个模块对象上,用
greet.name访问——回扣 02 的主线:Python 里一切皆对象,模块也不例外。 - 顶层代码会真的执行:文件顶部的 print、发请求、连数据库,import 那一刻都会跑。所以模块顶层只放定义,动作收进函数。
只执行一次:sys.modules 缓存
- 在 greet.py 顶部放一句 print,连续
import greet两次只打印一次——第二次直接从sys.modules这张缓存表里拿现成的模块对象。 - 好处:无论多少个文件 import 同一模块,大家共享同一份;模块级变量因此天然是全局单例。
- 想强制重跑要
importlib.reload(greet)(会再次打印),主要用于交互调试,工程代码里少见。
去哪找:sys.path 按顺序查
- 打印
sys.path:第一项是被运行脚本所在目录,之后才是标准库目录,最后是 site-packages(第三方包安装处)。 - 顺序意味着「先到先得」:你目录里的同名文件会抢在标准库前面被找到——见 pitfall。
- 找不到才报
ModuleNotFoundError;「明明装了却找不到」多半是装进了另一个环境,回看 01 的 venv。
# greet.py
print("greet 模块正在执行")
name = "world"
# main.py
import greet # 打印「greet 模块正在执行」
import greet # 无输出——sys.modules 缓存命中
print(greet.name) # world
import sys
print(sys.path[0]) # 脚本所在目录,排第一
# 之后依次:标准库目录 → site-packages
import importlib
importlib.reload(greet) # 强制重新执行(再次打印)json.py、random.py 这类标准库同名——脚本目录排在 sys.path 最前,会遮蔽标准库。在脚本旁放一个 json.py 再 import json 调 dumps,报「AttributeError: module 'json' has no attribute 'dumps' (consider renaming '/tmp/…/json.py' since it has the same name as the standard library module named 'json' and prevents importing that standard library module)」——3.14 的报错甚至直接提示你改名。def/class/常量,要执行的动作收进 main(),再用本章「__main__」卡的守护语句触发——因为 import 会不折不扣执行顶层代码,副作用会在别人 import 你时意外发生。包就是「装着模块的目录」,__init__.py 是它的门面——目录结构即导入路径:app/util.py 对应 import app.util,代码规模一上来就靠它分层。
目录如何成为包
app/下放__init__.py和util.py,从 app 的父目录出发就能import app.util。- __init__.py 在包(或其任一子模块)首次被导入时先执行——在里面放一句 print,
import app.util时它先打印。 - __init__ 的角色:可以完全为空(只当「这是个包」的标识);最常见的用法是把深层常用名提上来(
from .util import double),让使用者from app import double就够,不必记内部文件结构。
from x import y 的几种形态与取舍
| 写法 | 之后怎么用 | 取舍 |
|---|---|---|
import app.util | app.util.double(2) | 全名最清晰,但长 |
from app.util import double | double(2) | 最常用;名字来源要回文件头查 |
import app.util as u | u.double(2) | 长包名缩写,惯例如 numpy as np |
from app.util import * | double(2) | 导入了什么全凭运气,污染命名空间,别用 |
没有 __init__.py 也能导入?
- 能——这是 3.3+ 的「命名空间包」,无 __init__.py 的目录照样 import 成功。
- 但常规项目仍建议显式放 __init__.py:意图清楚、避免路径上的同名目录被意外拼成同一个包、打包与测试发现等工具对它也更友好。
# app/
# __init__.py ← 首次导入包时先执行
# util.py ← def double(x): return x * 2
import app.util # 全名访问,最清晰
app.util.double(2) # 4
from app.util import double # 名字直接绑进当前命名空间
double(3) # 6
import app.util as u # 别名
# app/__init__.py 里提升常用名:
# from .util import double
from app import double # 使用者无需知道 util 存在from app.util import double 拷贝的是「当下的名字绑定」:之后模块内部把 double 重新绑定成新对象,你手里拿的还是旧的。要始终看到模块的最新值,就 import app.util 后用全名 app.util.double 访问——这正是 02「名字绑定而非变量盒子」模型的直接推论。__all__ = ["double"] 声明公共 API——读者、IDE 和 from app import * 都以它为准。同一个文件有两种身份:被直接运行时,Python 把它的 __name__ 设为「__main__」;被 import 时,__name__ 就是模块名自己——这行 if 只是在问「我此刻是脚本还是库」。
两种身份
- dual.py 里打印
__name__:python dual.py输出__main__,守护块内的代码执行。 - 另一个文件
import dual:输出dual,守护块不执行——只拿到里面的函数定义。 - 用
python -m方式运行时同样是__main__:守护语句照常工作(-m 的用途见本章相对导入卡)。
为什么需要它
- 上一卡说过:import 会执行全部顶层代码。没有守护,你写在文件底部的测试调用、命令行解析、打印,会在别人 import 你时全部触发一遍。
- 有了守护,同一个文件既能被当作库复用,又能直接跑起来自证——标准库大量模块就是这么写的:
python -m http.server、python -m json.tool能直接当命令用,正是因为这些模块底部都有这行守护。
惯用形态
- 逻辑收进
main()函数,守护块里只留一行main()——这样 13 里的测试可以直接 import再调用main(),不必真起子进程。 - argparse 之类的参数解析也放进
main(),别摊在顶层——顶层只留 import、定义和这一行守护。 - 要向 shell 返回退出码时写
sys.exit(main()),让 main() 返回 0 或错误码。
# dual.py
print("__name__ =", __name__)
def main():
print("直接运行才会看到这句")
if __name__ == "__main__":
main()
# $ python dual.py
# __name__ = __main__ → main() 执行
# 另一文件 import dual 时:
# __name__ = dual → main() 不执行main(),其余全部装进函数。def main() + 守护一行」的形态写——将来重构成库、或想给它补测试时零成本。「from . import util」里的点号意思是「当前包」——它只在文件作为包的成员被导入时才成立;直接把包内文件当脚本跑,点号无从解析,当场报错。
相对导入的规则
- 只能用 from 形式:
from . import util、from .util import double、from ..common import x(上级包)。 - 点号根据文件所属的包(
__package__)解析——被 import 时才有这层包上下文。
直接跑包内文件:报错
- app/main.py 第一行是
from . import util,运行python app/main.py报错:ImportError: attempted relative import with no known parent package。 - 原因:直接运行时该文件以 __main__ 身份执行、不属于任何包,「.」失去指代。
- 这是照教程搭好包结构后撞上的第一个错——错不在代码,在运行方式。
python -m:正确姿势
- 站在包的父目录运行
python -m app.main(注意是模块路径、无 .py 后缀):Python 先按包语义定位模块,包上下文完整,相对导入成立——正常输出。 - 写成文件路径
python -m app/main.py报「No module named 'app/main'」,并提示改写成app/main。 - -m 同样把
__name__设为 __main__,上一卡的守护语句照常触发。
from app.util import x——来源一眼可见,全局搜索替换容易,应用代码默认它from .util import x——包整体改名或被嵌进别的项目时不用改内部导入,适合要发布的库# app/
# __init__.py
# util.py ← def double(x): return x * 2
# main.py ← 内容如下:
from . import util # 相对导入:「.」= 当前包
print(util.double(21))
# $ python app/main.py ← 直接跑:ImportError(见正文)
# $ python -m app.main ← 站在 app 的父目录,模块身份跑
# 42import util 会出现「两种跑法各对一半」:python app/main.py 能跑(脚本目录 app/ 进了 sys.path),换 python -m app.main 就报 ModuleNotFoundError: No module named 'util'。统一写包路径(绝对或相对),别依赖「脚本目录恰好在 sys.path」这个巧合。python -m 包.模块。IDE 的「运行当前文件」按钮做的就是 python 路径/文件.py——对包内模块正是错误姿势,去改运行配置而不是删相对导入。深水区
陷阱章(18 章)收的是高频五坑,这一章潜得更深——讲那些不报错、只是悄悄给错的契约:len 数的不是字符也不是字节、+= 不是 = 与 + 的缩写、进了 set 的对象再改就找不回、排序把稳定性写进规范却把混类型拒之门外、json 产出别家解析器不认的文本。「这个函数怎么调」是下一章「常用标准库速查」的活。
02 章说过 len("你好") 是 2 不是字节数;深水区把这条边界走完:len 数的既不是「字符」也不是字节,是 Unicode 码点——三个概念三把尺子,跨界必须显式 encode/decode。
三把尺子
- 码点:
len("你好")是 2;字节:同一个字符串encode("utf-8")后是 6、encode("gbk")后是 4——字节数是编码的属性,不是字符串的; - 「一个字符」也靠不住:
len("👍")是 1,但len("👨👩👧")是 5——用零宽连接符拼出来的家庭 emoji 是 5 个码点。按「用户感知的字符」(字素簇)切分,标准库没有现成函数,得靠第三方(grapheme、regex 库)。
跨界不报错,才是最险的
- bytes 按下标取出的是 int:
"你好".encode()[0]是228,不是「半个你」; - decode 猜错编码不报错、只给乱码:UTF-8 字节按 GBK 读,得「浣犲ソ」——网上乱码家族的标准来源;
- 真正会报错的是切了半个字符的字节流:
b"\xe4\xb8".decode("utf-8")报UnicodeDecodeError: … unexpected end of data——网络分包读半截、按字节数截断文本,撞的都是它。
为什么必须显式传 encoding
open()不传 encoding 时跟系统 locale 走——Windows 中文环境下locale.getpreferredencoding()是cp936(GBK),11 章 pathlib 卡那句「显式传 encoding」的根据就在这;- PEP 686 已被接受、计划 3.15 起默认 UTF-8——在那之前,跨平台代码把
encoding="utf-8"当成必填参数。
s = "你好"
len(s) # 2 —— 数码点
len(s.encode("utf-8")) # 6 —— 字节数属于编码
len(s.encode("gbk")) # 4 —— 换个编码就变
len("👍"), len("👨👩👧") # 1 5 —— ZWJ 家庭是 5 个码点
b = s.encode("utf-8")
b[0] # 228 —— bytes 下标给的是 int
b.decode("gbk") # '浣犲ソ' —— 猜错编码不报错,只给乱码
# b"\xe4\xb8".decode("utf-8") → UnicodeDecodeError: unexpected end of datalen(s) 和 len(s.encode()) 恰好相等,测试全绿;上线遇中文才炸。最典型的是按字节配额截文本——s.encode()[:100].decode() 会把多字节字符切半,撞上面那个 UnicodeDecodeError。截码点用 s[:100],截字节要 errors="ignore" 兜住半字符。a += b 优先调用 a.__iadd__(b)——列表有它,就地改;字符串、元组、数字没有它,退化成 a = a + b 重绑定。同一个运算符,两种语义,在别名和函数边界上行为完全相反。
两种语义,两种后果
- 列表:
x += [2]就地改,所有别名跟着变(alias变[1, 2]);字符串:s += "b"造新对象重绑定,别的名字还指着旧值(t2仍是'a'); - 函数边界:参数上
items += [99]穿透到调用者(外面变[1, 99]),items = items + [99]不穿透——两行「看起来等价」的代码,一个有副作用一个没有。02 章的名字绑定模型是理解这一切的钥匙。
元组悖论:报了错,但已经改了
tu = ([1], 2)后执行tu[0] += [3]:抛TypeError: 'tuple' object does not support item assignment,但 tu 已经变成([1, 3], 2);- 机制:
+=展开为「先调tu[0].__iadd__([3])(列表就地改,成功)、再把结果赋回tu[0](元组拒绝,抛错)」——异常发生在第二步,第一步的副作用不回滚。
def push(items): items += [99] # __iadd__ 就地改
def rebind(items): items = items + [99] # 造新对象,改的是局部名字
a, b = [1], [1]
push(a); rebind(b)
a, b # [1, 99] [1] —— 一个穿透,一个没有
x = [1]; alias = x
x += [2]; alias # [1, 2] —— 别名可见
tu = ([1], 2)
# tu[0] += [3] → TypeError!但此时 tu 已是 ([1, 3], 2)+= 用在共享对象上(默认参数、类属性、模块级列表),就地语义会把「共享」变成「污染」——18 章可变默认参数卡的很多变奏,根源都是这一条。a = a + b 或 [*a, *b],明确就地就写 a.extend(b)——都比 += 少一层脑内展开。dict/set 的一切 O(1) 都建立在一条契约上:对象待在容器里期间,哈希值不能变。06 章讲了怎么定义 __eq__/__hash__;深水区讲违约现场——按字段哈希的可变对象,入容器后一改字段就成了幽灵。
幽灵成员(全流程)
- 按
name字段做__eq__/__hash__的对象t进了 set,随后t.name = "b"——从此t in s是 False(按新哈希找错桶)、Tag("a") in s也是 False(桶找对了,==又不过)、len(s)还是 1; - 连删都删不掉:
s.remove(t)抛 KeyError——对象明明在集合里。除非把字段改回原值(哈希随之复原),否则只剩整个容器重建一条路。
键坍缩:1、1.0、True 是同一个键
hash(1) == hash(1.0) == hash(True)且三者相等,于是{1: "int", 1.0: "float", True: "bool"}只剩{1: 'bool'}——键保留最早的形态,值保留最晚的赋值。04 章 dict 键规则的极端角落。
自保三式
- 参与哈希的字段设为只读(property 不给 setter);
- dataclass 直接
frozen=True——06 章讲过:frozen 才会连带生成__hash__; - 对象确实要可变?键里放不可变镜像(如
(user_id,)),别放对象本身。
class Tag:
def __init__(self, name): self.name = name
def __eq__(self, other): return self.name == other.name
def __hash__(self): return hash(self.name)
t = Tag("a"); s = {t}
t in s # True
t.name = "b" # 入容器之后改了参与哈希的字段
t in s # False —— 按新哈希找错桶
Tag("a") in s # False —— 桶对了,== 不过
len(s) # 1 —— 还在里面,谁也找不到
s.remove(t) # KeyError!删也删不掉
{1: "int", 1.0: "float", True: "bool"} # {1: 'bool'} —— 键坍缩__hash__ 由 id() 派生(跟对象身份走,不依赖字段),可变对象照样能进 set,改字段也无害。契约是从你自定义按字段的 __eq__/__hash__ 那一刻起落到你头上的。04/05 章讲了 sort/sorted/key 怎么用,05 章也提了「Python 排序是稳定的」;深水区讲这个承诺怎么兑现、以及两条藏在默认里的边界。
稳定性的兑现:两轮排序做多级异向
- 元组 key 只能同向多级;一列升一列降时对数值可以取负号,对字符串就没辙了——正解是倒着排两轮:先按次关键字排、再按主关键字排,稳定性保证第一轮的顺序在同分组里存活(右侧);
- 这是语言文档白纸黑字的承诺(CPython 用 Timsort 实现),不是实现巧合——对照:Lua 的
table.sort、C 的qsort都没有这条承诺。
拒绝混类型:宁可抛错,不猜大小
sorted([3, "1", 2])报TypeError: '<' not supported between instances of 'str' and 'int'——连None都不能混(NoneType同样拒比);- 这是 Python 3 的立场:Python 2 时代「几乎任意值可比」产出过无数静默错序。清洗过的数据才配排序——混类型先归一成同一类型(数字列全转 int/float;真要按字符串序才
key=str)或分组各排各的。
默认是码点序,不是「人类序」
- 05 章说过大写整体排在小写前;中文的对应现象更隐蔽:
sorted(["赵","钱","孙","李"])得['孙','李','赵','钱']——按 Unicode 码点排,与拼音无关,但输出「看着像排过了」; - 要人类期望的顺序:英文
key=str.lower,中文按拼音得引入拼音库(如 pypinyin)转换后再当 key。
rows = [("乙", 85), ("甲", 90), ("丙", 85), ("丁", 90)]
rows.sort(key=lambda r: r[0]) # 第一轮:次关键字(名字升序)
rows.sort(key=lambda r: r[1], reverse=True) # 第二轮:主关键字(分数降序)
rows # [('丁', 90), ('甲', 90), ('丙', 85), ('乙', 85)]
# 同分组内保持第一轮的名字序 —— 稳定性兑现
sorted([3, "1", 2])
# TypeError: '<' not supported between instances of 'str' and 'int'
sorted(["赵", "钱", "孙", "李"])
# ['孙', '李', '赵', '钱'] —— 码点序,与拼音无关key=str 统一用,"12" < "3" 按字典序成立,静默错序比抛错难发现得多。key=lambda r: (r[1], r[0]),05 章讲过);异向多级用两轮排序。记忆锚点:后排的是主关键字。11 章 json 卡讲了键强转、tuple 变形;深水区是两个更阴的角落——dumps 产出的不一定是 JSON,loads 收下的不一定保数据。
NaN 与 Infinity:自家方言
json.dumps([float("nan"), float("inf")])输出[NaN, Infinity]——RFC 8259 里没有这些字面量:Python 自家 loads 认(正常读回),浏览器JSON.parse和多数语言的标准库直接抛错;- 跨系统边界加
allow_nan=False,让它在出口就报ValueError: Out of range float values are not JSON compliant: nan——宁可在自己家炸,别把非法文本送出门。
重复键:写不查重,读静默取末
json.dumps({1: "a", "1": "b"})产出{"1": "a", "1": "b"}——键强转(11 章)撞上同名字符串键,dumps 不查重;loads遇重复键静默保留最后一个(得到{'1': 'b'})——RFC 对重复键行为未作规定,各家实现不一,一半数据在往返间蒸发且无任何报错;- 读回后指纹全变:
d.get(1)拿到 None,d.get("1")才有值——int 键的调用方全部扑空。
要确定性,自己动手
- 读取端用
object_pairs_hook=lambda pairs: pairs能看到重复键原始列表(得到[('1','a'), ('1','b')])——包一层检查即可把「静默取末」升级成报错。
import json
json.dumps([float("nan"), float("inf")])
# '[NaN, Infinity]' —— 不是合法 JSON,别家解析器直接炸
json.dumps(float("nan"), allow_nan=False)
# ValueError: Out of range float values are not JSON compliant: nan
json.dumps({1: "a", "1": "b"}) # '{"1": "a", "1": "b"}' —— 重复键出门
json.loads('{"1": "a", "1": "b"}') # {'1': 'b'} —— 静默丢了一半
json.loads('{"1": "a", "1": "b"}',
object_pairs_hook=lambda pairs: pairs)
# [('1', 'a'), ('1', 'b')] —— 想报错还是想合并,自己说了算ensure_ascii=False(人能读)、allow_nan=False(不产非法文本)、sort_keys=True(输出顺序稳定,diff 和缓存键都友好,生效)。生产序列化建议全带上。常用标准库速查
本章是上一章「深水区」的速查伴侣:pathlib/json/re/datetime/collections/itertools——开箱即用的那批。
pathlib 把路径从「字符串拼拼剪剪」升级为对象操作:/ 运算符拼路径、属性取组成部分、方法直接读写文件——一个 Path 对象顶过去 os.path 里的一堆函数。
高频操作
- 拼路径:
Path("data") / "a.txt",分隔符跨平台自动处理;.suffix得「.txt」、.stem得「a」、.parent得 data。 - 读写:
p.read_text(encoding="utf-8")一步读完整个文件,write_text一步写入;写之前p.parent.mkdir(parents=True, exist_ok=True)先把目录备好。 - 找文件:
Path("data").glob("**/*.py")递归收所有 .py(能找到子目录里的),iterdir()列目录,exists()判存在,resolve()转绝对路径。
对照 os.path
| 任务 | os.path 旧写法 | pathlib |
|---|---|---|
| 拼接 | os.path.join("data", "a.txt") | Path("data") / "a.txt" |
| 扩展名 | os.path.splitext(p)[1] | p.suffix |
| 读文本 | with open(p) as f: f.read() | p.read_text() |
| 建多级目录 | os.makedirs(p, exist_ok=True) | p.mkdir(parents=True, exist_ok=True) |
| 递归找文件 | glob.glob("**/*.py", recursive=True) | p.glob("**/*.py") |
新代码直接 pathlib;os.path 只需在维护旧代码时读得懂。
from pathlib import Path
p = Path("data") / "a.txt" # / 拼路径,跨平台
p.suffix, p.stem, p.parent # '.txt' 'a' data
p.read_text(encoding="utf-8") # 一步读入全文
out = Path("out") / "r.txt"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("第一行\n", encoding="utf-8")
for f in Path("data").glob("**/*.py"):
print(f) # data/b.py data/logs/c.py(递归)glob() 返回的是惰性迭代器(3.14 是 map 对象),只能遍历一次——第二次 list 得到空列表。要反复使用或要数个数,先 list() 固化。read_text/write_text 显式传 encoding="utf-8"——不传时用系统默认编码,Windows 上常不是 UTF-8,中文文件一跨平台就出错。json 模块负责 Python 对象和 JSON 文本互转:dumps/loads 对字符串,dump/load 对文件——中文想直出、dataclass 想入库,各差一个开关和一步转换。
往返与中文
- dumps 默认把非 ASCII 全转成转义:
{"name": "小明"}输出"\u5c0f\u660e";加ensure_ascii=False直出「小明」,再配indent=2就是人能读的格式。 - 类型映射:dict→object、list 与 tuple→array、
True→true、None→null。反向 loads 时 tuple 已回不来——(1, 2)往返变[1, 2]。 - 非字符串键被强转:
dumps({1: "a"})得{"1": "a"},loads 回来键是字符串「1」。
dataclass 不能直接进 dumps
json.dumps(User("Bob", 30))报:TypeError: Object of type User is not JSON serializable——json 只认那几种基础类型。- 正解:
dataclasses.asdict(u)先转 dict 再 dumps(输出{"name": "Bob", "age": 30});或给 dumps 传default=钩子函数。 - 用 pydantic 建模的话自带
model_dump_json(),16 章会天天见。
import json
from dataclasses import dataclass, asdict
d = {"name": "小明", "ok": True, "note": None}
json.dumps(d) # "\u5c0f\u660e"…全被转义
json.dumps(d, ensure_ascii=False) # {"name": "小明", …}
json.loads('{"a": [1, 2]}') # {'a': [1, 2]}
@dataclass
class User:
name: str
age: int
json.dumps(asdict(User("Bob", 30))) # 先 asdict ✅
# json.dumps(User("Bob", 30)) → TypeError(见正文)Path("cfg.json").write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding="utf-8")——序列化、缩进、编码一次到位。datetime 的日常三件事:拿时间、算时间差、和字符串互转——而跨时区的一切痛苦都源于一个区分:这个时间对象「知不知道自己在哪个时区」。
加减与互转
- 加减用 timedelta:
d + timedelta(days=90)从 7 月 22 日跳到 10 月 20 日;两个 datetime 相减得 timedelta,用.days和.total_seconds()取值。 - 格式化
d.strftime("%Y-%m-%d %H:%M");解析datetime.strptime(文本, 格式)——格式必须严丝合缝,差个分隔符就报 ValueError: time data '2026/07/22' does not match format '%Y-%m-%d'。 - 机器间交换优先 ISO 8601:
isoformat()与fromisoformat()(带时区偏移也能解析),少手写格式串。
aware vs naive:不能混
datetime.now()给的是 naive 时间:tzinfo是 None,它「不知道自己在哪」;datetime.now(ZoneInfo("Asia/Shanghai"))(3.9+ 标准库 zoneinfo)才是 aware。- 两类不能混算:aware 减 naive 报 TypeError: can't subtract offset-naive and offset-aware datetimes。
- aware 才能换时区:上海 20:00 经
astimezone(ZoneInfo("America/New_York"))得 08:00-04:00。 utcnow()已弃用:3.14 发 DeprecationWarning,提示改用datetime.now(datetime.UTC)——因为 utcnow 返回的还是 naive。
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
d = datetime(2026, 7, 22, 8, 30)
d + timedelta(days=90) # 2026-10-20 08:30
(datetime(2026, 12, 31) - d).days # 161
d.strftime("%Y-%m-%d %H:%M") # '2026-07-22 08:30'
datetime.strptime("2026-07-22 08:30", "%Y-%m-%d %H:%M")
datetime.now().tzinfo # None —— naive
sh = datetime.now(ZoneInfo("Asia/Shanghai")) # aware
sh.astimezone(ZoneInfo("America/New_York")) # 换时区
datetime.now(timezone.utc).isoformat() # 存储用now()、一半 now(tz),迟早在相减处炸出上面那个 TypeError,或者存库时时区语义不明、静默差 8 小时。跨时区业务从第一天起就全 aware。datetime.now(timezone.utc).isoformat();展示给用户时再 astimezone 到本地时区——只在边界转换,内部永远 UTC。collections 三件套各有一招鲜:Counter 把「数数」变成一行,defaultdict 让「分组」不用判断键在不在,deque 给两端操作 O(1)——认准场景直接套。
各自的一招鲜
- Counter:
Counter("mississippi").most_common(2)得到[('i', 4), ('s', 4)];查不存在的键返回 0 而不抛 KeyError;还支持减法,两份计数直接相减。 - defaultdict:
d = defaultdict(list)后d[k].append(v)一行完成分组——键不存在时自动先造个空列表;同样的写法用普通 dict 抛 KeyError: 'x'。 - deque:
deque(maxlen=3)满员后 append 自动从另一端挤出,天然是滑动窗口和「最近 N 条记录」;popleft/appendleft都是 O(1),而 list 的头部操作是 O(n)。
选型速记
| 需求 | 用什么 |
|---|---|
| 数频次、Top-N | Counter |
| 按键分组收集 | defaultdict(list) |
| 按键累加 | defaultdict(int) 或 Counter |
| 队列、滑动窗口、最近 N 条 | deque |
| 其余场景 | 普通 dict 就好 |
from collections import Counter, defaultdict, deque
Counter("mississippi").most_common(2)
# [('i', 4), ('s', 4)]
d = defaultdict(list)
for subj, score in [("语文", 90), ("数学", 85), ("语文", 70)]:
d[subj].append(score) # 键不存在自动建空列表
# {'语文': [90, 70], '数学': [85]}
dq = deque([1, 2, 3], maxlen=3)
dq.append(4) # deque([2, 3, 4]) —— 左端被挤出
dq.popleft() # O(1),list.pop(0) 是 O(n)d["ghost"] 查询之后,"ghost" in d 变 True、空列表已被塞进去。判断存在必须用 in,别用方括号试探;也别把 defaultdict 交给只打算读它的代码。一个操作「数据流」,一个操作「函数本身」:itertools 的工具全是惰性迭代器,functools 把函数当对象加工——正好对应 07 的迭代协议和 05 的一等函数。
itertools:流水线三件
chain把多个可迭代对象串成一条流:chain([1, 2], (3, 4))依次吐出 1 2 3 4,list 和 tuple 混着来也行。islice给「切不动」的东西切片:生成器、无限流count()都能取前 N 个,不会先耗尽再切——这是对惰性流做「预览」的标准姿势。groupby只合并相邻相同键:乱序输入里键「a」出现了两组——先sorted(data, key=…)再用同一个 key 去 groupby 才是完整分组。
functools:函数加工
@lru_cache记忆化:递归 fib(100) 瞬间返回,cache_info 显示 98 次命中;只适合参数可哈希的纯函数。partial固定部分参数造新函数:partial(int, base=16)就是一个十六进制解析器("ff" 得 255);回调只差一两个参数时最常用。reduce沿序列折叠二元函数:连乘[1, 2, 3, 4]得 24;可读性一般,能用 sum/max/min 就别用它。
from itertools import chain, islice, groupby, count
from functools import lru_cache, partial, reduce
list(chain([1, 2], (3, 4))) # [1, 2, 3, 4]
list(islice(count(10), 3)) # [10, 11, 12] 无限流取 3 个
data = ["banana", "apple", "avocado"]
words = sorted(data, key=lambda w: w[0]) # 先排序!
for k, g in groupby(words, key=lambda w: w[0]):
print(k, list(g))
@lru_cache
def fib(n):
return n if n < 2 else fib(n-1) + fib(n-2)
fib(100) # 瞬间返回,缓存命中 98 次
int16 = partial(int, base=16)
int16("ff") # 255
reduce(lambda a, b: a * b, [1, 2, 3, 4]) # 24re 最容易混的不是模式语法,而是四个入口函数的语义差:match 只从开头试、search 扫到第一处、findall 收全部、sub 做替换——先分清这四个;模式语法本身,本站有独立的正则页可深挖。
同一句话,四种问法
对「订单 A12 和 B34 已发货」用模式 r"[A-Z]\d+":
| 函数 | 结果 | 语义 |
|---|---|---|
re.match | None | 只在开头尝试;开头是「订单」,失败 |
re.search | Match 'A12' | 扫描全串,返回第一处 |
re.findall | ['A12', 'B34'] | 所有匹配的字符串列表 |
re.sub | 订单 *** 和 *** 已发货 | 每处匹配替换成给定文本 |
分组:search(r"([A-Z])(\d+)") 后 m.group(1) 得 A、m.group(2) 得 12。
r"" 原始串为什么必须
\d在普通字符串里是个「无效转义」:3.14 下"\d"触发 SyntaxWarning: "\d" is an invalid escape sequence. Such sequences will not work in the future.——3.12 起就警告,未来版本将直接报错。r""让反斜杠原样保留、不做转义解释,正则模式一律r""开头,写成肌肉记忆。
import re
s = "订单 A12 和 B34 已发货"
re.match(r"[A-Z]\d+", s) # None —— 只从开头试
re.search(r"[A-Z]\d+", s) # 第一处:A12
re.findall(r"[A-Z]\d+", s) # ['A12', 'B34']
re.sub(r"[A-Z]\d+", "***", s)
# 订单 *** 和 *** 已发货
m = re.search(r"([A-Z])(\d+)", s)
m.group(1), m.group(2) # ('A', '12')
# 模式一律 r"":普通串里 \d 是无效转义(3.12+ 警告).group() 炸出 AttributeError: 'NoneType' object has no attribute 'group'——这是 re 使用错误第一名。先接住返回值,if m: 之后再取组。in/startswith/endswith 更快更清晰;真要写复杂模式(分组、断言、贪婪控制),本站有独立的正则页系统讲。类型标注与 mypy
渐进类型:标给人看、标给工具查,运行时并不强制。这一章把「把动态类型的麻烦挡在运行之前」需要的工具凑齐——标注与结构、把取值也收进类型、分支的穷尽性、Any 这个漏洞怎么堵,以及运行时才到的外部数据靠什么拦。
Python 的类型标注是「注释级」的存在:解释器把它记下来但完全不检查,真正拦住类型错误的是 mypy 这类外部工具——这套「想标多少标多少、检查发生在运行前」的设计叫渐进类型。
运行时不强制
def double(x: int) -> int,拿字符串调double("哈")照样返回「哈哈」——解释器全程没看标注一眼,字符串乘法照走。- 标注只是被存进
double.__annotations__(得到{'x': <class 'int'>, 'return': <class 'int'>}),留给工具和框架读取。
mypy 才拦
- 同一个文件跑
mypy --strict,报错:error: Argument 1 to "double" has incompatible type "str"; expected "int" [arg-type]。 - 检查发生在运行之前、覆盖全部代码路径——不像测试只覆盖真正跑到的分支。
那标注图什么
- 给人:签名即文档,少翻函数体。给 IDE:精准补全与跳转。给工具:mypy 在保存时就抓到错。给框架:FastAPI/pydantic 是真的在运行时读
__annotations__做校验和文档(16)。 - 回扣主线:Python 是运行时才报错的语言,类型检查是把一部分错误提前到「写代码的当下」的补丁——这正是大项目离不开它的原因。
def double(x: int) -> int:
return x * 2
print(double("哈")) # 哈哈 —— 运行时无人拦截
print(double.__annotations__)
# {'x': <class 'int'>, 'return': <class 'int'>}
# $ mypy --strict demo.py
# → 报 arg-type:str 传给了 int 参数(原文见正文)现代写法记两条就够:容器直接用内置泛型 list[int]、dict[str, int](3.9+),可空类型用竖线 X | None(3.10+)——不再需要 from typing import List、Optional。
参数与返回值(mypy)
def mean(nums: list[int]) -> float——传["a", "b"]时 mypy 报错:List item 0 has incompatible type "str"; expected "int" [list-item]。- 变量也能标:
scores: dict[str, list[int]] = {}——复杂结构先想清形状再动手。
X | None 与类型收窄
- 查询类函数惯用
-> str | None表达「可能没有」;调用方拿到就.upper()会被 mypy 拦下,报错:Item "None" of "str | None" has no attribute "upper" [union-attr]。 - 先
if name is not None:再用——mypy 理解这类判断并「收窄」类型,分支里 name 就是纯 str。这比运行时才炸 AttributeError(08)早了一整个阶段。
新旧写法对照
| 含义 | 旧写法(typing 模块) | 新写法 |
|---|---|---|
| 整数列表 | List[int] | list[int](3.9+) |
| 可空字符串 | Optional[str] | str | None(3.10+) |
| 多选一 | Union[int, str] | int | str(3.10+) |
旧写法仍然合法、维护老代码会遇到;新代码一律用新写法。
def mean(nums: list[int]) -> float:
return sum(nums) / len(nums)
def find_user(uid: int) -> str | None:
return "alice" if uid == 1 else None
name = find_user(2)
name.upper() # mypy 拦:可能是 None
if name is not None: # 收窄后放行
name.upper()
scores: dict[str, list[int]] = {}X | None 不会帮你挡 None,它的价值在于强迫每个调用点处理 None 分支。如果发现代码里到处 if x is not None,考虑让函数在查无结果时直接抛异常(08),而不是返回 None 层层上传。-> None——含义是「明确说了没有」,而不是「忘了标」;strict 档下这不是可选项。结构化三件套回答三个不同的问题:这个 dict 里该有哪些键(TypedDict)、这个数据记录长什么样(dataclass)、这个参数要会哪些方法(Protocol)——共同点是都「按结构判断类型」。
各自一分钟
- TypedDict:给「形状固定的 dict」建类型。运行时它就是普通 dict——
type(m)得<class 'dict'>,零开销;缺键被 mypy 拦:Missing key "year" for TypedDict "Movie" [typeddict-item]。 - dataclass:
@dataclass自动生成 __init__/__repr__/__eq__——Point(1)打印Point(x=1, y=0)、与Point(1, 0)判等为 True。这是运行时真实存在的类(06 有专卡)。 - Protocol:只声明「要会什么方法」。Dog 没继承 Greeter,只因有
greet()就通过;Cat 没有 greet,mypy 报 Argument 1 to "hello" has incompatible type "Cat"; expected "Greeter" [arg-type]——不用 mypy 的话要等运行到调用处才炸 AttributeError。
怎么选
| 场景 | 选 | 理由 |
|---|---|---|
| JSON、API 响应、配置这类 dict | TypedDict | 不改运行时结构,纯给检查器看 |
| 自己业务的数据记录 | dataclass | 真实的类:可加方法、默认值、frozen |
| 参数只要求「会某几个方法」 | Protocol | 面向行为不面向继承,不必 import 对方基类 |
共同的底色:结构决定类型
- Protocol 就是静态版鸭子类型:不问你是谁的子类,只问你会不会这些方法——06 里运行时的鸭子类型被原样搬进了类型系统。
- 三者都不要求调用方继承你的基类,依赖方向更干净,特别适合给第三方类「补」类型约束。
from typing import TypedDict, Protocol
from dataclasses import dataclass
class Movie(TypedDict):
title: str
year: int
m: Movie = {"title": "阿凡达", "year": 2009}
type(m) # dict —— 运行时零痕迹
@dataclass
class Point:
x: int
y: int = 0 # Point(1) → Point(x=1, y=0)
class Greeter(Protocol):
def greet(self) -> str: ...
class Dog: # 没继承 Greeter
def greet(self) -> str: return "汪"
def hello(g: Greeter) -> None:
print(g.greet())
hello(Dog()) # 结构匹配即通过标了 str 只说明「是个字符串」,可 "asc" 与 "ASC" 的差别恰恰是最常出错的地方。这一组工具把类型的粒度从「哪一类」细到「哪几个值」,让拼错、传反、被改写这三类事故在写代码的当下就暴露。
Literal:把合法取值列出来
Order = Literal["asc", "desc"],传"ASC"报错:Argument 2 to "sort_by" has incompatible type "Literal['ASC']"; expected "Literal['asc', 'desc']" [arg-type]。- 适合状态字段、模式开关、API 的 mode 参数这类「就那么几个字符串」的场景,成本只有一行类型别名。
Enum / StrEnum:取值要复用就升级成枚举
- 取值需要跨模块引用、或者想遍历全部取值(
list(Color))时,用enum.StrEnum(3.11+):成员同时是 str,f"{Color.RED}"得red、Color.RED == "red"运行时为 True。 - 但 mypy 对这个等值比较会报 Non-overlapping equality check (left operand type: "Literal[Color.RED]", right operand type: "Literal['red']") [comparison-overlap]——运行时成立、检查器仍嫌你在比两个不同类型。结论是别拿枚举和裸字符串比,两边都用
Color.RED。 - 怎么选:一次性的两三个字面量用 Literal;要复用、要遍历、要挂方法就用 Enum。
NewType:同样是 int,但不许混用
UserId = NewType("UserId", int)与OrderId = NewType("OrderId", int):把 OrderId 传给收 UserId 的函数报 Argument 1 to "load_user" has incompatible type "OrderId"; expected "UserId" [arg-type],裸7也一样被拦。- 运行时零开销:
type(UserId(7))就是<class 'int'>,UserId(...)只是个恒等函数。 - 它挡的是最隐蔽的一类 bug——两个 id 参数挨在一起,传反了程序照跑,错到数据里才被发现。
Final:这个名字不许再赋值
MAX: Final = 100之后再MAX = 200,报 Cannot assign to final name "MAX" [misc]。类属性同样适用。@final装饰类或方法则表示「不许继承 / 不许重写」,用来锁住不想被人改写的契约。
from typing import Literal, NewType, Final
from enum import StrEnum
Order = Literal["asc", "desc"]
def sort_by(field: str, order: Order = "asc") -> str: ...
sort_by("name", "ASC") # mypy 拦:不在 asc/desc 里
class Color(StrEnum): # 3.11+,成员同时是 str
RED = "red" # f"{Color.RED}" → "red"
UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)
def load_user(uid: UserId) -> str: ...
load_user(OrderId(7)) # mypy 拦:两个 id 传反了
load_user(7) # mypy 拦:裸 int 不算 UserId
type(UserId(7)) # int —— 运行时零痕迹
MAX: Final = 100
MAX = 200 # mypy 拦:final name动态语言里最难查的一类回归是「加了个新状态,忘了改某处 if/elif」——程序不报错,只是悄悄走进兜底分支。assert_never 把这件事变成写代码当下的类型错误。
问题长什么样
Event = Literal["click", "scroll", "hover"],处理函数只写了 click 和 scroll、末尾return "?"兜底——mypy --strict报 Success: no issues found。漏了一整个分支,检查器一声不吭。- 这就是兜底分支的代价:它同时吞掉了「没想到的情况」和「忘了写的情况」,而后者才是 bug。
assert_never 的做法
- 把兜底换成
case _: assert_never(e),立刻报 Argument 1 to "assert_never" has incompatible type "Literal['hover']"; expected "Never" [arg-type]——错误信息里直接点名漏掉的是 hover。补上 hover 分支后恢复 Success。 - 原理:检查器在每个分支做类型收窄,走到最后
e本应被收窄成Never(没有任何值属于它)。还剩Literal['hover'],就说明有情况没处理,而assert_never的形参恰好只接受Never。 typing.assert_never是 3.11+;更早的版本从typing_extensions导入。
用在哪
- 状态机、事件分发、消息类型路由、订单状态流转——凡是「一组固定取值 + 每个都得处理」的地方。
- Literal 和 Enum 都适用,写 match 或 if/elif 都行,关键只是最后那一句。
- 运行时它也不是摆设:真被执行到会抛 AssertionError(说明来了个类型系统之外的值),而不是静默返回一个错值。
from typing import Literal, assert_never
Event = Literal["click", "scroll", "hover"]
def handle(e: Event) -> str:
match e:
case "click":
return "点击"
case "scroll":
return "滚动"
case _:
assert_never(e) # mypy:还剩 Literal['hover'] 没处理
# 对照组:写成兜底返回,漏了分支也一声不吭
def handle_bad(e: Event) -> str:
if e == "click":
return "点击"
elif e == "scroll":
return "滚动"
return "?" # mypy: Success —— 保护没了else: return 默认值 会把穷尽性检查彻底废掉——检查器认为你已经处理了所有情况,此后加多少新取值它都不会吭声。想要这层保护,兜底分支就只能是 assert_never,两者不能并存。泛型解决「进什么类型就出什么类型」的联动约束;3.12 起在函数名后直接写 [T] 就行,不再需要先 TypeVar 一通样板代码。
新语法(3.12+,)
def first[T](items: list[T]) -> T:first([1, 2, 3])被推断为 int,赋给标 int 的变量通过;first(["a"])赋给 int 变量被 mypy 拦:Incompatible types in assignment (expression has type "str", variable has type "int") [assignment]。- 类同理:
class Box[T],Box(42).get() + 1通过——get() 的返回类型跟着构造参数走。 - 类型别名:
type Vector = list[float](3.12+,可运行、可标注)。
旧写法对照
| 目的 | 3.12+ 新语法 | 旧写法 |
|---|---|---|
| 泛型函数 | def first[T](items: list[T]) -> T | T = TypeVar("T") 后再定义函数 |
| 泛型类 | class Box[T]: | class Box(Generic[T]): |
| 类型别名 | type Vector = list[float] | Vector = list[float] |
两种写法可以共存于同一文件——迁移不必一次完成。
什么时候值得写泛型
- 写容器、工具函数、装饰器(05)这类「传进什么样、出来什么样」的代码时才需要。
- 业务代码大多用具体类型就够,别为了泛型而泛型。
def first[T](items: list[T]) -> T: # 3.12+
return items[0]
first([1, 2, 3]) # mypy 推断为 int
first(["a", "b"]) # 推断为 str
class Box[T]:
def __init__(self, item: T) -> None:
self.item = item
def get(self) -> T:
return self.item
Box(42).get() + 1 # get() 是 int,mypy 放行
type Vector = list[float] # 3.12+ 类型别名
# 旧写法(兼容 3.11 及以下):
from typing import TypeVar
T = TypeVar("T")
def last(items: list[T]) -> T:
return items[-1]def f[T] 和 type X = … 是语法级特性:在 3.11 及以下的解释器上直接 SyntaxError,没法靠 typing_extensions 兜底——库作者要支持旧版本,就得留在 TypeVar 写法上。-> Any 的时候。返回类型其实取决于入参类型的话,[T] 能把这层关系告诉 mypy,Any 则等于放弃这条线的检查。标注体系里有一个能让所有检查失效的值:Any。它的含义不是「任意类型」,而是「别检查这里」——而且会顺着赋值与取值一路传染。绝大多数「我明明开了 strict 却还是炸了」,根源都在这。
一个 strict 通过、运行时崩溃的例子
json.loads()的返回类型就是 Any。拿它取出的port去做port + 1,mypy --strict报 Success: no issues found,运行起来 TypeError: can only concatenate str (not "int") to str。- 连
port.no_such_method()也一并放行——Any 上的任何操作都合法。 - 传染路径:Any 赋给的变量是 Any,从 Any 上取的下标与属性还是 Any。一个没标注的第三方调用,足以让下游一整条链失去检查。
把 Any 挡在门口
--strict里已含warn_return_any:函数标了-> int却返回json.loads(raw)["port"],报 Returning Any from function declared to return "int" [no-any-return]。这是最实用的一道闸,它逼着你在边界上把 Any 落地成具体类型。- 更狠的
--disallow-any-expr会把上面那段的每一行都报出来(Expression has type "Any" [misc])——一般项目开不起,适合拿来审计某个关键模块。 - 正规做法:在边界处一次性转成 TypedDict / dataclass / pydantic 模型,内部代码再也不碰 Any。
cast 是承诺,不是转换
cast(int, raw["port"])让检查器闭嘴,运行时type(n)仍是<class 'str'>、值仍是'8080'——cast不生成任何转换代码,纯编译期声明。- 所以它只该用在「我确实知道,但检查器推不出来」的地方。用它掩盖不确定的输入,等于把错误推迟到更远、更难查的地方。要真转换请写
int(...)。
第三方库没有类型信息
import yaml在 strict 下报 Library stubs not installed for "yaml" [import-untyped],并直接给出提示pip install types-PyYAML。- 三条出路,按优先级:① 装官方 stub 包
types-xxx;② 库自带py.typed标记的话本就无需处理;③ 都没有,再对该模块配ignore_missing_imports——注意这等于亲手造了一个 Any 源头。
import json
from typing import Any, cast
def load(raw: str) -> Any: # json.loads 的返回类型就是 Any
return json.loads(raw)
cfg = load('{"port": "8080"}')
port = cfg["port"] # Any
port + 1 # mypy --strict: Success
# 运行时: TypeError
n = cast(int, cfg["port"]) # 只是个承诺
type(n) # 仍是 str!cast 不做转换
# 正解:在边界上把 Any 落地,内部就不再有 Any
def get_port(raw: str) -> int:
return int(json.loads(raw)["port"])Any 和 object 长得像、作用相反:object 是「什么都能装,但用之前必须先收窄」,直接 .upper() 会被拦;Any 是「什么都能装,随便用」。想表达「我不关心具体类型」时,绝大多数场合该写的是 object——写 Any 是在关掉检查,不是在放宽类型。reveal_type(x),mypy 会把它推断出的类型打印出来(这行不用真运行,检查完删掉即可)。看到 Any 就顺着往上找源头。前面所有工具都在运行之前起作用,可外部数据是运行时才到的——请求体、配置文件、CSV、别人家的 API。这条边界上需要一个真的会在运行时读标注、并且拦得住的工具,那就是 pydantic。
分水岭:dataclass 不校验,pydantic 校验
@dataclass class PointDC: x: int——PointDC(x="不是数字")照单全收,打印出PointDC(x='不是数字')。dataclass 只负责生成样板方法,从不看类型。class User(BaseModel)则会校验并转换:User(name="alice", age="30")得到age=30(字符串被转成 int);配上Field(ge=0)后age=-1抛 ValidationError:Input should be greater than or equal to 0 [type=greater_than_equal, input_value=-1];少给字段则报 Field required [type=missing]。- 报错里带着字段名、约束和实际值——直接就能回给调用方当错误信息用。
不写 Web 也用得上
- 不必为了 pydantic 引入 FastAPI。校验裸结构用
TypeAdapter:TypeAdapter(list[int]).validate_python(["1", 2, "3"])得[1, 2, 3];遇到"x"抛 ValidationError 并指明是第 1 个元素。 - 典型落点:读配置文件、解析第三方 API 响应、导入 CSV/Excel、消费消息队列。16 章的 FastAPI 只是把同一套东西接到了 HTTP 上。
边界校验一次,内部信任类型
- 推荐形状:外部输入 → 边界处 pydantic 校验成模型 → 内部代码全程使用这个模型的类型。校验只做一次,之后交给 mypy 接管。
- 反面教材是「到处 isinstance」——既啰嗦,又永远补不全。
- 只想「运行时确认函数参数确实符合标注」而不需要转换的话,看 typeguard / beartype 这类装饰器方案。
| 数据来自哪 | 用什么 | 为什么 |
|---|---|---|
| 自己造、自己用的内部结构 | dataclass | 零校验开销,真实的类 |
| 形状固定的 dict,只求检查器认识 | TypedDict | 运行时零痕迹 |
| 外部来的、不可信的数据 | pydantic BaseModel | 运行时真拦得住 |
from dataclasses import dataclass
from pydantic import BaseModel, Field, TypeAdapter
@dataclass
class PointDC:
x: int
PointDC(x="不是数字") # 照单全收 → PointDC(x='不是数字')
class User(BaseModel):
name: str
age: int = Field(ge=0)
User(name="alice", age="30") # → age=30,字符串被转换
User(name="bob", age=-1) # ValidationError: greater_than_equal
User(name="carol") # ValidationError: Field required
# 不建模型也能校验裸结构
TypeAdapter(list[int]).validate_python(["1", 2, "3"])
# → [1, 2, 3];遇到 "x" 则抛 ValidationError"30" → 30),处理表单和查询参数时很省事;但如果你要的是「类型不对就报错」,开严格模式——model_config = ConfigDict(strict=True),或者对单个字段用 Strict。类型不是全有或全无的选择:从最关键的函数签名标起,让 mypy 的严格程度和代码库现状匹配,才能既拿到收益又不被几百个报错压垮。
从哪标起
- 优先标边界:对外的公共函数、模块间接口、解析外部数据的地方——类型错误最常从边界渗入。
- 函数体内部的局部变量大多不用标,mypy 能自己推断。
- 新写的代码一律标全;存量代码按接触到的顺序补,不必专门开工程。
mypy 的宽严两档
- 默认档很宽:没有标注的函数直接跳过检查——一个含明显类型错误的无标注文件,默认档报 Success: no issues found。适合存量项目起步:只有标了的函数才被检查,标一个赚一个。
--strict很严:无标注函数直接报 error: Function is missing a type annotation [no-untyped-def],而且连被 import 的无标注模块也一并被要求。- 配置写进 pyproject.toml 的
[tool.mypy]:disallow_untyped_defs = true单开也能得到同样的报错——严格度可以逐项、逐模块拧紧。
mypy 还是 pyright
- 两个主流检查器:mypy 是参考实现,配置项最细、文档与生态的默认对象;pyright(微软出品)快得多,类型收窄也更聪明。
- 有件事值得知道:VS Code 装了 Python 扩展,你其实已经在跑 pyright 了——Pylance 的内核就是它。于是编辑器里的波浪线来自 pyright、CI 里的报错来自 mypy,两套规则不完全一致,「编辑器不报、CI 报」就是这么来的。
- 实用组合:编辑器里把
python.analysis.typeCheckingMode调到standard或strict(默认档很宽松),CI 里跑 mypy;或者两边统一用 pyright。关键是选定一个当准绳,别让两套规则互相打架。
# type: ignore 的克制用法
- 永远带错误码:
# type: ignore[operator]只压对应类别(strict 下通过),裸写会把这一行的所有问题都藏掉。 - strict 会盯着你别滥用:问题修好后残留的 ignore 报 error: Unused "type: ignore" comment [unused-ignore]——它是止血贴,不是常态。
- 第三方库缺类型信息时,优先找 stub 包(types-xxx),而不是满屏 ignore。
# pyproject.toml —— 严格度按模块逐步拧紧
# [tool.mypy]
# python_version = "3.12"
# disallow_untyped_defs = true ← strict 的核心项之一
# 默认档:无标注函数不检查(Success)
# $ mypy app/
# 严格档:全要求标注,连 import 到的模块一起查
# $ mypy --strict app/
x = 1 + "a" # type: ignore[operator] # 带码只压一类--strict,会收获海量报错然后放弃。正确顺序:默认档跑通 → 新代码强制标注 → 按模块开 disallow_untyped_defs → 最后才是全局 strict。反着来是弃坑的头号原因。测试:pytest 与标准库
pytest 是事实标准:裸 assert 即断言,加上 fixture 与参数化。但打桩用的 unittest.mock 在标准库里,遗留项目里满是 unittest.TestCase,文档里的例子还能用 doctest 钉住——本章两头都讲,并说清它们怎么共存(pytest 能一行不改地跑 unittest 的用例)。
pytest 是 Python 测试的事实标准,它把门槛压到了极限:一个 test_ 开头的函数加一句裸 assert 就是一条测试——不用继承任何类、不用记 assertEqual 一族方法,失败时它还会把表达式两边的值拆开摆给你看。
pytest 怎么找到测试
- 全靠命名约定:文件叫
test_*.py或*_test.py,里面test_开头的函数就是测试(Test开头且没有__init__的类里的test_方法也算)。 - 不带参数跑
pytest,它从当前目录递归收集;也可以点名pytest tests/test_first.py。 - 反过来说:不姓 test 的函数不会被跑。在文件里放一个断言必错的
check_add,结果照样1 passed——它根本没被收集。绿灯的第一含义是「收集到的都过了」,不是「都测到了」。
通过时的输出怎么读
pytest -q下每个.是一条通过的测试,右侧百分比是进度。两条通过的输出就两行:..加[100%],然后2 passed in 0.01s。- 其它符号:
F失败、E收集或 fixture 出错、s跳过。
失败输出是招牌:assert 重写
裸 assert 在普通 Python 里失败只有一句干巴巴的 AssertionError;pytest 在导入测试文件时重写了 assert 的字节码,失败时能把两边的值展开。一条列表比较失败,E 开头的关键几行是:
AssertionError: assert ['hello', 'world'] == ['hello', 'world', '']Right contains one more item: '',并提示Use -v to get more diff。
差在哪一眼可见——这正是 pytest 敢于只用裸 assert、不发明一堆断言方法的底气。
# test_first.py —— 文件名必须 test_ 开头
def add(a, b):
return a + b
def test_add(): # 函数名必须 test_ 开头
assert add(2, 3) == 5 # 裸 assert 就是断言
def test_add_strings():
assert add("py", "test") == "pytest"
def strip_words(s):
return s.split()
def test_strip_words(): # 这条会失败:
# split() 不会产生空串,pytest 会摆出两边列表的差异
assert strip_words("hello world") == ["hello", "world", ""]
# 运行:pytest -q (-q 精简输出)assert 后面套圆括号带消息——assert (x == 2, "说明")——断言的其实是一个非空元组,恒真。pytest 会发 PytestAssertRewriteWarning「assertion is always true, perhaps remove parentheses?」,但测试仍显示通过。想带消息用逗号语法不加括号:assert x == 2, "说明"。-q 精简输出、-v 每条测试一行、-x 第一个失败就停、--lf 只重跑上次失败的。测试文件通常集中放 tests/ 目录;pytest 找不到被测模块时,多半是 09 章讲的导入路径问题,从项目根目录跑最稳。同一段逻辑要试五种输入,不是复制五个测试函数,而是给一个函数配一张参数表——@pytest.mark.parametrize 把表里每一行变成一条独立的测试。
怎么写
- 装饰器第一个参数是逗号分隔的参数名字符串(
"text, expected"),第二个是用例列表,每个元组按位置对应参数名;参数名必须和函数形参一致。 - 每行生成一条独立测试,ID 由参数值自动拼出:4 行的回文表在
-v下显示为test_is_palindrome[level-True]、test_is_palindrome[python-False]等 4 条 PASSED——哪行是哪个用例一目了然。
失败只挂那一行
- 三行表
(2, 4)、(3, 9)、(4, 15)测平方:只有[4-15]那条挂掉,失败输出直接给出n = 4, expected = 15和assert (4 * 4) == 15,另两行照常通过。 - 这正是它优于「for 循环里连环 assert」的地方:循环版第一个失败就中断,后面的输入根本没测,而且失败信息里看不出是哪个输入炸的;参数化版每行独立跑、独立报告。
边界进表
参数表是安置边界用例最顺手的地方:空字符串、零、负数、正好压着界限的值,各占一行、成本极低。的回文表里就放了空串 ("", True) 和带空格标点的长句——写表的时候顺手问自己一句「最刁钻的输入是什么」,测试的价值大半来自这几行。
import pytest
def is_palindrome(s):
s = "".join(ch.lower() for ch in s if ch.isalnum())
return s == s[::-1]
# 一行一个用例:输入 + 期望
@pytest.mark.parametrize("text, expected", [
("level", True),
("Was it a car or a cat I saw", True),
("python", False),
("", True), # 边界:空串
])
def test_is_palindrome(text, expected):
assert is_palindrome(text) == expected
# pytest -v 会列出 4 条独立测试:
# test_is_palindrome[level-True] / [python-False] / ...@pytest.mark.parametrize 会做笛卡尔积(3 行 × 2 行 = 6 条测试);想给某行起可读的名字用 pytest.param("", True, id="empty-string")。pytest -k 关键字 可以只跑名字匹配的那部分用例。测试常需要「先准备点东西」——一条数据库连接、一个临时目录、一份配置。pytest 的答案是 fixture:把准备逻辑写成带 @pytest.fixture 的函数,测试在形参里写它的名字,pytest 负责造好递进来——地道的依赖注入,不用继承什么 setUp/tearDown。
yield 分界:前置 / 后置
- fixture 里
yield之前是前置,yield交出的对象就是测试拿到的值,yield之后是后置清理——测试失败也照样执行,资源不会泄漏。 - 默认每条测试拿到全新的一份:两条测试都要
db,第一条往rows里塞了数据,第二条拿到的仍是空表——互不污染是默认行为。
内置 fixture 开箱即用
tmp_path:递给你一个真实存在的临时目录(pathlib.Path对象),测文件读写不用自己建目录、也不怕删错东西。往tmp_path / "config.json"写入再读回,直接通过。- 还有
capsys(截获 print 输出)、monkeypatch(临时改环境变量或对象属性,测完自动还原)。跑pytest --fixtures能列出当前全部可用的 fixture。
共享与作用域
- 放进
conftest.py的 fixture,同目录及子目录的测试文件不用 import 就能在形参里直接要。 @pytest.fixture(scope="module")或"session"让昂贵资源(数据库容器、大文件)整个文件或整轮测试只建一次。- fixture 的形参里还可以写别的 fixture,层层组装——16 章测接口用的 TestClient 就很适合包成一个 fixture 供全部用例共享。
import json
import pytest
@pytest.fixture
def db():
conn = {"connected": True, "rows": []} # 前置:建资源
yield conn # 把资源交给测试
conn["connected"] = False # 后置:清理,测试挂了也会跑
def test_insert(db): # 形参名 = fixture 名
db["rows"].append("alice")
assert len(db["rows"]) == 1
def test_isolated(db):
assert db["rows"] == [] # 每条测试拿到全新的一份
def test_config_file(tmp_path): # 内置:真实临时目录
cfg = tmp_path / "config.json"
cfg.write_text(json.dumps({"debug": True}))
assert json.loads(cfg.read_text())["debug"] is Truescope="session",但只共享只读的东西。「该报错的地方真的报错」和正常路径一样是函数行为的一部分——pytest.raises 就是断言「这段代码必须抛出这个异常」的写法(异常本身的机制见 08 章)。
pytest.raises 的两个方向
with pytest.raises(ValueError):块里的代码必须抛出 ValueError(或其子类):抛了,测试通过;没抛,测试失败——失败消息是「Failed: DID NOT RAISE ValueError」,这条消息出现时说明你的校验逻辑根本没拦住坏输入。as exc_info可以拿到异常对象本体,块外用exc_info.value继续断言细节。
match:把「抛对了原因」也钉住
- 只断言类型不够:同一个函数可能因为两种原因抛 ValueError(金额为负、余额不足)。
match="must be positive"按正则搜索异常消息(re.search 语义,子串即可命中),确保拦下的是你想测的那个错误。 - 因为是正则,消息里的
(、.、$等元字符要转义,或干脆match=re.escape(消息)。
该测什么:行为,不是实现
- 测公开接口的「输入 → 输出/异常」,别测「内部调了几次哪个私有函数」——后者一重构就碎,哪怕对外行为一点没变。测试应该保护重构,而不是阻止重构。
- 边界最值得写:0、空串、正好压着界限的值。
withdraw(100, 100)应返回 0 而不该报「余额不足」——>和>=差一个字符的 bug 就靠这类用例抓出来。
import pytest
def withdraw(balance, amount):
if amount <= 0:
raise ValueError(f"amount must be positive, got {amount}")
if amount > balance:
raise ValueError("insufficient funds")
return balance - amount
def test_negative_amount():
# match 锁定原因:抛 ValueError 且消息匹配才算过
with pytest.raises(ValueError, match="must be positive"):
withdraw(100, -5)
def test_overdraw():
with pytest.raises(ValueError, match="insufficient") as exc_info:
withdraw(100, 200)
assert "funds" in str(exc_info.value) # 块外查细节
def test_boundary_exact_balance():
assert withdraw(100, 100) == 0 # 边界:正好取空不该报错with pytest.raises(...) 块里塞多行代码:第一行抛出后,后面的行永远不执行,你以为测了其实没测;而且块里任何一行抛出对的类型都算通过,可能遮住真正想测的那行。块里只放那一句会抛的调用,其余断言放块外。==:assert 0.1 + 0.2 == pytest.approx(0.3)——二进制浮点的表示误差(02 章讲过)会让直接相等失败,approx 按合理容差比较。凡是碰网络、碰时间、碰文件、碰随机数的代码,测试时都得把那一块换成假的。Python 的打桩工具在标准库里——unittest.mock,不管你用 pytest 还是 unittest 都能用;pytest 另外给了一个更轻的 monkeypatch。两条路都有同一个致命细节:patch 的目标写错了,测试会安静地测真货。
头号坑:patch 要打在「被使用的那个名字」上
- 模块里写
from svc import fetch时,fetch这个名字已经被复制到了当前模块的命名空间。此时去patch("svc.fetch")换不掉它——被测函数照样返回真实数据,而且测试通过,你以为打了桩,其实真的调了出去; - 同一个
patch("svc.fetch")对写import svc/svc.fetch()的模块是生效的,因为那种写法每次都通过模块对象去查名字; - 规则一句话:
patch("使用它的模块.名字")。上例应写patch("consumer.fetch"),这样才换得掉。这条坑之所以隐蔽,是因为「没换掉」的表现是测试变慢或偶发失败,而不是报错; - 好消息是
patch不允许你打一个不存在的名字:patch("app.no_such")当场AttributeError——所以模块名写错会被立刻发现,只有「打错了模块但那个模块恰好也有同名符号」才会静默。
第二坑:Mock 什么都答应,包括你拼错的方法名
MagicMock()的任意属性、任意调用都成立。把save拼成svae调用后,m.svae.called是 True——被测代码调错了方法,测试照样全绿;- 解法是给它一个原型:
Mock(spec=Db)或patch(..., autospec=True)。加了spec之后同样的拼错立刻抛AttributeError: Mock object has no attribute 'svae';写替身默认就该带autospec=True,它还会一并校验参数签名; - 断言调用用
assert_called_once_with(...)/assert_any_call(...)/call_args_list(等于[call(1), call(2)])。失败信息很具体,调两次后断言只调一次得到Expected 'mock' to be called once. Called 2 times.; - 千万别写
assert_called_once少了后缀之类的拼写——那是个不存在的属性,Mock 会当场造一个出来给你,断言永远通过。带spec同样能挡住这个。
怎么选:mock.patch 还是 monkeypatch
monkeypatch(pytest fixture)胜在轻:setattr/setenv/delenv/chdir,用例结束自动还原(在一条用例里setenv("API_KEY"),下一条用例里该变量已经不存在)。适合「换掉一个值、设一个环境变量」;unittest.mock.patch胜在能记录调用:调了几次、每次什么参数、按次序返回不同值(side_effect=[1, 2, ValueError(...)]第三次抛出)。要断言「有没有调、怎么调的」就得用它;return_value给固定返回,side_effect给序列、异常或一个函数——需要「按参数返回不同结果」时把side_effect设成函数;- 时间这类全局依赖,优先把它作为参数传进来(依赖注入),实在不行再 patch——可测性问题十有八九是设计问题。
# —— 头号坑:patch 打在哪 ——
# svc.py
def fetch(url): return "真实数据"
# consumer.py
from svc import fetch # ← 名字被复制到本模块
def run(): return fetch("/a")
# test_consumer.py
with patch("svc.fetch", return_value="假"):
consumer.run() # ❌ "真实数据" —— 没换掉,测试却通过
with patch("consumer.fetch", return_value="假"):
consumer.run() # ✅ "假" —— 打在使用处才对
# —— 第二坑:Mock 什么都答应 ——
m = MagicMock()
m.svae("data") # save 拼错成 svae
assert m.svae.called # ❌ True —— 这条断言毫无约束力
s = Mock(spec=Db) # 给它一个原型
s.svae("data")
# AttributeError: Mock object has no attribute 'svae' ← 当场拦下
# —— 常用形态 ——
from unittest.mock import patch, MagicMock, call
@patch("app.now", return_value=0) # 装饰器参数顺序:自下而上
@patch("app.fetch", autospec=True) # autospec 顺便校验签名
def test_report(mock_fetch, mock_now):
mock_fetch.return_value = "F"
assert app.report("/x") == "/x @ 0 -> F"
mock_fetch.assert_called_once_with("/x")
m = MagicMock(side_effect=[1, 2, ValueError("第三次炸")])
m(); m() # 1, 2
m() # ValueError: 第三次炸
# pytest 的 monkeypatch:更轻,自动还原
def test_env(monkeypatch):
monkeypatch.setattr("consumer.fetch", lambda url: "假")
monkeypatch.setenv("API_KEY", "k") # 用例结束自动删掉判据和别的语言一样:只在「进程边界」上打桩——网络、数据库、文件系统、时钟、随机数。自家的纯函数直接调真的。如果为了测一个函数得 mock 掉五样东西,那是设计在报警:该把依赖变成参数传进去(见 11 章的依赖注入思路),而不是继续加桩。
patch 三种写法通用:装饰器(@patch(...),注意多个装饰器的参数顺序是自下而上——离函数最近的那个对应第一个参数,确实如此)、上下文管理器(with patch(...) as m:,作用域最清楚,推荐)、手动 start/stop(配 addCleanup,用于 setUp 里)。patch.object(某模块.某类, "方法名") 比字符串路径更安全——它是真实引用,写错了 IDE 和类型检查器都能发现,而字符串路径只有运行时才知道。pytest 是事实标准,但它不在标准库里。Python 自带两个测试工具:unittest(xUnit 风格)和 doctest(把文档里的例子当测试跑)。知道它们不是为了替代 pytest,而是因为你一定会遇到——标准库自己的测试、大量企业遗留代码、以及「这台机器不许装第三方包」的场景。
unittest:形状和别的语言的 xUnit 一模一样
- 继承
unittest.TestCase,方法名以test开头;准备放setUp、清理放tearDown(整类一次的是setUpClass/tearDownClass); - 断言是方法不是裸
assert:assertEqual/assertTrue/assertIn/assertAlmostEqual(浮点)/assertRaises(配with用)。比 pytest 的裸assert啰嗦,但失败信息一样具体——assertEqual(add(2,2), 5)报AssertionError: 4 != 5; - 跑法
python -m unittest 模块 -v,输出每条用例的 ok/skipped,末尾Ran 5 tests in 0.001s与FAILED (failures=1, skipped=1),失败时退出码 1; - 跳过用
@unittest.skip("原因")(输出标skipped '原因')、@unittest.skipIf(条件, ...)、@unittest.expectedFailure。
关键事实:pytest 能直接跑 unittest 的用例
- 把上面那个
TestCase原封不动交给 pytest:pytest ut.py得到1 failed, 3 passed, 1 skipped——一行都不用改,setUp/skip/assertEqual全部照常工作; - 所以接手遗留项目时不需要先做迁移:直接换成 pytest 跑,立刻拿到更好的失败输出和 fixture / 参数化,新用例用 pytest 风格写,老的
TestCase留在原地慢慢换; - 反过来 pytest 的 fixture、
parametrize对TestCase子类不生效——这是混写时唯一要记住的边界。
doctest:让文档里的例子无法说谎
- docstring 里形如
>>> add(2, 3)后跟一行期望输出,就是一条用例。跑python -m doctest 文件.py,一个含两个例子的函数报2 tests in ut.add/Test passed,通过时退出码 0; - 它真正的价值不是当主力测试,而是防止文档里的例子过期——README 和 docstring 里那些「用法示例」平时没人验证,改了 API 就成了假的。挂上 doctest,例子一旦对不上就红;
- pytest 一句
--doctest-modules就把它们一并收进来:同一个文件从 3 passed 变成 4 passed,多出来的正是 docstring 里那条; - 局限也要认:它按字符串精确比对输出,字典顺序、浮点尾数、对象的
repr、异常回溯稍有出入就失败。所以只适合输出稳定且简短的例子,不要拿它测复杂结构。
import unittest
def add(a, b):
"""把两个数相加。
>>> add(2, 3)
5
>>> add(-1, 1)
0
""" # ← 这四行就是两条 doctest 用例
return a + b
class TestAdd(unittest.TestCase):
def setUp(self): self.base = 10 # 每条用例前跑一次
def test_ok(self):
self.assertEqual(add(2, 3), 5) # 断言是方法,不是裸 assert
def test_fail(self):
self.assertEqual(add(2, 2), 5) # AssertionError: 4 != 5
def test_raises(self):
with self.assertRaises(TypeError):
add(1, None)
@unittest.skip("演示跳过")
def test_skipped(self): pass
# $ python -m unittest ut -v
# test_skipped (ut.TestAdd.test_skipped) ... skipped '演示跳过'
# AssertionError: 4 != 5
# Ran 5 tests in 0.001s
# FAILED (failures=1, skipped=1) ← 退出码 1
# $ python -m doctest ut.py -v
# 2 tests in ut.add ... Test passed ← 退出码 0
# 同一个文件交给 pytest,一行不用改:
# $ pytest ut.py → 1 failed, 3 passed, 1 skipped
# $ pytest --doctest-modules ut.py → 1 failed, 4 passed, 1 skipped
# ↑ doctest 也被收进来了TestCase 里方法名不以 test 开头就不会被跑,而且没有任何提示。把 test_login 敲成 tets_login、或者写了个 check_xxx 辅助方法却忘了它不会自动执行,结果都是那条用例静默消失、汇总里的数字少一个。和 node --test 不认 *.spec.*、GoogleTest 未实例化的参数化用例是同一类事故——看 Ran N tests 这个 N 对不对,比看有没有红色更可靠。doctest 这边的对应陷阱是期望输出多一个或少一个空格就失败,而且字典与集合的
repr 顺序、浮点尾数都可能因版本而变——它更适合钉住「这个 API 怎么用」,不适合钉住复杂返回值。unittest 当成「过时的东西」。它有两处到今天仍不可替代:一是 unittest.mock(上一张卡的主角,pytest 用户同样在用);二是不能装第三方包的环境——受限的构建机、标准库自身的测试、给别人的最小复现脚本,python -m unittest 永远在。python -m unittest discover -s tests 会自动发现 tests/ 下的 test*.py,无需任何配置文件。工具链:uv / pip / ruff / 打包
依赖、虚拟环境、格式化与 lint——2026 年的 Python 工程配置。
uv 是 Astral(ruff 同门)用 Rust 写的包管理器,把「建项目、建 venv、装依赖、锁版本、跑脚本」收进一个工具。2026 年起新项目直接用它;pip + venv 那套手工流程当作底层原理去理解(下一卡)。
四个命令的工作流(uv 0.11)
| 命令 | 做什么 |
|---|---|
uv init demo | 建项目骨架:pyproject.toml、main.py、.python-version、.gitignore(还顺手 git init) |
uv add requests | 把依赖写进 pyproject.toml 的 dependencies,解析后装进 .venv,同时更新 uv.lock |
uv run main.py | 在项目环境里跑脚本——运行前自动确保 .venv 与锁文件一致,不用手动激活 |
uv sync | 按 uv.lock 精确重建环境——新同事拉下仓库后的第一条命令 |
快在哪
- 删掉整个
.venv后uv sync重建(requests 连同 4 个传递依赖,缓存已热)毫秒级完成;同样场景 pip 重装是秒级——差两个数量级以上。首次下载受网络限制,谁都快不了。 - 快的来源不是魔法:Rust 实现的并行解析器,加上全局缓存——每个包只解压一次,各项目用硬链接复用同一份文件。
锁文件是关键产出
pyproject.toml表达「我要什么」(uv add 写入的是requests>=2.34.2),uv.lock回答「实际装了什么」——锁文件里连 certifi、idna、urllib3 这些传递依赖的精确版本和 sha256 哈希都在。- 两个文件都提交进 git,团队成员和 CI 才能用
uv sync复现出逐字节一致的环境。为什么锁版本这么要紧,下一卡展开。
# 从零到跑起来(uv 0.11,Linux)
$ curl -LsSf https://astral.sh/uv/install.sh | sh
$ uv init demo && cd demo # 项目骨架 + git init
$ uv add requests # 声明依赖 → 装进 .venv → 写 uv.lock
$ uv run main.py # 不激活、不 source,直接跑
# 换一台机器 / CI 上复现:
$ git clone ... && cd demo
$ uv sync # 按 uv.lock 重建 .venv(热缓存毫秒级)
# pyproject.toml 里多了:
# dependencies = ["requests>=2.34.2"]uv sync 会被原样清掉(手动装的 six 在 sync 后被卸载,「Uninstalled 1 package」)。要加依赖永远走 uv add;另外 WSL 里项目别放 /mnt/c 挂载盘——跨文件系统时 uv 警告无法硬链接、退化为拷贝,速度优势会被文件系统吃掉。uv run --with requests script.py 临时拉依赖跑完即走。uv 还能管解释器本体:uv python install 3.14(按官方文档,机器上甚至可以不预装 Python)。装 uv 本身只要一条 curl,装到用户目录、不动系统。uv 再快,底下那套「venv 隔离 + pip 安装 + requirements 锁版本」仍是 Python 生态的通用语言——读老项目、写 Dockerfile、进了没有 uv 的环境,这套流程就是保底方案,值得完整过一遍手。
标准四步
- ①
python3 -m venv .venv建隔离环境 → ②source .venv/bin/activate激活(提示符前出现环境名) → ③pip install requests装包 → ④pip freeze > requirements.txt锁版本。 - 换机器或新同事接手:建好 venv 后一条
pip install -r requirements.txt装回全部依赖。
为什么要锁版本
pip install requests装的是「此刻的最新版」——半年后在新机器重装,可能是另一个版本:你的代码没变,依赖的行为变了,「在我机器上是好的」多半源于此。pip freeze输出的是精确快照:装完 requests 后 freeze 出 5 行,全部是==钉死的版本(requests==2.34.2 连同 certifi、charset-normalizer、idna、urllib3)。写进 requirements.txt 提交,重建时人人一致。- 进阶习惯是分两个文件:手写的宽松声明(我要 requests)和 freeze 出的精确清单(实际装了哪 5 个包、各是什么版本)——这正是上一卡 pyproject.toml 加 uv.lock 这对组合的前身思路。
venv 是隔离的根
- 不激活 venv 就 pip install,包会落进系统或用户级环境,项目之间互相污染版本。一个项目一个
.venv,装乱了删目录重来,代价为零。 - 本 lab 的 Ubuntu:系统
python3根本没带 pip(「No module named pip」),裸机第一步永远是先建 venv——环境问题的排查思路见 01 章。
# 经典四步(Linux / macOS;Windows 激活脚本在 .venv\Scripts\)
$ python3 -m venv .venv
$ source .venv/bin/activate # 提示符变成 (.venv) $
$ pip install requests
$ pip freeze > requirements.txt
# requirements.txt 的内容——全部 == 钉死:
# certifi==2026.7.22
# charset-normalizer==3.4.9
# idna==3.18
# requests==2.34.2
# urllib3==2.7.0
# 别人复现:
$ pip install -r requirements.txtpip freeze 是整个环境的快照:你手滑装过的调试工具也会混进 requirements.txt;反过来 pip uninstall requests 只卸它自己,不会带走它当初拖进来的 certifi、urllib3——环境只会越用越脏。最省心的清洁法就是删掉 .venv 按 requirements 重建。python3 -m pip 代替裸 pip,保证 pip 和解释器对应同一个环境;查「现在到底用的哪个」:which python 加 pip --version(后者会打出所属环境路径)。一个编辑器加一个检查器就够起步了。工具的意义是帮你更快看到错误,而不是在配置里耗完入门的热情。
VS Code + Python 扩展
- 装微软官方的 Python 扩展(自带 Pylance):补全、跳转定义、悬浮看文档、把类型问题用波浪线标出来;
- 打开项目文件夹后,在右下角或命令面板的 Python: Select Interpreter 里选中
.venv里的解释器——这一步做对,编辑器里的运行和终端里的运行才是同一个环境; - 右上角的运行按钮等价于终端里
python 文件名,初学两种方式都用一下,理解它们是一回事。
ruff:一个工具管 lint + 格式化
ruff check .找问题(没用的导入、没定义的名字、可疑写法),ruff format .统一风格——两条命令替代了老一代 flake8 + isort + black 全套,而且快到保存时自动跑也毫无感觉;- 它的价值在于把一部分运行时才会爆的错误提前到写代码时,正好补动态语言的短板;
- 先别急着上 PyCharm 全家桶:run configuration、项目模型这些概念对初学是噪音。先在终端把「解释器、venv、脚本」这条链路走熟,之后工具换成什么都不慌。调试起步用
print(f"{x=}")完全不丢人。
# 在激活的 venv 里装 ruff(uv 用户:uv add --dev ruff)
(.venv) $ pip install ruff
# 查问题:没用的导入、未定义的名字……
(.venv) $ ruff check .
# 统一格式:缩进、引号、行宽,全自动
(.venv) $ ruff format .
# VS Code 侧:装两个扩展就够
# 1. Python(微软官方,含 Pylance)
# 2. Ruff(保存时自动检查/格式化)ruff 用一个 Rust 可执行文件同时顶替了 flake8(挑毛病)和 black(排版面),全项目秒级跑完。新项目的 lint 和格式化,默认选它就够了。
ruff check:抓的是真问题(ruff 0.15)
对一段五脏俱全的烂代码跑 ruff check,默认规则抓出 4 条:
| 规则 | 告警 | 为什么值得改 |
|---|---|---|
| F401 ×2 | `os` imported but unused | 死 import 误导读者、拖慢启动 |
| E711 | Comparison to `None` should be `cond is not None` | 比较 None 该用 is(02 章讲过 is 与 ==) |
| E741 | Ambiguous variable name: `l` | 小写 l 和数字 1 肉眼难分 |
有告警时退出码为 1,CI 里直接当门禁用。
--fix 与 format 各管一段
ruff check --fix只自动修「机器确定安全」的部分:4 条修了 2 条(删掉两个死 import),涉及语义的 E711/E741 留给人判断。ruff format只管排版:把def load( path ):收成def load(path):、f=open补出空格——风格与 black 兼容,团队从此不吵格式。
配置进 pyproject.toml
- 不用单独的配置文件,
[tool.ruff]一节就行:line-length、lint.select挑规则族。默认只开 E4/E7/E9 加 F(相当克制),值得加的:I(自动排 import 顺序)、B(bugbear,抓可变默认参数这类真 bug,18 章的常客)。
# messy.py —— ruff check 对它报 4 条
import os # F401:导入了没用(--fix 会删)
import json
import sys # F401 同上
def load( path ): # format:括号内空格会被收掉
f=open(path) # format:补成 f = open(path)
data = json.load(f)
l = [x for x in data if x != None] # E741 歧义名 l;E711 应写 is not None
return l
# 三条命令:
# ruff check . 找问题(有告警退出码 1)
# ruff check --fix . 自动修安全项
# ruff format . 统一排版--fix 之外还有一档「unsafe fixes」默认不启用——输出里那句「1 hidden fix can be enabled with the --unsafe-fixes option」指的就是它:比如把 x != None 改成 x is not None,在类重载过 __eq__ 时行为可能真的不同。启用前过一眼 diff,别盲改。ruff check . 和 ruff format --check .——后者只检查不改写,有文件需要重排时输出「1 file would be reformatted」并以退出码 1 失败。想让别人 pip install 你的库,只差三步:元数据写进 pyproject.toml、构建出发行文件、上传 PyPI。本卡按官方文档梳理流程(构建与上传环节未),目的是心里有一张完整的图。
pyproject.toml 是唯一中心
- setup.py / setup.cfg 的时代已经过去:项目名、版本、依赖、构建方式,现在全部集中在 pyproject.toml 的
[project](PEP 621 元数据)和[build-system]两节。上一卡的 ruff 配置、12 章的 mypy 配置也都住在这个文件里——一个文件管全项目。 [build-system]声明用哪个构建后端(hatchling、setuptools、flit 等),pip 从源码安装时按这一节自举出构建环境。uv init 生成的骨架就是这套结构。
构建两件套:sdist 与 wheel
python -m build(或uv build)在dist/下产出两个文件:.tar.gz是源码分发包(sdist),.whl是预构建的轮子(wheel)。- wheel 解压即装、安装时不执行构建代码,快而且安全——纯 Python 项目的 wheel 一份通吃全平台(文件名带
py3-none-any)。
上传与排练
twine upload dist/*(或uv publish)推上 PyPI;账号需要 API token(PyPI 已强制 2FA,上传不再接受账号密码)。- 第一次发布先推到 TestPyPI 排练:流程一模一样、搜索引擎不收录、随便折腾。
# pyproject.toml —— 打包的核心两节(文档流程,未发布)
[project]
name = "demo"
version = "0.1.0"
description = "一个示例包"
requires-python = ">=3.12"
dependencies = ["requests>=2.34"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# 构建与上传:
# python -m build → dist/demo-0.1.0.tar.gz
# → dist/demo-0.1.0-py3-none-any.whl
# twine upload dist/* (需要 PyPI API token)pip install dist/*.whl,import 一把再发。requests>=2.34),钉死会让下游解析冲突;应用才用锁文件钉死每个版本。给库钉 == 是打包新手最常见的好心办坏事。并发:线程、asyncio 与 GIL
IO 密集选什么、CPU 密集选什么、GIL 到底挡住了什么。
Python 并发的第一课不是学 API,而是先给任务分类:瓶颈在「等」(网络、磁盘)还是在「算」(CPU)——选错模型,加再多线程也快不起来。
两类任务
- IO 密集:大部分时间在等外部世界——发网络请求、读写文件、查数据库。等待期间 CPU 闲着,要的是「等的时候去干别的」。
- CPU 密集:大部分时间在算——图像处理、数值计算、解析大文件。要的是「多个核一起算」。
- 判别法:跑起来看 CPU 占用——一直接近满载是 CPU 密集;占用很低、时间都花在等回包上,是 IO 密集。
决策表
| 任务画像 | 选什么 | 为什么 |
|---|---|---|
| IO 密集,高并发(成百上千连接) | asyncio | 单线程调度海量协程,没有线程切换与锁的开销 |
| IO 密集,任务不多或依赖阻塞库 | threading / ThreadPoolExecutor | 线程等 IO 时释放 GIL;对旧代码改动最小 |
| CPU 密集,纯 Python 计算 | multiprocessing / ProcessPoolExecutor | 每个进程有独立 GIL,真·多核并行 |
| CPU 密集,数组数值 | numpy 等原生扩展 | C 层计算时释放 GIL,常常比多进程更省事 |
这条分界线从哪来
全部答案都指向 GIL:常规 CPython 同一时刻只让一个线程执行字节码,所以多线程帮不了「算」,只帮得了「等」,下一卡用数字把这条线钉死。混合型任务(既要扛连接又要算)的常见做法是组合:asyncio 管连接,重计算丢给进程池,用 loop.run_in_executor 桥接两边。
# 任务瓶颈在哪?——先答这个,再挑 API
#
# ├─ 在等(网络 / 磁盘 / DB)→ IO 密集
#│ ├─ 新代码、高并发 → asyncio(本章第 5、6 卡)
#│ └─ 旧代码、阻塞库 → threading / ThreadPoolExecutor
#│
# └─ 在算(编解码 / 数值)→ CPU 密集
# ├─ 纯 Python 计算 → multiprocessing / ProcessPoolExecutor
# └─ 数组数值 → numpy / 原生扩展(C 层绕开 GIL)concurrent.futures 里线程池与进程池接口完全一致:先写 ThreadPoolExecutor 版,确认瓶颈真在 CPU 后改一个类名换成 ProcessPoolExecutor——低成本试错。GIL(全局解释器锁)保证同一时刻只有一个线程在执行 Python 字节码。它存在的原因很朴素:CPython 用引用计数管内存,计数本身不是线程安全的,一把全局大锁是最简单的保护。它挡住的是「多线程用多核」,没挡住「等 IO 时换人干活」——这一条就是上一卡整张决策表的物理基础。
CPU 密集:多线程不提速
- 2000 万次递减循环:单线程一口气跑完,和拆成两个线程各跑一半,总耗时几乎一样(比值约 1:1;绝对秒数因机器而异,比值不会)。
- 原因:两个线程轮流抢同一把 GIL,任何时刻仍只有一个在算——本质还是串行,白付线程切换的开销。
IO 密集:多线程真提速
- 4 个各等 0.5 秒的模拟 IO 任务:串行 2 秒,4 线程约 0.5 秒——正好折成四分之一。
- 原因:线程进入等待(sleep、socket 收发、文件读写)时释放 GIL,其它线程接着跑。numpy 这类 C 扩展在重计算时也常释放 GIL,所以「numpy 重活 + 多线程」有时也吃得到多核。
free-threading:分界线正在松动
- CPython 3.13 起提供官方的 free-threaded(无 GIL)构建(PEP 703),3.14 起官方宣布其进入「正式支持」阶段、不再是实验特性(按官方文档;本页 lab 用的是常规构建,未)。
- 它是单独的构建(如
python3.14t):默认下载安装的解释器仍带 GIL,C 扩展生态也在跟进适配。本卡结论在你大概率使用的常规构建下依然成立。
import threading, time
def count(n): # 纯 CPU:递减循环
while n > 0:
n -= 1
N = 20_000_000
t0 = time.perf_counter()
count(N) # 单线程一口气跑完
print("single:", time.perf_counter() - t0)
t0 = time.perf_counter()
a = threading.Thread(target=count, args=(N // 2,))
b = threading.Thread(target=count, args=(N // 2,))
a.start(); b.start(); a.join(); b.join() # 两线程各跑一半
print("two threads:", time.perf_counter() - t0)
# 常规(带 GIL)构建下:两个耗时几乎相同,多线程没帮上忙sys._is_gil_enabled()(3.13+),常规构建返回 True。写并发代码前先确认自己站在哪种构建上。threading 的 API 三板斧:Thread(target=...) 建线程、start() 启动、join() 等它结束。真正的难点不在 API,在共享数据——多个线程同时「读-改-写」同一个变量,更新会悄悄丢。
竞态是真的
- 8 个线程各把全局计数器加 20 万次,「读-改-写」不加锁:期望 160 万,连跑三次分别得到约 40 万、43 万、47 万——每次结果都不同,六成以上的更新被互相覆盖丢掉了。
- 复现说明:现代 CPython 默认约 5 毫秒才切换一次线程,简单例子经常「侥幸跑对」;里用
sys.setswitchinterval(1e-6)调密切换后竞态立刻显形。侥幸跑对不等于线程安全。
Lock 修复
- 用
with lock:包住读-改-写,同一时刻只有一个线程能进临界区:同样的程序稳定得到 160 万。 - 锁的粒度是门手艺:包住「必须一起发生」的最小区间——包粗了并发退化成串行,包细了等于没保护。用到多把锁时,所有线程按同一顺序获取,否则两个线程各持一把互等对方,死锁。
日常写法:别手搓 Thread
- 手写 Thread 适合理解模型;真实代码多用
concurrent.futures.ThreadPoolExecutor:ex.map(fn, tasks)一行分发,线程生命周期自动管理。 - 线程间传数据用
queue.Queue(线程安全的队列)比共享变量加锁省心得多——能用「传消息」就别用「共享内存」。
import sys, threading
sys.setswitchinterval(1e-6) # 调密线程切换,让竞态现形
counter = 0
lock = threading.Lock()
def add1(x):
return x + 1
def bump(n):
global counter
for _ in range(n):
counter = add1(counter) # 读-改-写:不加锁会丢更新
# 修复:with lock: counter = add1(counter)
threads = [threading.Thread(target=bump, args=(200_000,))
for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join() # 等全部干完
print("期望 1600000,实际", counter) # 每次运行都不同daemon=True——但 daemon 线程是被硬掐的,别让它做写文件这类不能中断的事。进程各带一个独立解释器、各持一把独立 GIL——multiprocessing.Pool 把任务分发到多个进程,是 CPU 密集任务吃满多核的标准做法。代价是隔离:数据要序列化搬运,子进程启动要重新走导入。
提速对比
- 4 个各 2000 万次的平方和任务,4 核机器:串行约 2.7 秒,
Pool(4)约 0.95 秒——接近 3 倍。没到 4 倍是因为进程启动和结果搬运有固定开销;任务越重越接近核数倍。
隔离与序列化开销
- 进程之间不共享内存:传给
pool.map的参数和返回值都要 pickle 序列化、走管道、再反序列化。传大数组时这笔搬运费可能吃掉并行收益——尽量传路径、索引这类「小引用」,别传数据本体。 - 只有模块顶层的具名函数能被 pickle:把 lambda 传给
pool.map,直接报PicklingError: Can't pickle <function <lambda>...>。
if __name__ == "__main__" 为什么必须
- 子进程要重新导入你的主模块才能找到目标函数。建池代码若没包进
if __name__ == "__main__":,子进程导入时又执行一遍建池,无限套娃——3.14 下抛 RuntimeError:「An attempt has been made to start a new process before the current process has finished its bootstrapping phase」。 - 平台差异:Windows/macOS 默认 spawn,一直必须保护;老版本 Linux 默认 fork(不重新导入,缺保护也常能跑——显式
set_start_method("fork")后无保护确实正常)。但 3.14 起 Linux 默认改为 forkserver(get_start_method()返回 forkserver),同样重新导入——现在三大平台缺保护都会炸,这行样板别省。
import multiprocessing as mp
def heavy(n): # 必须是模块顶层函数(可 pickle)
s = 0
for i in range(n):
s += i * i
return s
if __name__ == "__main__": # 子进程会重新导入本模块——必须保护
N = 20_000_000
with mp.Pool(4) as pool: # 4 个工作进程
results = pool.map(heavy, [N] * 4)
print(len(results), "done")
# 4 核串行约 2.7s → Pool(4) 约 0.95s(接近 3 倍).py 文件、从命令行跑最稳(09 章讲过 __main__ 的机制)。asyncio 用一个线程跑成千上万个「协程」:函数标上 async def 变协程,await 表示「这里要等,先让别人跑」。没有线程切换开销,也基本没有竞态锁——代价是调用链全程都得用 async 写。
三个关键字
async def定义协程函数:调用它不会执行,只得到一个协程对象,必须 await 它或交给事件循环。await只能写在 async def 里:在等待点交出控制权,事件循环转头去跑别的协程。asyncio.run(main())建事件循环、跑到 main 结束——整个程序入口调一次就够。
gather:并发的第一个工具
- 三个各等 1.0/0.8/0.5 秒的模拟请求,
await asyncio.gather(...)并发执行:总耗时 1.00 秒——约等于最长的那一个,而不是三者之和 2.3 秒。 - gather 返回值的顺序与传入顺序一致,和谁先完成无关——不用自己对号入座。
- 想让某个协程「先跑着,稍后再收结果」,用
asyncio.create_task把它包成任务立即调度;gather 内部对传入的协程做的也是同一件事。
asyncio.sleep 在真实代码里是什么
例子里的 asyncio.sleep 对应真实世界的异步 IO 库:httpx 的 AsyncClient、aiohttp、异步数据库驱动。选型时最实际的问题是「我依赖的库有没有 async 版」——有,才能全链路异步;16 章的 FastAPI 整个框架就构建在 asyncio 上。
import asyncio, time
async def fetch(name, delay):
await asyncio.sleep(delay) # 模拟等网络:让出事件循环
return f"{name} done"
async def main():
t0 = time.perf_counter()
results = await asyncio.gather( # 三个并发跑
fetch("a", 1.0), fetch("b", 0.8), fetch("c", 0.5),
)
print(results) # 顺序与传入一致
print(time.perf_counter() - t0) # 约 1.0s,不是 2.3s
asyncio.run(main()) # 程序入口只调一次results = fetch("a") 拿到的是协程对象,函数体根本没执行,程序退出时报 RuntimeWarning「coroutine 'fetch' was never awaited」。看到这条警告就是哪里丢了 await——它是警告不是异常,逻辑已经悄悄错了。asyncio.run,再一层层把调用链改成 async——普通函数里写不了 await,「半吊子异步」编不过也跑不对。起手新项目则直接全 async。事件循环是单线程的:所有协程轮流在这一个线程上跑。任何一个协程调了阻塞函数——time.sleep、requests.get、一段重计算——整个循环就停下来陪它,并发瞬间归零。
一行 time.sleep 毁掉整场并发
- 三个协程各调阻塞的
time.sleep(1),用 gather 并发:总耗时 3.00 秒——完全退化成串行。事件循环卡在第一个 sleep 里,其它协程排不上队。 - 对照上一卡:换成
await asyncio.sleep(1)总耗时 1 秒。同样是「睡一秒」,差别只在等的时候让不让路。
逃生舱:asyncio.to_thread
- 绕不开的阻塞调用(依赖库没有 async 版),用
await asyncio.to_thread(func, 参数)丢进线程池,事件循环继续跑别的:三个阻塞 sleep 用 to_thread 包住,总耗时回到 1.00 秒。 - 重计算别用 to_thread——线程仍受 GIL 限制。CPU 活交给进程池:
loop.run_in_executor(ProcessPoolExecutor(), func)。
怎么发现阻塞混进来了
- 症状:并发数上去了、吞吐没上去,别的请求出现莫名的延迟尖刺。
- 调试模式直接点名:
asyncio.run(main(), debug=True)会把执行过久的任务打出来——卡了 0.3 秒的协程被打上「Executing <Task ...> took 0.300 seconds」。
import asyncio, time
async def bad(name):
time.sleep(1) # 阻塞调用:整个事件循环停下来陪它睡
return name
async def good(name):
await asyncio.to_thread(time.sleep, 1) # 丢进线程池,循环继续跑
return name
async def main():
t0 = time.perf_counter()
await asyncio.gather(bad("a"), bad("b"), bad("c"))
print("阻塞版:", time.perf_counter() - t0) # 约 3.0s,串行了
t0 = time.perf_counter()
await asyncio.gather(good("a"), good("b"), good("c"))
print("to_thread:", time.perf_counter() - t0) # 约 1.0s
asyncio.run(main())to_thread 不是加速器——它只是把阻塞挪去别的线程、让事件循环别被卡住;线程照样受 GIL 管(本章第 2 卡),CPU 重活丢进 to_thread 依旧占不满多核,得用进程池。把它当万能药,吞吐不会涨,只是延迟不再互相传染。async def/await 风格:requests 阻塞、httpx 有 AsyncClient;time.sleep 阻塞、asyncio.sleep 异步。拿不准就开 debug=True 跑一轮,让事件循环自己举报。本章 asyncio 入门那张卡用的 gather,有个长期被忽视的问题:其中一个任务失败时,其余任务并不会停,它们在后台继续跑、继续占资源,而你已经拿着异常走了。asyncio.TaskGroup(3.11+)把这件事修正成「一荣俱荣、一损俱损」——这就是结构化并发。
同一组任务,两种收场(3.14.6)
- 实验设定:三个任务,一个 0.05 秒后抛
ValueError,另两个要跑 0.3 秒并在被取消时留下标记。 gather:抛出ValueError: boom;再等一会儿去看那两个任务的标记,是['A 跑完了', 'B 跑完了']——它们照常跑完了,没人告诉它们出事了。TaskGroup:抛出的是ExceptionGroup(子异常['ValueError']);那两个任务的标记是['B 被取消', 'A 被取消']——兄弟任务被自动取消了。- 这就是全部区别,也是它值得替换掉
gather的唯一理由:失败时不留下失控的后台任务。
它抛的是异常组,所以要用 except*
TaskGroup里可能同时失败多个任务,因此它总是抛ExceptionGroup(哪怕只挂了一个)。接它必须用except* ValueError这类分组捕获——08 章那张卡讲的就是这套语法。- 直接照搬老写法
except ValueError会抓不到(08 章有)。这是从gather迁到TaskGroup时最常见的疏漏:并发行为改对了,错误处理却全漏了。 async with块结束时会等待组内全部任务,所以块外的代码不必再自己await——任务的生命周期被限制在这个块里,和with管文件是同一种思路。
asyncio.timeout:给一整段加超时
async with asyncio.timeout(0.1):给块内所有 await 设一个总预算,超时就取消并抛TimeoutError(确认捕获到的就是TimeoutError)。- 比老的
asyncio.wait_for(coro, t)好用在它包的是一段代码而不是一个协程——一次请求里有三步 await,用wait_for得包三次并自己算剩余预算,用timeout就是一层async with。 - 一个容易记混的点:3.11 起
asyncio.TimeoutError已经就是内置的TimeoutError——TimeoutError is asyncio.TimeoutError为True。新代码直接写内置的那个即可。
import asyncio
# ✗ gather:boom 失败后,A/B 仍然跑完
try:
await asyncio.gather(boom(), slow("A"), slow("B"))
except ValueError as e:
... # A、B 还在后台跑
# ✓ TaskGroup:boom 失败 → A/B 自动被取消
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(boom())
tg.create_task(slow("A"))
tg.create_task(slow("B"))
except* ValueError as eg: # 注意是 except*
... # 抛的是 ExceptionGroup
# 给一整段加总超时(3.11+)
try:
async with asyncio.timeout(5):
r = await fetch_user()
p = await fetch_posts(r) # 两步共用 5 秒预算
except TimeoutError: # 就是内置的那个
...except 要一起改成 except*,否则错误处理静默失效(08 章有);② 别在 TaskGroup 块外面留 create_task 的引用去手动 await——块结束时已经等过一遍了,再 await 一次拿到的是已完成任务,容易让人误以为「这里还会等」。任务的生命周期就该止于这个块,这正是结构化并发的意思。TaskGroup,别再用 gather。gather 还剩一个正当用途:return_exceptions=True 时「我就是要每个任务各自的结果或异常,一个都别取消」——比如批量探测一堆 URL 的可达性。除此之外都该是 TaskGroup。FastAPI:Web API 实战
类型标注驱动的路由、校验与文档——Python Web 的现代路线。
FastAPI 的核心思路一句话:把 12 章的类型标注变成生产力——参数怎么转换、怎么校验、文档长什么样,全部从函数签名推导出来,一行不用重复写。
三行起步
app = FastAPI()建应用;装饰器@app.get("/items/{item_id}")把 URL 绑到函数(装饰器机制见 05);函数返回 dict / list / pydantic 模型,自动序列化成 JSON 并设好响应头。- 路径里的
{item_id}是占位符,函数的同名参数自动接住它。
类型标注 = 转换 + 校验
- URL 里一切都是字符串。声明
item_id: int后,FastAPI 自动把"5"转成5——GET /items/5返回{"item_id": 5, "double": 10}。 - 转不动就拦下:
GET /items/abc返回 422,响应体是{"detail": [{"type": "int_parsing", "loc": ["path", "item_id"], "msg": "Input should be a valid integer, unable to parse string as an integer", "input": "abc"}]}。 - 关键在于:非法请求根本进不了你的函数体。函数里拿到的
item_id保证是 int,防御代码不用写。
免费的交互文档
- 启动后打开
/docs是 Swagger UI(返回 200 的 HTML 页面),能直接在浏览器里填参数发请求;/openapi.json是机器可读的 OpenAPI 规范。 - 文档从代码生成,永远不会和实现脱节——这是「声明式」路线的最大红利。
# pip install "fastapi[standard]" # 已含 uvicorn
from fastapi import FastAPI
app = FastAPI()
@app.get("/items/{item_id}")
def read_item(item_id: int): # 类型标注 = 转换 + 校验
return {"item_id": item_id, "double": item_id * 2}
# 启动:uvicorn main:app --reload
# GET /items/5 → 200("5" 已转成 int)
# GET /items/abc → 422(进不了函数体)
# 文档:http://127.0.0.1:8000/docsdef 还是 async def?没用异步库就写普通 def——FastAPI 会把它丢进线程池执行,不会卡事件循环。反过来,在 async def 里调 time.sleep()、requests 这类阻塞函数,会把单线程的事件循环整个卡住,一个请求睡觉全服务陪等(异步机制见 15)。--reload,改完代码自动重启;调接口先开 /docs 点「Try it out」,比 curl 顺手得多。路径里没占位的函数参数自动成为查询参数——有没有默认值决定必填与否,Annotated[..., Query(...)] 把约束声明在签名里,越界请求由框架替你拦下。
必填、可选与默认值
- 不给默认值就是必填:漏传时返回 422,
type是missing、loc是["query", "q"]。 q: str | None = None是「可以不传」的标准写法(str | None语法见 12);size: int = 20则是「不传用 20」。裸请求/search返回全默认值{"q": null, "page": 1, "size": 20}。
Query 约束:把规则写进签名
Annotated[int, Query(ge=1)](大于等于)、gt/le(大于 / 小于等于)、max_length(字符串长度)。size=500(约束le=100)→ 422,type为less_than_equal、msg为「Input should be less than or equal to 100」、ctx里带{"le": 100};超长字符串 →string_too_long;page=0(约束ge=1)→greater_than_equal。- 这些约束同时会出现在
/docs里——一份声明,校验和文档两处生效。
转换不止 int
bool参数很宽容:?on=yes、?on=1、?on=true都转成True;乱写?on=maybe则是 422bool_parsing。- 同请求多个参数越界时,
detail数组把每条错误都列出来(page=0&size=999返回两条),一次改完不用来回试。
from typing import Annotated
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/search")
def search(
q: Annotated[str | None, Query(max_length=10)] = None,
page: Annotated[int, Query(ge=1)] = 1,
size: Annotated[int, Query(gt=0, le=100)] = 20,
):
return {"q": q, "page": page, "size": size}
# /search → 全默认值
# /search?size=500 → 422 less_than_equal
# /search?page=0&size=999 → 422,两条错误一起列出detail 是数组:多个参数同时非法时每条错误都在里面(两个越界参数返回两条)。前端若写死只读 detail[0],用户改完第一个错还会再收到一次 422——展示错误时遍历整个数组。PageNo = Annotated[int, Query(ge=1)],多个路由共享同一份校验规则,改一处全生效。POST 的 JSON 请求体用 pydantic 的 BaseModel 声明:解析、校验、类型转换、文档一次搞定,进到函数手里的已经是干净的模型实例。
声明即接口
- 写一个类继承
BaseModel(类语法见 06),字段 = 名字 + 类型标注,可带默认值:tags: list[str] = [](pydantic 对每个实例深拷贝默认值,没有 18 章可变默认参数的坑)。 - 路由参数标注
book: Book,FastAPI 自动从请求体读 JSON、反序列化、逐字段校验。合法请求直接拿到book.title、book.price可用的对象。
422 的结构,值得记牢
detail数组每项四个字段:loc(出错位置,如["body", "price"])、type(机器可读错误码)、msg(人类可读描述)、input(原始输入)。- 缺
price字段 →type为missing、msg为「Field required」;price传"abc"→float_parsing;请求体根本不是 JSON →json_invalid。前端拿loc就能把错误精确标到表单字段上。
宽松模式与严格模式
- pydantic v2 默认宽松(lax):
price传字符串"9.5"会被转成9.5收下;要拒绝就Field(strict=True),同样输入变成 422float_type。 - 请求体里多余的字段默认忽略、不报错(多传
hacker字段照常 200)——契约外的输入静默丢弃。
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Book(BaseModel):
title: str # 必填
price: float # 必填,"9.5" 也会被收下
tags: list[str] = [] # 可选,默认空列表
@app.post("/books")
def create_book(book: Book): # 请求体自动解析成 Book
return {"saved": book.title, "total": book.price * 1.1}
# 缺 price → 422 {"type":"missing","loc":["body","price"]}
# price 传 "abc" → 422 {"type":"float_parsing", ...}@validator、.dict()、class Config。v2 分别改成了 @field_validator(还要加 @classmethod)、.model_dump()、model_config = ConfigDict(...)——照抄老教程轻则弃用警告,重则直接报错。看到这几个旧名字就知道教程过时了。book 是对象不是 dict:属性访问 book.title,要转 dict 用 book.model_dump()——这是 v2 写法,v1 的 .dict() 已废弃。好几个路由都要解析分页参数、验证登录、拿数据库连接?把这段逻辑写成一个函数,用 Depends 声明「我需要它」,FastAPI 在每次请求时替你调用并把结果注入进来。
依赖就是普通函数
def pagination(page: int = 1, size: int = 20)返回一个 dict;路由参数写p: Annotated[dict, Depends(pagination)]就拿到它。/articles?page=3&size=10注入{"offset": 20, "limit": 10}。- 依赖自己的参数(
page/size)同样从请求里解析、同样走校验——把注解抽成别名Page = Annotated[dict, Depends(pagination)],多个路由一行复用。
鉴权:在依赖里短路
- 依赖里
raise HTTPException(401),请求直接被拦下,路由函数根本不执行。不带 token 访问返回401 {"detail": "请先登录"},带对 token 才进函数。 - 鉴权、权限、限流这类「进门检查」都适合放依赖里,路由函数保持只写业务。
yield 依赖:带清理的资源
- 依赖函数里用
yield(机制见 07):yield之前是准备、之后是收尾,行为像上下文管理器。执行顺序为open → use → close——响应处理完后清理必然执行。数据库会话、文件句柄就该这么发。 - 清理逻辑包在
try/finally里更稳:路由抛异常时finally也会走到(异常语义见 08)。
同一请求内有缓存
- 同一个依赖被多个参数声明时,一次请求只调用一次,结果复用——两个参数共享同一依赖,计数器只加了 1。
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Header
app = FastAPI()
def pagination(page: int = 1, size: int = 20):
return {"offset": (page - 1) * size, "limit": size}
Page = Annotated[dict, Depends(pagination)] # 别名,到处复用
@app.get("/articles")
def list_articles(p: Page):
return {"pagination": p}
def get_conn(): # yield 依赖:像上下文管理器
conn = open_conn() # yield 前:准备资源
try:
yield conn # 注入给路由
finally:
conn.close() # 响应后:必然清理
def current_user(x_token: Annotated[str | None, Header()] = None):
if x_token != "secret":
raise HTTPException(401, "请先登录") # 路由不会执行
return {"user": "stanley"}Depends(fn, use_cache=False)——否则你以为调了两次,其实第二次拿的是缓存。app.dependency_overrides[get_conn] = fake_conn 一行把真依赖换成假实现,测完清掉即可(配合 13 章的 pytest fixture 用最顺)。路由全堆在 main.py 只能撑到第十个接口。APIRouter 把路由按业务拆成模块,HTTPException 统一错误出口,response_model 决定响应长什么样——包括把不该出去的字段拦下来。
APIRouter:按业务拆文件
- 子模块里建
router = APIRouter(prefix="/users", tags=["users"]),路由用@router.get注册;主文件app.include_router(router)一行挂载(模块拆分见 09)。 prefix自动拼到每条路径前,tags让/docs里按业务分组——users.py、orders.py 各管各的,互不打架。
HTTPException:业务错误的标准出口
raise HTTPException(status_code=404, detail=f"用户 {user_id} 不存在")——响应正是404 {"detail": "用户 999 不存在"},detail 里的中文原样到达客户端。- 还能带自定义响应头:
headers={"x-why": ...}(客户端能收到)。它是异常(08),在依赖、子函数里抛都会被框架接住转成响应。
response_model:出口的守门员
- 数据库记录常带敏感字段。声明
response_model=UserOut(只列 id/name/email)后,返回完整记录,响应里password_hash被剔除——过滤发生在序列化层,业务代码不用手动挑字段。 - 它同时出现在
/docs的响应示例里,前端照着契约开发。
# routers/users.py
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/users", tags=["users"])
class UserOut(BaseModel): # 响应契约:没有 password_hash
id: int
name: str
email: str
@router.get("/{user_id}", response_model=UserOut)
def get_user(user_id: int):
if user_id not in DB:
raise HTTPException(404, f"用户 {user_id} 不存在")
return DB[user_id] # 多余字段被 response_model 剔除
# main.py
# from routers.users import router
# app.include_router(router) # 一行挂载response_model 会校验你返回的数据:返回的 dict 缺了模型必填字段时抛 ResponseValidationError,客户端收到的是 500 而不是 422——422 留给「客户端数据不合法」,500 意味着是你自己的 bug,看服务端日志而不是怪调用方。tags 顺手让 /docs 按业务分组,接口多了也好找。Python 的界面:GUI、TUI 与数据应用
「Python 能做界面吗」的完整回答:桌面 GUI、终端 TUI、几十行出网页的数据应用,以及什么时候该退回 16 章的 Web 方案。先按「界面给谁用」选路,再谈具体库——这一步选错,后面所有功夫都白费。
Python 的强项从来不是桌面 GUI——它没有官方的现代 UI 框架,生态是拼出来的。但「给这堆脚本弄个界面」的需求太常见,所以真正的问题不是「用哪个库」,而是这个界面给谁用。
四条路,各自的用户是谁
| 界面给谁用 | 走哪条路 | 代价 |
|---|---|---|
| 自己和同事,内容以数据 / 模型为主 | Streamlit / Gradio | 交互能力有限,多用户与鉴权很勉强 |
| 命令行工具,想要更好的交互 | Textual / Rich(TUI) | 只活在终端里,不适合非技术用户 |
| 必须是离线桌面程序 | PySide6 / PyQt6,最简则 tkinter | 打包体积与逐平台构建 |
| 外部用户,要权限与多人协作 | FastAPI(16)+ 前端框架 | 要真的学前端,见 Web UI / React 页 |
两个问题就能定下来
- 先问「能不能不做桌面程序」。绝大多数内部工具用 Streamlit 或 TUI 就够,省掉的是打包、签名、自动更新、跨平台构建这一整套。
- 再问「要不要给没装 Python 的人用」。要,才需要考虑打包;不要,一句
uv run(14)就能分发。
打包才是 Python GUI 的隐性大头
- 桌面路线真正的成本不在写界面,在把解释器和依赖塞进一个可执行文件:PyInstaller(当前 6.21)最常用;Nuitka(当前 4.1)走编译路线,体积与启动更好但构建更复杂。
- 一个 Qt 小工具打出来动辄几十到上百 MB,而且要在每个目标平台各构建一次——没有交叉编译这回事。
- 杀毒软件误报、macOS 签名公证、冷启动慢,都会在这一步冒出来。这些账,cpp 14 章那些原生方案不必付。
# ── 四条路的「起步成本」对照 ──────────────────
# 数据应用:装 1 个包,写 4 行,一条命令起服务
pip install streamlit && streamlit run app.py
# 终端 TUI:装 1 个包,能力接近 GUI,还能进 CI 测
pip install textual
# 桌面 GUI:标准库自带,零安装
python -c "import tkinter; print(tkinter.TkVersion)" # 8.6
# 桌面 GUI(能力全开):装绑定只是开始
pip install PySide6 # 几百 MB 级别的轮子
pip install pyinstaller # 分发时才真正开始头疼
# 给外部用户:回 16 章
pip install fastapi uvicorntkinter 是标准库的一部分——不用 pip 装任何东西,import tkinter 就能弹出窗口。它的价值不在好看,在零安装:内部小工具、给同事的临时界面,用它可以完全不引入依赖。
你的 Python 里多半已经有它了
- Python 3.14.6(Windows 官方安装包):
tkinter.TkVersion得 8.6,root.tk.call("info", "patchlevel")得 8.6.15,ttk.Style().theme_use()得 vista。 - 例外是 Linux:发行版常把它拆成单独的包(Debian/Ubuntu 上是
python3-tk)。import tkinter报 ModuleNotFoundError 时先装这个,不是 pip 的事。
三步就能跑
- 建根窗口 → 放控件 → 进事件循环
mainloop()。mainloop()会阻塞在那里,直到窗口关闭。 - 布局三选一:
pack()(顺序堆叠,最快)、grid()(表格,最常用)、place()(绝对坐标,少用)。 - 控件的值用
StringVar/DoubleVar这类「变量对象」双向绑定,比手动 get/set 干净得多——右侧代码里f.set(...)一句,界面上的 Label 就跟着变了。
一定要用 ttk
from tkinter import ttk:ttk 是主题化控件集,Button / Entry / Combobox 会跟随系统外观,比裸 tk 控件好看一个档次,用法几乎一致。- 新代码一律优先
ttk.X,只有 ttk 没有的(Text、Canvas)才回退到tk.X。
天花板在哪
- Tk 长期停在 8.6——本机 3.14.6 自带的仍是 8.6.15。控件集老旧、没有现代动画、HiDPI 要手动处理。
- 没有像样的表格控件(
ttk.Treeview勉强顶替),富文本与图表都得自己造或引第三方。 - 判断线:界面超过「十来个控件 + 一个列表」,或者要交给外部用户,就该换 Qt 了。
import tkinter as tk
from tkinter import ttk
def convert():
try:
f.set(f"{c.get() * 9 / 5 + 32:.1f} °F")
except tk.TclError: # 输入框里不是数字
f.set("请输入数字")
root = tk.Tk()
root.title("温度换算")
c = tk.DoubleVar(value=25) # 变量对象:和控件双向绑定
f = tk.StringVar()
ttk.Label(root, text="摄氏度").grid(row=0, column=0, padx=8, pady=8)
ttk.Entry(root, textvariable=c).grid(row=0, column=1)
ttk.Button(root, text="换算", command=convert).grid(
row=1, column=0, columnspan=2, pady=8)
ttk.Label(root, textvariable=f).grid(row=2, column=0, columnspan=2)
convert() # 25 → 77.0 °F
root.mainloop() # 阻塞,直到窗口关闭pack() 和 grid(),程序会无提示地卡住:两个布局管理器互相等对方让出尺寸,不报错、不退出,窗口就是不出来。一个容器只用一种布局管理器——这是 tkinter 最经典的「查不出原因」故障。mainloop() 是阻塞的,耗时任务直接写在按钮回调里会让窗口「假死」。把它丢到线程里(15),再用 root.after(ms, fn) 回到主线程更新界面——所有 GUI 库都有这条规矩:只能在主线程碰控件。要做「像样的桌面程序」,Python 这边的答案基本只有 Qt。它有两套 Python 绑定,API 几乎一样,真正的区别在许可证——这一条往往比技术选型更早决定结果。
两套绑定,先分清
| PySide6(当前 6.11.1) | PyQt6(当前 6.11.0) | |
|---|---|---|
| 出品方 | Qt 公司官方 | Riverbank Computing |
| 许可 | LGPL / 商业 | GPL / 商业 |
| 闭源商用 | 可以(动态链接并遵守 LGPL 义务) | 不可以,需购买商业授权 |
| API | 类名、信号槽写法基本一致 | 差别只在 import 名与少数枚举 / 重命名 |
结论很直白:没有特别理由就用 PySide6——官方出品、LGPL、版本与 C++ 侧的 Qt 对齐。
它给你的是什么
- 完整控件集(表格、树、停靠面板、富文本、图表)、跨平台一致外观,还能用 Qt Designer 拖界面再由
pyside6-uic转成 Python。 - 信号槽:
spin.valueChanged.connect(handler)——和 cpp 14 章讲的是同一套东西,那边关于保留模式、对象树与生命周期的讨论在这里同样成立。 - 隐性优势:遇到问题时,C++ 的 Qt 文档与答案基本可以直接翻译过来用,这是它生态深度的来源。
代价
- 体积:PySide6 一装就是几百 MB 级别的轮子(Qt 运行时都在里面)。
- 打包:PyInstaller 打出来动辄几十上百 MB,且必须在每个目标平台各构建一次。
- 学习曲线:Qt 的对象模型、布局系统、事件机制自成一套,不是「学几个控件」的量级。上手前先确认这个投入值得。
from PySide6.QtWidgets import (QApplication, QWidget,
QVBoxLayout, QLabel, QSpinBox)
app = QApplication([])
w = QWidget()
w.setWindowTitle("温度换算")
box = QVBoxLayout(w)
spin = QSpinBox()
spin.setRange(-40, 100)
spin.setValue(25)
out = QLabel()
def convert(c: int) -> None:
out.setText(f"{c * 9 / 5 + 32:.1f} °F")
spin.valueChanged.connect(convert) # 信号 → 槽
convert(spin.value())
box.addWidget(spin)
box.addWidget(out)
w.show()
app.exec() # 事件循环
# PyQt6 版:把 PySide6 换成 PyQt6,改几处重命名即可pyqtSignal ↔ Signal 这类重命名。真被许可证卡住时,不必推倒重来。如果界面的用户是你自己、同事,或者「会用命令行的人」,那完全不必碰桌面 GUI。这条路的共同点是不用打包、不用学前端、改代码即生效——大多数内部工具停在这里就够了。
Textual:终端里的现代 UI
- Textual(当前 8.2.8)用 CSS 式的布局与样式在终端里搭界面,控件、滚动、鼠标、异步一应俱全,跑在 SSH 上也一样。
- 状态用
reactive声明,值一变自动触发watch_xxx重绘——心智模型接近前端框架,而不是「自己刷屏」。 - 它能测:
async with app.run_test() as pilot:可以在没有终端的环境里驱动整个应用(点按钮、按键、断言状态),CI 里跑得起来。这是 TUI 相对桌面 GUI 一个实打实的优势(13)。 - 只想把脚本输出弄好看(表格、进度条、语法高亮、彩色 traceback),用它的底座 Rich(当前 15.0.0)就够,不必上完整 App。
Streamlit / Gradio:几行 Python 换一个网页
- 右侧那 4 行脚本 +
streamlit run app.py,浏览器打开就是一个完整应用(本地返回 HTTP 200);改文件保存即热更新。当前 1.60.0。 - 心智模型要先说清:每次交互 Streamlit 会从头重跑整个脚本。这既是它简单的来源,也是它全部坑的来源——耗时计算要
@st.cache_data缓存,跨次要保留的状态要放st.session_state,否则每点一下全部重来。 - Gradio(当前 6.20.0)定位更窄:给模型做 demo,输入输出组件和 Hugging Face 生态贴得更紧。做数据看板选 Streamlit,做模型试玩选 Gradio。
边界在哪
- 没有真正的路由、鉴权与权限模型,并发模型也简单。
- 一旦出现「不同角色看不同页面」「要对公网提供服务」「要和别的系统集成」,就该回到 16 章的 FastAPI + 真前端。
# ── Textual:状态驱动的 TUI ──────────────────
from textual.app import App, ComposeResult
from textual.reactive import reactive
from textual.widgets import Button, Static
class Counter(App):
count = reactive(0) # 值一变就触发下面的 watch
def compose(self) -> ComposeResult:
yield Static("0", id="n")
yield Button("+1", id="inc")
def on_button_pressed(self) -> None:
self.count += 1
def watch_count(self, value: int) -> None:
self.query_one("#n", Static).update(str(value))
# 无终端也能测(13):
# async with app.run_test() as pilot:
# await pilot.click("#inc")
# ── Streamlit:这 4 行就是整个应用 ───────────
import streamlit as st
st.title("温度换算")
c = st.slider("摄氏度", -40, 100, 25)
st.metric("华氏度", f"{c * 9 / 5 + 32:.1f} °F")await pilot.click(...) 会被当成双击合并——点三次只加了两次;两次点击之间 await pilot.pause(0.4) 拉开间隔才逐次生效。写 TUI 测试时遇到「点了 n 次只生效 n-1 次」,先怀疑这个,别去翻业务逻辑。uv run(14)或者你起一个内网地址即可,桌面路线里签名、公证、自动更新那一整套全省了。常见陷阱
可变默认参数、闭包晚绑定、浅拷贝、is 与 ==——不知道就会踩。
def f(item, acc=[]) 里的空列表不是每次调用新建的——它在 def 执行那一刻创建一次,从此挂在函数对象上,所有不传 acc 的调用共用同一个列表。
现象:函数有了「记忆」
append_to(1)返回[1],再调append_to(2)返回[1, 2]——上次的结果还在。- 打印
append_to.__defaults__得([1, 2],):那个列表就躺在函数对象的属性里,被一次次复用。 - 最隐蔽的是它时对时错:只要调用方每次都显式传
acc,坑就一直不发作——直到某天有人省略了参数,数据开始神秘串台。
原因:def 是运行时执行的语句
def不是编译期声明,而是一条会被执行的语句(05):执行到它时,默认值表达式求值一次,结果存进函数对象。之后每次调用只是取出同一个对象。- 回扣本页主线「一切皆对象」:函数是对象,默认值是它身上的数据;列表可变,于是每次
append都改的是同一个。 - 不可变默认值(数字、字符串、
None、元组)没有这个问题——改不动,自然共享无害。
解法:None 哨兵
- 默认值写
None,函数体内if acc is None: acc = []——列表在调用时创建,每次全新。fixed(1)、fixed(2)分别返回[1]、[2],互不干扰。 - 判
None用is,理由见本章「is 与 == 与 None」。类型标注相应写成acc: list[int] | None = None(12),意图一目了然。
def append_to(item, acc=[]): # [] 在 def 时创建一次
acc.append(item)
return acc
append_to(1) # [1]
append_to(2) # [1, 2] ←上次的还在!
append_to.__defaults__ # ([1, 2],) 列表挂在函数对象上
def fixed(item, acc=None): # None 哨兵
if acc is None:
acc = [] # 调用时才创建,每次全新
acc.append(item)
return acc
fixed(1) # [1]
fixed(2) # [2] 互不干扰def log(msg, when=datetime.now()) 同样如此——now() 在 def 时执行一次,时间戳永远停在模块导入那一刻。判据:凡是「调用时才该算」的表达式,都不能放默认值位置,一律用 None 哨兵。B006 规则(14)直接标出可变默认值,开着它这坑基本绝迹。循环里造出来的闭包记住的不是「当时的值」,而是变量本身——等你调用它时循环早已结束,所有闭包看到的都是最后一个值。
复现:全是 2
fns = [lambda: i for i in range(3)],逐个调用得[2, 2, 2],不是期望的[0, 1, 2]。- 原因是名字绑定(02):闭包捕获的是变量
i这个名字,不是某一刻的快照。调用发生在循环结束后,i停在 2,三个闭包读的是同一个i。 - 不限于推导式:普通
for循环里写def或lambda同样如此——for的循环变量在循环结束后依然存在,且停在最后一个值。
解法:把「现值」显式塞进去
- 参数默认值:
lambda i=i: i——默认值在def时求值(上一张卡的机制,这回反过来救场),得到[0, 1, 2]。 - 或
functools.partial:[partial(times, n) for n in range(3)],[0, 10, 20]——比默认值写法意图更明白。 - 两种解法是同一味药:把求值时机从「调用闭包时」提前到「创建闭包时」。晚绑定的对症药就是早求值。
生成器里同样的坑,而且更隐蔽
- 循环里造生成器表达式,body 引用的外层变量同样晚绑定:循环
n取 1、2、3 造三个(x * n for x in [10]),最后消费时全部产出[30]。 - 生成器是惰性的(07):创建时一行代码都不跑,消费得越晚,读到的
n离你以为的越远——坑被惰性放大了。
fns = [lambda: i for i in range(3)]
[f() for f in fns] # [2, 2, 2] ←不是 [0, 1, 2]
# 解法一:默认值在 def 时求值,锁住现值
fns = [lambda i=i: i for i in range(3)]
[f() for f in fns] # [0, 1, 2]
# 解法二:partial 显式传参
from functools import partial
def times(n, x): return x * n
fns = [partial(times, n) for n in range(3)]
[f(10) for f in fns] # [0, 10, 20]
# 生成器同坑:三个生成器最后全部产出 [30]
gens = []
for n in [1, 2, 3]:
gens.append(x * n for x in [10])
[list(g) for g in gens] # [[30], [30], [30]]i=i 默认值或 partial 显式传进去,让求值发生在创建那一刻。「明明拷贝了,里面怎么还连着」是 Python 最隐蔽的坑之一:赋值根本不拷贝,切片和 copy 只拷第一层,只有 deepcopy 才把嵌套结构整个复制。
四层对照
基准 a = [[1, 2], [3, 4]],之后外层 a.append([5, 6])、内层 a[0][0] = 99,看四种「拷贝」各受多少影响:
| 写法 | 外层追加可见? | 内层修改可见? | 本质 |
|---|---|---|---|
b = a | 可见 | 可见 | 不是拷贝,是同一对象的新名字 |
b = a[:] | 不可见 | 可见 | 浅拷贝:新外层,内层共享 |
copy.copy(a) | 不可见 | 可见 | 同上,list(a)、dict.copy() 同族 |
copy.deepcopy(a) | 不可见 | 不可见 | 递归复制,完全独立 |
为什么:容器存的是引用
- list 的每个槽位存的是指向对象的引用(02 名字绑定、04)。浅拷贝新建了外层列表,但每个槽位照抄引用——
sliced[0] is a[0]为True,内层还是同一批对象。 - dict 一样:
b.copy()之后改原字典里的列表值,拷贝里跟着变。嵌套配置最常出问题——「复制一份配置改两个值」结果把原配置也改了。
选哪个:一条判据
- 容器只有一层、元素全是数字/字符串这类不可变对象——浅拷贝就够,还快。
- 嵌套了可变对象、且两份要独立演化——才上
deepcopy(代价是递归复制整棵结构)。 - 顺带一条同根的直觉修正:元组「不可变」只保证外层——元组里装着列表时,列表内容照样能改。「不可变容器装可变元素」和浅拷贝是同一机制的两张脸。
import copy
a = [[1, 2], [3, 4]]
alias = a # 只是新名字
sliced = a[:] # 浅拷贝
shallow = copy.copy(a) # 浅拷贝
deep = copy.deepcopy(a) # 深拷贝
a.append([5, 6]) # 改外层
a[0][0] = 99 # 改内层
alias # [[99, 2], [3, 4], [5, 6]] 全跟着变
sliced # [[99, 2], [3, 4]] 外层独立,内层被穿透
shallow # [[99, 2], [3, 4]] 同上
deep # [[1, 2], [3, 4]] 完全独立
sliced[0] is a[0] # True——内层是同一个对象self.items = items 之后 self.items.sort()——调用方手里的列表也被排序了,因为参数传递传的同样是引用。想隔离就在边界处显式拷一份:self.items = list(items)。a[:] / list(a) / dict.copy() 随便挑;两层以上且要独立改,直接 copy.deepcopy,别赌哪层不会被碰。== 问「值相等吗」,is 问「是同一个对象吗」——日常比较几乎永远用 ==,is 的正当高频用法只有一个:判 None。
语义差:一个比值,一个比身份
x = [1, 2]; y = [1, 2]:x == y为True(值相等),x is y为False(两个独立对象)。==调用__eq__,类可以自定义(06);is比较对象身份,不可重载、永远不会被骗。
为什么判 None 必须用 is
None是单例:整个进程只有这一个,is None既最快也最准,PEP 8 明文规定。== None大多数时候「碰巧也对」,但==可被__eq__任意重载——按 NumPy 文档,数组的==返回逐元素比较的数组而非布尔值,放进if直接报错。is None没有这种意外。
字面量 is:能跑、碰巧对、解释器会警告(3.8 起)
x = 5后写x is 5:能跑且结果是True,但解释器发出SyntaxWarning: "is" with 'int' literal. Did you mean "=="?(字符串字面量同样警告)。- 「碰巧对」的来源是 CPython 的小整数缓存:
int("256") is 256为True、int("257") is 257为False——缓存只覆盖 -5 到 256,这是实现细节不是语言承诺,换个数就出错。 - 同理内容相同的两个字符串
==为True,is可能为False(取决于驻留,不可依赖)。
x = [1, 2]
y = [1, 2]
x == y # True 值相等
x is y # False 两个独立对象
result = None
result is None # 唯一正当高频用法(PEP 8)
# 小整数缓存:实现细节,不是语言承诺
int("256") is 256 # True (-5..256 有缓存)
int("257") is 257 # False 出了缓存区就出错
x = 5
x is 5 # SyntaxWarning:"is" with 'int' literal
# 程序照跑、结果碰巧 True——更迷惑if x == None 的险恶在于它大多数时候结果正确,代码能一直活到 x 变成重载了 __eq__ 的对象(如 NumPy 数组)那天才爆。反直觉的坑「平时能跑」才传播得广——从今天起肌肉记忆换成 is None / is not None。==;问「是不是同一个对象/是不是 None」用 is。自定义哨兵对象(sentinel = object())也用 is 判。0.1 在二进制里是无限循环小数,float 存的只是最接近的近似值——于是 0.1 + 0.2 != 0.3,而 round 的「五」也不一定入。
现场
0.1 + 0.2得0.30000000000000004;0.1 * 3同样是它,都不等于0.3。f"{0.1:.20f}"展开是0.10000000000000000555——平时打印看到的0.1只是「能还原回同一 float 的最短表示」,不代表存的真是 0.1。这不是 Python 的 bug,是 IEEE 754 双精度的天性,所有主流语言一样。
round 是银行家舍入
round(0.5)得0、round(1.5)得2、round(2.5)得2——「五」往偶数靠,不是小学的四舍五入。统计上更公平(一半进一半舍,不产生系统性偏差),但和直觉相反。- 更绕的是
round(2.675, 2)得2.67:这回不怪银行家规则,是 2.675 本身就存成了略小于 2.675 的近似值——两层反直觉会叠加。
要精确怎么办
- 比较:
math.isclose(0.1 + 0.2, 0.3)True——浮点比较一律用容差,别用==。 - 金额:标准库的
decimal.Decimal("0.1"),Decimal("0.1") + Decimal("0.2") == Decimal("0.3")为True。必须从字符串构造:Decimal(0.1)会把 float 已有的误差原样搬进来,显示为0.1000000000000000055511…。 - 更朴素的方案:整数「分」记账(
1999 + 350分),只在展示时除以 100。
0.1 + 0.2 # 0.30000000000000004
0.1 + 0.2 == 0.3 # False
round(0.5), round(1.5), round(2.5) # (0, 2, 2) 五往偶数靠
round(2.675, 2) # 2.67——存储值本就低于中点
import math
math.isclose(0.1 + 0.2, 0.3) # True 容差比较
from decimal import Decimal
Decimal("0.1") + Decimal("0.2") == Decimal("0.3") # True
Decimal(0.1) # 0.1000000000000000055511… 误差被搬进来
# 金额的朴素方案:整数分
price_fen = 1999 + 350 # 2349 分,展示时才 /1000.1 * 3 != 0.3,一连串加减后误差会爬到分位。数据库列用 DECIMAL、Python 里用 Decimal 或整数分,只在展示边界转成 float。sum() 从 3.12 起对 float 使用补偿求和,(3.14)sum([0.1] * 10) == 1.0 已成立;但这救不了单次运算,逐笔比较照样用 math.isclose。从这里到精通:路线图
接下来做什么项目、读什么、怎么自测。
语法看完只是拿到了地图,路要靠项目走出来——挑一个比「刚好会」难半级的项目做完它,比再看十篇教程都有用。
三个递进的练手项目
- ① CLI 小工具:批量重命名照片、按扩展名整理下载目录(
pathlib在 11;argparse查官方文档即可)。产出一个自己每周真会跑的脚本——练标准库手感和「用代码消灭重复劳动」的本能。 - ② 爬虫 + 数据清洗:抓一个公开页面或开放接口,清洗成 CSV/JSON 再做简单统计。网络会超时、页面会改版、数据会缺字段——08 章的异常处理在真实世界里被逼着练熟。
- ③ FastAPI 小服务:把 ② 的数据包成带校验和自动文档的 REST API(16),pytest 覆盖核心路由(13),venv 加锁定依赖(14)。三个做完,「脚本 → 数据 → 服务」的闭环就通了。
读什么
- 官方 Tutorial(docs.python.org,有中文版):系统过一遍,标准库导览别跳过——大部分「要不要装个库」的问题答案是「标准库就有」。
- 《Fluent Python》第二版:数据模型、迭代器、装饰器、异步一网打尽,从「会写」到「写得地道」的最佳一本。
- PEP 8(风格)、PEP 484(类型标注)值得读原文;日常查主题教程认准 Real Python。
这一页怎么用
- 本页不是让你背的,是索引:写代码卡住时按主题跳回对应章——忘了参数种类回 05,报错读不懂回 08,环境坏了回 01,查完继续写。
- 陷阱章(18)值得在每个项目开工前重扫一遍:那五个坑在真实代码里的出场率高得惊人。
# 项目 ① 的骨架:整理下载目录(真的能用)
from pathlib import Path
downloads = Path.home() / "Downloads"
for f in downloads.iterdir():
if f.is_file() and f.suffix:
target = downloads / f.suffix.lstrip(".").lower()
target.mkdir(exist_ok=True)
f.rename(target / f.name)
# 从这里长出去:
# - argparse 加 --dry-run 参数(先看会移动什么)
# - 处理重名冲突(异常处理登场)
# - 写成定时任务,彻底忘掉手动整理下面每一问都对应本页的一章——能不翻书讲清楚才算真过了这一站,卡壳的就是该回头重读的章。
语言模型:反直觉都在这里
- Python 的「变量」是盒子还是名字牌?
b = a之后改a,b什么时候跟着变、什么时候不变?(02、04) - 可变默认参数为什么会「记住」上次调用?
def到底在什么时候执行?(05、17) is和==什么时候结果不同?为什么判None必须用is?(18)- 迭代器耗尽后再
for一次会发生什么?生成器换列表、列表换生成器,各牺牲了什么?(07)
工程与生态:跑起来的那半边
- venv 到底隔离了什么?为什么「跳过 venv 直接全局 pip」迟早出事?(01、14)
- 类型标注运行时强制吗?——
def add(a: int, b: int)传两个字符串照样返回"python",一声不吭。那类型标注的价值在哪、谁来查?(12) - GIL 挡住的是什么、没挡住什么?IO 密集和 CPU 密集分别该选哪种并发?(15)
except该抓多窄?裸except:为什么是坏味道?(08)
及格线
- 每题的合格答案不是「是 / 否」,而是机制层面的解释 + 一个能跑的最小例子。答案说不出口但「感觉懂了」,就是还没懂。
- 全部过关后,回到上一张卡挑项目开工——检查点是入场券,不是终点。
# 三道「写出来才算数」的快测
# 1. 不看书:这段输出什么?为什么?
def f(x, acc=[]):
acc.append(x)
return acc
print(f(1), f(2))
# 2. 类型标注拦得住这行吗?谁能拦住?
def add(a: int, b: int) -> int:
return a + b
print(add("py", "thon"))
# 3. 这两个比较各是什么结果?为什么不一样?
x = [1, 2]; y = [1, 2]
print(x == y, x is y)
# 答案与机制分别在:陷阱章 / 类型章 / 陷阱章