15_Python迭代器、生成器与推导式:写出更优雅的循环代码

📅 2026/7/13 15:51:26
15_Python迭代器、生成器与推导式:写出更优雅的循环代码
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() 从可迭代对象获取迭代器ititer(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.currentstartdef__iter__(self):returnself# 迭代器本身也是可迭代对象def__next__(self):ifself.current0:raiseStopIteration# 没有更多了通知 for 循环停止valueself.current self.current-1returnvalue# 使用fornuminCountDown(5):print(num)# 5 4 3 2 1写自定义迭代器的场景不常见——大多数时候生成器下面马上讲更简单。但理解迭代器协议能帮你理解为什么 Python 的 for 循环如此普适。生成器最优雅的迭代器创建方式迭代器需要管理内部状态比如上面的self.current写起来不够简洁。生成器让你用写普通函数的方式来实现迭代器——用yield代替return# 上面的 CountDown 用生成器重写——从 10 行缩减到 4 行defcountdown(start):whilestart0: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,encodingutf-8)asf:forline_no,lineinenumerate(f,1):ifERRORinline: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%20]# 嵌套循环pairs[(x,y)forxinrange(3)foryinrange(3)]# 复杂表达式students[(小明,85),(小红,92)]names_upper[name.upper()forname,scoreinstudentsifscore90]字典推导式和集合推导式# 字典推导式scores{小明:85,小红:92,小刚:78}passed{name:scoreforname,scoreinscores.items()ifscore60}# 调换键和值score_to_names{score:nameforname,scoreinscores.items()}# 集合推导式numbers[1,2,2,3,3,3,4]unique_evens{xforxinnumbersifx%20}# {2, 4}生成器表达式# 圆括号——创建生成器而非列表sum_of_squaressum(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()ifscore60]# 传统循环适合的场景逻辑复杂有副作用需要多步骤处理passed_names[]forname,scoreinscores.items():ifscore60: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_itemschain([1,2,3],[a,b,c])print(list(all_items))# [1, 2, 3, a, b, c]# islice对迭代器做切片first_5islice(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:ifERRORinline:yieldlinedefextract_timestamps(lines):提取时间戳forlineinlines:timestampline[:19]# 前 19 个字符是时间戳yieldtimestamp# 管道组装读取 → 过滤 → 提取logsread_logs(server.log)errorsfilter_errors(logs)timestampsextract_timestamps(errors)# 此时还没有读取任何数据所有处理都是懒的# 直到你开始遍历数据才流过管道fortsintimestamps:print(ts)这是一个非常有用的设计模式。每个生成器只做一件事单一职责它们可以独立测试、独立修改通过管道灵活组合。当你需要处理复杂的数据转换逻辑时生成器管道往往比一个巨大的处理函数优雅得多。图15-3 列表一次性加载 vs 生成器按需产出处理大数据时生成器内存恒定列表随数据量线性增长。三、实战与总结实战用生成器写几个实用工具斐波那契数列生成器deffibonacci(limitNone):生成斐波那契数列可指定个数上限a,b0,1count0whilelimitisNoneorcountlimit:yielda a,bb,ab count1# 取前 10 个print(list(fibonacci(10)))# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]# 取不超过 100 的result[]fornuminfibonacci():ifnum100:breakresult.append(num)print(result)# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]分块读取大文件defread_in_chunks(file_path,chunk_size1024):每次读取指定大小的数据块适合处理二进制文件withopen(file_path,rb)asf:whilechunk:f.read(chunk_size):yieldchunk# 计算大文件的 MD5importhashlibdeffile_md5(file_path):md5hashlib.md5()forchunkinread_in_chunks(file_path,8192):md5.update(chunk)returnmd5.hexdigest()无限序列——生成器可以永不结束definfinite_counter(start0):whileTrue:yieldstart start1counterinfinite_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, start1)把输入的可迭代对象每行加上行号前缀输出。素数生成器用生成器实现无限素数序列。用筛法或其他算法。用生成器实现一个分页器在实际项目中你需要分批展示大量数据比如每页显示 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 高级语法的标志性概念。