关于yield的二三事

一切从一张图片开始:

 如何用turtle画这张图?我相信,即使你画不出来,也能说出来:用递归

OK!递归确实可以实现,但是官方给出的代码是:

from turtle import Turtle, mainloop
from time import perf_counter as clock

def tree(plist, l, a, f):

    if l > 3:
        lst = []
        for p in plist:
            p.forward(l)
            q = p.clone()
            p.left(a)
            q.right(a)
            lst.append(p)
            lst.append(q)
        for x in tree(lst, l*f, a, f):
            yield None

def maketree():
    p = Turtle()
    p.setundobuffer(None)
    p.hideturtle()
    p.speed(0)
    p.getscreen().tracer(30,0)
    p.left(90)
    p.penup()
    p.forward(-210)
    p.pendown()
    t = tree([p], 200, 65, 0.6375)
    for x in t:
        pass

def main():
    a=clock()
    maketree()
    b=clock()
    return "done: %.2f sec." % (b-a)

if __name__ == "__main__":
    msg = main()
    print(msg)
    mainloop()

你会发现核心函数tree中的“递归”似乎与我们常见的递归不同。

for x in tree(lst, l*f, a, f):
    yield None

没错,确实不同,因为这根本不是函数的递归,这是生成器的使用。

好了,现在的问题就来了:

1. 什么是生成器

2.生成器的运行机制是什么

3.为什么生成器可以实现递归的效果

那我们就一一来看。

什么是生成器

生成器的字面理解就是用来生成内容的东西,源自英文单词generator。生成器产生的原因妄自揣测一下是为了更经济的提供内容。

看一个例子:

def foo(datas):
    rst = 0
    nums = 0
    while rst <= 10000:
        data = datas[nums]
        rst += data
        nums += 1

    print(rst, nums)

foo函数利用循环从数据源中获取数据累加并进行计数。累加值超过10000后退出循环,打印累加与计数结果后退出。

现在为foo函数准备一个数据源:

datas_lst = random.sample(range(1, 10000), k=2000)

datas_lst是由2000个不重复的1~10000之间数组组成的列表。

好了我们看一下程序的运行情况:

import random
import sys


def foo(datas):
    rst = 0
    nums = 0
    while rst <= 10000:
        data = datas[nums]
        rst += data
        nums += 1

    print(rst, nums)


datas_lst = random.sample(range(1, 10000), k=2000)


print(sys.getsizeof(datas_lst))
foo(datas_lst)

运行结果是:

数据源占用了16056个bytes的空间,但是只用了前两个数据,foo函数就运行结束了。

看到这样的结果,心里可能只有一个想法:“浪费”。辛辛苦苦费时费力“生产”出的2000个数据,结果扔了1998个,浪费可耻。

如果用生成器来做数据源,会是什么情形呢?生成器会按照foo函数的需要来“生产”数据,每一次来自foo函数的“索取”,生成器会“生产”1个数据。所以,如果说datas_lst是提供给foo函数“消费”的2000个产品,那么这一次提供给foo函数是能够制作2000个产品的“机器”,foo函数索要一个数据时,就用“机器”生产一个数据。

好,那么Python中如何用代码来创造出生成器,以及相较于2000个产品,这样的“机器”会占用多少存储空间呢?

创建生成器有两种方式,先看old school的方式:

def gen():
    print(f"gen开始运行,准备产生数据")
    for x in random.sample(range(1, 10000), k=2000):
        yield x


datas_gen = gen()
print(datas_gen)

如果你第一次看到这样的代码,那么会认为定义了一个gen函数,然后我调用gen函数,将函数的返回值赋值给datas_gen变量,那么显然datas_gen引用的内容为None。

好了,datas_gen要是None的话,后面也没得玩了。所以gen觉不会是一个普通的函数。

没错,gen不但不是一个普通的函数,它甚至连函数都不是!要是不信可以运行这段代码,你会发现第一:屏幕上没有打印字符串"gen开始运行,准备产生数据",第二:datas_gen不是None。所以gen是个啥?

yield是Python的关键字,凡是函数体中出现yield的“函数”就不再是函数了,而是生成器。执行gen()会获得一个生成器对象,也就是前面说的,生产2000个不重复的1~10000之间数字的机器。

所以,现在的代码应该改写为:

import random
import sys


def foo(datas):
    rst = 0
    nums = 0
    while rst <= 10000:
        data = datas[nums]
        rst += data
        nums += 1

    print(rst, nums)


