中文Python资料收编! Py2.5 yield 详说 -- shhgs

发布

limodou 之理解

2.5版yield之学习心得 Inbox mli_python-cn mli-CZUG

limodou         
to python-cn, python-chinese.
         More options     9:54 pm (11 hours ago)

原文发表在我的blog上: http://blog.donews.com/limodou/archive/2006/09/04/1028747.aspx 这是在看了shhgs关于yield之后,再与他交流后的学习心得。可以算是shhgs文章的一个补充吧。


在 shhgs 发布了关于《 Py 2.5 what's new 之 yield》之后,原来我不是特别关注 yield 的用法,因为对于2.3中加入的yield相对来说功能简单,它是作为一个 generator 不可缺少的一条语句,只要包含它的函数即是一个 generator 。但在2.3中,generator 不能重入,不能在运行过程中修改,不能引发异常,你要么是顺序调用,要么就创建一个新的 generator。而且 generator 中的 yield 只是一个语句。但到了 2.5 版之后,情况发生了很在的变化。

在 shhgs 的文章中对于 yield 并没有做太多的描述,也因此让我在理解上产生了许多问题,于是我仔细地研究了 What's new 和 PEP 342 文档,有了一些体会,描述在下面。

这里不说为什么要对 yield 进行修改,只说功能。

  1. yield 成为了表达式,它不再是语句,但可以放在单独的行上。原文:

Redefine "yield" to be an expression, rather than a statement. The
current yield statement would become a yield expression whose value is
thrown away.

可以看到,如果你还是写成语句形式的话,其实还是一个表达式,只是它的值被扔掉了。

那么一个 yield 表达式可以这样写:

x = yield i
y = x + (yield x)

那么这种机制到底是如何工作的呢?在2.3版很容易理解,你完全可以把 yield 语句理解为一个 "return" 语句,只不过 "return" 完后,函数并不结束,而是断续运行,直到再次遇到 yield 语句。那么到了 2.5 版不仅仅是一个 "return" 语句那么简单了,让我们看完下面关于 send() 的说明再描述它吧。

  1. 增加了 send(msg) 方法,因此你可以使用它向 generator 发送消息。原文:

Add a new send() method for generator-iterators, which resumes the
generator and "sends" a value that becomes the result of the current
yield-expression. The send() method returns the next value yielded by
the generator, or raises StopIteration if the generator exits without
yielding another value.

执行一个 send(msg) 会恢复 generator 的运行,然后发送的值将成为当前 yield 表达式的返回值。然后 send() 会返回下一个被 generator yield 的值,如果没有下一个可以 yield 的值则引发一个异常。

那么可以看过这其实包含了一次运行,从将msg赋给当前被停住的 yield 表达式开始,到下一个 yield 语句结束,然后返回下一个yield语句的参数,然后再挂起,等待下一次的调用。理解起来的确很复杂,不知道你明白了没有。

那么让我们开始想象一下,把 yield 转变为易于理解的东西吧。

我们可以把 yield 想象成下面的伪代码:

x = yield i ==> put(i); x = wait_and_get()

可以看到,可以理解为先是一个 put(i),这个 i 就是 yield 表达式后面的参数,如果 yield 没有参数,则表示 None。它表示将 i 放到一个全局缓冲区中,相当于返回了一个值。

wait_and_get() 可以理解为一个阻塞调用,它等待着外界来唤醒它,并且可以返回一个值。

经过这种转换就容易理解多了。让我们来看一个例子:

>>> def g():
   print 'step 1'
   x = yield 'hello'
   print 'step 2', 'x=', x
   y = 5 + (yield x)
   print 'step 3', 'y=', y

很简单,每执行一步都显示一个状态,并且打印出相关变量的值,让我们执行一下看一看。

>>> f = g()
>>> f.next()
step 1
'hello'

看见什么了。当我们执行 next() 时,代码执行到 x = yield 'hello' 就停住了,并且返回了 yield 后面的 'hello'。如果我们把上面的程序替换成伪代码看一看是什么样子:

def g():
   print 'step 1'
   put('hello')    #x = yield 'hello'
   x = wait_and get()
   print 'stpe 2', 'x=', x
   put(x)
   y = 5 + wait_and_get()
   print 'step 3', 'y=', y

可以从伪代码看出,第一次调用 next() 时,先返回一个 'hello', 然后程序挂起在 x = wait_and_get() 上,与我们执行的结果相同。

