第9章 9.1 列表推导式与生成器表达式

🎯 开场 3 分钟:为什么要学这个?

上一章我们学会了用 pathlib 管理文件、用 typing 写出让机器人都能看懂的类型注解,手里已经有了不少趁手的工具。

但你有没有这种感觉:写代码时经常遇到这种情况——要处理一批数据,比如把 100 个文件名改成小写、过滤掉列表里的空值、从一堆数字里挑出偶数……

你的第一反应是不是写个 for 循环?没问题,能跑。但写出来的代码经常是:

result = []
for item in original_list:
if condition(item):
    result.append(transform(item))

三行变五行,五行变十行。代码读起来像在绕圈子。

有没有更简洁的办法?

这一章我们要学的「列表推导式」和「生成器表达式」,就是来解决这个痛点的。学完之后,同样的功能你可能只需要一行代码,而且读起来更接近自然语言。


🧱 基础 25 分钟:核心概念

什么是列表推导式?说白了就是「一条流水线」

想象你去奶茶店点单。传统做法是这样:

老方法(for 循环):

# 场景:把 5 杯奶茶的价格都加上 10 元包装费
prices = [25, 30, 28, 35, 22]
new_prices = []
for price in prices:
new_prices.append(price + 10)
print(new_prices)  # [35, 40, 38, 45, 32]

新方法(列表推导式):

prices = [25, 30, 28, 35, 22]
new_prices = [price + 10 for price in prices]
print(new_prices)  # [35, 40, 38, 45, 32]

看明白了吗?[price + 10 for price in prices] 这一行,顶得上上面四行。

列表推导式的结构:

[ 对每个元素做的操作 for 变量 in 可迭代对象 ]

拆开来看:for price in prices 负责「从袋子里一个一个拿奶茶」,price + 10 负责「加上包装费」,中括号负责「把处理完的放回新袋子」。

带条件的列表推导式:流水线上的质检员

有时候我们不是处理所有元素,而是只处理符合某个条件的。这就像流水线上的质检员——只对合格品进行包装,不合格的直接跳过。

场景:只把偶数挑出来翻倍

numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
even_doubled = [n * 2 for n in numbers if n % 2 == 0]
print(even_doubled)  # [4, 8, 12, 16, 20]

结构扩展:

[ 操作 for 变量 in 可迭代对象 if 条件 ]

多个条件也可以加:if n % 2 == 0 if n > 5 或者 if n % 2 == 0 and n > 5

嵌套列表推导式:处理二维数据

想象你有个成绩单,每个学生有三门课成绩:

scores = [
[85, 90, 78],   # 小明
[92, 88, 95],   # 小红
[77, 82, 80],   # 小刚
]

# 传统方法:先遍历每个学生,再遍历每门成绩
all_flat = []
for student in scores:
for score in student:
    all_flat.append(score)
print(all_flat)  # [85, 90, 78, 92, 88, 95, 77, 82, 80]
# 用嵌套列表推导式,一行搞定
all_flat = [score for student in scores for score in student]
print(all_flat)  # [85, 90, 78, 92, 88, 95, 77, 82, 80]

注意看,嵌套推导式的 for 是从左到右写的,跟嵌套循环的顺序一致。

配图1 - 配图1

生成器表达式:自来水厂,按需供水

列表推导式虽好,但有个问题——它会一次性把所有结果都算出来存进内存。如果你的数据量是 1000 万条,那内存可能就爆了。

这时候我们需要「生成器表达式」,它就像自来水厂:不一次性生产完,而是你用多少,它生产多少

核心区别:

# 列表推导式:一次性全部算出
list_result = [x * 2 for x in range(5)]
print(list_result)   # [0, 2, 4, 6, 8]
print(type(list_result))  # <class 'list'>

# 生成器表达式:用圆括号,返回一个生成器对象
gen_result = (x * 2 for x in range(5))
print(gen_result)    # <generator object <genexpr> at 0x...>
print(type(gen_result))  # <class 'generator'>

生成器对象像个水龙头,你拧一下(调用 next()),它就流出来一点:

gen = (x * 2 for x in range(5))
print(next(gen))  # 0
print(next(gen))  # 2
print(next(gen))  # 4
print(next(gen))  # 6
print(next(gen))  # 8
print(next(gen))  # StopIteration - 没水了!

什么时候用生成器?

  • 数据量很大,不可能一次性装进内存
  • 不需要重复访问,只是顺序遍历一次
  • 配合 for 循环使用时,语法几乎一样