def gen():
    print(f"gen开始运行,准备产生数据")
    for x in random.sample(range(1, 10000), k=2000):
        yield x


datas_gen = gen()

print(sys.getsizeof(datas_gen))
foo(datas_gen)

运行这段代码,可以获得的信息是这个“机器”只有112个字节的大小,但是foo函数在报错。

“机器”的大小显然远远小于2000个产品的大小,而且可以透露的是,“机器”的大小并不会随着产能的改变(可以生产数字的多少)而发生改变。 

忍不住插一句,这个特性太酷了,它意味着我们可以用一个112字节大小的机器,来“生产”出2*10^N规模的数字,而且N可以是巨大的。这种数据量的需求在科学计算中是非常常见的,但是如果这样的数据规模使用常规的容器来盛放,简直不敢想象。而且在后文中我们还会看到,因为Python语法糖的存在,语法层面上可以无差别的访问容器中的内容或“机器”生产的内容。

好,回到正文中来,现在foo函数有问题的原因是,foo函数在使用[ ]表达式从数据源中获得数据,然后生成器对象目前并不支持[ ]表达式,所以产生了错误。

那么接下来就要修改一下foo函数,按照生成器的规则来从生成器中获得数据:

import random
import sys


def foo(datas):
    rst = 0
    nums = 0
    while rst <= 10000:
        # data = datas[nums]
        data = next(datas)
        rst += data
        nums += 1

    print(rst, nums)


def gen():
    print(f"gen开始运行,准备产生数据")
    for x in random.sample(range(1, 10000), k=2000):
        yield x


datas_gen = gen()

print(sys.getsizeof(datas_gen))

使用Python内置函数next,是向生成器索取数据的正确姿势。现在运行程序看一下结果:

我们看到此时控制台上输出了一句"gen开始运行,准备产生数据 ",并且根据foo函数的需要,仅生产了3个数据后,foo函数打印累和与计数结果后结束运行。

那么生成器的运行过程到底是什么呢?我们来继续看。

生成器的运行机制

 生成器是围绕着关键字yield来执行的。

foo函数开始运行时,rst初始值为0,进入while循环。现在foo函数通过next函数开始第一次向生成器索要数据。形参datas的实参是通过gen()生成器创建的生成器对象,所以生产数据的方式完全安装gen()中的定义方式来。这是gen()生成器第一次运行,那么会从生成器的第一句话开始执行。也就是在控制台打印"gen开始运行,准备产生数据 ",然后进入for语句,第一次执行yield x表达式。yield表达式的作用是将运算对象x作为next函数的返回值,从生成器中将数字提供给索取者foo函数使用,随着yield表达式运算完毕,生成器便暂停运行,处于一种挂起的状态。至此,gen()生成器生产了第一个数据,gen()生成器的挂起状态会一直保持到下一次索要数据时。foo函数通过next函数的返回值拿到gen()生产的第一个数字后赋值给变量data,然后与rst进行累和操作,nums进行计数操作。至此,foo函数while循环执行了一次。

如果foo函数的while循环条件检测为真,那么开始第二次的循环体执行。foo函数会再次调用next函数向gen()生成器索要数据。此时gen()生成器便从挂起状态回到运行状态,并且gen()会从之前挂起的位置继续执行:

gen()是在yield表达式运算完成后挂起的,所以重新开始执行就是从红线位置开始执行。这里没有赋值操作也没有后续语句,所以gen()生成器进入了for语句的第二次执行,然后第二次进行yield表达式运算,将运算结果作为next函数的返回值提供给foo函数,随着yield表达式的运算完成,gen()再次进入挂起状态。这边,foo函数再次拿到next函数的返回值后赋值给变量data,然后再次累和与计数,foo函数的循环体执行了第二次。

随后就是不断重复这个过程,如果foo函数通过while检测,就继续通过next函数向gen()生成器索要数据,gen()再次运行提供数据后再次挂起。通过next函数的返回值拿到新生产出来的数据,赋值给data继续累和和计数。直到某一次无法通过while的检测,while循环随之结束。此时foo函数也没有机会调用next函数了,foo函数随即打印结果结束执行,gen()生成器则一直处于挂起状态,随着整个程序的结束而最终被终结。

现在我们把gen()生产数据的方式变化一下:

import random
import sys