让我们继续:

>>> f.send(5)
step 2 x= 5
5

这次我们使用了 send(5) 而不是 next() 了。要注意 next() 在 2.5 中只算是 send(None) 的一种表现方式。正如伪代码演示的,send()一个值,先是激活 wait_and_get() ,并且通过它返回 send(5) 的参数5,于是 x 的值是 5,然后打印 'step 2',再返回 x 的值5,然后程序挂起在 y = 5 + wait_and_get() 上,与运行结果一致。

如果我们继续:

>>> f.send(2)
step 3 y= 7

Traceback (most recent call last):
 File "<pyshell#13>", line 1, in <module>
   f.send(2)
StopIteration

可以看到先是激活 wait_and_get(),并且通过它返回 send(2) 的参数 2,因此 y 的值是 7,然后执行下面的打印语句,但因为后面没有下一个 yield 语句了,因此程序无法挂起,于是就抛出异常来。

从上面的伪代码的示例和运行结果的分析,我想你应该对 yield 比较清楚了。还有一些要注意的:

next()相当于send(None)
yield后面没有参数表示返回为None

在文档中有几句话很重要:

Because generator-iterators begin execution at the top of the
generator's function body, there is no yield expression to receive a
value when the generator has just been created. Therefore, calling
send() with a non-None argument is prohibited when the generator
iterator has just started, and a TypeError is raised if this occurs
(presumably due to a logic error of some kind). Thus, before you can
communicate with a coroutine you must first call next() or send(None)
to advance its execution to the first yield expression.

意思是说,第一次调用时要么使用 next() ,要么使用 send(None) ,不能使用 send() 来发送一个非 None 的值,原因就是第一次没有一个 yield 表达式来接受这个值。如果你转为伪代码就很好理解。以上例来说明,转换后第一句是一个 put() 而不是wait_and_get(),因此第一次执行只能返回,而不能接受数据。如果你真的发送了一个非 None 的值,会引发一个 TypeError 的异常,让我们试一试:

>>> f = g()
>>> f.send(5)

Traceback (most recent call last):
 File "<pyshell#15>", line 1, in <module>
   f.send(5)
TypeError: can't send non-None value to a just-started generator

看到了吧,果然出错了。

  1. 增加了 throw() 方法,可以用来从 generator 内部来引发异常,从而控制 generator 的执行。试验一下:

>>> f = g()
>>> f.send(None)
step 1
'hello'
>>> f.throw(GeneratorExit)

Traceback (most recent call last):
 File "<pyshell#17>", line 1, in <module>
   f.throw(GeneratorExit)
 File "<pyshell#6>", line 3, in g
   x = yield 'hello'
GeneratorExit
>>> f.send(5)

Traceback (most recent call last):
 File "<pyshell#18>", line 1, in <module>
   f.send(5)
StopIteration

可以看出,第一次执行后,我执行了一个f.throw(GeneratorExit),于是这个异常被引发。如果再次执行f.send(5),可以看出 generator 已经被停止了。GeneratorExit 是新增加的一个异常类,关于它的说明:

A new standard exception is defined, GeneratorExit, inheriting from
Exception. A generator should handle this by re-raising it (or just
not catching it) or by raising StopIteration.

可以看出,增加它的目的就是让 generator 有机会执行一些退出时的清理工作。这一点在 PEP 342 后面的 thumbnail 的例子中用到了。

  1. 增加了 close 方法。它用来关闭一个 generator ,它的伪代码如下(从文档中抄来):

def close(self):
   try:
       self.throw(GeneratorExit)
   except (GeneratorExit, StopIteration):
       pass
   else:
       raise RuntimeError("generator ignored GeneratorExit")
# Other exceptions are not caught

因此可以看出,首先向自身引发一个 GeneratorExit 异常,如果 generator 引发了 GeneratorExitStopIteration 异常,则关闭成功。如果 generator 返回了一个值,则引发 RuntimeError 异常。如果是其它的异常则不作处理,相当于向上层繁殖,由上层代码来处理。关于它的例子在 PEP 342 中的 thumbnail 的例子中也有描述。

还有其它几点变化,不再做更深入的描述。

关于 PEP 342 中的例子也很值得玩味。简单说一些,其实我也些也不是很懂也就是明白个大概其吧。

文档中一共有4个例子,其实是两个例子构成。

