Py学习  »  Python

Python生成器那些事儿

蚂蚁学Python • 2 年前 • 421 次点击  


导读

读完本文你会有两点收获:

  1. 了解Python生成器的工作原理。
  2. 学会查询PEP(Python Enhancement Proposals)。让你知道python的一些设计理念。
  3. 最后会通过码农工作中一个实际的例子,来了解不同实现的好坏。

什么是生成器?

生成器(Generator)是一个非常强大的迭代器。其按照一定的算法生成一个序列。

包含yield的函数会返回一个生成器。生成器函数和普通函数看上去很像,不同的是生成器的返回值是用yield实现的。

也有一种写法是(expression for i in s if condition),注意两边是括号而不是方括号。这种写法等效于

def a_generator():
    for i in s:
        if condition:
            yield expression

我们先实现一个简单的生成器来演示一下:

def gen():
    yield 3
    yield 1
    yield 4

a = gen()

next(a) # output 3
next(a) # output 1
next(a) # output 4
next(a)
# Traceback (most recent call last):
#   File "", line 1, in 
# StopIteration

通过help(a),你会看到a是一个generator object,而且实现了__iter____next__,符合迭代器协议。说明函数gen返回了一个迭代器

可以用next来进行迭代,迭代到最后会返回一个StopIteration的异常。

与普通迭代器不同的是,生成器只能迭代一次。如果我们接着上面的代码重新迭代,会发现没有任何输出。

for item in a:
    print(item, end=' ')

# output: 没有任何输出。

生成器与yield关键字息息相关,我们先来了解一下yield。

yield的发展史

yield in python2.3

yield在python2.3引入。最早的功能只有一个,就是将值返回给调用方,然后停止执行函数,保存现场并且再下次调用时回复。对比普通的函数,函数在return后就退出了。中断然后恢复是yield的特异功能。

yield in python2.5

