Python 的关键字 yield 有哪些用法和用途?

函数和生成器的执行流程、实现原理

首先浅显地介绍一下yield的实现原理,和题主的初衷有点偏离了,但即便如此相信对题主也是有帮助的。我们先来看看python是如何执行函数的

def mashiro():
    ...

def satori():
    mashiro()

satori()

1.python虽然是解释型语言,但也要进行一次编译,编译成字节码对象。

2.python解释器去执行对应的字节码

3.当执行到satori函数的字节码时,会为其创建一个栈帧(Stack Frame),表示函数调用栈当中的某一帧,相当于一个上下文,函数要在对应的栈帧上运行。正所谓python中一切皆对象,栈帧也是一个对象(PyFrameObject),注意的是:"栈帧对象是存储在堆上面的,python中的对象本质上就是C语言中的malloc函数为结构体在堆上申请的一块内存"。这就意味着即便函数退出了,只要有指针指向它,就能拿到对应的栈帧,这一特性就决定了我们能够对函数进行非常精确的控制,也为后面的生成器实现埋下了伏笔。

4.然后会调用一个叫做PyEval_EvalFrameEx(PyFrameObject *f)的C语言函数,在satori函数对应的栈帧上去执行对应的字节码,参数就是satori函数的栈帧对象。

5.当执行到mashiro函数的字节码时同样会为其创建一个栈帧,然后把控制权交给新的栈帧对象,在mashiro函数对应的栈帧中运行mashiro函数的字节码

我们可以看看执行流程

我是一名python开发工程师,整理了一套python的学习资料,从基础的python脚本到web开发、爬虫、
数据分析、数据可视化、机器学习、面试真题等。想要的可以进群:688244617免费领取

import dis

def mashiro():
    ...

def satori():
    mashiro()

dis.dis(satori)
"""
  7           0 LOAD_GLOBAL              0 (mashiro)
              2 CALL_FUNCTION            0
              4 POP_TOP
              6 LOAD_CONST               0 (None)
              8 RETURN_VALUE
"""
# 首先LOAD_GLOBAL,把mashiro这个函数给load进来
# 然后CALL_FUNCTION,调用mashiro函数的字节码
# POP_POP,从栈的顶端把元素打印出来
# LOAD_CONST,我们这里没有return,所以会把None给load进来
# RETURN_VALUE,把None给返回

我们之前说过可以获取栈帧,那怎么获取呢?

import inspect

frame = None
def mashiro():
    global frame
    name = "椎名真白"
    home = "樱花庄"
    frame = inspect.currentframe()  # 拿到当前函数的栈帧

def satori():
    name = "古明地觉"
    home = "东方地灵殿"
    mashiro()

# 但函数执行完,显然frame不会空
satori()

# 那么这个栈帧有啥属性呢?

# f_code:字节码,再调用co_name就能拿到函数名
print(frame.f_code.co_name)  # mashiro
# f_back:调用者的上一级栈帧, 这里显然是satori函数的栈帧
print(frame.f_back.f_code.co_name)  # satori

# f_locals:当前栈帧当中的局部变量
print(frame.f_locals)  # {'name': '椎名真白', 'home': '樱花庄'}
print(frame.f_back.f_locals)  # {'name': '古明地觉', 'home': '东方地灵殿'}

"""
因为我们拿到了栈帧,有一个全局变量在指向它,所以不会被销毁
那么与之关联的调用者的栈帧同样也不会被销毁,
因为当前栈帧的f_back指向了调用者的栈帧
"""

之前说过,栈帧是分配在堆内存上面的,也正因为如此,生成器才会实现。

是如何实现的呢?实际上是对PyFrameObject做了一层封装(注意:如果函数内部有yield,那么在编译的时候就已经确定是生成器了),封装成了PyGenObject,这个PyGenObject有两个属性,一个是gi_frame:之前的PyFrameObject,一个是gi_code:PyCodeObject(字节码),重点是这个gi_frame,这里面除了之前的f_locals,还有一个最重要的f_lasti

def gen_func():
    yield 123
    name = "古明地觉"
    yield 456
    home = "东方地灵殿"
    return "我永远喜欢古明地觉"


g = gen_func()
print(g.gi_frame.f_lasti)  # -1

"""
结果是-1,说明在生成器刚创建的时候,f_lasti为-1
"""
# 我们send一下
try:
    g.send("古明地觉")
except TypeError as e:
    print(e)  # can't send non-None value to a just-started generator