1,2两个例子完成了一个 thunmbnail 的处理。第一个例子 consumer 其实是一个 decorator ,它实现了对一个 generator 的封装,主要就是用来调用一次 next() 。为什么,因为这样调一次下一次就可以使用 send() 一个非 None 的值了,这样后面的代码在使用 generator 可以直接使用 send() 非 None 值来处理了。第二个例子完成对一系列的图片的缩略图的处理。这里每个图片的处理做成了一个 generator,对于图片文件的处理又是一个顶层的 generator ,在这个顶层的 generator 来调用每个图片处理的 generator。同时这个例子还实现了当异常退出时的一种保护工作:处理完正在处理的图片,然后退出。

3,4两个例子完成了一个 echo 服务器的演示。3完成了一个调度器,4是在3的基础上将listen处理和socket联通后的handle处理都转为可调度的 generator ,在调度器中进行调度。同时可以看到 socket 使用的是非阻塞的处理。

通过以上的学习,我深深地感受到 yield 的确很精巧,这一点还是在与 shhgs 语音交流之后才有更深的体会,许多东西可以通过 generator 表现得更优美和精巧,是一个非常值得玩味的东西。以至于 shhgs 感觉到在 2.5 中 yield 比 with 的意义要大。希望大家一同体会。

不过说实在的,yield 的东西的确有些难于理解,要仔细体会才行。

[全文完]

原稿

Py 2.5 what's new 之 yield 

------------------------------



:Date: 2006-8-31

:Author: shhgs

:Copyright: 为了表达本人对CSDN论坛“脚本语言(Perl/Python)”专区的强烈不满,

            特此宣布,本文档不允许任何人发布或者链接到CSDN论坛的“脚本语言Perl/Python”专区。

            除此之外,任何人均可以阅读,分发本文档的电子版,或者本文档的链接。此外,

            任何人均可以将本文档张贴到除CSDN论坛“脚本语言Perl/Python”专区之外的其它

            BBS。任何人均可以打印本文档,以供自己或他人使用,但是不得以任何名义向任何人收取任何费用。

            上述名义包括,但不限于,纸张费,打印费,耗材费等等。分发、张贴本文档的时候,必须保留这段版权申明。

            如果有人要出版本文档,必须事先获得本人的同意。

            

Py 2.5 对yield做了本质性的增强,使得Py有了自己的first class的coroutine。



我们先来看看传统的yield。Py 2.3加入的yield使得Python实现了first class的generator。

generator是enumerator/iterator的自然延伸,其区别在于,iterator/enumerator遍历的是

一个既有的有限集合,而generator则是依次生成集合的各个元素,并且这个集合还可以是无限的。

从算法上讲,generator同递归一样,源出数学归纳法,但是与递归相比,一是其代码更为清晰,

二是它没有嵌套层数的限制。



但是你提供工具想让别人干什么和别人会怎么去用这个工具,从根本上讲是两码事。generator

问世之初就有人敏感地指出,这是一个semi coroutine。所谓的coroutine是指,一种有多个

entry point和suspend point的routine。Py 2.3的yield实现了多个entry/suspend point,

但是由于其无法在generator每次重新启动的时候往里面传新的数据,因此只能被称作semi 

coroutine。



当然也不是全然没有办法。但是总的来说,要想往里面传新的数据,你就得动用一些技巧。

本文的主旨不在于向诸位介绍这些技巧,这里我们关心的是,为什么那些大牛们要挖空心思去

改造generator,他们想要干什么,以及怎么干。



Py 2.5 yield 的语法

=====================================



讲了半天往generator里面传数据,那么怎么个传法呢?



Py 2.5的generator有了一个新的send方法,我们就是用这个send往里面传数据。::





    gen.send(message)





那么generator又是怎样接收数据的呢?这里,Py 2.5对yield的语法做了改造。现在yield已经

不是一个语句了,而是一个表达式。因此当你::



    val = yield i



传给generator的值就被赋予val了,而generator还是像以前那样生成i。



现在::



    gen.next()



成了::



    gen.send(None)



的简写,而::

   

   yield i



则表示generator会忽略传进来的值。



yield的语法就这么简单,如果读者还有什么疑问的话,可以参看Python Manual里面的what's new。





yield的用途

===============================



1. 合作多任务

.................................