到了python2.5,yield又有了表达式的功能(详细见:https://www.python.org/dev/peps/pep-0342/)。也就是

a = yield b

需要注意的是,这个表达式其实有两部分,一部分是右侧的yield b ,一部分是赋值操作=。按照运算优先级来说,要先执行yield b。因为yield的特性,返回b之后函数暂停,所以下一次yield前会执行赋值操作,那么a到底被赋予了一个什么样的值呢?

这就要解释PEP-0342为迭代器引入的一个新函数,send在yield之后,程序暂停,然后a就等待下一次send的输入。注意,send(None)next是等价的。如下示例:

c = None

def gen2():
    global c
    b = 0
    a = yield b
    yield
    c = yield a

x = gen2()
x.send(None# output 0, a等待接受send来的值。
x.send(3)    # output None, 输出前执行a=3
x.send(None# output 3, c等待接受send来的值。
x.send(5)    # output StopIteration, 同时输出前c=5
print(c)     # output 5

这段代码可以用如下流程来解释,右侧带颜色的程序块之间会暂停,并等待新的send信号:

yield in python3.3

python3.3中,新加了yield from这个操作。yield from g 基本等价于 for v in g: yield v,但是内部帮忙实现了很多边界处理,比如异常等。

明白了yield的基本操作,那么生成器有什么用呢?

Python中生成器的作用

这里告诉大家一个小技巧,当你不知道python某个功能有什么用的时候,可以查一下PEP(Python Enhancement Proposals),中文叫《Python增强提 案》。这里面除了重要的通知外,还有一些新功能的描述,以及为什么要设计这个功能。

目录为:https://www.python.org/dev/peps/ 可以按关键字检索。

拿生成器来说,首次出行在PEP255《Simple Generators》(https://www.python.org/dev/peps/pep-0255/),在Motivation一栏详细描述了其设计的动机。可以看出,生成器的设计初衷是要优化生产者函数迭代+回调函数的场景。某些处理可能会使用生成器之前生产的值,会让调用者的设计变复杂。而yield的中断恢复机制让迭代和调用者都变得更加自然。基于这一功能,可以很方便的实现 协程操作。

所以说使用生成器有如下的好处:

  1. 实现协程(Coroutine),通过yield的中断恢复功能,可以实现一个线程实现多任务的调度。没有了线程切换和锁,没有了用户态和内核态的切换 ,性能上会提升不少,尤其是IO场景较多的情况下。关于协程内容较多,而且有更好的方法(async/await),有时间另开一篇讲。
  2. 一般情况下生成器比迭代速度要快一些。
  3. 代码可读性更高。
  4. 由于本身也是个迭代器,所以也拥有迭代器的优点:惰性计算。不需要把所有的数据加载到内存,而是即取即用。

不适合生成器的场景:

  1. 多次读取。此时生成器无法满足,可以用list(a_generator)来转换成list
  2. 随机读取。生成器没有类似x[i]这样的下标操作。
  3. 拼接字符串。''.join(alist)''.join(a_generator)更快。

生成器现实中的例子

比如要读取一些网络日志,然后统计发送的字节数。格式如下:

127.0.0.1 - - [24


    
/Feb/2008:00:08:59 -0600"GET /ply/ply.html HTTP/1.1" 200 97238
192.168.0.1 - - [24/Feb/2008:00:08:59 -0600"GET /favicon.ico HTTP/1.1" 404 133

最后一列是发送字节数。我们要把文件中每一行最后一列的数字累计求和。

第一种方法,硬编码直接实现。


def read_file_count_inside(filename):
    total = 0
    with open(filename) as wwwlog:
        for line in wwwlog:
            bytes_sent = line.rsplit(None1)[1]
            if bytes_sent != '-':
                total += int(bytes_sent)
    return total

# User time (seconds): 13.09
# System time (seconds): 0.35
# Elapsed (wall clock) time (h:mm:ss or m:ss): 0:13.45
# Maximum resident set size (kbytes): 6880

直接实现很好,但是复用性较差。假如我换个任务,统计ip的频次。那就要修改现有的逻辑,或者重写一个类似的函数。

第二种实现,读取和处理分开。

def read_file(filename):
    with open(filename) as wwwlog:
        return wwwlog.readlines()

def count_bytes(line_list):
    total = 0
    for line in line_list:
        bytes_sent = line.rsplit(None1)[1]
        if bytes_sent != '-':
            total += int(bytes_sent)
    return total

# User time (seconds): 13.90
# System time (seconds): 2.87
# Elapsed (wall clock) time (h:mm:ss or m:ss): 0:16.78
# Maximum resident set size (kbytes): 2315280

第二种逻辑清晰,可扩展性也好。但是占用内存太恐怖。

第三种实现:callback函数

G_TOTAL = 0

def count_callback(line):
    global G_TOTAL
    bytes_sent = line.rsplit(None1)[1]
    if bytes_sent != '-':
        G_TOTAL += int(bytes_sent)

def read_file_with_callback(filename, callback_fn):
    with open(filename) as wwwlog:
        for line in wwwlog:
            callback_fn(line)
    return G_TOTAL

# User time (seconds): 15.18
# System time (seconds): 0.38
# Elapsed (wall clock) time (h:mm:ss or m:ss): 0:15.57
# Maximum resident set size (kbytes): 6880

callback代码也算清晰,但是不免保存一些现场的变量。

第四种实现:生成器

def read_file_gen(filename)


    
:
    with open(filename) as wwwlog:
        for line in wwwlog:
            yield line

def count_gen(generator):
    bytecolumn = (line.rsplit(None1)[1for line in generator)
    bytes_sent = (int(x) for x in bytecolumn if x != '-')
    return sum(bytes_sent)

# User time (seconds): 14.92
# System time (seconds): 0.34
# Elapsed (wall clock) time (h:mm:ss or m:ss): 0:15.26
# Maximum resident set size (kbytes): 6884

生成器实现就很舒服,可以说是综合了第二第三种的优点。

第五种:走火入魔生成器

sum(int(x) for x in (line.rsplit(None1)[1for line in open(filename)) if x != '-')

# User time (seconds): 14.03
# System time (seconds): 0.32
# Elapsed (wall clock) time (h:mm:ss or m:ss): 0:14.35
# Maximum resident set size (kbytes): 6916

这种方法虽然免去了很多函数调用,但是可读性并不太好,只适合简单场景。不建议使用。

第六种:实际工作中采用的方法

awk { total += $NF } END { print total } big-access-log
# User time (seconds): 10.72
# System time (seconds): 0.32
# Elapsed (wall clock) time (h:mm:ss or m:ss): 0:11.04
# Maximum resident set size (kbytes): 1348

程序员总是想用最少的代码来实现一个功能。对于文本处理来说,awk和sed可以满足绝大多数的需求,而且速度比python更快。但是python胜在跨平台,毕竟windows可没有awk。

你喜欢哪一种呢?





Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/120737
 
421 次点击