神奇的yield

鬆直發表於2019-01-26

We find two main senses for the verb “to yield” in dictionaries: to produce or to give way.

Luciano Ramalho 在他的《Fluent Python》協程一節中如是寫道。yield 是一個在很多語言中都有的關鍵字和特性,和它有關的種種概念——生成器,協程……可能讓人費解,但一旦真正理解了它們的含義,一扇新的大門將為我們展開。

代替遞迴

很多時候,生成器可以用來代替遞迴。眾所周知,遞迴實現的演算法簡潔,優雅,但對於Python來說,效能很差,而且還有遞迴深度限制。當然我們可以把某些遞迴改寫成迴圈和迭代的形式,但生成器可以幫助我們寫出既優雅又高效能的程式碼。

我們先來個簡單的例子——生成斐波那契數列。

def fib_rec(n):
    if n==0 or n==1:
        return 1
    else:
        return fib_rec(n-2) + fib_rec(n-1)
def fib_gen():
    before2 = 0 #原諒變數名起的渣
    before1 = 1
    while True:
        now = before2 + before1
        yield now
        before2, before1 = before1 , now複製程式碼

這個例子很簡單,而且好像生成器版本的程式碼也不怎麼優雅和易讀,但是理解了程式流就會覺得很好理解。

開胃小菜過後,我們來道可口的。

David Beazley 在他的《Python Cookbook(第三版)》中的一節中介紹瞭如何使用生成器來改寫訪問者類的遞迴版本。讓人拍案。

首先我們看一下改寫的基礎程式碼

import types

class Node:
    pass

class NodeVisitor:
    def visit(self, node):
        stack = [node]
        last_result = None
        while stack:
            try:
                last = stack[-1]
                if isinstance(last, types.GeneratorType):
                    stack.append(last.send(last_result))
                    last_result = None
                elif isinstance(last, Node):
                    stack.append(self._visit(stack.pop()))
                else:
                    last_result = stack.pop()
            except StopIteration:
                stack.pop()

        return last_result

    def _visit(self, node):
        methname = `visit_` + type(node).__name__
        meth = getattr(self, methname, None)
        if meth is None:
            meth = self.generic_visit
        return meth(node)

    def generic_visit(self, node):
        raise RuntimeError(`No {} method`.format(`visit_` + type(node).__name__))複製程式碼

遞迴的呼叫

class UnaryOperator(Node):
    def __init__(self, operand):
        self.operand = operand

class BinaryOperator(Node):
    def __init__(self, left, right):
        self.left = left
        self.right = right

class Add(BinaryOperator):
    pass

class Sub(BinaryOperator):
    pass

class Mul(BinaryOperator):
    pass

class Div(BinaryOperator):
    pass

class Negate(UnaryOperator):
    pass

class Number(Node):
    def __init__(self, value):
        self.value = value

# A sample visitor class that evaluates expressions
class Evaluator(NodeVisitor):
    def visit_Number(self, node):
        return node.value

    def visit_Add(self, node):
        return self.visit(node.left) + self.visit(node.right)

    def visit_Sub(self, node):
        return self.visit(node.left) - self.visit(node.right)

    def visit_Mul(self, node):
        return self.visit(node.left) * self.visit(node.right)

    def visit_Div(self, node):
        return self.visit(node.left) / self.visit(node.right)

    def visit_Negate(self, node):
        return -self.visit(node.operand)

if __name__ == `__main__`:
    # 1 + 2*(3-4) / 5
    t1 = Sub(Number(3), Number(4))
    t2 = Mul(Number(2), t1)
    t3 = Div(t2, Number(5))
    t4 = Add(Number(1), t3)
    # Evaluate it
    e = Evaluator()
    print(e.visit(t4))  # Outputs 0.6複製程式碼

一旦巢狀過深,就會出現問題

>>> a = Number(0)
>>> for n in range(1, 100000):
... a = Add(a, Number(n))
...
>>> e = Evaluator()
>>> e.visit(a)
Traceback (most recent call last):
...
    File "visitor.py", line 29, in _visit
return meth(node)
    File "visitor.py", line 67, in visit_Add
return self.visit(node.left) + self.visit(node.right)
RuntimeError: maximum recursion depth exceeded
>>>複製程式碼

而我們用生成器的方式來呼叫,一切又都可以執行了