PEP342_ 提到的coroutine的用途包括“模拟,游戏,异步I/O,以及其它形式的事件驱动或合作多任务编程”。那么我们就从相对简单的合作多任务开始。 



.. _PEP342: http://www.python.org/dev/peps/pep-0342/ 



所谓合作多任务的意思是,一个系统同时有多个任务在运行,而且这些任务都非常的合作,会自愿地 

将系统的控制权转交给其它任务。与多线程相比,合作多任务有两个非常显著的特点。首先是顺序的

决定性。大家都知道多线程环境是非决定性的。各个线程什么时候开始,什么时候挂起都是由线程

调度机制决定的,因此你永远也无法知道某个线程会在什么时候挂起,什么时候重新启动。

而合作多任务从本质上讲还是单线程的程序。只不过我们将每个任务封装成一个单独的函数

(这里就是generator),然后通过调度程序按照一定的算法轮流调用这种函数,从而推进

任务的进展。



讲到这里,大家应该对“合作”有一点体会了。这里,每个任务都必须合作,也就是说必须能在较短

的时间里将系统的控制权转交出去。如果某个任务进入了死循环,那么整个系统也就死了。



下面我们就来举一个用generator实现合作多任务的例子。假设这是一盘棋,电脑引擎和

用户界面程序分别做成了generator。::



    player = GetUserInput(...) 

    engine = Engine(...)

    

    def game(red, black) :

        ...

        move = red.next()

        while move != Move.Resign :

            if turn == black : 

                turn = red

            else :

                turn = black

            game_state.update(move)

            move = yield turn.send(move)

        game_state.update(move)



这里能很清楚地看出generator所实现的合作多任务的单线程本质。因此如果我们的象棋引擎耍赖的话,::

    

    def Engine() :

        ...

        if game.LoseInevitable :

        while 1 :

            sleep(1000)

        yield Move.Resign



那么你的程序就死了。



这是合作多任务的先天缺陷,因此在设计的时候你就得想好了,这个任务是不是

适合用合作多任务来解决。





2. 异步I/O

.................................



coroutine的另一个用途是异步I/O。关于异步I/O,我曾经在邮件列表里写过 `一封信`_ ,

有兴趣的读者可以去看看。



.. _`一封信`: http://groups.google.sm/group/python-cn/browse_thread/thread/1b4903dbf21b4fcf/1c10ce45d41b9246?lnk=raot&hl=it



在异步环境下,你把一堆socket交给监听器。监听器则负责告诉你socket是不是可读可写。

监听器只能帮你把数据读出来,至于读出来的东西是不是合法,该怎么用,它就无能为力了。

因此你得写一大堆回调函数,让监听器帮你把信息分发到回调函数里。



这个任务可不容易。因为监听器是根据收到的信息来判断调用哪个回调函数的,

但是函数却不一定知道该怎么处理这个信息。比方说,监听器听到用户输入了一个PASS命令,

于是调用do_PASS。但是这个口令是谁的,或者用户先前有没有使用USER命令,监听器都不知道。

既然监听器不知道,do_PASS也就无从获知,因此回调函数里面还有一大堆麻烦事等着。



有了coroutine之后,我们可以将每个会话封装成一个generator。当监听器听到数据的时候,

可以用send方法,把信息传给coroutine,让coroutine继续运行,

等yield完值之后再睡。coroutine的这种工作方式与线程很相似,因此也被称作pseudo-thread。



