3

Python 中生成器的原理 - zikcheng

 1 year ago
source link: https://www.cnblogs.com/zikcheng/p/16462284.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

生成器的使用

在 Python 中,如果一个函数定义的内部使用了 yield 关键字,那么在执行函数的时候返回的是一个生成器,而不是常规函数的返回值。

我们先来看一个常规函数的定义,下面的函数 f() 通过 return 语句返回 1,那么 print 打印的就是数字 1。

def f():
    return 1
print(f())

如果我们将上面的 return 改成 yield,也就是下面这样

def f():
    yield 1
    yield 2
g = f()
print(g)
print(next(g))
print(next(g))
print(next(g))

最终的输出如下,调用函数 f() 得到的是一个生成器(generator)对象 g,通过 Python 内置的 next() 函数可以驱动生成器往下执行,每调用一次 next() 函数,生成器就会执行到下一个 yield 语句处,并将 yield 语句中的表达式返回,当没有更多 yield 语句时继续执行 next() 函数会触发 StopIteration 异常。

<generator object f at 0x10c963c50>
1
2
Traceback (most recent call last):
  File "<string>", line 8, in <module>
StopIteration

当然更优雅的使用生成器的方式是使用 for 循环,如下所示,会依次打印 1、2,并且不会抛出 StopIteration 异常,因为本质上生成器也是一种迭代器,所以可以用 for 循环遍历。另外,生成器也可以用生成器表达式如 g = (i for i "hello world") 来创建,这不是本文重点,就不详细介绍了。

def f():
    yield 1
    yield 2
for i in f():
    print(i)

生成器的原理

要理解 Python 中生成器的原理其实就是要搞清楚下面两个问题

  • 调用包含 yield 语句的函数为什么同普通函数不一样,返回的是一个生成器对象,而不是普通的返回值
  • next() 函数驱动生成器执行的时候为什么可以在函数体中返回 yield 后面的表达式后暂停,下次调用 next() 的时候可以从暂停处继续执行

这两个问题都跟 Python 程序运行机制有关。Python 代码首先会经过 Python 编译器编译成字节码,然后由 Python 解释器解释执行,机制上跟其他解释型语言一样。Python 编译器和解释器配合,就能完成上面两个问题中的功能,这在编译型语言中很难做到。像 C、Golang 会编译成机器语言,函数调用通过 CALL 指令来完成,被调用的函数中遇到 RET 指令就会返回,释放掉被调用函数的栈帧,无法在中途返回,下次继续执行。

虽然操作系统在线程切换的时候也会中断正在执行的函数,再次切换回来的时候继续执行,但是被中断的函数在切换的时候并没有返回值产生,这点与 Python 生成器是不同的,不要混淆了。

下面我们具体来看一下 Python 是如何解决上面两个问题的(基于 CPython 3.10.4)。

生成器的创建

Python 编译器在编译 Python 代码的时候分为词法分析、语法分析、语义分析和字节码生成这几个阶段,在进行语义分析的时候有一项重要的工作是构建符号表,主要用于确定各个变量的作用域,顺带做了一件跟生成器相关的事,也就是在分析过程中如果遇到了 yield 语句就将当前代码块的符号表标记为是生成器。
相关源码如下

static int
symtable_visit_expr(struct symtable *st, expr_ty e)
{
    if (++st->recursion_depth > st->recursion_limit) {
        PyErr_SetString(PyExc_RecursionError, "maximum recursion depth exceeded during compilation");
        VISIT_QUIT(st, 0);
    }
    switch (e->kind) {
    ...
    case Yield_kind:
        if (!symtable_raise_if_annotation_block(st, "yield expression", e)) {
            VISIT_QUIT(st, 0);
        }
        if (e->v.Yield.value)
            VISIT(st, expr, e->v.Yield.value);
        st->st_cur->ste_generator = 1; // 如果遇到了 yield 语句,就将 ste_generator 标志位置 1
        if (st->st_cur->ste_comprehension) {
            return symtable_raise_if_comprehension_block(st, e);
        }
        break; 
    ...
    }
    ...
}

最后在生成字节码的时候,会根据符号表的属性计算字节码对象的标志位,如果 ste_generator 为 1,就将字节码对象的标志位加上 CO_GENERATOR,相关源码如下

