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的一點簡單介紹,覺得文章還不錯的話不妨收藏起來慢慢看,有任何建議或看法歡迎大家在評論區分享討論!

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