第 11 章 · 元组、推导式与解包
这一章要解决什么:把"建一个空列表 → for → append"三行压成一行,把"交换两个变量"从三行压成一行。 四个关键坑:单元素元组必须带逗号
(1,)、生成器只能消费一次、[... if/else]和[... if]的if位置完全不同、列表不能当字典的键。
本章你需要完成的代码:demos/ch11-comprehension.py
11.1 元组 tuple:不可变的"打包"
元组就是用逗号写在一起、之后不能改的一组值。
point = (3.7, 25.0) # (电压, 温度)
point[0] # 3.7 —— 和列表一样能下标、能切片
point[0] = 4.2
# TypeError: 'tuple' object does not support item assignment ← 改不了list | tuple | |
|---|---|---|
| 能不能改 | ✅ lst[0] = 1、append | ❌ 建好就固定 |
| 能当字典的键吗 | ❌ TypeError: unhashable type: 'list' | ✅ 可以 |
能放进 set 吗 | ❌ | ✅ |
| 语义 | "一批同类数据,还会变" | "一个固定记录 / 一个坐标" |
为什么元组能当键、列表不行:字典和 set 靠哈希找位置,而哈希要求对象不可变 —— 一个能被修改的键会让字典的内部结构失效。所以不可变 = 可哈希 = 能做键:
{(2.5, 0.8): "C001"} # ✅ 用元组做键:容量 2.5 Ah、内阻比 0.8
{[2.5, 0.8]: "C001"} # ❌ TypeError: unhashable type: 'list'
type((1,)) # tuple ← 逗号才是元组的标志
type((1)) # int ← 括号只是"括号",和 (1 + 2) * 3 里的括号一样推论:return a, 返回的是元组,return (a) 返回的是 a 本身 —— 逗号决定它是不是元组,括号只负责分组。什么时候用元组:函数返回多个值(第 10 章)、一个"记录"性质的数据((电压, 温度))、 需要当字典键或放进 set 时。其余情况用列表,可读性更好也更灵活。
11.2 多重赋值与解包
soh, r_int, temp = 0.88, 52.8, 31.5 # 一次赋三个右边先打包成一个元组,左边再按位置拆开 —— 这就是"解包"(unpacking)。 最经典的用途是不用临时变量交换两个值:
a, b = 1, 2
a, b = b, a
print(a, b) # 2 1为什么不用临时变量:等号右边 b, a 会先被求值成元组 (2, 1),然后左边才重新绑定。 右边先生成一个新元组,所以不会互相覆盖。
星号解包:* 用来"收拢剩下的":
caps = [2.50, 2.46, 2.42, 2.38, 2.33, 2.29]
first, *rest = caps # first=2.5 rest=[2.46, 2.42, 2.38, 2.33, 2.29]
head, *mid, tail = caps # head=2.5 mid=[2.46, 2.42, 2.38, 2.33] tail=2.29| 写法 | 结果 |
|---|---|
first, *rest = caps | 第一个 + 剩下的(列表) |
head, *mid, tail = caps | 头 + 中间 + 尾(* 只能出现一次) |
别踩的两条:左边变量个数和右边元素个数必须对得上(否则 ValueError: too many values to unpack),除非用了 *;* 在左边只能有一个。 解包在日常里到处都是:for cid, soh in pairs:、for i, c in enumerate(caps):、 for cid, c in zip(cell_ids, caps): —— 三个写法都在用同一个机制。
11.3 列表推导式:三行变一行
目标:把每个容量换算成 SOH。老写法是"建空列表 → 循环 → append":
sohs_loop = []
for c in caps:
sohs_loop.append(round(c / cap_rated, 4))推导式(comprehension) 把它压成一行: sohs = [round(c / cap_rated, 4) for c in caps],结果完全一样 ([1.0, 0.984, 0.968, 0.952, 0.932, 0.916])。
骨架是固定的,读的顺序也是固定的:
[ 表达式 for 元素 in 可迭代对象 if 条件 ]
# ② 结果长什么样 ① 从哪来 ③ 过滤(可选)带 if:只保留符合条件的(条件在最后):
[c for c in caps if c / cap_rated < 0.95] # [2.33, 2.29]带 if/else:每一项都要产出,只是值不同(if/else 在最前面):
["健康" if c / cap_rated >= 0.85 else "预警" for c in caps]| 想要的效果 | 写法 | if 在哪 |
|---|---|---|
| 过滤(少几个元素) | [f(x) for x in xs if 条件] | 最后 |
| 分档(元素个数不变) | [a if 条件 else b for x in xs] | 最前 |
| 两者都要 | [a if c1 else b for x in xs if c2] | 前 + 后 |
这两个位置搞混是本章最常见的错误,写反了通常直接报 SyntaxError。
11.4 字典推导式与集合推导式
把 [] 换成 {},表达式写成 键: 值,就得到字典推导式(第 09 章用过):
{cid: round(c / cap_rated, 4) for cid, c in zip(cell_ids, caps)}
# {'C001': 1.0, 'C002': 0.984, 'C003': 0.968}只写一个表达式、不写冒号,就是集合推导式(自动去重):
{round(c / cap_rated, 1) for c in caps} # {0.9, 1.0} ← 自动去重| 符号 | 造出什么 | 例子 |
|---|---|---|
[...] | list | [c * 2 for c in caps] |
{k: v ...} | dict | {cid: c for cid, c in zip(ids, caps)} |
{v ...} | set | {round(c, 1) for c in caps} |
(...) | 生成器(下一节) | (c * 2 for c in caps) |
注意 {} 的歧义:{x for x in ...} 是集合推导式(只有一个表达式), {x: y for ...} 是字典推导式。区别就在有没有冒号。
11.5 生成器表达式:懒加载,但只能消费一次
把方括号换成圆括号,就是生成器表达式:
gen = (c / cap_rated for c in caps)
print(gen) # <generator object <genexpr> at 0x...> ← 不是列表!
print(list(gen)) # [1.0, 0.984, 0.968, 0.952, 0.932, 0.916][...] 列表推导式 | (...) 生成器表达式 | |
|---|---|---|
| 什么时候算 | 立刻算出全部 | 用到时才一个个算 |
| 占内存 | 全部元素都在内存里 | 同一时刻只有一个元素 |
| 能重复遍历吗 | ✅ 能反复用 | ❌ 只能消费一次 |
能 len() / 下标吗 | ✅ | ❌ |
"只能消费一次"是什么意思:
gen = (c for c in caps)
sum(gen) # 可以
list(gen) # [] ← 空了!刚才已经走完了所以生成器的典型用法是"直接用掉,不存变量":
mean_soh = sum(c / cap_rated for c in caps) / len(caps) # 0.9587
min_soh = min(c / cap_rated for c in caps)为什么值得用它:当数据是 13967 行而不是 6 个容量时, 列表推导式会把 13967 个结果全部塞进内存,生成器同一时刻只占一个。 sum(...) / max(...) / any(...) 这类"聚合"操作,天生适合直接喂生成器。
11.6 嵌套推导式与"拆平"
拆平一个嵌套列表(把二维变一维):
matrix = [[1, 2], [3, 4, 5]]
[x for row in matrix for x in row] # [1, 2, 3, 4, 5]读法:for row in matrix 在外层,for x in row 在内层 —— 两个 for 的顺序,就是写普通嵌套循环的顺序。
两层带下标的推导(能看懂就行,谨慎使用):
[[f"{r}x{c}" for c in range(1, 3)] for r in range(1, 3)]
# [['1x1', '1x2'], ['2x1', '2x2']]纪律:超过两层就该停手。三层推导式没人能一遍读懂, 拆成"函数 + 普通循环"反而更快(对读的人来说)。
11.7 什么时候不该用推导式
推导式是"从一个可迭代对象造一个新集合"的语法糖,不是万能缩写:
| 情况 | 为什么不该用 | 该用什么 |
|---|---|---|
有副作用(print、写文件、发请求) | 推导式的目的是产出值,副作用藏在里面让人看不出执行了几次 | 普通 for |
需要 try/except | 表达式里没法写异常处理 | 普通 for |
| 超过两层嵌套 | 可读性崩塌 | 函数 + 普通循环 |
| 逻辑长到要换行 | 一行放不下就别硬塞 | 函数 + 普通循环 |
判断标准很简单:如果推导式里出现了"做某件事"(而不是"算出某个值"),就该用 for。
11.8 完整示例与真实输出
运行 cd code && python demos/ch11-comprehension.py,真实输出(节选):
=== ① 元组 tuple ===
point = (3.7, 25.0) 类型: tuple
想改元组 → 'tuple' object does not support item assignment
{(2.5, 0.8): 'C001'} → {(2.5, 0.8): 'C001'} ← 元组能当字典的键
type((1,)) = tuple type((1)) = int ← 括号只是括号
=== ② 多重赋值与解包 ===
交换两个变量不需要临时变量:a, b = b, a 之后 → a=2, b=1
first=2.5 rest=[2.46, 2.42, 2.38, 2.33, 2.29]
head=2.5 mid=[2.46, 2.42, 2.38, 2.33] tail=2.29
=== ③ 列表推导式 ===
for + append : [1.0, 0.984, 0.968, 0.952, 0.932, 0.916]
推导式 : [1.0, 0.984, 0.968, 0.952, 0.932, 0.916] ← 完全等价 ✅
带条件的推导式:[c for c in caps if c / cap_rated < 0.95] → [2.33, 2.29]
=== ④ 其他推导式 ===
字典推导式: {'C001': 1.0, 'C002': 0.984, 'C003': 0.968}
集合推导式: {0.9, 1.0} ← 自动去重
生成器表达式: <generator object <genexpr> at 0x...> ← 括号不是方括号
它不占内存地逐个产出: [1.0, 0.984, 0.968, 0.952, 0.932, 0.916]
求和直接喂进去: 0.9587
=== ⑤ 嵌套与拆平 ===
拆平嵌套列表: [1, 2, 3, 4, 5]常见坑
| 报错 / 现象 | 原因 | 怎么改 |
|---|---|---|
type((1)) 得到 int | 单元素元组必须有逗号 | 写 (1,) |
TypeError: unhashable type: 'list' | 用列表当字典键 / 放进 set | 换成元组或字符串 |
ValueError: too many values to unpack | 左边变量个数和右边对不上 | 减少变量,或用 *rest 收拢 |
SyntaxError: invalid syntax(推导式) | if/else 和 if 的位置写反了 | 过滤放最后,分档放最前 |
第二次 list(gen) 得到 [] | 生成器只能消费一次 | 每次重新生成,或先 list() 存下来 |
TypeError: object of type 'generator' has no len() | 生成器没有长度 | 先 list(),或改用 sum(...) 之类聚合 |
推导式返回值全是 None | 把 append 之类的动作写进了推导式 | 说明你该用普通 for |
本章练习
练习 1:用三种推导式做汇总
给定 cap_rated = 2.5、caps = [2.50, 2.46, 2.42, 2.38, 2.33, 2.29]、 cell_ids = ["C001", ..., "C006"],用推导式完成: ① 把 6 个容量换算成 SOH(保留 4 位,不许写 for + append); ② 生成 {编号: SOH} 的字典;③ 挑出 SOH < 0.95 的容量;④ 找出最低 SOH 和它对应的编号。
期望输出:
SOH 列表: [1.0, 0.984, 0.968, 0.952, 0.932, 0.916]
编号→SOH: {'C001': 1.0, 'C002': 0.984, ...}
SOH < 0.95 的容量: [2.33, 2.29]
最低 SOH: 0.916(来自 C006)
平均 SOH(生成器表达式): 0.9587参考答案
cap_rated = 2.5
caps = [2.50, 2.46, 2.42, 2.38, 2.33, 2.29]
cell_ids = ["C001", "C002", "C003", "C004", "C005", "C006"]
sohs = [round(c / cap_rated, 4) for c in caps] # ① 列表推导式
print(f"SOH 列表: {sohs}")
soh_map = {cid: soh for cid, soh in zip(cell_ids, sohs)} # ② 字典推导式
print(f"编号→SOH: {soh_map}")
lows = [c for c in caps if c / cap_rated < 0.95] # ③ 带条件的推导式
print(f"SOH < 0.95 的容量: {lows}")
min_soh = min(sohs)
worst_id = min(soh_map, key=soh_map.get) # 按「值」找最小的「键」
print(f"最低 SOH: {min_soh}(来自 {worst_id})")
mean_soh = sum(c / cap_rated for c in caps) / len(caps) # ④ 生成器表达式
print(f"平均 SOH(生成器表达式): {mean_soh:.4f}")四个要点:① zip 把两个列表并起来一起遍历("两个列表 → 一个字典"的标准做法), 但 zip 按最短的那个截断,两边长度不一致会静默丢数据(第 07 章讲过); ② min(soh_map, key=soh_map.get) 是"按字典的值找键"的标准写法 —— soh_map.get 作为 key 函数返回每个键对应的值,而直接写 min(soh_map) 找的是键的最小值; ③ 第 ③ 问过滤的是"容量"而不是 SOH:表达式是 c、条件是 c / cap_rated < 0.95,两者是两件事; ④ 生成器表达式不建中间列表,数据大到几百万行时才体现出价值。
完整练习在 exercises/ex04-11-comprehension —— 含骨架 exercise.py 和参考答案 solution.py。
练习 2:用星号解包做"去头去尾"
给定 caps = [2.50, 2.46, 2.42, 2.38, 2.33, 2.29],完成: ① 用解包取出第一个容量和剩下的(first / rest);② 用解包取出"头、中间、尾"; ③ 求中间那些容量的均值(排除首尾);④ 把首尾互换后打印整个列表。
参考答案
caps = [2.50, 2.46, 2.42, 2.38, 2.33, 2.29]
first, *rest = caps # ① first=2.5 rest=[2.46, ..., 2.29]
print(f"first={first} rest={rest}")
head, *mid, tail = caps # ② 头 + 中间 + 尾
print(f"head={head} mid={mid} tail={tail}")
mid_mean = sum(mid) / len(mid) # ③ 中间段的均值
print(f"中间 {len(mid)} 个的均值 = {mid_mean:.4f}") # 2.3975
head, tail = tail, head # ④ 交换两个变量,不需要临时变量
caps = [head, *mid, tail] # 再用星号解包拼回一个新列表
print(f"首尾互换后: {caps}")
# [2.29, 2.46, 2.42, 2.38, 2.33, 2.5]四个要点:① 星号收拢出来的永远是 list(rest、mid 都是列表), 所以可以直接对它做 sum()、sorted();② * 在左边只能出现一次, 写 a, *b, *c = caps 会直接 SyntaxError; ③ a, b = b, a 的原理:等号右边先求值成元组 (b, a),再解包赋给左边,所以不需要临时变量; ④ [head, *mid, tail] 是"列表里嵌星号解包"的写法([*a, *b] 可以把两个列表接起来, 比 a + b 更能表明意图)。
一句话:解包的意义不是"少写几行",而是让"第一个 / 中间的 / 最后一个"这种语义直接出现在代码里。
练习 3:找出这段代码的三处问题
下面这段代码有三处问题(两处会报错,一处不报错但结果是错的)。先自己找,再对照答案:
caps = [2.50, 2.46, 2.42]
gen = (c / 2.5 for c in caps)
print(sum(gen))
print(list(gen)) # ①
row = (2.50)
key = [2.5, 0.8]
d = {key: "C001"} # ②参考答案
问题 ①:生成器只能消费一次
print(sum(gen)) # 可以,把生成器走完了
print(list(gen)) # [] ← 已经空了sum(gen) 已经把它从头走到尾,再 list(gen) 只能得到空列表。 它不报错,所以最危险 —— 你以为拿到的是数据,其实是空的。 改法:要么每次重新生成,要么先 list() 存下来再反复用。
问题 ②:(2.50) 不是元组,[2.5, 0.8] 也不能当键
row = (2.50) # 这是 float!元组要写 (2.50,)
d = {[2.5, 0.8]: "C001"} # ❌ TypeError: unhashable type: 'list'
row, key = (2.50,), (2.5, 0.8) # ✅ 单元素元组要逗号;元组可以当键
d = {key: "C001"}type((2.50)) 是 float —— 括号只是括号,逗号才是元组的标志。 第二行则是"用列表当字典键",字典的键必须可哈希。
三个要点:① "不报错但结果是错的"比报错更值得警惕 —— list(gen) 返回 [] 完全合法,只能靠"知道生成器是一次性的"来避免; ② 写代码时想清楚"这东西能不能被改",是选 list 还是 tuple 的唯一标准; ③ 调试这类问题最快的办法是 print(type(x), x)(第 03 章)—— type(row) 打出 float 的瞬间,问题就明摆着了。
面向 AI 开发:向量化的前奏,与生成器省内存
① 推导式是"向量化思维"的第一站。
[c / 2.5 for c in caps] # Python 推导式:一行,但还是逐元素跑解释器
caps / 2.5 # NumPy:整列一次算完(第 20 章)两者的"想法"完全一样:对每一个元素做同一个变换,区别只在谁来执行这个循环。 先在推导式里习惯"整批处理"的心智,第 20 章的向量化就不是新概念,而是"换个更快的引擎"。 (第 09 章的 {cid: info["soh"] for ...}、第 12 章的 {r["cell_id"] for r in rows} 都是同一套写法在字典和集合上的投影。)
② 生成器 = "流式读取",是处理大文件的唯一姿势。
rows = list(csv.DictReader(f)) # ❌ 一次性全读进内存:10 GB 文件直接爆掉
total = 0.0 # ✅ 流式:内存里同时只有一行
for row in csv.DictReader(f):
total += float(row["capacity_ah"])list(...) 把 13967 行(或 1000 万行)全部装进内存;而 csv.DictReader(f) 本身就是迭代器, 读一行处理一行,读完就丢。Pandas 也留着这个口子(第 24 章): for chunk in pd.read_csv(path, chunksize=100_000): process(chunk)。
③ 训练循环本身就是"生成器":DataLoader 每次只吐出一个 batch, 不会把整个数据集塞进显存 —— 这和 (f(x) for x in xs) 是同一个道理。 "懒加载 / 流式"是 AI 工程里最重要的资源纪律之一。
④ 特征工程里最常见的推导式形态。
ids = [r["cell_id"] for r in rows] # 抽一列 → 列表
by_id = {r["cell_id"]: r for r in rows} # 建索引 → 字典(O(1) 查)
chans = {r["batch"] for r in rows} # 数不重复的类别 → 集合
X = [feat(r) for r in rows if r["soh"] > 0] # 过滤 + 变换一条写完⑤ 一条经验法则:几十 ~ 几万条用列表推导式(够快、够清楚); 十万条以上、只需聚合用生成器表达式;上百万行、要反复用就交给 NumPy / Pandas(第 17、23 章)。 别过早优化 —— 6 个容量用列表推导式就好,硬写生成器只会让代码更难调试; 但"知道有生成器这个选项"很重要,当某天你要处理一个 2 GB 的日志时,它会救你一次。