# 提示我们只能发送一个None

我们看一下源码

你看到了什么,是的,这个f_lasti就是标记生成器执行到哪一步了。

import dis

def gen_func():
    yield 123
    name = "古明地觉"
    yield 456
    home = "东方地灵殿"
    yield 789
    return "我永远喜欢古明地觉"


g = gen_func()
dis.dis(g)
"""
  4           0 LOAD_CONST               1 (123)
              2 YIELD_VALUE
              4 POP_TOP

  5           6 LOAD_CONST               2 ('古明地觉')
              8 STORE_FAST               0 (name)

  6          10 LOAD_CONST               3 (456)
             12 YIELD_VALUE
             14 POP_TOP

  7          16 LOAD_CONST               4 ('东方地灵殿')
             18 STORE_FAST               1 (home)

  8          20 LOAD_CONST               5 ('我永远喜欢古明地觉')
"""
# 可以看到,有两个YIELD_VALUE,因为我们生成器当中有两个yield
# 最后的LOAD_CONST则是把'我永远喜欢古明地觉'load进来
# 然后返回

# f_lasti:标记生成器的执行状态
# f_locals:当前生成器里面的局部变量
print(g.gi_frame.f_lasti)  # -1
print(g.gi_frame.f_locals)  # {}
"""
我们创建了生成器,但是还没执行,因此f_lasti是-1,当前也没有局部变量
"""

g.__next__()
print(g.gi_frame.f_lasti)  # 2
print(g.gi_frame.f_locals)  # {}
"""
当我们__next__之后,f_lasti变成了2,
显然对应那个2 YIELD_VALUE,说明之前load 123,然后yield出去了
"""

g.__next__()
print(g.gi_frame.f_lasti)  # 12
print(g.gi_frame.f_locals)  # {'name': '古明地觉'}
"""
当走到第二个yield的时候,f_lasti是12
这个时候f_locals
注意:上面输出的
6 LOAD_CONST               2 ('古明地觉')
8 STORE_FAST               0 (name)

表示先把'古明地觉'这个字符串load进来,然后使用name变量进行存储
这个时候已经创建了相应的变量
"""

g.__next__()
print(g.gi_frame.f_lasti)  # 22
print(g.gi_frame.f_locals)  # {'name': '古明地觉', 'home': '东方地灵殿'}
"""
f_lasti为22,此时f_locals又多了一个元素,说明又创建了一个局部变量
"""

因此我们便很容易理解为什么生成器能够实现了,因为无论生成器执行到哪一步,内部PyGenObject的f_lasti都进行了完美的监督,或者说知道并记录了生成器停下来的位置。我们可以通过yield使生成器暂停,并把值yield出来(可以把yield简单看成return,或者把生成器当成可以暂停的函数),并且还能通过send、next方法让其从停下来的地方开始前进,当然生成器的栈帧也是在堆上的,我们也是随时都可以拿到它,这说明我们不仅能够让其暂停、并从停下来的地方前进,还能随时都这样。也正因为可以随时控制它,python早期版本中协程才会得以实现,这也是协程能够实现的理论基础。

但是还有两个问题,为什么生成器只能生成一次,第二次执行就没了?

def gen():
    yield 1
    yield 2
    yield 3


g = gen()
print(sum(g))  # 6
print(sum(g))  # 0

为什么使用生成器暂停之后可以使用send和next唤醒?

这两个问题可以合并一块回答

def gen():
    yield 1
    yield 2
    yield 3


g = gen()

for _ in g:
    print(g.gi_frame)
"""
<frame at 0x000002700....
<frame at 0x000002700....
<frame at 0x000002700....
"""

print(g.gi_frame)  # None

我们看到当我们最后试图去拿栈帧的时候,居然返回的是None,这是因为f_lasti大限已至,走到了尽头,不可能再从头开始,除非你像函数一样,重新生成一个新的生成器。而且每一次执行到yield的时候会创建一个新的栈帧,然后将局部变量保存之后就把f_back清空,并将新的栈帧从栈帧链当中移除(注意:只是保存在了别的地方),然后当我们使用send、next的时候,又会将其插入到栈帧链当中执行。周而复始,直到f_lasti走到头。

yield和yield from

yield from又是个啥,当一个函数中出现了yield from,那么这个函数还有一个专业名词,叫做委托生成器。目的就是在调用方和子生成器之间建立一个双向通道。

