本节摘要:推导式是 Python 构造列表、字典、集合的简洁语法,能把三五行的"建空容器、循环、append"压成一行。生成器表达式则是它的惰性版本,按需产出元素、几乎不占内存。本节讲清楚各种推导式的写法与适用边界、生成器表达式的内存优势,以及什么时候该用、什么时候别用。通过矩阵展平、数据筛选等练习,把"写得又简洁又高效"的能力练出来。
阅读完本节,你应当能够:
回想一下你写过多少次这种代码:建一个空列表,循环遍历某个序列,对每个元素做点处理,append 到列表里。这个模式出现得太频繁,Python 专门为它设计了更简洁的语法——推导式。
# 传统写法:四行 squares = [] for x in range(10): squares.append(x * x) # 推导式:一行 squares = [x * x for x in range(10)]
推导式不只是"短",它还把"我要构造一个新列表"这个意图放在最前面,读代码的人一眼就能看出目的,而不必读到循环体才明白。这是表达力的提升,不只是字符数的减少。
生成器表达式解决的是另一个问题:当你只需要遍历一次结果,没必要把所有元素都先存进内存。列表推导式会一次性算出所有结果存进列表,数据量大时内存吃紧;生成器表达式把方括号换成圆括号,变成"按需产出"的迭代器,一百万个元素的序列也几乎不占内存。
理解这两者的差异和取舍,是 Python 高效数据处理的基础。
# 基本形式:[表达式 for 变量 in 可迭代对象] squares = [x * x for x in range(5)] # [0, 1, 4, 9, 16] # 带条件:[表达式 for 变量 in 可迭代对象 if 条件] evens = [x for x in range(10) if x % 2 == 0] # [0, 2, 4, 6, 8] # 表达式可以复杂 pairs = [(x, x*2) for x in range(3)] # [(0,0), (1,2), (2,4)]
推导式的阅读顺序是先看 for,再看 if,最后看表达式——遍历谁、筛谁、留什么。
把方括号换成花括号,配合"键:值"或单个表达式,就得到字典或集合推导式:
# 字典推导式:{键表达式: 值表达式 for ...} word_len = {w: len(w) for w in ["apple", "bee", "cherry"]} # {'apple': 5, 'bee': 3, 'cherry': 6} # 集合推导式:{表达式 for ...} unique_lens = {len(w) for w in ["apple", "bee", "cat", "dog"]} # {5, 3} 长度去重
推导式可以嵌套,处理二维结构时有用,但别超过两层,否则可读性塌方:
# 展平二维列表 matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] flat = [num for row in matrix for num in row] # [1, 2, 3, 4, 5, 6, 7, 8, 9]
阅读嵌套推导式的诀窍:从左到右就是循环的嵌套顺序,外层循环在前,内层在后,等价于:
flat = [] for row in matrix: # 外层 for num in row: # 内层 flat.append(num)
把列表推导式的方括号换成圆括号,就变成生成器表达式——它不一次性算出所有结果,而是返回一个按需产出的生成器:
# 列表推导式:立即算出全部,占内存 squares_list = [x * x for x in range(1000000)] # 约占 8MB # 生成器表达式:几乎不占内存,按需产出 squares_gen = (x * x for x in range(1000000)) # 占几十字节
生成器表达式常作为函数参数直接传入,省去中间变量:
# 求和:直接传生成器,不建中间列表 total = sum(x * x for x in range(1000000))
推导式强大,但不是万能。以下情况应该退回普通 for 循环:
# 坏:逻辑太复杂,推导式难以阅读 result = [transform(x) for x in data if validate(x) and not is_skip(x) or override(x)] # 改成循环,加注释,更清楚 # 坏:有副作用(除了构造容器还要做别的事) results = [] for x in data: val = process(x) log(val) # 副作用,不该藏在推导式里 results.append(val)
💡 关键直觉:推导式适合"纯转换/过滤"——输入一个序列,输出一个新序列,中间没有副作用。一旦逻辑需要打印、写文件、累加外部变量,就老老实实写循环,别把副作用塞进推导式。
map/filterPython 有 map 和 filter 函数,做的事和推导式类似:
# map/filter 写法 squares = list(map(lambda x: x*x, range(5))) evens = list(filter(lambda x: x%2==0, range(10))) # 推导式写法(更 Pythonic) squares = [x*x for x in range(5)] evens = [x for x in range(10) if x%2==0]
绝大多数场景,推导式比 map/filter 更可读,是社区推崇的写法。
生成器表达式(以及第 3 章的生成器函数)只能遍历一次,遍历完就耗尽:
gen = (x * x for x in range(5)) print(list(gen)) # [0, 1, 4, 9, 16] print(list(gen)) # [] 已经耗尽!
如果你需要反复遍历,要么每次重新创建生成器,要么直接用列表。判断标准:只需遍历一次且数据大,用生成器;需要多次访问或数据小,用列表。
⚠️ 常见坑:把生成器表达式赋给变量,然后在多处用
if x in gen判断成员——第一次判断后生成器就消耗了一部分,后续判断结果不对。需要成员判断就先转成集合或列表。
any/all 配合生成器any 和 all 接收可迭代对象,配合生成器表达式能写出高效的判断,而且利用短路特性提前终止:
numbers = [2, 4, 6, 8, 9] # 判断是否全是偶数 all_even = all(n % 2 == 0 for n in numbers) # False,遇到 9 就停 # 判断是否有任何偶数 any_even = any(n % 2 == 0 for n in numbers) # True,遇到 2 就停
题面:给定一个数字列表 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],用列表推导式生成两个新列表:一个是所有数的平方,一个是偶数的平方。
思路点拨:第一个是基本推导式;第二个加 if x % 2 == 0 条件。
参考骨架:
nums = list(range(1, 11)) all_squares = [x * x for x in nums] # [1, 4, 9, 16, 25, 36, 49, 64, 81, 100] even_squares = [x * x for x in nums if x % 2 == 0] # [4, 16, 36, 64, 100]
题面:给定一个二维列表(矩阵)[[1, 2, 3], [4, 5, 6], [7, 8, 9]],用嵌套推导式:①展平成一维列表;②构造一个"数字到其平方"的字典;③取出所有偶数组成集合。
思路点拨:展平用两层 for;字典推导式遍历展平后的数;集合推导式加 if x % 2 == 0。
参考骨架:
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] flat = [n for row in matrix for n in row] # [1, 2, 3, 4, 5, 6, 7, 8, 9] square_dict = {n: n * n for n in flat} # {1: 1, 2: 4, ..., 9: 81} even_set = {n for n in flat if n % 2 == 0} # {8, 2, 4, 6}
💡 思考延伸:注意集合是无序的,
even_set的输出顺序可能和输入不同——如果顺序重要,应该用列表推导式而不是集合推导式。这又回到了 4.1 节"按场景选容器"的道理。
题面:假设要处理一个非常大的"日志行"序列(这里用 range(1000000) 模拟),统计其中满足某条件(比如能被 7 整除)的行数。要求:不能把所有数据都加载进内存。用生成器表达式实现。
思路点拨:生成器表达式 (x for x in range(1000000) if x % 7 == 0) 按需产出,sum(1 for ...) 统计个数。整个过程几乎不占额外内存。
参考骨架:
# 生成器表达式 + sum 统计,内存占用极小 count = sum(1 for x in range(1000000) if x % 7 == 0) print(count) # 142857 # 对比:如果先建列表再算长度,会占大内存 # bad = len([x for x in range(1000000) if x % 7 == 0])
⚠️ 常见坑:用
len([x for ...])会对生成器强制求值成列表,丧失内存优势。要统计个数用sum(1 for ...),让生成器保持惰性。
条件写在 for 前面和后面有什么区别? 位置决定语义。写在后面([x for x in xs if cond])是过滤器:满足条件的元素原样保留。写在前面([x if cond else y for x in xs])是三目表达式:每个元素都会产出,只是值按条件二选一。新手常把"想要过滤"写成"想要映射",或者反过来。辨认方法很简单——看 if 有没有配对的 else:有 else 必在前面做值选择,没有 else 必在后面做筛选。
推导式里的变量会泄漏到外面吗? 不会,Python 3 的推导式有自己的作用域,循环变量 x 在推导式外不可见(Python 2 会泄漏,这是老资料里常见的说法)。但要注意赋值目标例外:使用海象运算符 := 时值会绑定到外层。日常写代码记住"推导式是一次性的命名空间"即可,别依赖外部变量碰巧同名。
生成器表达式能做成员判断吗? 能但有陷阱。999999 in (x*x for x in range(10**6)) 会逐个产出平方直到找到为止,平均要扫一半元素,比列表还慢(列表至少能一次性建索引?不,列表的 in 也是线性扫)。真正的坑是把生成器存下来反复用 in:第一次判断消耗了一部分,第二次从断点继续,结果完全不对。规则很朴素:成员判断先转集合,生成器只做"流式消费"。
嵌套推导式读不懂有什么技巧? 口诀是"从左往右就是从外到内"。也可以做个小实验辅助理解:把推导式里每个 for 依次抄成缩进的循环,append 的就是最前面的表达式。练习题里的矩阵展平就是这样翻译的。反向技巧同样有用——看到三层嵌套的循环想压缩时,先合并最内两层试试,保持"一次只压一层"的节奏,可读性不容易失控。
题面:给定一个单词列表,用推导式一次性产出三种结果:一个"单词到长度"的字典;一个长度超过 5 的单词集合;用生成器表达式统计所有单词的总字符数(不许建中间列表)。
思路点拨:三个小问分别练字典推导、带条件的集合推导、生成器配合 sum。第三问的关键是把"总字符数"翻译成"长度求和"——sum(len(w) for w in words),让惰性求值全程无中间列表。
参考骨架:
words = ["python", "list", "comprehension", "generator", "set"] length_map = {w: len(w) for w in words} # {'python': 6, 'list': 4, 'comprehension': 13, ...} long_words = {w for w in words if len(w) > 5} # {'python', 'comprehension', 'generator'} total_chars = sum(len(w) for w in words) # 33
三问共用同一个数据源、三种产物形态,恰好对应本章讲的"括号决定产物"。做完自问一句:如果还要按长度分组(长度到单词列表的映射),该用什么?答案是第 4.1 节的 defaultdict(list) 或字典推导配合循环——学到这里,工具箱之间的连接感就建立起来了。
再做一个对比实验加深理解:把第三问故意改错成 sum([len(w) for w in words])(方括号),代码照样能跑出 33,但中间建了一个长度列表。数据小看不出差别;把单词表换成一百万个词试试,方括号版本要多占几十兆内存。亲眼看过这个对比的人,对"圆括号不是可有可无的细节"会有体感。调试器里观察 type(...) 的输出(一个是 list,一个是 generator)也是好办法——类型不同,行为约定就不同,这是一切差异的源头。
最后提醒一个阅读顺序上的技巧:拿到别人写的复杂推导式,先数 for 的个数(决定了几层循环),再找有没有 if(区分过滤还是三目),最后看最前面的表达式(每轮产出什么)。三步拆完,任何推导式都能在脑中还原成等价的循环结构。反过来,自己压缩代码时也按这三步的逆序检查一遍,保证"压完的版本自己隔周还能读"。
[表达式 for 变量 in 序列 if 条件],阅读顺序是 for→if→表达式。sum(1 for ...):别用 len([...]) 强制求值,否则丧失惰性优势。下一章我们把前面学的东西综合起来,做文件处理和综合实战项目。