Python 中的迭代器、生成器以及协程

前言

本文要讲述的是 Python 中非常重要(可以说是最有用的特性之 N)的一些内容。从迭代器(Iterator)到生成器(Generator)再到协程(Coroutine)。
主要目的是为之后一个基于 Sanic 框架的一个项目铺路,因为没有掌握这些概念和知识使用这种框架是很“危险”的,你无法理解“为什么不该这样做”以及“为什么要这么做”。

例如下面这个(最基本的例子):

#!/usr/bin/env python3
import asyncio

from sanic import Sanic
from sanic.response import text

app = Sanic()


@app.route("/")
async def hello(request):
    await asyncio.sleep(0.15)  # 我是关键代码
    return text("Hello World!")


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

在不了解 Coroutine 的概念和 asyncio 库的基本实现原理之前,你肯定无法理解为什么要用 asyncio.sleep 而不是 sleep 。

注意:以下代码全部基于 Python3

迭代器

什么是迭代器?答:Python 中大部分 ”容器“ 都是迭代器,例如内置的 dict、list 和 tuple 还有 str 。
既然是迭代器,当然最基本的共同点就是可迭代了。Python 中的 for <var> in <iterator> 就是用来迭代对象的语法:

for elem in [1, 2, 3]:              # list
    print(elem)
for elem in (1, 2, 3):              # tuple
    print(elem)
for key in {'one': 1, 'two': 2}:    # dict
    print(key)
for char in "123":                  # str
    print(char)

我们可不可以认为能被 for in 遍历的对象都是迭代器?答:当然,例如 open 函数返回的 io.TextIOWrapper 类型对象,就是一个迭代器:

for line in open('file.text'):  # io.TextIOWrapper
    print(line, end='')

是否可以让我自己定义的类型也支持迭代?答:当然,只需要实现两个方法就能让自定义类型变成迭代器:

class MyIterableClass:
    """支持迭代的自定义类型    
    """

    def __init__(self, data):
        self.data = data[::-1]
        self.index = len(data)

    def __iter__(self):
        """获取迭代器对象    
        :return: 包含 __next__ 方法的对象
        :rtype: MyIterableClass
        """
        return self

    def __next__(self):
        """获取一次迭代产生的值    
        :return: 迭代对象当次迭代一次的返回值
        :rtype: int
        """
        if self.index == 0:
            raise StopIteration
        self.index = self.index - 1
        return self.data[self.index]

这两个方法分别是 __iter__ 和 __next__ ,不需要继承任何类,直接添加即可。让我们迭代这个类的对象:

for char in MyIterableClass('我是一段没有任何标点的话'):  # MyIterableClass
    print(char, end='。')

输出:

我。是。一。段。没。有。任。何。标。点。的。话。

在幕后,for 语句帮你调用了 __iter__ 方法获得迭代器对象,然后一次又一次的调用了迭代器对象的 __next__ 方法得到值,直到遇到 StopIteration 异常。这便是迭代器可迭代的原理。

PS:全局函数 iter 和 next 分别是对上面两个方法的调用,参数自然是调用上述方法的迭代器对象了。

生成器

生成器是一种简单而强大的让函数构造迭代器的方式,它的核心在于 yield 关键字:

def reverse(data):
    for index in range(len(data) - 1, -1, -1):
        yield data[index]

上面的函数将参数 data 的下标反转过来遍历,然后依次 yield 下标对应的值。说简单点:构造一个将 data 容器中的数据倒序产生值的迭代器。
我们使用它:

for char in reverse('Hello'):
    print(char, end='.')

输出:

o.l.l.e.H.

在生成器函数的执行过程中,每迭代一次便执行到下一个 yield 处暂停并且返回值。所以不要被循环中只有一次 yield 关键字而迷惑了眼,实际上 yield 可以多次定义,执行流仍然是顺序执行(不会从函数开头重新执行):

def multi_yield():
    yield 1
    yield 2
    yield 3
    yield 4
    yield 5