def foo(datas):
    rst = 0
    nums = 0
    while rst <= 10000:
        # data = datas[nums]
        data = next(datas)
        rst += data
        nums += 1

    print(rst, nums)


def gen():
    print(f"gen开始运行,准备产生数据")
    for x in random.sample(range(1, 100), k=10):
        yield x


datas_gen = gen()

print(sys.getsizeof(datas_gen))

按照现在的规则,datas_gen这台"机器"的生产能力锐减,只能生产不重复的10个1~100间的数字。再次运行程序会发生什么事情呢?

112证实了无论“机器”的生产能力变成什么样,“机器”的大小都不会改变。控制台打印了"gen开始运行,准备产生数据 ",说明生成器确实启动了,但是现在导致程序错误的是一个名为StopIteration的异常。这个StopIteration是从哪里来的呢?

根据foo函数的情况来看,这一次我们提供的“机器”产能明显有些不足,生产的数字不胆小,而且可以产出的数量也只有10个。我们现在现在假设foo函数已经向gen()生成器索要了10个数据,但显然这一次10个数据累和远远达不到10000,所以foo函数马上开始了第11次while循环体的执行。此时foo函数第11次调用next函数向gen()生成器索要数据,gen()生成器从挂起状态回到运行状态。再次进入运行状态后,gen()生成器的for语句已经执行完毕,gen()生成器已经没有机会通过计算yield表达式为next函数提供返回值了,此时gen()生成器会为next函数提供最后一个数据是StopIteration异常,将这个异常作为next函数的返回值。随后gen()生成器不再挂起而是结束了运行。这边foo函数拿到了next函数的返回值StopIteration异常,因为没有异常处理机制,所以整个程序也因为这个异常结束了运行。

所以,我们有必要将foo函数完善一下,加入对StopIteration的处理机制:

import random
import sys


def foo(datas):
    rst = 0
    nums = 0
    while rst <= 10000:
        try:
            # data = datas[nums]
            data = next(datas)
            rst += data
            nums += 1
        except StopIteration:
            break

    print(rst, nums)


def gen():
    print(f"gen开始运行,准备产生数据")
    for x in random.sample(range(1, 100), k=10):
        yield x


datas_gen = gen()

print(sys.getsizeof(datas_gen))

 这样,当gen()生成器的生产能力不足时,while循环进而foo函数可也可以正常结束,并打印累和和计数结果:

 程序运行结束时,foo函数向gen()生成器索要出了10个数据,这10个数据累和结果为475。

至此关于生成器最基本的知识已经说完了,再看看其它的相关内容。

首先是关于foo函数中数据源的访问问题。前面我们已经看到,如果是datas是列表,那么从datas中获取数据使用的是[ ]表达式,如果datas是生成器对象,那么从datas中获取数据需要使用next函数。有没有可以兼容的方式呢?有的,就是将while循环改为for...in...语句。其实Python中的循环语句只有while,for...in...根本不是标准的循环语句,for...in...就是为了简化生成器的使用而特别提供的语法糖

def foo(datas):
    rst = 0
    nums = 0

    for data in datas:
        rst += data
        nums += 1
        if rst > 10000:
            break

    print(rst, nums)

for...in...的执行本质其实就是利用迭代器/生成器这样的数据生产“机器”来获取数据。如果for...in...的是容器,那么for...in...会先获得容器的迭代器/生成器,然后再利用容器提供的迭代器/生成器来获取数据。另外,for...in...也会捕获StopIteration异常,当迭代器/生成器提供数据结束产生StopIteration时,for...in...语句也会随之结束,去执行for...in...后面的语句。

所以,如果datas是一个列表的话,那么for...in...语句会先获取列表的迭代器/生成器。没错,Python中的很多容器都实现了自己的迭代器/生成器。然后for...in...会使用Python列表的迭代器/生成器一个个从datas列表中获取数据。如果datas本身就是迭代器/生成器,那for...in...直接就会利用该迭代器/生成器进行数据的获取。

注:其实生成器是迭代器的一种,是迭代器的“轻量级”实现。以前写过关于迭代器和生成器的文章,这里不再赘述,有兴趣可以点击一文读懂Python可迭代对象、迭代器和生成器

那么看到这里大家也应该能更加充分理解for x in range(...)的含义了吧?range是Python的内置函数,返回值是range类型对象。·通过语法糖for...in...获取该range类型对象的迭代器,再利用迭代器来获取数值,随着数据的获取完毕for...in...语句结束运行。只不过for...in range的运行现象跟其它语言的for循环语句有很多相似的地方,所以也被“误认为”是Python的循环语句。