# 用 for 循环遍历生成器,跟列表一样用
gen = (x * 2 for x in range(1000000))
for item in gen:
print(item)  # 一个一个输出,不会撑爆内存

生成器表达式作为函数参数

生成器有一个骚操作:可以直接当成参数传给函数,不用加括号:

# 两种写法完全等价
sum(x * 2 for x in range(5))      # 不用括号包裹
sum((x * 2 for x in range(5)))    # 用括号包裹

这在配合 sum()max()min()any()all() 这些内置函数使用时特别方便。

列表推导式 vs 生成器表达式 vs map/filter

老派 Python 程序员可能用过 map()filter()

numbers = [1, 2, 3, 4, 5]

# map:把每个元素做操作
doubled = list(map(lambda x: x * 2, numbers))

# filter:过滤符合条件的元素
evens = list(filter(lambda x: x % 2 == 0, numbers))

说实话,这俩玩意儿阅读体验很差——lambda x: x * 2 这种匿名函数可读性远不如列表推导式 [x * 2 for x in numbers]

我的建议:
- 简单操作 → 列表推导式
- 需要惰性计算 → 生成器表达式
- map/filter → 忘掉吧,你不需要它

配图2 - 配图2


🔥 实战 35 分钟:3 个递进的小项目

项目 1(5 分钟):批量重命名文件

场景: 你下载了一批图片,名字乱七八糟,想把它们统一改成 photo_001.jpgphoto_002.jpg 这种格式。

import pathlib

# 模拟一堆乱名字的文件
files = ["IMG_1234.PNG", "screenshot2024.JPEG", "photo.PNG", "DSC08987.JPG", "pic1.png"]

# 用列表推导式统一改成小写 + 序号
renamed = [f"{name.rsplit('.', 1)[0].lower()}_{i:03d}.{name.rsplit('.', 1)[1].lower()}"
       for i, name in enumerate(files, start=1)]

for original, new in zip(files, renamed):
print(f"{original} → {new}")

预期输出:

IMG_1234.PNG → img_1234_001.png
screenshot2024.JPEG → screenshot2024_002.jpeg
photo.PNG → photo_003.png
DSC08987.JPG → dsc08987_004.jpg
pic1.png → pic1_005.png

这一行的关键:enumerate(files, start=1) 给文件加上了序号,rsplit('.', 1)[1] 用来取文件扩展名。


项目 2(15 分钟):数据清洗小工具

场景: 你从 CSV 文件里读取了一批用户数据,里面有各种脏数据(空值、重复、格式不对)。你需要清洗它们。

# 模拟从CSV读取的数据(实际用 open('users.csv') 读取)
raw_data = [
{"name": "张三", "age": "25", "email": "zhangsan@example.com"},
{"name": "", "age": "30", "email": "lisi@example.com"},      # 名字为空
{"name": "王五", "age": "N/A", "email": "wangwu@example.com"}, # 年龄格式错
{"name": "赵六", "age": "28", "email": ""},                    # 邮箱为空
{"name": "张三", "age": "25", "email": "zhangsan@example.com"}, # 重复数据
{"name": "孙七", "age": "35", "email": "sunqi@example.com"},
]

# 清洗规则:
# 1. 名字不能为空
# 2. 年龄必须是有效数字
# 3. 邮箱不能为空
# 4. 去除重复(保留第一次出现的)

def is_valid_age(age_str):
"""检查年龄是否是有效数字"""
try:
    int(age_str)
    return True
except (ValueError, TypeError):
    return False

# 第一步:过滤掉无效数据
valid_data = [
row for row in raw_data
if row["name"]                    # 名字不为空
and is_valid_age(row["age"])      # 年龄有效
and row["email"]                   # 邮箱不为空
]

# 第二步:去除重复(根据邮箱判断)
seen_emails = set()
unique_data = [
row for row in valid_data
if row["email"] not in seen_emails
and not seen_emails.add(row["email"])  # add 返回 None,not None 恒为 True
]

# 第三步:格式化输出
cleaned = [
{
    "name": row["name"],
    "age": int(row["age"]),
    "email": row["email"].lower()
}
for row in unique_data
]

print(f"原始数据:{len(raw_data)} 条")
print(f"清洗后:{len(cleaned)} 条")
print("\n清洗后的数据:")
for user in cleaned:
print(f"  {user['name']} ({user['age']}岁) - {user['email']}")

预期输出:

原始数据:6 条
清洗后:4 条

清洗后的数据:
张三 (25岁) - zhangsan@example.com
王五 (35岁) - wangwu@example.com
孙七 (35岁) - sunqi@example.com

看明白了吗?三个列表推导式各司其职:第一个过滤脏数据,第二个去重,第三个做格式转换。


项目 3(15 分钟):日志分析小工具

场景: 你有一个访问日志文件,每行是一条记录,格式是 时间 IP 地址 状态码 URL。你想统计一下有哪些 404 错误(页面不存在)。

# 模拟日志数据(实际用 open('access.log') 读取)
log_lines = [
"2024-01-15 10:23:45 192.168.1.100 200 /index.html",
"2024-01-15 10:23:46 10.0.0.1 404 /missing.html",
"2024-01-15 10:23:47 192.168.1.101 200 /products.html",
"2024-01-15 10:23:48 172.16.0.5 500 /api/users",
"2024-01-15 10:23:49 192.168.1.100 404 /old-page.html",
"2024-01-15 10:23:50 10.0.0.2 404 /nonexistent.html",
"2024-01-15 10:23:51 192.168.1.102 200 /about.html",
]

# 解析日志行
def parse_log(line):
"""解析单行日志,返回 (时间, IP, 状态码, URL)"""
parts = line.split()
return parts[0] + " " + parts[1], parts[2], int(parts[3]), parts[4]

# 提取所有404记录
not_found = [
parse_log(line) for line in log_lines
if int(line.split()[3]) == 404
]

# 统计每个IP的404次数
from collections import Counter
ip_counts = Counter(record[1] for record in not_found)

# 生成报告
print("=" * 50)
print("404 错误统计报告")
print("=" * 50)
print(f"\n总共 {len(not_found)} 条 404 错误\n")
print("按 IP 地址统计:")
for ip, count in ip_counts.most_common():
print(f"  {ip}: {count} 次")

print("\n详细信息:")
for time, ip, code, url in not_found:
print(f"  [{time}] {ip} -> {url}")

预期输出:

==================================================
404 错误统计报告
==================================================

总共 3 条 404 错误

按 IP 地址统计:
10.0.0.1: 1 次
192.168.1.100: 1 次
10.0.0.2: 1 次

详细信息:
[2024-01-15 10:23:46] 10.0.0.1 -> /missing.html
[2024-01-15 10:23:49] 192.168.1.100 -> /old-page.html
[2024-01-15 10:23:50] 10.0.0.2 -> /nonexistent.html

这个工具虽然小,但已经具备了一个日志分析器的雏形。真实工作里,你可以把 log_lines 换成 open('access.log') 直接读文件。


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:列表推导式 vs 生成器表达式,傻傻分不清

错误: 以为生成器表达式返回的是列表

result = (x * 2 for x in range(5))
print(result[0])  # TypeError: 'generator' object is not subscriptable

正确: 要么转成列表,要么直接用循环

result = (x * 2 for x in range(5))
result_list = list(result)  # 一次性转成列表
print(result_list[0])  # 0

坑 2:在列表推导式里修改可变对象

错误: 列表推导式里不要用 append,那是给 for 循环用的

# 有人可能想这样写
result = [result.append(x) for x in range(5)]  # 大错特错!
# result 变成了 [None, None, None, None, None]

正确: 列表推导式只做表达式计算,不做语句执行

result = [x for x in range(5)]  # 这才是正确姿势

坑 3:生成器只能用一次

错误: 以为生成器可以反复遍历

gen = (x for x in range(3))
print(list(gen))  # [0, 1, 2]
print(list(gen))  # [] —— 第二次啥都没有了!

正确: 生成器是「一次性」的,用完就没了。要反复用就转成列表。

gen = (x for x in range(3))
gen_list = list(gen)  # 先存起来
print(list(gen_list))  # [0, 1, 2]
print(list(gen_list))  # [0, 1, 2] —— 想用几次用几次

坑 4:字典推导式和集合推导式忘了加括号

错误: 把列表推导式的语法套到字典上

# 想生成 {0: 0, 1: 2, 2: 4}
result = {x: x*2 for x in range(3)}  # 错误:花括号默认是 set!
print(result)  # {0, 1, 2} —— 变成了集合,不是字典

正确: 字典推导式需要 key: value 格式

result = {x: x*2 for x in range(3)}
print(result)  # {0: 0, 1: 2, 2: 4}

坑 5:列表推导式太长,写成一行鬼画符

错误: 为了炫技把代码全塞进一行

result = [func(x, y, z) for x in items if condition(x) for y in sub_items if condition2(x, y) for z in sub_sub_items if condition3(x, y, z)]

正确: 超过两个 for 嵌套就该拆成多行