for n in multi_yield():
    print(n, end='.')

输出:

1.2.3.4.5.

不用 for 语法迭代,手动迭代生成器函数所创建的对象:

yld = multi_yield()
print(next(yld))  # 1
print(next(yld))  # 2
print(next(yld))  # 3
print(next(yld))  # 4
print(next(yld))  # 5
print(next(yld))  # 发生 StopIteration

上面提到过,next 函数是对参数中的迭代器对象的 __next__ 方法的调用,而生成器函数又是一种快捷创建迭代器对象的方式,所以这段代码就很好理解了。
值得注意的是在手动第六次迭代(不会产生下一个值)的时候,发生了 StopIteration 异常,这点和手动实现的迭代器一致。而 for 语法帮我们捕获并处理(停止迭代)了 StopIteration ,所以用 for 不会出现异常。

在这里,可以清楚的看出 yield 和 return 这两个关键字在函数用途中的区别:

  1. return 会结束函数执行(即 return 以后函数执行完毕)。yield 不会导致函数执行完成,会暂停函数并保存函数内当前的所有变量状态,直到下一次迭代行为产生后再继续执行。遇到下一个 yield 方式相同。遇到 return 或者函数结束引发 StopIteration 异常。
  2. 普通函数显式调用表示执行函数直到结束(接收到返回值,None 也算)。而包含 yield 的生成器函数显式调用则表示创建了一个迭代器对象,此时函数体不会执行,除非显式调用 next 或者被 for 语句使用,即对此对象产生迭代行为。在迭代的过程中,函数体会一段一段的执行,直到迭代全部完成整个函数才会结束。也就是说虽然可能迭代了很多次,但整个函数体的流程只会被执行一次。迭代器对象使用(迭代)过后无法重新(函数起首)开始执行,所以重新调用生成器函数的意义是创建一个新的迭代器对象来使用。

即:普通函数是用来执行一段逻辑,直接调用即可。生成器函数的内部逻辑是由迭代器对象的迭代行为分段执行的,显式调用只是用来创建迭代器对象,不会执行任何内部逻辑。

如果你理解了生成器,其实就会发现,生成器做的事情手动创建一个迭代器类型的对象也有相同的效果。但是生成器有例如:自动引发 StopIteration 异常,自动保存变量状态(实际幕后保存的不仅仅是本地变量状态,还包括指令指针、内部堆栈)等行为。所以它比手动扩展一个类型的迭代功能这种常规方式更加省力。

send 是生成器的一个方法,作用在于给生成器函数发送数据:

def send_exam_generator(val):
    while True:
        val = (yield val)


seg = send_exam_generator(0)

# 使用 next 函数让生成器函数执行到第一个 yield
print(next(seg))        # 0

# 使用 send 方法让生成器函数继续执行,并传递和接收数据
print(seg.send(1))      # 1
print(seg.send(2))      # 2

# 使用 next 函数相当于 generator.send(None)
print(next(seg))        # None

从这里可以明白,Generator 迭代器和普通的类方法扩展迭代器是有区别的,生成器函数创建的对象同时是一个 Generator 的类型。而非 Generator 类型的迭代器对象是没有 send 这种能力的,所以在这个环节我们不用将生成器仍然硬生生的理解为创建迭代器的方式,因为它在此处一般不以常规迭代的方式使用。

上面的生成器函数中,yield 被放在了等号右边,类似于赋值操作。它的确是赋值给了 val 变量,但是这个值是从生成器对象 send 方法的调用处传递来的,send 传递给函数内部的参数,用 yield 放在等号右边接收。此时的生成器函数就存在多个入口和出口。

正如注释中写的那样,因为生成器对象在初次使用的时候执行的内容是 “before yield”,也就是第一个 yield 之前。此时的 val = (yield val) 相当于仅仅执行到右边的 yield val 就暂停了,还没有进行赋值操作(没有接收任何值)。所以第一次使用生成器对象是不允许 send 非 None 内容的,否则会引发 TypeError:

