首页 / 资讯中心 / 文章详情

“两个方法,一个关键字”,Python迭代器与生成器的底层秘密

“两个方法,一个关键字”,Python迭代器与生成器的底层秘密 ★ FEATURED ARTICLE
Python 迭代器和生成器我刚开始学 Python 的时候感觉for循环用得很顺手但从没想过一个问题它凭什么能遍历一个列表它的底层原理是什么久而久之我也没再管过这个东西只留下了疑问。再后来随着深入的学习我接触到了迭代器和生成器又打开了我尘封已久的记忆再次会想到了前面的疑问也更深层次的解开了for循环的神秘面纱这东西本身不难所以这篇我不打算从定义开始讲我们从一行for循环开始拆。for 循环背后其实是三个东西在配合写一行最普通的代码fornin[1,2,3]:print(n)它看起来是一个动作实际上是三层调用叠在一起。Python 会把上面这几行翻译成大概这样的东西nums[1,2,3]ititer(nums)# 第一步要一个迭代器whileTrue:try:nnext(it)# 第二步一个一个往外拿exceptStopIteration:# 第三步拿不到了就结束breakprint(n)内置函数 iter() 负责问对象“你能给我一个迭代器吗”next()负责从迭代器里取下一个值StopIteration这个异常则是一条约定好的暗号——意思是“没了别取了”。for循环的全部魔法就在这里。它自己并不认识列表、字典、文件……等等它只认这套接口。任何对象只要你能给它一个迭代器它就能被for遍历。这个设计我觉得挺妙Python 没有为每种容器单独写一套遍历逻辑而是把“怎么遍历”这件事下放给对象自己语言层面只管调用。所以你能遍历的东西和 Python 内置的东西用的是同一套机制——你写的类和list在这件事上是完全平等的。图 1for 循环的工作只有三件——要迭代器、取下一个、接住 StopIteration。那么迭代器到底长什么样按 Python 官方文档的说法一个对象只要满足两个条件它就是迭代器有__iter__方法返回迭代器本身有__next__方法返回下一个值没有下一个了就抛StopIteration就这两条。没有基类要继承没有接口要注册纯靠约定。这种风格在 Python 里叫“协议”protocol跟 Java 那种implements Iterator是两回事——你只要长成那个样子它就是那个东西有点像 duck typing走起来像鸭子、叫起来像鸭子那就是鸭子。手写一个试试比如倒计时classCountdown:def__init__(self,start):self.currentstartdef__iter__(self):returnself# 迭代器返回自己def__next__(self):ifself.current0:raiseStopIteration# 到点了报个信valueself.current self.current-1returnvalueforninCountdown(3):print(n)# 3# 2# 1跑起来完全正常。注意__next__里那个raise StopIteration——它不是错误是正常的“下班信号”。for循环捕获到它安安静静地退出不会给你报错。这也是为什么很多人第一次见到StopIteration会觉得莫名其妙它出现在 traceback 里的时候通常是因为你在for外面手动调了next()没人接住这个信号。__iter__返回self这件事值得多说一句。迭代器的__iter__必须返回自己因为for循环做的第一件事就是对你调iter()。如果它返回的不是自己for就会拿一个新的东西去遍历而你手里这个迭代器的状态永远不动。迭代器是消耗品上面这个Countdown有个特点遍历一次就废了。cCountdown(2)print(list(c))# [2, 1]print(list(c))# []第二次是空的因为self.current已经减到 0 了。迭代器不是容器它不存数据它只记“我走到哪了”。这东西像一张单程车票也像挤过的牙膏——用掉的部分不会自己回来。如果你希望每次遍历都从头开始那就得让__iter__每次返回一个新的迭代器而不是self。这正是“可迭代对象”和“迭代器”的分界线。可迭代对象 ≠ 迭代器这是初学者最容易糊在一起的一组概念但区分开之后很多疑惑会自己散掉。列表是可迭代对象但它不是迭代器。你没法对它调next()nums[1,2,3]next(nums)# TypeError: list object is not an iteratoriter(nums)# list_iterator object at 0x...iter(nums)每次都给你一个全新的list_iterator各走各的。所以列表可以被反复遍历每次都是从零开始。我习惯这么记可迭代对象是仓库迭代器是仓库里开出来的一条传送带。仓库里的货不动你想要几条传送带都行但每条传送带上的货取走了就没了。按这个划分Python 里的东西大致分三类可迭代但不是迭代器list、tuple、str、dict、set、range、dict.keys()。能反复遍历。本身就是迭代器文件对象、zip()、map()、filter()、enumerate()、生成器。只能走一遍。两者都不是int、None、你随手写的普通类。图 2可迭代对象每次 iter() 都发一个新游标因此能反复遍历迭代器自己就是游标走一遍就空了。然后是 yield到这儿你大概已经有感觉了手写迭代器有点啰嗦。一个倒计时而已要写一个类、两个方法、一个计数器属性、一个边界判断。要是逻辑再复杂点状态得拆成一堆self.xxx读起来很难受。生成器就是来解决这个问题的。同样一个倒计时defcountdown(start):whilestart0:yieldstart start-1fornincountdown(3):print(n)# 3# 2# 1一个类变成三行__iter__、__next__、StopIteration全都不见了。但效果一模一样——countdown(3)返回的东西可以直接丢进for也可以用next()一个个取。yield这个词中文一般译成“产出”但我觉得它更像“交货”。函数执行到yield这一行语法上叫 yield 表达式把手里的值交出去然后就地停住卷帘门一拉等你下次来再继续。下次你next()一下它从刚才停的地方接着往下跑所有局部变量都还在——start是几、循环走到第几轮一样不少。图 3暂停时执行位置与局部变量都保留在函数内部中间状态不必存到外面。这是return和yield最本质的区别return是跑完全程然后退场yield是跑到一半把东西递出来人还站在那儿。生成器函数被调用时一行都不执行这一点我第一次遇到时觉得很反直觉。看代码defdemo():print(开始执行)yield1print(中间)yield2print(结束)gdemo()print(函数已经调用完了)你先猜猜输出。答案是函数已经调用完了就这一行。demo()确实被调用了g也确实有值了但print(开始执行)一个字都没打出来。因为demo里有yield它就不再是普通函数而是**生成器函数**。调用它不会执行函数体只是打包一个生成器对象给你里面装着这段代码和一份还没开始的执行状态。真正的执行要等你第一次next()。继续print(next(g))# 先打印开始执行再打印 1print(next(g))# 先打印中间再打印 2next(g)# 打印结束然后抛 StopIteration顺手把执行顺序标一下这个顺序值得盯几秒next(g)让函数从头开始跑打印“开始执行”撞到yield 1把 1 交出去暂停。next()的返回值就是 1再next(g)从yield 1后面接着跑打印“中间”撞到yield 2交出 2再暂停第三次next(g)打印“结束”函数自然结束抛StopIteration注意print(next(g))是先执行完next(g)触发函数跑到yield再打印返回值。所以“开始执行”排在 1 的前面。这个细节在调试生成器时特别重要——你看到的输出顺序和脑子里想的顺序经常对不上。惰性你不给它派活它绝不干活生成器是惰性的lazy。上面那个demo你调用一百次demo()只要不next()函数体一行都不会跑。这既是它最大的优点也是它最容易坑人的地方。先看优点importsys big_list[iforiinrange(1_000_000)]big_gen(iforiinrange(1_000_000))print(sys.getsizeof(big_list))# 八百多万约 8 MBprint(sys.getsizeof(big_gen))# 两百字节左右同样是一百万个数一个占 8MB一个占两百字节。而且注意这 8MB 只是列表本身那个指针数组整数对象还没算进去——真实的堆内存是这个数的好几倍。顺带说一句sys.getsizeof()量的是对象自己占的那块内存不包括它引用的对象所以上面这个数字只能看个量级。生成器为什么这么小因为它根本没存数据。它只存了一个“怎么生成下一个”的方法和当前走到哪儿的进度。一百万个数是一个一个现算出来的算完就扔不在内存里堆着。列表像把一整车货全卸在仓库里生成器像流水线需要什么现给你做一个。生成器的几个脾气用久了会发现生成器有些自己的性格提前知道能少踩点坑。第一只能遍历一次。跟迭代器一样这是个消耗品。g(x*2forxinrange(3))print(list(g))# [0, 2, 4]print(list(g))# []这事在函数传参时特别容易出问题。你把一个生成器传给两个函数第一个用完了第二个拿到的是个空壳还不报错只是默默地什么也不做。查这种 bug 很费时间。第二它不知道自己有多长。len(g)会报TypeErrorg[0]也不行。因为它压根没把数据存下来也没法回退。想按索引取先list()转过去。第三return在生成器里是“提前收工”。生成器函数里可以写return但它不会把值返回给for循环作用只是让生成器结束。如果写了return 值这个值会被塞进StopIteration异常的value属性里for循环默认看不到它。defgen():yield1return结束语yield2# 永远到不了ggen()print(next(g))# 1try:next(g)exceptStopIterationase:print(e.value)# 结束语第四生成器可以双向通信。除了next()往里推还可以用send()往里面塞值。这个特性用的人不多但它是后面协程的基础设计动机见 PEP 342defaverager():total,count,avg0,0,NonewhileTrue:valueyieldavg# yield 既把 avg 交出去也把 send 进来的值收进来totalvalue count1avgtotal/count gaverager()next(g)# 先启动到第一个 yieldprint(g.send(10))# 10.0print(g.send(20))# 15.0print(g.send(30))# 20.0value yield avg这一行是整段代码里最别扭的地方——一个表达式同时干了两件事把avg交出去再把外面send()进来的值赋给value。第一次看会觉得这是语法层面的巧合其实asyncio那套await就是从这儿长出来的。迭代器和生成器到底是什么关系捋到这儿其实已经很清楚了但我还是想明确说一句因为这是面试常问的生成器是迭代器的一种。一个生成器对象实现了__iter__和__next__所以它满足迭代器协议能用在任何需要迭代器的地方。反过来不成立——迭代器不一定是生成器你可以像我前面那样用一个类手写出来。可以这么理解迭代器是一个规范生成器是满足这个规范的一种便捷实现。就像“可调用对象”是规范函数是其中最常见的一种实现。术语表里对生成器的定义本身也很朴素一个用yield产出值的函数。把区别列成一张表方便对照迭代器生成器本质一套协议__iter____next__迭代器的一种实现怎么写写个类实现两个方法写个函数用yield状态存在哪对象的属性里函数的栈帧里自动保留内存占用看实现通常很小很小一次只产出一个值能否多次遍历一般不能不能能否表示无限序列能但写起来麻烦能而且很自然那到底该用哪个我的经验是日常写代码99% 的情况直接写生成器别碰手写迭代器。生成器的代码短、可读性好、状态管理不用你操心。只有两种情况我会考虑手写迭代器类一是这个迭代对象还需要别的方法和属性比如一个带reset()、seek()、progress的游标它更像一个有状态的服务顺手实现了迭代协议而已。二是状态逻辑特别复杂塞进一个函数里会变得又长又难测拆成类更清楚。除此之外生成器就够了。几个我踩过的坑别把生成器list()了。这是最讽刺的一种错误。你辛辛苦苦搭了一条流水线最后来一句results list(pipeline)等于把所有货又堆回仓库前面省的存全部白费。要聚合就用sum()、Counter、max()这种边吃边算的方式别先物化。调试的时候看不到内容。print(g)只会给你generator object gen at 0x...啥也看不出来。想看内容就list(g)但记得这会消费掉它。也可以考虑itertools.tee复制一份代价是它必须缓存所有已经产出的值内存优势也就没了。生成器表达式和列表推导长得太像。一个方括号一个圆括号差别巨大[x*2forxinrange(3)]# 立刻算完得到列表 [0, 2, 4](x*2forxinrange(3))# 立刻返回生成器一个都还没算括号打错不会报错但行为完全不同尤其在传给sum()、any()这类函数的时候——传列表是先把整个列表造出来再算传生成器是边算边喂。后者往往快得多还能提前短路any()碰到第一个True就停了。生成器也不是永远更快。它省内存但每次next()都有状态切换的开销。数据量本来就小的时候比如几百条生成器往往比列表慢因为多出来的那层调度不值当。我的判断标准是数据量可能很大或者根本不知道有多大就用生成器明确就几十上百条列表更直接。最后回过头看这套东西的核心其实只有一句话迭代器是把“遍历”这件事抽象出来的一套约定生成器是写这套约定最省事的方式。for循环之所以能遍历一切是因为它只依赖__iter__和__next__这两个约定而不关心对象内部是什么。这种“面向协议而不是面向类型”的思路在 Python 里到处都是——上下文管理器靠__enter__/__exit__可调用对象靠__call__都是同一个套路。理解了一个其他几个就都好懂了。如果这篇能让你以后看到yield不再发怵那目的就达到了。
阅读完成 · 觉得有帮助?
咨询建站