def gen():
    yield "haha"
    yield "gaga"

def foo():
    yield from gen()


f = foo()
print(f.__next__())  # haha

"""
foo就是我们的委托生成器,gen就是我们的委托生成器

我们生成的f调用__next__是直接和gen通信的,不需要经过foo
"""

并且还有一个区别

def gen():
    yield [1, 2, 3]

def foo():
    yield from [1, 2, 3]


g = gen()
print(g.__next__())  # [1, 2, 3]
f = foo()
print(f.__next__())  # 1

"""
yield一次性将后面的值迭代出来
yield from后面是一个序列的话,那么只会迭代序列的第一个
"""

但是这有什么用呢?实际上,理解yield和yield from是理解后面的async和await的基础。yield from帮我们做了很多事情,比如捕获子生成器抛出的异常。我们如果想获取返回值的话,一般是通过捕获异常,但是有了yield from就方便很多了。

def gen():
    yield 1
    return "xxx"

def foo():
    a = yield from gen()
    print(a)


f = foo()
print(f.__next__())
try:
    f.__next__()
except StopIteration:
    import sys
    print(sys.exc_info()[0])
"""
1
xxx
<class 'StopIteration'>
"""

"""
为什么会有上述这个结果
首先当我们进行send或者next的时候,就会走到下一个yield
当第二次next的时候,对于gen来说已经没有yield了,直接return了,按理说会报错的
这时候委托生成器就登场了
一旦子生成器return,yield from会拿到返回值,然后异常向上抛,会在委托生成器里面寻找yield,
如果委托生成器找不到yield,那么异常会继续抛出
"""

def gen():
    yield 1
    return "xxx"

def foo():
    # 因此我们可以写成这种形式
    # yield和yield from是可以赋值的,如a = yield, a = yield from
    # a = yield,是当send的时候可以传入一个值然后赋给a
    # a = yield from 子生成器调用 ,则是子生成器在返回的时候赋值给a
    # 因此当gen()返回的时候,yield from gen()这个整体就相当于返回值"xxx"
    # 然后我们再将这个返回值yield出来
    yield (yield from gen())


f = foo()
f.__next__()
print(f.__next__())  # xxx

但是感觉好像也没啥用蛤。其实不然yield from做的事情远不止这些,当然之所以引入yield from,主要是为了引出async和await

async和await

这是python在3.5开始引入的两个关键字,专门用于创建协程,还提供了asyncio这个用于事件循环的异步网络库。很多人刚开始对这些新的概念不是很了解, 比如coroutine、future、task、event_loop等等。

我们来看一个例子:

import asyncio


async def foo1():
    print("foo1")
    await asyncio.sleep(2)
    return 123

async def foo2():
    await asyncio.sleep(1)
    print("foo2")


async def bar():
    res = await foo1()
    print(res)


asyncio.run(asyncio.wait([bar(), foo2()]))
"""
foo1
foo2
123
"""

首先我们运行bar()和foo2()这两个协程,bar()里面await foo1(),这就相当于之前的yield from,在event_loop和foo1()之间建立一个双向通道,当打印完"foo1"的时候,阻塞了,因此要通知事件循环,注意:这一步是不需要经过bar()的,而是foo1()和事件循环直接通信。1s后,打印"foo2",最后foo1()执行结束,当返回的时候,await 会捕获到异常,并拿到返回值。所以await和yield from是有着异曲同工之妙的。

再比如tornado,早期在python还不支持原生协程的时候,tornado不得不使用生成器来模拟协程。

from tornado import web
from tornado import gen


class IndexHandler(web.RequestHandler):

    @gen.coroutine
    def get(self):
        ...

但是自从python提供了原生协程之后,tornado也支持使用async和await

from tornado import web


class IndexHandler(web.RequestHandler):

    async def get(self):
        ...

现在可以这么定义,并且tornado之前的事件循环也改成了asyncio,不再使用自己之前的那一套了。怎么证明呢?

async def foo():
    print("~~~foo~~~")

if __name__ == '__main__':
    import tornado.ioloop
    tornado.ioloop.IOLoop.instance().run_sync(foo)

"""
​~~~foo~~~
"""

我们使用tornado也能启动,说明底层使用的都是同一个事件循环。当然其实官方也说了

 

以上就是对yield的一点简单介绍,觉得文章还不错的话不妨收藏起来慢慢看,有任何建议或看法欢迎大家在评论区分享讨论!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章