其次,我们再来补充一下生成器的另外一种new school的写法,生成器推导式:

def gen():
    print(f"gen开始运行,准备产生数据")
    for x in random.sample(range(1, 100), k=10):
        yield x


datas_gen = gen()

datas_gen_new = (x for x in random.sample(range(1, 100), k=10))

datas_gen与datas_gen_new作为生成器对象从“产能”上来说完全一样,都是生成10个1~100之间的整数。所以这也是为什么Python有列表推导式、字典推导式、集合推导式确偏偏没有元组推导式的原因。因为元组的()被征用过来当作生成器推导式了。

生成器实现递归效果

进入到本文的最后一个段落,用生成器来替代递归。

我们从调用栈的角度来考虑观察递归,永远只有调用栈栈顶的函数有执行的机会。递归其实就是A函数在执行的过程中被打断了,因为需要执行“另一个函数”(另一个函数有可能是B,也可以是自己A),那么需要把当前正在执行的A函数的参数、局部变量和执行到的语句这些内容记录下来,然后在调用栈中压入新函数,作为栈顶的新函数开始执行。直到某一时刻,这个压在A头上的函数执行完毕从调用栈里面弹出了,A会从记录的位置开始继续执行。

通过这段描述,就会发现从调用栈的角度观察递归与生成器的执行方式有异曲同工之处。两者都会有临时挂起和重新执行的特性。

我们先用递归来实现阶乘,然后再用生成器的方式来改写:

def boo(n):
    if n:
        return n * boo(n - 1)
    else:
        return 1


print(boo(5))

如果我们从调用栈的角度来看这段代码,就是boo(5)执行到return n * boo(4)的时候,要被“挂起”,然后把boo(4)压入栈开始执行,同理boo(4)也是执行到return n * boo(3)的时候,要被“挂起”,然后把boo(3)压入栈开始执行...依次类推,栈中会后续压入boo(2),boo(1)和boo(0)。不过boo(0)的执行不会被打断,boo(0)通过return返回执行结果1,然后boo(0)执行结束被弹出,boo(1)成为栈顶函数,boo(1)从被打断的位置继续执行,也就是retrun 1*1,随着结果1被返回,boo(1)执行结束被弹出,boo(2)成为栈顶函数,boo(2)从被打断的位置继续执行,也就是retrun 2*1,随着结果2被返回,boo(2)被弹出,依次类推,最终被打断的当boo(5)获得从return 5*24的地方继续执行的机会,返回结果120后,boo(5)从调用栈中弹出,此时调用栈中不再有内容,表明所有函数都执行完毕了。

接下来用生成器来实现,先创建一个生成器:

def boo_gen(n):
    a = 1
    b = 1
    while b <= n:
        yield a * b
        a = a * b
        b = b + 1

boo_gen的产能是n,产出的产品就是阶乘数字。

在boo函数中使用生成器来计算阶乘:

def boo(n):
    # if n:
    #     return n * boo(n - 1)
    # else:
    #     return 1

    for i in boo_gen(n):
        pass
    return i


print(boo(5))

第一次看到可能有点不习惯,我们结合前面已学的知识来分析一下现在boo函数的执行方式。

boo是普通的函数,boo(5)执行到for i in boo_gen(5)这句时,会首先执行boo_gen(5),获得一个产能是5的boo_gen生成器对象。然后,在for...in...语句的催动下,boo_gen生成器对象开始生产数据,boo_gen生成器第一次运行是从第一行开始,两个局部变量赋值然后进入while循环,执行yield表达式,将表达式计算结果1作为第一次生产的结果交给for...in...,然后挂起。for...in...语句在拿到数据后赋值给变量i,然后开始执行for...in...的语句体部分,我们并不关心阶乘的中间结果,所以for...in...语句体部分直接pass。那么for...in...就开始第二次索要数据,boo_gen生成器从挂起位置开始执行,改变了局部变量a和b的值,通过while的条件检测,开始第二次yield表达式计算,将表达式计算结果2作为产出交给for...in...然后挂起。for...in...语句在拿到数据后赋值给变量i,然后开始执行for...in...的语句体部分。依次类推,随着for...in...的连续索取,很快boo_gen生成器就会产生StopIteration异常,for...in...语句结束。n阶乘的结果就是最后一个boo_gen生成器生产的数据。所以for...in...语句结束后,i中保存着boo_gen生成器生产的最后一个数据的引用,将i作为boo(5)函数的返回值返回。

