AsyncIO 从入门到放弃 #02

生成器


by DavyCloud, 2022

复习一下迭代器

可以看见这里的代码有点啰嗦:

目录

什么是生成器(Generator)

yield 很特别

调用函数 gen() 没有像普通函数那样执行其中的代码,而是返回了一个 generator 对象。

术语概念澄清

通常,我们把含有 yield 的函数称之为「生成器函数 generator function」,把调用生成器函数返回的结果称为「生成器 generator」。

但是根据 Python 官方文档:

generator 生成器

A function which returns a generator iterator. ... 一个返回生成器迭代器函数

Usually refers to a generator function, but may refer to a generator iterator in some contexts. In cases where the intended meaning isn’t clear, using the full terms avoids ambiguity. 通常指的是生成器函数,但是在一定的语境下也可以指代生成器迭代器。为了避免歧义,推荐使用完整的术语。

generator iterator 生成器迭代器

An object created by a generator function.

一个由生成器函数创建的对象。

在不会产生歧义的情况下,提到「生成器」可能指的是函数,也可能是函数生成的对象,具体需要根据上下文判断。

为了区分,我一般将「生成器函数」的返回结果称为「生成器对象」。

这两者之间的关系将在稍后的技术内幕中详细解释。

生成器是迭代器

generator iterator 这个名字非常拗口,实际很少这么说,但是它却指出了:

「生成器对象」是迭代器

既然是迭代器,那么肯定要满足「迭代器协议」:

关于迭代器的性质我们已经有所了解,现在要理解的是 yield 在其中是如何发挥作用的。

yield 关键字

语句 or 表达式?

在 Python 文档里 yield 词条有两个:一个是 yield 语句,一个是 yield 表达式

刚开始,yield 关键字是作为一个语句(statement)被引入的,引入的目的正是为了构建生成器。

后来为了更好地实现协程,增强了语法,变成了表达式(expression),但是仍然完全兼容原有用法。

本章中,我们只介绍 yield 语句的用法,在下一章中再介绍 yield 表达式语法所增强的部分。

yield 对函数做了什么

yield 关键字最根本的作用是改变了函数的性质:

  1. 调用生成器函数不是直接执行其中的代码,而是返回一个对象。
  2. 生成器函数内的代码,需要通过生成器对象来执行。

从这一点上说,生成器函数的作用和类(class)是差不多的。

生成器对象就是迭代器,所以它的运行方式和迭代器是一致的:

简单示例1

稍微修改上面的例子,通过参数来控制是否要执行 yield 语句。

传入两个不同的参数调用该函数,分别得到两个生成器对象。

因为生成器就是迭代器,所以直接使用 next() 函数来驱动它。

此时,生成器对象会开始执行对应的函数中的代码,就和普通函数没什么两样。

由于这个生成器函数中根本都没机会运行到 yield 所在的语句,所以它将直接就运行到结束的地方。

但是其中的 return 语句并不会像普通函数那样返回,而是抛出 StopIteration 异常。因为这才是迭代器该有的结束姿势。

注意,跟在 return 语句后面的返回值将作为异常的参数一并抛出。

再次调用,将直接报错。

接着来看遇到 yield 会怎样:

这一次没有报错,代码执行到了 yield 所在的位置。

并且 yield 语句后面的对象作为 next 函数的返回值被赋值给了变量 x

继续通过 next 函数调用生成器对象,函数执行到结束。

在循环中使用 yield

只遭遇一次 yield 语句的生成器就是只能迭代一次的迭代器,通常没什么实用价值。

要想能迭代多次,可以在函数内多次使用 yield 语句:

更多的时候,迭代器基本都是在循环中使用:

相对应地,yield 一般也是搭配循环使用的:

通过这些例子可以看出来,使用 yield 来实现一个迭代器实在是太简洁了。

生成器的 4 个状态

yield 重构迭代器

class 定义的迭代器进行对比

动作 class 实现的迭代器 yield 生成器
定义迭代器 class Iterator:
def __init__(self, *args):
def iter_fun(*args): ...
构建迭代器 Iterator(args) iter_fun(args)
next(iterator) def __next__(self): return value yield value
StopIteration raise StopIteration return
iter(iterator) def __iter__(self): return self 自动实现

在没有 yield 的时候,我们必须用 class 来定义迭代器类,代码非常的啰嗦。

现在只需要定义生成器函数即可实现同样的功能。

甚至有时候连 yield 都不用,往往只需要一行代码就可以搞定。

生成器的三种应用场景:

为数据类实现 __iter__ 接口

在上一章中,我们介绍了如何自定义迭代器,即同时包含 __iter____next__ 方法的类。

有时候我们还需要自定义的是容器类型的可迭代对象,即只包含 __iter__ 方法的类。

如果没有 yield,则需要额外定义一个迭代器类,实现 __iter____next__ 方法;最后在容器类的 __iter__ 方法中返回迭代器的实例。

现在有了 yield,不需要定义迭代器类,只要在容器类的 __iter__ 方法中使用 yield 即可。

把原来应该在 __next__ 中的代码逻辑稍加修改:

需要注意的地方是:

迭代的进度总是由迭代器来保存的,容器类型的可迭代对象不应该有感知。