class Evaluator(NodeVisitor):
    def visit_Number(self, node):
        return node.value

    def visit_Add(self, node):
        yield (yield node.left) + (yield node.right)

    def visit_Sub(self, node):
        yield (yield node.left) - (yield node.right)

    def visit_Mul(self, node):
        yield (yield node.left) * (yield node.right)

    def visit_Div(self, node):
        yield (yield node.left) / (yield node.right)

    def visit_Negate(self, node):
        yield - (yield node.operand)複製程式碼
>>> a = Number(0)
>>> for n in range(1,100000):
...     a = Add(a, Number(n))
...
>>> e = Evaluator()
>>> e.visit(a)
4999950000
>>>複製程式碼

神奇嗎?僅僅是將return換成了yield,就能有如此巨大的改變。

我們來梳理一下程式碼。顯然,重要的地方是第一段中NodeVisitor的定義。他用一個stack來儲存程式計算中的資料結構,一開始,這裡儲存的是一個node的例項——t4。然後呼叫evaluator的visit方法,取出棧頂元素——此時是t4——儲存在last中。判斷它是一個Node的例項,再對其呼叫evaluator的_visit方法,同時把它從棧中彈出。而_visit 方法基本就是一個典型的訪問者的設計模式的實現。然後,我們又看到,在後幾段程式碼中,evaluator的visit_xxx方法的實現中將return換成了yield,這意味著,它將返回一個生成器——而不是和前面的實現中遞迴地呼叫。這個生成器被追加到了stack中。這時,Nodevisitor又檢查棧頂元素,是生成器,呼叫其send方法,引數是last_result(此時值是None)。根據evaluator的定義,它又將返回一個Node的例項,然後再把它轉換為一個生成器,或者如果是一個特定的子類(這裡是Number)的話,直接返回值,如此迴圈往復。要注意的是,如果直接返回了值,說明已經產生了一個結果,這時將它賦值給last_result(原來的值是None的哦),再由evaluator將其通過send方法傳給上一個層次的生成器,如此來實現結果的傳遞。直至最後計算出一個總的結果,返回。

思想是什麼呢?原先巢狀的呼叫(遞迴)是由python直譯器來處理的。現在,我們將每一次分解轉化為一個生成器儲存在棧中,每次檢查棧頂元素的型別來決定執行什麼操作。如果是一個Node的例項,就再將其轉化為生成器,或者,直接返回值。如果是數值,將其儲存在last_result中,將其從棧中彈出。如果是一個生成器,呼叫它的send方法,引數是last_result。這樣,原本面對很深的巢狀,我們可能會需要遞迴地呼叫很多次才能真正返回一個值。而現在,yield將執行權再次交還給了evaluator,告訴它先計算第一個節點,出結果之後,再計算下一個——恰好和遞迴的執行順序相反(雖然程式碼極其相似)。而生成器依然儲存著執行狀態,隨時等待呼叫。自然遞迴深度限制也就不會再有。

我們再來看看這個例子是如何將生成器的特性發揮的淋漓盡致的。

其實,我們已經不能把它叫成是單純的生成器,它還用到了協程的概念。首先,就像我們開頭說的,yield有兩個意思——to produce or to give way 。yield (yield node.left) + (yield node.right)這一句中的yield將node返回,既是produce 也是 give way,執行權交還給了evaluator,那evaluator怎麼將結果傳遞給生成器呢?這就是send方法的作用。send方法的引數就是生成器中yield生成的值,這句話好像有點難理解,就是說,生成器恢復執行之後,原先的yield產生的值就是send傳入的引數。而生成器會執行到下一個yield處,或者raise StopIteration。這時的生成器又會產生一個值,這個值哪了呢?它就是呼叫send方法後返回的值。所以我們才說還用到了協程的概念,事實上,協程的邏輯和這裡基本相同。

狀態機

ES6向Python借鑑了列表推導的語法糖,同時,它還新增了生成器的新特性(當然不是從Python中借鑑的)。

在阮一峰的《ES6標準入門》中,他介紹了使用生成器來定義狀態機,用yield來劃分不同狀態的技巧。我在Python書籍和社群中沒有見過(可能是我孤陋寡聞)。但仔細一想,python的標準庫中就有類似的用法——contextlib.contextmanager

它的用法就是使用yield來劃分程式碼,之前的相當於上下文管理器的__enter__(),之後的相當於__exit__()。我們也可將其看作是一個狀態機,只不過控制它的是python直譯器。

最後

前面說的幾個例子,其實也就是用了關於yield的那幾個特性,只是要有想象力來充分的利用。希望我們都能讓它們變成改善程式碼的好幫手。

相關文章