TypeError: can't send non-None value to a just-started generator

而初次之所以使用 next 函数,是因为 next 函数其实就是 send 了一个 None 值。

yield from 可以让生成器函数的部分操作委托给另一个生成器,“另一个” 被看做外部生成器的子生成器。子生成器可以直接接收调用处作用域发送的值,并且产生一个最终值返回外部生成器。

def subgenerator_accumulate():
    """子生成器
    :return: send 过来的数字总和
    """
    result = 0
    while True:
        val = yield
        if val is None:
            return result
        else:
            result += val


def gather_generator(results):
    """外部生成器
    :param results: 结果汇总列表 
    """
    while True:
        result = yield from subgenerator_accumulate()
        results.append(result)

使用生成器:

# 创建储存结果的 list
results = []
# 创建外部生成器对象
gather = gather_generator(results)
# 首次使用生成器
next(gather)

for i in range(4):  # 计算 0 - 3 四个数字
    gather.send(i)

gather.send(None)   # 结束第一批值的计算

for i in range(6):  # 第二批值开始计算
    gather.send(i)

gather.send(None)   # 结束第二批值的计算

print(results)      # 查看汇总结果

输出:

[6, 15]

上面的代码主要进行了下面这几个流程:

  1. 外部生成器的求和操作委托给了子生成器,子生成器直接接收调用处传递过来的值,不断的进行求和操作
  2. 子生成器收到 None 后结束求和并且返回最终结果给外部生成器
  3. 外部生成器将子生成器的求和结果添加到参数容器中,继续委托子生成器

这就是一个典型的 yield from 用法。当然,yield from 可以在子生成器中进一步进行委托,没有限制。

我们再来看一个复杂点的 yield 例子:

创建文件 file.txt

我是一段 Java 代码!
我是一段 Python 代码!
我是一段 Javascript 代码!
啦啦啦~ 我什么都不是!

创建这样一段代码:

def coroutine(func):
    def wrapper(*args, **kws):
        cr = func(*args, **kws)
        next(cr)
        return cr

    return wrapper


def follow(the_file, target):
    """
    按行读取文件,并将每一行 send 给目标 Generator 对象
    :param the_file: 文件对象
    :param target: 目标 Generator 对象
    """
    try:
        while True:
            line = the_file.readline()
            if not line:
                target.send(None)
            target.send(line)
    except StopIteration:
        pass


@coroutine
def printer():
    """将接收到的值打印到控制台的生成器函数
    """
    while True:
        line = (yield)
        if not line:
            break
        print(line, end='')

最上面的 coroutine 函数用作生成器函数的装饰器,其作用是首次自动执行 next 函数,它不重要(可以无视),明白首次使用生成器没有用 next 是因为这个装饰器就够了。
follow 函数是一个普通函数,作用跟注释中写的一样,printer 生成器函数的作用注释也有写。我们直接使用它们:

f = open('file.txt')
follow(f, printer())

这个没啥特别的,就是将每一行都 send 给 printer 生成器,然后打印而已。但是我们加一个 grep 生成器函数模拟 grep 程序的作用:

@coroutine
def grep(pattern, target):
    print("Grep matching for 「%s」" % pattern)
    while True:
        line = (yield)
        if line is None:
            break
        if pattern in line:
            target.send(line)

grep 生成器函数将接收到值和要匹配的参数关键字进行匹配,如果匹配成功(接收字符串包含了参数字符串)那么就 send 给目标生成器。我们可以将多个生成器连接起来使用:

f = open('file.txt')
follow(f,
       grep('Java', printer()))

输出:

Grep matching for 「Java」
我是一段 Java 代码!
我是一段 Javascript 代码!

其中每一行字符串都会先经过 grep 和 printer,在 grep 经过过滤,决定是否传递给 printter 。当然,我们可以将多个 grep 连起来:

