当前位置: 首页 > 科技观察

Python进阶——如何正确使用yield?

时间:2023-03-18 11:16:18 科技观察

在Python开发中,yield关键字其实用的比较频繁,比如大集合的生成,代码结构的简化,协程和并发都会用到。但是你真的了解yield是如何工作的吗?在这篇文章中,我们就来看看yield的操作流程,以及哪些场景适合在开发中使用yield。生成器如果方法中包含yield关键字,则该函数是“生成器”。生成器实际上是一种特殊的迭代器,它可以像迭代器一样迭代输出方法中的每个元素。如果你还不知道什么是“迭代器”,可以参考我写的这篇文章:Python进阶——迭代器和可迭代对象有什么区别?我们来看一个包含yield关键字的方法:#coding:utf8#Generatordefgen(n):foriinrange(n):yieldig=gen(5)#Createageneratorprint(g)#print(type(g))##迭代生成器中的数据foriing:print(i)#Output:#01234注意,在这个例子中,当我们执行g=gen(5)时,代码中的gen不是实际执行。这个时候我们只是创建了一个类型为generator的“generator对象”。那么,当我们执行foriing时,每执行一次循环,就会执行一次yield,返回yield之后的值。这个迭代过程是与迭代器最大的区别。也就是说,如果我们要输出5个元素,生成器创建的时候,这5个元素实际上还没有生成,那什么时候生成呢?仅当for循环遇到yield时,才会依次产生每个元素。另外,生成器除了像迭代器一样实现迭代数据,还包括其他方法:如果没有数据迭代,则抛出StopIterator异常,for循环结束[,value[,traceback]]):向生成器外部抛出异常generator.close():关闭生成器通过使用生成器的这些方法,我们可以完成很多有趣的功能。__next__我们先来看生成器的__next__方法。让我们看看下面的例子。#coding:utf8defgen(n):foriinrange(n):print('yieldbefore')yieldiprint('yieldafter')g=gen(3)#创建生成器print(g.__next__())#0print('----')print(g.__next__())#1print('----')print(g.__next__())#2print('----')print(g.__next__())#StopIteration#输出:#yieldbefore#0#----#yieldafter#yieldbefore#1#----#yieldafter#yieldbefore#2#----#yieldafter#Traceback(mostrecentcallast):#File"gen.py",line16,在#print(g.__next__())#StopIteration#StopIteration这个例子中,我们定义了gen方法,其中包含yield关键字。然后我们执行g=gen(3)来创建一个生成器,但是这次我们不是执行for来迭代它,而是多次调用g.__next__()来输出生成器中的元素。我们看到在执行g.__next__()的时候,代码会执行到yield,然后返回yield之后的值。如果你继续调用g.__next__(),注意,你会发现本次执行的开始位置,就是上次yield结束的地方,同时也保留了上次执行的上下文,继续往回迭代。这就是使用yield的作用。在迭代生成器时,每次执行都可以保留之前的状态,而不是像普通方法那样遇到return就返回结果,下次执行只能再次重复之前的过程。除了保存生成器的状态,我们还可以通过其他方式改变它的内部状态,就是下面要讲的send和throw方法。在上面send的例子中,我们只展示了yield之后的值。其实也可以使用j=yieldi的语法。我们看下面的代码:#coding:utf8defgen():i=1whileTrue:j=yieldii*=2ifj==-1:break如果我们此时执行下面的代码:foriingen():print(i)time。sleep(1)输出结果将是1248163264...循环继续,直到我们杀死进程才会停止。这段代码之所以一直循环,是因为要等到分支j==-1跳出后才能执行。如果我们想让代码执行到这个地方,怎么办呢?这里使用了生成器的send方法。send方法可以将外部值传递给生成器,从而改变生成器的状态。代码可以这样写:g=gen()#创建生成器print(g.__next__())#1print(g.__next__())#2print(g.__next__())#4#sendput-1传进generator去了j=-1分支print(g.send(-1))#StopIterationiterationstop当我们执行g.send(-1)时,相当于把-1传入generator,而然后在yield前面赋值给j,此时j=-1,那么这个方法就会break掉,不会继续迭代。除了向生成器传递一个值,throw之外还可以传递一个异常,即调用throw方法:#coding:utf8defgen():try:yield1exceptValueError:yield'ValueError'finally:print('finally')g=gen()#创建生成器print(g.__next__())#1#给生成器引入异常并返回ValueErrorprint(g.throw(ValueError))#Output:#1#ValueError#finally这个例子创建完生成器后,使用g.throw(ValueError)向生成器传递异常,进入生成器异常处理的分支逻辑。关闭发电机的关闭方法也比较简单,就是手动关闭发电机,关闭的发电机不能再运行。>>>g=gen()>>>g.close()#关闭生成器>>>g.__next__()#无法迭代数据Traceback(mostrecentcallast):File"",line1,inStopIterationclose方法在开发中很少用到,了解一下即可。使用场景了解了yield和generator的使用方法,yield和generator一般用在哪些业务场景?下面我介绍几个例子,分别是大集合的生成、简化的代码结构、协程和并发。你可以参考这些使用场景来使用yield。大集合的生成如果要生成一个非常大的集合,如果使用list创建集合,这会导致在内存中分配很大的存储空间。例如,想想以下内容:#coding:utf8defbig_list():result=[]foriinrange(10000000000):result.append(i)returnresult#一次在内存中生成一个大集合会占用大量内存。foriinbig_list():print(i)这种场景下,我们可以使用生成器很好的解决这个问题。因为生成器只有在执行yield时才会迭代数据,此时只申请需要返回元素的内存空间,所以代码可以这样写:#coding:utf8defbig_list():foriinrange(10000000000):yieldi#只有在迭代时才顺序生成元素,减少内存占用foriinbig_list():print(i)简化代码结构我们在开发中经常会遇到这样的场景,如果一个方法返回一个列表,但是这个列表只能经过组合后生成多个逻辑块是的,这会导致我们的代码结构变得很复杂:#coding:utf8defgen_list():#多个逻辑块组成一个列表result=[]foriinrange(10):result.append(i)forjinrange(5):result.append(j*j)forkin[100,200,300]:result.append(k)returnresultforitemingen_list():print(item)这种情况下,我们只能在每个逻辑块中使用append向列表添加元素,代码写起来比较冗长。这时候如果使用yield生成这个列表,代码会简洁很多:#coding:utf8defgen_list():#多个逻辑块使用yield生成一个列表foriinrange(10):yieldiforjinrange(5):yieldj*jforkin[100,200,300]:yieldkforitemingen_list():print(i)使用yield后,不再需要定义list类型的变量。只需要在每个逻辑块中直接yield和return元素,就可以实现和前面例子一样的功能。我们可以看到使用yield的代码更加简洁,结构更加清晰。另一个好处是只在迭代元素时才分配内存空间,减少了内存资源的消耗。协程与并发还有一种场景yield用的比较多,那就是“协程与并发”。如果我们想提高程序的执行效率,我们通常会采用多进程、多线程的方式来编写程序代码。最常用的编程模型是“生产者-消费者”模型,即一个进程/线程生产数据,其他进程/线程消费数据。在开发多进程、多线程程序时,为了防止共享资源被篡改,我们通常需要加锁进行保护,这增加了编程的复杂度。在Python中,除了使用进程和线程,我们还可以使用“协程”来提高代码执行的效率。什么是协程?简单来说,由多个程序块组合执行的程序称为“协程”。在Python中使用“协程”,需要使用yield关键字来配合。也许很容易理解。我们使用yield来实现一个协程生产者和消费者的例子:#coding:utf8defconsumer():i=NonewhileTrue:#获取生产者发送的数据j=yieldiprint('consume%s'%j)defproducer(c):c.__next__()foriinrange(5):print('produce%s'%i)#发送数据给consumerc.send(i)c.close()c=consumer()producer(c)#Output:#produce0#consume0#produce1#consume1#produce2#consume2#produce3#consume3...这个程序的执行流程如下:c=consumer()创建一个生成器对象producer(c)开始执行,c.__next()__将启动生成器消费者,直到代码运行到j=yieldi。此时consumer第一次执行,returnproducer函数继续执行,直到c.send(i),这里使用generator的send方法向consumer发送数据。consumer函数被唤醒,从j=yieldi开始继续执行,并从producer那里接收数据赋值给j,然后打印出来,直到再次执行yield,返回给producer继续循环执行以上过程,依次向cosnumer发送数据,直到循环结束,最后c.close()关闭消费者生成器,程序退出。在这个例子中,我们发现程序在生产者和消费者这两个函数之间来回切换。协同完成生产任务和消费任务的业务场景。最重要的是,整个程序是在单进程单线程下完成的。这个例子使用了上面提到的yield,生成器的__next__、send、close方法。如果难以理解,可以多看几遍这个例子,最好自己测试一下。当我们使用协程为生产者和消费者编写程序时,它的好处是:整个程序运行过程中没有锁,不需要考虑共享变量的保护,降低了编程复杂度。程序在功能之间来回切换。它是在用户态进行的,不像进程/线程会陷入内核态,减少了内核态上下文切换的消耗,执行效率更高。因此Python的yield和generator实现了协程的编程方式,为程序的并发执行提供了编程基础。Python中很多第三方库都是基于这个特性进行封装的,比如gevent、tornado等,大大提高了程序的运行效率。总结一下,在这篇文章中,我们主要讲了yield的使用和generator的各种特性。生成器是一种特殊的迭代器。除了迭代数据,它还可以在执行过程中保存方法中的状态。此外,它还提供了一种从外部改变内部状态,并将外部值传递给生成器的方法。利用yield和generator的特性,我们可以在开发中的大集成生成、代码结构简化、协程、并发等业务场景中使用。Python的yield也是实现协程和并发的基础。它提供了协程等用户态编程方式,提高了程序运行的效率。