static int compute_code_flags(struct compiler *c)
{
    PySTEntryObject *ste = c->u->u_ste;
    int flags = 0;
    if (ste->ste_type == FunctionBlock) {
        flags |= CO_NEWLOCALS | CO_OPTIMIZED;
        if (ste->ste_nested)
            flags |= CO_NESTED;
        if (ste->ste_generator && !ste->ste_coroutine)
            flags |= CO_GENERATOR; // 如果符号表中 ste_generator 标志位为 1,就将 code 对象的 flags 加上 CO_GENERATOR 
        if (!ste->ste_generator && ste->ste_coroutine)
            flags |= CO_COROUTINE;
        if (ste->ste_generator && ste->ste_coroutine)
            flags |= CO_ASYNC_GENERATOR;
        if (ste->ste_varargs)
            flags |= CO_VARARGS;
        if (ste->ste_varkeywords)
            flags |= CO_VARKEYWORDS;
    }
    ...
    return flags;
}

最终 g = f() 会生成下面的字节码

0 LOAD_NAME                0 (f)
2 CALL_FUNCTION            0
4 STORE_NAME               1 (g)

Python 解释器会执行 CALL_FUNCTION 指令,将函数 f() 的调用返回值赋值给 g。CALL_FUNCTION 指令在执行的时候会先检查对应的字节码对象的 co_flags 标志,如果包含 CO_GENERATOR 标志就返回一个生成器对象。相关源码简化后如下

PyObject *
_PyEval_Vector(PyThreadState *tstate, PyFrameConstructor *con, PyObject *locals, PyObject* const* args, size_t argcount, PyObject *kwnames)
{
    PyFrameObject *f = _PyEval_MakeFrameVector(tstate, con, locals, args, argcount, kwnames);
    if (f == NULL) {
        return NULL;
    }
    // 如果 code 对象有 CO_GENERATOR 标志位,就直接返回一个生成器对象
    if (((PyCodeObject *)con->fc_code)->co_flags & CO_GENERATOR) { 
        return PyGen_NewWithQualName(f, con->fc_name, con->fc_qualname);
    }
    ...
}

可以看到编译器和解释器的配合,让生成器得以创建。

生成器的运行

Python 解释器用软件的方式模拟了 CPU 执行指令的流程,每个代码块(模块、类、函数)在运行的时候,解释器首先为其创建一个栈帧,主要用于存储代码块运行时所需要的各种变量的值,同时指向调用方的栈帧,使得当前代码块执行结束后能够顺利返回到调用方继续执行。与物理栈帧不同的是,Python 解释器中的栈帧是在进程的堆区创建的,如此一来栈帧就完全是解释器控制的,即使解释器自己的物理栈帧结束了,只要不主动释放,代码块的栈帧依然会存在。

2045526-20220709234337933-213029852.svg

执行字节码的主逻辑在 _PyEval_EvalFrameDefault 函数中,其中有个 for 循环依次取出代码块中的各条指令并执行,next(g) 在执行的时候经过层层的调用最终也会走到这个循环里,其中跟生成器相关的源码简化后如下

PyObject* _Py_HOT_FUNCTION _PyEval_EvalFrameDefault(PyThreadState *tstate, PyFrameObject *f, int throwflag)
{
    ...
    for (;;) {
        opcode = _Py_OPCODE(*next_instr);
        switch (opcode) {
        case TARGET(YIELD_VALUE): {
            retval = POP(); // 将 yiled 后面的表达式的值赋给返回值 retval

            if (co->co_flags & CO_ASYNC_GENERATOR) {
                PyObject *w = _PyAsyncGenValueWrapperNew(retval);
                Py_DECREF(retval);
                if (w == NULL) {
                    retval = NULL;
                    goto error;
                }
                retval = w;
            }
            f->f_state = FRAME_SUSPENDED; // 设置当前栈帧为暂停状态
            f->f_stackdepth = (int)(stack_pointer - f->f_valuestack);
            goto exiting; // 结束本次函数调用,返回上级函数
        }
        }
    }
    ...
}

可以看出 Python 解释器在执行 yield 语句时会将 yield 后面的值作为返回值直接返回,同时设置当前栈帧为暂停状态。由于这里的栈帧是保存在进程的堆区的,所以当这次对生成器的调用结束之后,其栈帧依然存在,各个变量的值依然保存着,下次调用的时候可以继续当前的状态往下执行。

本文介绍了 Python 中生成器的使用方法,然后介绍了 Python 代码的运行机制,并结合源码对生成器的工作原理做了介绍。Python 解释器能实现生成器,主要是因为其是用软件来模拟硬件的行为,既然是软件,在实现的时候就可以添加很多功能,对解释器的一顿魔改,在 Python 2.2 版本中就引进了生成器。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK