Python迭代器、生成器与推导式:写出更优雅的循环代码
前面写循环时,你一直用for i in range(100)或for item in my_list,没出过问题。但你有没想过——为什么range(100)和my_list都能放在for后面?range不是列表,它凭什么也能被遍历?如果range(10**9)真的在内存里创建了十亿个整数,你的电脑早就炸了——但程序却运行得飞快,内存占用几乎为零。这背后的魔法就是迭代器。
另外,你有没有觉得for i in range(len(data))这种写法很啰嗦?有没有发现创建新列表时那四五行循环代码可以压缩成一行?这就是推导式的威力。
而当你需要处理的数据量大到内存装不下时(比如一个 20GB 的日志文件),生成器就是你的救星——它让你在内存里永远只保留当前正在处理的那一条数据,用完就丢。
这一篇把迭代器、生成器、推导式三个紧密关联的概念一次性讲清楚。
一、迭代器与生成器
可迭代对象和迭代器——for 循环背后的秘密
概念区分
Python 的for循环能遍历一切"可迭代对象"——列表、元组、字符串、字典、集合、range 对象、文件对象……"可迭代"意味着它能提供一个迭代器。
可迭代对象(Iterable):可以被for遍历的东西。内部实现了__iter__方法,调用它返回一个迭代器。
迭代器(Iterator):记住"当前遍历到哪了",并能给你"下一个元素"的东西。内部实现了__next__方法。
# 列表是可迭代对象,但不是迭代器nums=[1,2,3]# next(nums) # TypeError:列表不是迭代器# 用 iter() 从可迭代对象获取迭代器it=iter(nums)# 获取 nums 的迭代器print(next(it))# 1 ——取第一个print(next(it))# 2 ——取下一个print(next(it))# 3 ——再下一个# print(next(it)) # StopIteration ——取完了,抛出异常for循环背后就是不断地调用iter()和next(),直到捕获StopIteration异常后退出。这就是为什么for对所有类型都有效——它只依赖迭代器协议,不关心你是什么类型。
自己写一个迭代器
classCountDown:"""倒计时迭代器"""def__init__(self,start):self.current=startdef__iter__(self):returnself# 迭代器本身也是可迭代对象def__next__(self):ifself.current<=0:raiseStopIteration# 没有更多了,通知 for 循环停止value=self.current self.current-=1returnvalue# 使用fornuminCountDown(5):print(num)# 5 4 3 2 1写自定义迭代器的场景不常见——大多数时候生成器(下面马上讲)更简单。但理解迭代器协议能帮你理解为什么 Python 的 for 循环如此普适。
生成器:最优雅的迭代器创建方式
迭代器需要管理内部状态(比如上面的self.current),写起来不够简洁。生成器让你用写普通函数的方式来实现迭代器——用yield代替return:
# 上面的 CountDown 用生成器重写——从 10 行缩减到 4 行defcountdown(start):whilestart>0:yieldstart# yield:产出一个值,然后暂停start-=1fornumincountdown(5):print(num)yield和return的区别:return把值返回给调用者,函数执行结束。yield把值产出给调用者,函数暂停执行,下次调用next()时从暂停处继续。一个生成器可以yield很多次,每次产出一个值。
生成器的核心优势:懒加载
# 列表方式:一次性创建一百万个整数,占用约 8MB 内存nums_list=[iforiinrange(1000000)]# 生成器方式:不创建任何数据,只在需要时计算下一个值nums_gen=(iforiinrange(1000000))生成器表达式用圆括号而不是方括号。它和列表推导式的语法完全一样,只是不立即创建数据——你遍历它时才逐个计算。这个"不提前创建不需要的数据"的特性,在处理大文件、数据库查询结果、网络流数据时是无价的。
# 读取一个超大日志文件,查找包含 "ERROR" 的行deffind_errors(log_path):withopen(log_path,"r",encoding="utf-8")asf:forline_no,lineinenumerate(f,1):if"ERROR"inline:yieldline_no,line.strip()# 只取前 5 条错误——生成器不会读完整个文件forline_no,contentinfind_errors("server.log"):print(f"第{line_no}行:{content}")因为yield一次只产出一条,内存里永远只有当前这一行,哪怕文件有 20GB。这就是生成器在处理大数据时的统治力。
yield from:委托给另一个生成器
defall_students():yieldfromclass_a_students()# 委托给另一个生成器yieldfromclass_b_students()yieldfromclass_c_students()yield from把产出工作委托给另一个可迭代对象,比自己写for item in other_gen: yield item更简洁、更高效。这在构建多层生成器管道时非常方便。
图15-1 可迭代对象与迭代器:iter() 从可迭代对象获取迭代器,next() 从迭代器逐个取值,StopIteration 表示结束。
迭代器的气——可以暂停和恢复的函数
生成器和普通函数最核心的区别可以归结为一点:普通函数一旦return,一切都结束了——它的局部变量消失了,调用栈清空了,下次调用从头开始。而生成器yield之后,它只是"暂停"——局部变量完好地保留着,下次调用next()时从暂停的地方继续执行,就像你按了游戏的"暂停"和"继续"按钮。
这个"可以暂停和恢复的函数"赋予了 Python 一种不同以往的编程风格。你不再需要手动管理遍历的状态变量,不需要用类来包装"当前遍历到哪了",不需要把逻辑拆成"读取下一个"和"处理当前"两个分离的函数。所有这些复杂性都被yield优雅地吸收了——写在同一个函数里,Python 帮你记住状态。
这种风格的典型应用是异步编程。Python 3.5 引入的async/await语法底层就是基于生成器实现的。当你写await some_io_operation()时,Python 暂停当前协程、去执行其他任务、等 IO 完成后再回来继续。整套机制的核心概念和生成器的暂停/恢复如出一辙。学了生成器,你已经触摸到了 Python 异步编程的底层基石。
二、推导式与工具
推导式全家桶
推导式是 Python 最具辨识度的语法之一——它把循环 + 处理 + 过滤压缩到一行,读起来像数学公式。
列表推导式
# 基础squares=[x**2forxinrange(10)]# 带过滤even_squares=[x**2forxinrange(10)ifx%2==0]# 嵌套循环pairs=[(x,y)forxinrange(3)foryinrange(3)]# 复杂表达式students=[("小明",85),("小红",92)]names_upper=[name.upper()forname,scoreinstudentsifscore>=90]字典推导式和集合推导式
# 字典推导式scores={"小明":85,"小红":92,"小刚":78}passed={name:scoreforname,scoreinscores.items()ifscore>=60}# 调换键和值score_to_names={score:nameforname,scoreinscores.items()}# 集合推导式numbers=[1,2,2,3,3,3,4]unique_evens={xforxinnumbersifx%2==0}# {2, 4}生成器表达式
# 圆括号——创建生成器而非列表sum_of_squares=sum(x**2forxinrange(1000000))# 注意:如果你把生成器表达式作为函数的唯一参数,可以省略一层括号# sum((x ** 2 for x in range(100))) 等价于# sum(x ** 2 for x in range(100))推导式 vs 传统循环——如何选择
# 推导式适合的场景:逻辑简单,能一眼看明白passed_names=[nameforname,scoreinscores.items()ifscore>=60]# 传统循环适合的场景:逻辑复杂,有副作用,需要多步骤处理passed_names=[]forname,scoreinscores.items():ifscore>=60:log(f"{name}及格了")# 副作用:记录日志passed_names.append(name)send_notification(name)# 更多副作用推导式不要写得太复杂。如果你的推导式超过了一行,或者里面嵌套了第三个for,就把它改成普通循环。代码的读者(包括三个月后的你)会感谢你的。
图15-2 生成器 yield 执行流程:函数执行到 yield 时暂停并产出值,下次调用 next() 从暂停处继续,直到函数结束抛出 StopIteration。
itertools:标准库里的迭代器工具箱
Python 的itertools模块提供了一系列高效的迭代器工具。它是处理和组合迭代器的瑞士军刀:
fromitertoolsimportchain,cycle,islice,combinations,groupby# chain:串联多个迭代器all_items=chain([1,2,3],["a","b","c"])print(list(all_items))# [1, 2, 3, 'a', 'b', 'c']# islice:对迭代器做切片first_5=islice(range(1000),5)print(list(first_5))# [0, 1, 2, 3, 4]# combinations:组合fromitertoolsimportcombinations teams=["A","B","C","D"]print(list(combinations(teams,2)))# [('A', 'B'), ('A', 'C'), ('A', 'D'), ('B', 'C'), ('B', 'D'), ('C', 'D')]还有很多:permutations(排列)、product(笛卡尔积)、zip_longest(不等长 zip)、takewhile/dropwhile(条件取/舍)……不需要记住全部,知道itertools这个盒子里有这些工具就行。
生成器管道:像流水线一样处理数据
生成器可以像管道一样串联——一个生成器的输出是下一个生成器的输入:
defread_logs(path):"""读取日志行"""withopen(path)asf:forlineinf:yieldline.strip()deffilter_errors(lines):"""只保留 ERROR 行"""forlineinlines:if"ERROR"inline:yieldlinedefextract_timestamps(lines):"""提取时间戳"""forlineinlines:timestamp=line[:19]# 前 19 个字符是时间戳yieldtimestamp# 管道组装:读取 → 过滤 → 提取logs=read_logs("server.log")errors=filter_errors(logs)timestamps=extract_timestamps(errors)# 此时还没有读取任何数据!所有处理都是"懒"的# 直到你开始遍历,数据才流过管道fortsintimestamps:print(ts)这是一个非常有用的设计模式。每个生成器只做一件事(单一职责),它们可以独立测试、独立修改,通过管道灵活组合。当你需要处理复杂的数据转换逻辑时,生成器管道往往比一个巨大的处理函数优雅得多。
图15-3 列表一次性加载 vs 生成器按需产出:处理大数据时生成器内存恒定,列表随数据量线性增长。
三、实战与总结
实战:用生成器写几个实用工具
斐波那契数列生成器
deffibonacci(limit=None):"""生成斐波那契数列,可指定个数上限"""a,b=0,1count=0whilelimitisNoneorcount<limit:yielda a,b=b,a+b count+=1# 取前 10 个print(list(fibonacci(10)))# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]# 取不超过 100 的result=[]fornuminfibonacci():ifnum>100:breakresult.append(num)print(result)# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]分块读取大文件
defread_in_chunks(file_path,chunk_size=1024):"""每次读取指定大小的数据块,适合处理二进制文件"""withopen(file_path,"rb")asf:whilechunk:=f.read(chunk_size):yieldchunk# 计算大文件的 MD5importhashlibdeffile_md5(file_path):md5=hashlib.md5()forchunkinread_in_chunks(file_path,8192):md5.update(chunk)returnmd5.hexdigest()无限序列——生成器可以永不结束
definfinite_counter(start=0):whileTrue:yieldstart start+=1counter=infinite_counter(100)print(next(counter))# 100print(next(counter))# 101# 这个生成器永远不会自动停止——但你可以用 islice 或 break 来控制何时用迭代器、何时用列表——一个简单的性能实验
很多初学者困惑于"既然生成器这么好,为什么不全用生成器?"答案在于:生成器只能遍历一次,列表可以反复访问。如果你需要对同一组数据做多次迭代(比如先用max()再用min()再用sum()),用列表更方便——它允许你多次遍历。生成器遍历过一次之后就"耗尽"了,第二次遍历什么都不会产出。
另外有一个微妙的性能事实:列表推导式在小数据量上通常比生成器表达式快——因为它在 C 语言层面一次性构建了所有元素,而生成器表达式每次 yield 都要在 Python 层面做函数调用。但数据量变大、内存变得稀缺时,生成器的优势就体现出来了。
实际使用中一个很实用的经验法则:如果需要多次遍历,或者数据量不超过几千条——用列表。如果只遍历一次,或者数据量很大(几十万条以上)——用生成器。大部分日常场景下列表推导式已经足够好了,生成器是你在遇到"数据太大装不进内存"时的最优解,而不是默认选项。
迭代器的"消费"特性——一个需要记住的细节
迭代器和生成器有一个共同的特点:它们是一次性的。遍历完一次之后,迭代器就"耗尽"了,再次遍历什么都不会产出。这个特性在调试时尤为微妙——如果你在交互模式中it = iter(range(5)); list(it)后再用for i in it: print(i),什么都不会输出,因为list(it)已经把迭代器消费完了。
这个行为是设计上的有意选择而非 bug——迭代器的价值恰恰在于它不需要在内存中保留历史数据。但"一次性"特性也确实是一个常见的困惑来源。当你需要反复遍历同样的数据时,有两个选择:如果数据量不大,直接用列表;如果数据量很大,每次遍历前重新创建一个生成器。Python 的itertools.tee()可以创建一个迭代器的多个副本,但它内部会缓存数据,使用时要小心内存占用。
动手练习
用生成器实现一个"惰性管道"处理大文件:写三个生成器函数——第一个逐行读取一个模拟的大日志文件,第二个过滤出包含 “ERROR” 的行,第三个提取每行的时间戳和错误信息。把它们串联成管道
extract_errors(filter_errors(read_lines("huge.log"))),然后遍历最终结果。在整个过程中,你的内存占用不会随着文件大小增长——管道里的每个生成器在任意时刻都只处理当前这一行,处理完就丢弃。这是你未来在实际工作中处理大数据文件时最核心的技能之一。文件内容去重:写一个生成器,逐行读取文件,只 yield 之前没有出现过的行(去重),保持原始顺序。
日期范围生成器:写一个生成器
date_range(start_date, end_date),按天生成两个日期之间的所有日期。用datetime模块。行号添加工具:写一个生成器
add_line_numbers(lines, start=1),把输入的可迭代对象每行加上行号前缀输出。素数生成器:用生成器实现无限素数序列。用筛法或其他算法。
用生成器实现一个"分页器":在实际项目中,你需要分批展示大量数据(比如每页显示 20 条记录)。写一个生成器
paginate(items, page_size),让它每次 yield 一页的数据。用itertools.islice实现内部逻辑。这个模式在 Web 开发(数据库分页查询)和数据处理(分块处理大文件)中极其常用。用生成器模拟一个"数据流":写三个串联的生成器——第一个
generate_sensor_data()模拟传感器每秒产出一个温度值(用time.sleep(1)模拟),第二个filter_abnormal()过滤掉超出正常范围的值,第三个moving_average()计算滑动平均。用for循环遍历最终结果,观察数据如何像流水线一样流经三个生成器。这个练习能帮你建立"生成器管道"的完整心智模型。对比生成器表达式和列表推导式的内存:创建一个一千万个元素的生成器表达式
(x**2 for x in range(10_000_000))和列表推导式[x**2 for x in range(10_000_000)]。用sys.getsizeof()查看它们的内存占用。你会直观地感受到"懒加载"到底省了多少内存。生成器表达式只占用几十个字节(它只存了"怎么算下一个"的规则),而列表推导式占用约 80MB(存了全部一千万个结果)。80MB vs 80 bytes——这就是惰性求值在处理大数据时的价值。理解了这个对比,你就真正理解了生成器。这不仅仅是学术上的好奇——在实际工作中,你能否用生成器替代列表来处理大数据,直接决定了你的程序是否能在普通机器上运行。当数据量超过可用内存时,列表方案直接崩溃(MemoryError),而生成器方案依然从容工作。这就是为什么 Python 社区把"惰性求值"看作数据处理中最重要的设计原则之一。在 Python 3 中,map()、filter()、zip()、range()全部返回迭代器而不是列表——这个语言级别的设计选择本身就是对惰性求值哲学的认可。当你习惯了"需要的时候才计算"的思维,你的程序会自然地变得更高效、更省内存、更适合处理真实世界中的大规模数据。
生成器在实际项目中的角色——你看不到它,但它无处不在
你可能觉得生成器是一个"高级特性",日常用不到。但实际上,Python 标准库里到处都是生成器的影子。open()返回的文件对象就是一个生成器——你用for line in file时,Python 不是一次性把整个文件读到内存,而是读一行、给你一行、丢掉上一行。range()也是一个惰性序列——range(1000000000)不会占用你的内存。zip()、map()、filter()在 Python 3 中全部返回迭代器而非列表。
理解了生成器,你就理解了为什么 Python 能处理大数据——不是因为它算得快,而是因为它"懒"得聪明。需要的时候才算,算完就丢,不囤积数据。这种设计贯穿了整个 Python 生态。你在 pandas 中逐块读取大 CSV 文件、在 Django 中用.iterator()遍历百万级数据库查询结果、在 asyncio 中处理并发请求——底层全都是迭代器/生成器的思想。
现在学的时候你可能会觉得"不就是一种特殊的写法嘛"。等你处理第一个大到内存装不下的数据集时,你会回过头来感谢这一篇。
要点回顾
- 可迭代对象提供迭代器,迭代器提供
__next__,for循环依赖迭代器协议 - 生成器用
yield创建迭代器,代码简洁、内存友好、支持懒加载 - 推导式(列表/字典/集合/生成器)是一行完成"循环 + 处理 + 过滤"的优雅语法
- 推导式超过一行、嵌套超过两层 → 改成普通循环
- 生成器管道让数据处理像流水线一样灵活组合
itertools是迭代器工具箱,需要时回来翻
下篇预告
下一篇进入装饰器和闭包——Python 高级语法的标志性概念。