在迭代器类中,进度变量是通过 self 绑定到迭代器的实例上的。改为使用 yield 的方法后,应该避免和可迭代对象的实例绑定到一起。

这里的代码主要是为了讲解,事实上绝大多数情况下,也无需这么麻烦。

因为自定义的数据结构基本都会依赖某个内置数据结构,比如列表,直接利用已有的迭代器就可以了。

实现有处理数据能力的迭代器

在上一章中,我们定义了一个迭代器类:

重构如下:

可以看到,整体代码非常的简洁,基本上只有业务逻辑部分的代码是要保留的。

事实上,这里我有意把逻辑复杂了,让它既有过滤,又有选择性地处理。

如果处理逻辑简单,还可以使用更加简洁的写法,这里就不展开讨论了。

实现一个数据生成器

最后来看一下用来生产数据的迭代器。

使用 yield 实现同样的功能:

生成器的技术内幕

请耐心看完,对理解协程非常重要!

函数对象和代码对象

每当定义了一个函数之后,就得到了一个「函数对象」:

函数中的代码是保存在「代码对象(Code object)」中的:

代码对象随着函数对象一起创建,是函数对象的一个重要属性。

Code objects represent byte-compiled executable Python code, or bytecode.

代码对象中重要的属性以 co_ 开头:

函数运行帧

函数对象和代码对象保存了函数的基本信息,当函数运行的时候,还需要一个对象来保存运行时的状态。

这个对象就是 「帧对象(Frame object)」。

Frame objects represent execution frames.

每一次调用函数,都会自动创建一个帧对象,记录当次运行的状态。

帧对象是 Python 内部使用的对象,平常不会去主动操控它,在函数执行完毕会自动清理掉。

一般只有在程序运行出错或者 Debug 的时候,我们才会通过 Traceback 对象来追溯其中的信息。

可以通过 inspect 模块来获取当前的帧对象。

帧对象中重要的属性以 f_ 开头:

通常,帧对象的生命周期是随着函数的返回而结束。

函数运行栈

当一个函数中调用了另一个函数,此时前一个函数还没有结束,所以这两个函数的帧对象是同时存在的。

比如,我们的程序一般都始于一个 main 函数,然后又调用其它函数,以此类推。

因此,一个程序的运行期,同时存在很多个帧对象。

函数之间的调用关系是先执行的后退出,所以帧对象之间的关系也是先入后出,正好以的形式保存。

因此,函数的运行帧又称为栈帧

注意:一个线程只有一个函数运行栈。

有兴趣的可以继续利用 inspect 模块强大的自省能力来探究一番。

每次新调用一个函数,就在栈顶压入新的栈帧,而每当一个函数执行完,就从栈顶弹出该函数栈帧,回到了上一个函数。

因此,里面保存着所有正在运行的函数的上下文数据。

所以普通函数的运行有这些特点:

生成器对象的作用

对普通函数的运行有了基本的认知之后,我们就能更好地来理解生成器函数的运行了。

生成器函数仍然是函数对象,当然也包括了代码对象。

调用生成器函数不会直接运行(也就是说,不像普通函数那样创建帧对象并且压入函数栈),而是得到一个生成器对象。

那么秘密自然是藏在生成器对象里:

可以看到,生成器对象中自带了一个帧对象。

当每次使用 next() 对生成器进行迭代时,都用这个帧对象(gi_frame)来保存状态:

生成器的 frame 对象在暂停状态下看不到调用的关系图:

想要在生成器运行的过程中观察运行栈的关系图也是可以的:

当多次调用 use_gen 并打印关系图时,会发现其中 use_gen 对应的 frame 对象也是没有变化的。

这是因为多次调用的时候 Python 复用了同一块内存,和生成器的帧一直不变是有本质区别的。

所以这里强制把它返回,让 Python 不能重复利用这个地址。

综上,我们可以总结出:

Python 是怎么做到的

学过 C/C++ 可能产生的疑惑

我们用的主流 Python 都是 CPython,其中的内存管理是依赖于底层 C 语言的。

如果学过 C/C++ 语言的同学可能记得,栈内存中的数据,退出后就没了,Python 是如何做到出栈后还能继续访问的呢?

关于 Python 解释器是如何管理内存的,知道以上 3 个结论基本就足够了。

更多的细节,有兴趣的可以去看 CPython 的源码。

生成器的意义

现在,我们可以更好地理解所谓的 generator iterator 是什么了:

「生成器对象」是一个用来迭代执行「生成器函数」的迭代器

是不是有异曲同工之妙?

让一个函数可以多次迭代运行其中的代码才是生成器对象最最根本的作用,而不仅是字面意思上的生成数据的东西

迭代产出数据只是迭代执行代码的自然结果而已。

当用生成器来实现迭代器的时候,我们关注的是重点是:yield <value> 返回出来的数据。

如果把焦点集中到「被迭代执行的代码」上,就能对生成器有个全新的视角,那就是「协程」。

附录A:

使用生成器表达式

如果你已经掌握了列表解析式的用法,那么一秒钟就能掌握生成器表达式,把 [] 换成 () 即可。

可能从直觉上,用 [] 包围的结果是列表,所以会想当然的以为 () 的结果是元组,实际此处的结果是一个生成器对象。

迭代相关的内置函数:

附录:辅助工具

以下代码用来自动画图。