下面我们举一个完整的例子。程序清单如下: ::



      1    #!/usr/local/bin/python2.5

      2    

      3    import socket, select, collections

      4    

      5    SOCK_TIMEOUT = 0.1

      6    BUFSIZ = 8192

      7    PORT   = 10000

      8    

      9    def get_auth_config() :

     10        return {'shhgs': 'hello', 'limodou': 'world'}

     11    

     12    def task() :

     13        authdb = get_auth_config()

     14    

     15        username = yield 'Greetings from EchoServer on %s\nUserName Please: \r\n' % socket.gethostname()

     16    

     17        username = username.strip()

     18        if username not in authdb :

     19            yield '\nInvalid user. Byebye\r\n'

     20            return

     21        else :

     22            password = yield '\nYour Password Please:\r\n'

     23    

     24        password = password.strip()

     25        if authdb[username] == password :

     26            val = yield '\nMay you enjoy the EchoServer.\r\n'

     27        else :

     28            yield '\nWrong Password\r\n'

     29            return

     30            

     31        while  val:

     32            val = val.strip()

     33            val = yield ( ">>> " + val + '\r\n')

     34    

     35    def main(proto) :

     36        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

     37        sock.bind(('' , PORT))

     38        sock.listen(5)

     39        sock.settimeout(SOCK_TIMEOUT)

     40    

     41        connPool = {}        # 这两个变量相当主要,主控程序要通过connPool选择pseudo-thread 

     42        msgQueue = {}        # 而msgQueue则是存储传入generator的消息队列的

     43

     44        try :

     45            while 1 :

     46                try :

     47                    conn, addr = sock.accept()

     48                    connPool[conn] = proto()

     49                    greetings = connPool[conn].next()    # 注意,第一次调用generator的send时,只能传None。或者像这样,调用next

     50                    conn.sendall(greetings)

     51                except socket.timeout :

     52                    pass

     53    

     54                conns = connPool.keys()

     55                try :

     56                    i,o,e = select.select(conns, conns, (), SOCK_TIMEOUT )

     57                except :

     58                    i = o = e = []

     59    

     60                for conn in i :

     61                    try :

     62                        data = conn.recv(BUFSIZ)

     63                        if data :

     64                            response = connPool[conn].send(data)

     65                            if conn in msgQueue :                

     66                                msgQueue[conn].append(response)   # msgQueue的值必须是list

     67                            else :                               

     68                                msgQueue[conn] = [ response, ]

     69                    except socket.error :

     70                        try : 

     71                            connPool.pop(conn)

     72                            msgQueue.pop(conn)

     73                        except :

     74                            pass

     75                        conn.close()

     76    

     77                for conn in o :

     78                    try :

     79                        if conn in msgQueue :

     80                            msgs = msgQueue.pop(conn)

     81                            for response in msgs :

     82                                conn.sendall(response)

     83                                if response in ('\nInvalid user. Byebye\r\n', '\nWrong Password\r\n') : # 终于知道正规的协议为什么都是用错误号的了。

     84                                    connPool.pop(conn)

     85                                    conn.close()

     86                    except socket.error :

     87                        try : 

     88                            connPool.pop(conn)

     89                            msgQueue.pop(conn)

     90                        except :

     91                            pass

     92                        conn.close()

     93    

     94        except :

     95            sock.close()

     96    

     97    if __name__ == "__main__" :

     98    #    t = task()

     99    #    input = raw_input(t.next())

    100    #    while input :

    101    #        resp = t.send(input)

    102    #        input = raw_input(resp)

    103        main(task)

    



task就是一个pseudo-thread,其调试部分在最后,就是被注释掉的那几行。

如果把raw_input代进去,这就是一个非常简单的程序,相信初学者也应该能写。

但是如果你要求用callback,那问题就复杂了。



主控程序虽然比较长,但也很简单。这里主要提几个地方。



1)  拿到generator之后,第一次只能send一个None,或者调用next。如果你想把接口做得

    友好一点,可以参考 PEP342_ 的consumer函数。这是一个decorator,可以返回一个能直接

    send消息的generator。



2)  connPool和msgQueue是必不可少的。对于读,我们可以不用list。因为不管哪种协议,

    每次循环的时候,每个socket只会读一次。

    但是写必须要用list。因为在有些协议里,比方说IM,

    很可能会出现一次循环里有多个pseudo-thread要往同一个socket里面写东西的情况。

    这时你就必须用list保存数据了。



3)  这一点不是generator的东西。第39行,我们设了sock的timeout,因此47行的时候,

    sock就不会傻等下去了。此外,第56行,select的SOCK_TIMEOUT也很重要。如果你不给

    timeout值,那么select就block了。第一次循环的时候,sock.accept很可能没听到连接,

    因此conns是空的。而select要等至少有一个socket能读写才会退出。于是程序就死了。

    这里你也可以指定timeout为0。这样就变成poll了。



4)  coroutine本质上还是单线程。读者可以这样修改程序: ::



         31        while  val:

         32            val = val.strip()

         33            val = yield ( ">>> " + val + '\r\n')

        -->                if username == 'shhgs' :

        -->            sleep(30)



    你会发现,如果shhgs输入了东西,EchoServer就会停上一段时间。从这也能看出,

    coroutine从本质上讲还是单线程的。所以,我们再强调一遍。使用coroutine之前,

    先想好了你的任务是不是适合用coroutine解决。