result = [
func(x, y, z)
for x in items
if condition(x)
for y in sub_items
if condition2(x, y)
for z in sub_sub_items
if condition3(x, y, z)
]

性能小贴士:生成器真的能省内存

如果你要处理 100 万个数字的平方和,用列表推导式会很占内存:

import sys

# 列表版本
list_version = [x**2 for x in range(100000)]
print(f"列表占用: {sys.getsizeof(list_version)} bytes")  # 约 800KB

# 生成器版本
gen_version = (x**2 for x in range(100000))
print(f"生成器占用: {sys.getsizeof(gen_version)} bytes")  # 只有几百 bytes

差了上千倍!这就是生成器的威力。

调试技巧:把推导式拆成普通循环

如果你的列表推导式报错了,别硬着头皮调试,直接拆成普通 for 循环:

# 有 bug 的推导式
data = ["1", "2", "hello", "4"]
numbers = [int(x) for x in data]  # 报错了,哪个出问题了?

# 拆开来定位
for x in data:
print(f"正在处理: {x}")
int(x)  # 这行会报错,卡住的就是问题数据

✏️ 练习题 + 作业题

练习题(5 道,10 分钟内完成)

练习 1(2 分钟):抄改入门
- 输入:names = ["alice", "BOB", "Charlie"]
- 预期输出:['ALICE', 'BOB', 'CHARLIE']
- 提示:把项目 1 的代码复制过来,改一下具体变量

练习 2(2 分钟):加个条件
- 输入:numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
- 预期输出:只保留大于 5 的数字的平方 [36, 49, 64, 81, 100]
- 提示:在列表推导式最后加上 if n > 5

练习 3(3 分钟):处理新数据
- 输入:一组温度(摄氏温度)celsius = [0, 20, 37, 100]
- 预期输出:转成华氏温度 [32.0, 68.0, 98.6, 212.0]
- 提示:公式是 F = C * 9/5 + 32

练习 4(5 分钟):串联项目 2 和 3
- 输入:一组用户数据 ["Tom:25", "Jerry:invalid", "Spike:30", "":":"]
- 预期输出:过滤掉无效行后,转成字典 [{"name": "Tom", "age": 25}, {"name": "Spike", "age": 30}]
- 提示:先用 split(":") 拆开,过滤掉名字为空或年龄不是数字的,再用字典推导式转换格式

练习 5(5 分钟):读图分析
- 输入:以下代码运行后会报什么错?

gen = (x**2 for x in [1, 2, 3])
print(gen[0])
  • 预期输出:说明错误类型和原因
  • 提示:生成器对象能不能用下标访问?

作业题(1 道大题,30 分钟-2 小时)

作业:做个「数据汇总统计工具」

小明在一家小公司做运营,手上有一份销售数据(CSV 格式),想做个简单的统计分析。你帮他做一个 Python 脚本:

  • 需求描述: 读取 CSV 文件,按月统计销售额,并找出销售额最高和最低的月份
  • 功能点:
    1. 用 pathlib 读取 sales.csv 文件(自己造一份模拟数据)
    2. 用列表推导式/生成器表达式解析和过滤数据
    3. 用字典推导式按月汇总
    4. 找出最高/最低月份并输出
  • 加分项:
    1. 支持命令行参数指定文件路径
    2. 输出漂亮的表格格式
  • 验收标准: 能跑起来 + 输出正确 + 代码有中文注释

参考数据结构:

date,amount,product
2024-01-15,1200,笔记本电脑
2024-01-20,300,鼠标
2024-02-10,2500,显示器
2024-02-28,800,键盘
2024-03-05,1500,耳机
2024-03-20,200,鼠标垫

📚 总结 + 资源

本文学了 3 件事:
1. 列表推导式:[x for x in items] 让批量处理数据从 4 行变 1 行
2. 生成器表达式:(x for x in items) 用圆括号,按需生产,超级省内存
3. 实战组合拳:配合 pathlib 读文件、enumerate 加序号、collections 做统计

延伸学习资源:
- Python 官方文档 - 列表推导式
- 《Python Crash Course》第 5 章 - 列表操作
- 视频:B 站小甲鱼《零基础入门学 Python》第 38 讲


互动钩子:

你有没有遇到过这种情况——本来要写 10 行 for 循环,结果用列表推导式一行搞定?或者踩过生成器「只能用一次」的坑?评论区聊聊你是怎么解决的,老粉我优先回复!


下一章我们要学的「装饰器」,跟今天学的推导式是「好基友」——生成器返回的是数据流,装饰器能给你的函数加上一层「数据处理流水线」。准备好接收新工具吧!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。