談談Python的生成器

發表於2016-12-30

第一次看到Python程式碼中出現yield關鍵字時,一臉懵逼,完全理解不了這個。網上查下解釋,函式中出現了yield關鍵字,則呼叫該函式時會返回一個生成器。那到底什麼是生成器呢?我們經常看到類似下面的程式碼

這段程式碼執行後列印序列0到4,所以我一開始以為這個生成器就是生成一個序列呀。那這跟迭代器有什麼區別呢?我們來看下迭代器的例子:

CountIter類就是一個迭代器,它的__iter__()方法返回可迭代物件,next()方法則執行下一輪迭代(注:在Python 3.x裡是__next__()方法)。上面的程式碼執行後也會列印序列0到4,看上去跟之前的生成器效果一樣,就是程式碼長一點。不僅如此,生成器自帶next()方法,而且在越界時也會丟擲StopIteration異常。

那區別到底是什麼,在何種情況下,我們應該使用生成器呢?

每次執行迭代器的next()方法並返回後,該方法的上下文環境即消失了,也就是所有在next()方法中定義的區域性變數就無法被訪問了。而對於生成器,每次執行next()方法後,程式碼會執行到yield關鍵字處,並將yield後的引數值返回,同時當前生成器函式的上下文會被保留下來。也就是函式內所有變數的狀態會被保留,同時函式程式碼執行到的位置會被保留,感覺就像函式被暫停了一樣。當再一次呼叫next()方法時,程式碼會從yield關鍵字的下一行開始執行。很神奇吧!如果執行next()時沒有遇到yield關鍵字即退出(或返回),則丟擲StopIteration異常。

本文的第一個例子是使用生成器函式來構造生成器,Python也提供了生成器表示式,下面的例子也可以列印序列0到4。

到目前為止,我們瞭解了生成器同迭代器在實現機制上的不同,但似乎功能是一樣的,那生成器的存在有什麼價值呢?我們先來看看除了next()方法外,生成器還提供了哪些方法。

    1. close()方法

顧名思義,close()方法就是關閉生成器。生成器被關閉後,再次呼叫next()方法,不管能否遇到yield關鍵字,都會立即丟擲StopIteration異常。

    1. send()方法

這是我認為生成器最重要的功能,我們可以通過send()方法,向生成器內部傳遞引數。我們來看個例子:

    還是之前的count函式,唯一的區別是我們將”yield x”的值賦給了變數value,並將其列印出來。如何給value傳值呢?

    我們先呼叫next()方法,讓程式碼執行到yield關鍵字(這步必須要),當前列印出0。然後當我們呼叫”gen.send(‘Hello’)”時,字串’Hello’就被傳入生成器中,並作為yield關鍵字的執行結果賦給變數”value”,所以控制檯會列印出”Received value: Hello”。然後程式碼繼續執行,直到下一次遇到yield關鍵字後暫定,此時生成器返回的是1。

    簡單的說,send()就是next()的功能,加上傳值給yield。如果你有興趣看下Python的原始碼,你會發現,其實next()的實現,就是send(None)。

      1. throw()方法

    除了向生成器函式內部傳遞引數,我們還可以傳遞異常。還是先看例子:

    如果像往常一樣呼叫next()方法,會返回’Normal’。再次呼叫next(),會進入finally語句,列印’Finally’,同時由於函式退出,生成器會丟擲StopIteration異常。我們換個方式,在第一次呼叫next()方法後,呼叫throw()方法,情況會怎樣?

    我們會看到,throw()方法向生成器函式內部傳遞了”ValueError”異常,程式碼進入”except ValueError”語句,當遇到下一個yield時才暫停並退出,此時生成器返回的是’Error’字串。簡單的說,throw()就是next()的功能,加上傳異常給yield。

    聊到這裡,相信大家對生成器的功能已經有了一個很好的理解。生成器不但可以逐步生成序列,不用像列表一樣初始化時就要開闢所有的空間。它更大的價值,我個人認為,就是模擬併發。很多朋友可能已經知道,Python雖然可以支援多執行緒,但由於GIL(全域性解釋鎖,Global Interpreter Lock)的存在,同一個時間,只能有一個執行緒在執行,所以無法實現真正的併發。我們暫且不討論GIL存在的意義,這裡我們提出了一個新的概念,就是協程(Coroutine)。

    Python實現協程最簡單的方法,就是使用yield。當一個函式在執行過程中被阻塞時,就用yield掛起,然後執行另一個函式。當阻塞結束後,可以用next()或者send()喚醒。相比多執行緒,協程的好處是它在一個執行緒內執行,避免執行緒之間切換帶來的額外開銷,而且多執行緒中使用共享資源,往往需要加鎖,而協程不需要,因為程式碼的執行順序是你完全可以預見的,不存在多個執行緒同時寫某個共享變數而導致出錯的情況。

    我們來使用協程寫一個生產者消費者的例子:

    執行下例子,你會看到控制檯交替列印出生產和消費的結果。消費者consumer()函式是一個生成器函式,每次執行到yield時即掛起,並返回上一次的結果給生產者。生產者producer()接收到生成器的返回,並生成一個新的值,通過send()方法傳送給消費者。至此,我們成功實現了一個(偽)併發。

    本文中的示例程式碼可以在這裡下載

    相關文章