f = open('file.txt')
follow(f,
       grep('Java',
            grep('script',
                 printer())))

输出:

Grep matching for 「script」
Grep matching for 「Java」
我是一段 Javascript 代码!

这样就形成了类似“管道”的效果。如果用普通函数完成同样的操作,需要多次调用 grep 函数,在调用的中间和前后操作结果变量和参数的状态,不会很直观。

协程

协程是程序的组件之一,如果你对这种说法感到陌生,比较正常。但是和它同级的概念:子例程。我想基本没有人会陌生的,因为 Python 中的普通函数就是子例程(多数编程语言的函数都是子例程):

# 调用了一个子例程
print('Hello World!')


# 定义了一个子例程
def exe_me():
    print("我被执行了!")

正如大家所熟知的,普通函数的一次完整执行就要等到结果返回,且只会被执行一次。第二次调用则又是一个新的重头开始的逻辑,没有内部状态(虽然可能存在副作用函数使用外部状态)。这也是子例程的特征。

在子例程的执行过程中,调用另一个子例程,它们是上下级关系(存在一种包含的状态)。每一个子例程的生命周期都是后进先出的原则:

调用(执行开始):

子例程 A -> 子例程 B -> 子例程 C -> 子例程 D

返回(执行结束):

子例程 D -> 子例程 C -> 子例程 B -> 子例程 A

在只存在子例程的程序中,从程序的开始到结束都充斥着这种上下级关系和后进先出生命周期的子例程相互调用。

而协程的不同在于,协程和协程之间的调用是平级的,也没有这种后进先出的生命周期,反而由每个协程自己控制。因为协程可以多次调用和返回,即存在多个入口和出口。而协程在执行过程中可以平行的将调用和暂停过程穿梭于其它协程的入口和出口之间,不需要像子例程那样必须等待一个完整的生命周期结束。

生成器与协程

写到这里,还没有解释过协程和生成器有什么区别?又有什么关系?

  • 生成器和协程非常相似。它们都可以暂停保存状态,都可以返回多次,都有多个入口。
  • 协程和生成器的区别在于,生成器自身无法控制返回后继续执行的位置,通常由调用者控制。

用生成器表示协程工作状态,消费者 - 生产者模型是一个典型的例子:

queue = []
total = 0


def cor_produce():
    while True:
        global total
        if total >= 10:  # (1)
            break
        elif len(queue) <= 6:  # (2)
            val = int(random.uniform(1, 1000))
            print("Consumption data: %s" % val)
            queue.append(val)
            total += 1
            if val >= 500:  # (3)
                continue
        yield


def cor_consume(produce):
    next(produce)
    while 1:
        if len(queue) > 0:
            val = queue.pop()
            print("Production data: %s" % val)
        try:
            next(produce)
        except StopIteration:
            print("No data can be produced")
        sleep(1)


cor_consume(cor_produce())

这个程序的输出是这样的:

Consumption data: 237
Production data: 237
Consumption data: 912
Consumption data: 668
Consumption data: 927
Consumption data: 867
Consumption data: 26
Production data: 26
Consumption data: 707
Consumption data: 894
Consumption data: 911
Production data: 911
Consumption data: 172
Production data: 172
No data can be produced
Production data: 894
No data can be produced
Production data: 707
No data can be produced
Production data: 867
No data can be produced
Production data: 927
No data can be produced
Production data: 668
No data can be produced
Production data: 912
No data can be produced
No data can be produced
No data can be ...

代码(1)控制了生产者生产的数据总量限制,超过 10 就不允许继续生产(模拟没有数据可产生)。代码(2)固定了队列的容量,已满则不继续生产,等待消费。代码(3)模拟了生产数据频率的随机性。消费函数每消费一次遍通知生产者生产,如果没有数据就直接通知生产。 结果也能证明了这个的效果,生产者和消费者之间协作执行,固协程的意思。

协程并行和多线程并行

待更。