在解决阶乘这个问题,生成器的解题思路实际是一种递推思维的体现。递归和生成器都是编程的手法,不同的手法是在解决问题时不同思维方式的体现。

结尾

callback一下开头画图的代码,分析一下它的画图思维:

def tree(plist, l, a, f):

    if l > 3:
        lst = []
        for p in plist:
            p.forward(l)
            q = p.clone()
            p.left(a)
            q.right(a)
            lst.append(p)
            lst.append(q)
        for x in tree(lst, l * f, a, f):
            yield None


def maketree():
    p = Turtle()
    p.setundobuffer(None)
    p.hideturtle()
    p.speed(0)
    p.getscreen().tracer(30, 0)
    p.left(90)
    p.penup()
    p.forward(-210)
    p.pendown()
    t = tree([p], 200, 65, 0.6375)
    for x in t:
        pass

maketree其实做了两件事:

1.将画笔移动到(0,-210)位置,并保持笔尖呈90向上

2.获得生成器对象,利用for...in...语句开始向这个生成器对象索要数据。

tree是生成器,生成器的产能与参数l有关。我们来看一下tree的工作情况

maketree索要数据是生成器对象t第一次工作,所以代码是从tree的第一行开始执行。在第一个for语句中做了两件事:

1.依次使用plist中的画笔,画长度为l的树的内容

2.每使用plist中的一支画笔绘制后,都会依据这支画笔的状态生成下一次绘画的两只画笔,并存入局部变量lst列表中。

tree在第二个for语句中做了两件事:

1.生成新的tree生成器对象,新的tree生成器对象中plist中的画笔数量翻一倍,但是l会缩短(0.635)

2.for...in...会马上向新的tree生成器对象索要数据。新的tree生成器对象第一次执行,是以新的参数从tree生成器的第一行代码开始执行。

所以,这是一种以递归的手法(在生成器中创建并使用下一个生成器)递推式的在绘制一层层的树。随着新的生成器不断产生,终于在某一个生成器(按代码看是第11个生成器),因为不满足l>3这个条件,直接StopIteration。这个StopIteration会传递给上一个生成器,导致上一个生成器直接在for...in...处退出,但是这就意味着上一个生成器其实没有机会yield出任何内容,所以上一个生成器只产出了StopIteration,很尴尬,有产能,但是因为代码执行的原因导致一个数据都没有生产就扔出了StopIteration。上一个生成器产出的StopIteration,扔给了上上个生成器,所以上上个生成器也在有充足产能且一个数据都没有生产的情况下直接StopIteration,依次类推,这个StopIteration会一直扔到maketree中创造的第一个生成器对象t的for...in...语句处,maketree的for...in...语句检测到StopIteration异常后,结束了自己的执行,从而maketree函数执行完毕。

11个生成器,在10个生成器都有产能的情况下,一个数据都没有生产,但完成了树的绘制。所以,官方的程序上有这样一句注释:

a tree-generator, where the drawing is quasi the side-effect, whereas the generator always yields None.

最后的最后,我用递归来改写一下官方的代码实现树的绘制:

from turtle import Turtle, mainloop
from time import perf_counter as clock


def tree(plist, l, a, f):

    if l > 3:
        lst = []
        for p in plist:
            p.forward(l)
            q = p.clone()
            p.left(a)
            q.right(a)
            lst.append(p)
            lst.append(q)
        # for x in tree(lst, l * f, a, f):
        #     yield None
        tree(lst, l * f, a, f)


def maketree():
    p = Turtle()
    p.setundobuffer(None)
    p.hideturtle()
    p.speed(0)
    p.getscreen().tracer(30, 0)
    p.left(90)
    p.penup()
    p.forward(-210)
    p.pendown()
    # t = tree([p], 200, 65, 0.6375)
    # for x in t:
    #     pass
    tree([p], 200, 65, 0.6375)


def main():
    a = clock()
    maketree()
    b = clock()
    return "done: %.2f sec." % (b - a)


if __name__ == "__main__":
    msg = main()
    print(msg)
    mainloop()

哈哈哈,超级简单吧。没有了yield的tree就是正经的函数了,通过函数自己调用自己就是递归画树了。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值