Python yield

最近在新公司里工作,有时候需要面试,但我之前没有面试过,为了防止扼杀了人才,我时时刻刻提醒自己要理解别人的代码,充分做好homework再评价,以免误人子弟,又没有招到好的人才,这就比较麻烦了。

话说最近遇到的一位同学,特别喜欢用yield,普通函数也使用yield,显得特别高级。yield关键词在Python中可是相当的高级,我自己平时大部分时间是不使用yield的,毕竟没有遇到什么特别需要使用的场景,所以不是很了解,于是就狠心下来了解一下yield如何实现。


小六同学简述了一下Python的Generator,具体在这里:

Python Generator 更加进阶的 Generator

我这里不会详细的说Generator到底怎么使用,我是在想,yield如何实现这样功能的,说的更清楚一点,Python是如何实现一个Generator,并且,如何保存函数上下文并且返回呢?

可以先从Python代码出发(Python的C代码我简称CPython)可以粗略的想象的一下,如果有一个Generator,代码如下:

def gen():    count = 0    while count < 10:        count += 1        print 'call here'        yield count

知道,函数并没有真正的执行,这个函数只是返回了一个Generator,也就是说如果我创建一个Generator如下:

f = gen()

代码在这里,实际上只是创建了一个Generator,并没有真正的执行。也就是说f现在是一个Generator,而不是返回值。那么使用f.next()的时候,函数才会真正的执行。当执行到yield之后,虽然使用起来和return一样,f.next()等于return了一个值,但是这个时候,Python把函数的上下文保存了起来,直到下一次调用next()才会继续执行。

举个例子,如果我要遍历100万个单词,传统方法我可能要把100万个单词全部放到内存中去,而使用Generator之后,我取一个单词,如果暂时不需要单词,那么这个函数的上下文被保存在内存中了,直到下次调用才会再把数据拿回来。这有一个好处是,如果处理大数据或流式的数据,不需要一次性全部读出来,但是可以很自然的处理整个逻辑1。


简单的说明了一下yield使用之后,回到问题的关键所在。我很好奇,Python如何保存上下文,大量使用yield会有什么不好的地方吗?于是为了在面试别人提出问题的时候,我自己也能够心里有数,我特意看了Python的代码,以下我就简称为CPython。

这里的分析仅仅是我自己的思考,可能有误解甚至错误。

在这里,有两篇文章特别重要:

Python’s Innards: Interpreter Stacks Python’s Innards: Hello, ceval.c!

这两篇文章都是讲解CPython中PyEval_EvalFrameEx的作用,第一篇讲的是内部的堆栈(Stack)的实现,第二篇详细的讲解了PyEval_EvalFrameEx。这里我简单的说一下这两个重点,可能理解有偏差,最好能自己去了解原文。

堆栈(Stack)

堆栈(Stack)是计算机里最常用的数据结构,这里我就假装大家都知道,并且使用起来非常灵活。那么在Python中,有三个堆栈结构,Call Stack、Value Stack和Block Stack。

Call Stack,也被称作调用栈,是用于存储子程序信息的一类栈,别称执行栈(execution stack)、控制栈(control stack)、运行时栈(run-time stack)与机器栈(machine stack),在英语中亦经常简称为“栈”(“the stack”)。

Call Stack

Value Stack,在Python中需要操作对象的时候用来操作内部对象时使用,比如说有Python的操作码(opcode)BINARY_SUBTRACT,这个操作码的作用是从栈中弹出两个值,在它们身上使用PyNumber_Subtract方法,然后设置新的top值。每个Frame都有一个Value Stack。

Block Stack,基本上是在for、try、while里使用的堆栈,并且是一个有长度的Stack,所以执行循环次数过多的时候,会有deepest之类的报错。

Frame

了解之后可以看看Frame这个东西,以我的理解,每个Call Stack里都有一些Frame,每个Frame都对应着Value Stack。Frame之间还有很多其他的关系,比如会有指向前一个Frame的指针,但这里就不需要深入了解了。只要大概知道Frame的作用就行。

所以,每个Frame都相当于一段背后执行的代码,并且每个Frame都恰好指向一个Code Object。所以当Python保存上下文时,在调用栈中可以很轻松的保存函数的执行的信息,包括地址,Frame的指针以及一些Value。


PyEval_EvalFrameEx

如果我们要执行一个表达式,实际上会翻译成Python的操作码,也就是opcode,举例说明,假设我们有这么个一个函数。

def a():    b = 1+1    return b

那么可以通过以下方法看Python的操作码:

from dis import disdis(a)

可以看到输出如下:

0 LOAD_CONST               2 (2)3 STORE_FAST               0 (b)6 LOAD_FAST                0 (b)9 RETURN_VALUE

无需了解特定的含义,但其实已经很明显的知道是什么意思了。那么其中的LOAD_CONST就是操作码。所以回到关键的PyEval_EvalFrameEx,这个函数就是执行操作码的一个函数,例如LOAD_CONST。

所以简单的来看,这个函数代码如下。

PyEval_EvalFrameEx(PyFrameObject *f, int throwflag){    /* variable declaration and initialization stuff */    for (;;) {        /* do periodic housekeeping once in a few opcodes */        opcode = NEXTOP();        if (HAS_ARG(opcode)) oparg = NEXTARG();        switch (opcode) {            case YIELD_VALUE:                retval = POP();                f->f_stacktop = stack_pointer;                why = WHY_YIELD;                goto fast_yield;            /* lots of more complex opcode implementations */            default:                /* become rather unhappy */        }        /* handle exceptions or runtime errors, if any */    }    /* we are finished, pop the frame stack */    tstate->frame = f->f_back;    return retval;}

也就是说,如果执行了yield命令,那么就会生成YIELD_VALUE操作码,也就是会被该函数执行到case YIELD_VALUE中,于是我们就可以了解是如何进行Frame的操作了。


再次回到Python代码,如下:

def gen():    count = 0    while count < 10:        count += 1        print 'call here'        yield count

我们看一下机器码:

 0 LOAD_CONST               1 (0) 3 STORE_FAST               0 (count) 6 SETUP_LOOP              36 (to 45) 9 LOAD_FAST                0 (count)12 LOAD_CONST               2 (10)15 COMPARE_OP               0 (<)18 POP_JUMP_IF_FALSE       4421 LOAD_FAST                0 (count)24 LOAD_CONST               3 (1)27 INPLACE_ADD28 STORE_FAST               0 (count)31 LOAD_CONST               4 ('call here')34 PRINT_ITEM35 PRINT_NEWLINE36 LOAD_FAST                0 (count)39 YIELD_VALUE40 POP_TOP41 JUMP_ABSOLUTE            944 POP_BLOCK45 LOAD_CONST               0 (None)48 RETURN_VALUE

我们大概了解了机器码,确认其最终会使用YIELD_VALUE这个操作码。所以先想象Generator Object首先会操作自己的Frame,而操作的方法就是通过PyEval_EvalFrameEx函数来执行。

为了确认,同样创建一个Generator,如下:

f = gen()

仔细的深入了解,这个时候做了什么操作?实际上在CPython中,有一个Object/genobject.c,这个类是Python中Generator的实现,可以看看,当f=gen()的时候,实际上调用了以下代码。

PyObject *PyGen_New(PyFrameObject *f){    PyGenObject *gen = PyObject_GC_New(PyGenObject, &PyGen_Type);    if (gen == NULL) {        Py_DECREF(f);        return NULL;    }    gen->gi_frame = f;    Py_INCREF(f->f_code);    gen->gi_code = (PyObject *)(f->f_code);    gen->gi_running = 0;    gen->gi_weakreflist = NULL;    _PyObject_GC_TRACK(gen);    return (PyObject *)gen;}

这个代码很简单,就是创建了一个PyGenObject,注册了一个GC之类的就不说了,总得来说没有做什么事情。其中使用了一个PyFrameObject对象的实例作为参数,将其相关的信息例如f_code传给Generator对象,暂时可以想象为使用了之前的运行时的Frame并把相关信息给了Generator Object之类的吧。之后,Generator就有了自己的Frame成员。


在创建之后,可以看看代码,我们平时使用的Generator主要的接口是使用next和send,其实next和send的函数差不多,都是使用了gen_send_ex,仅仅是参数有区别。

static PyObject *gen_send(PyGenObject *gen, PyObject *arg){    return gen_send_ex(gen, arg, 0);}static PyObject *gen_iternext(PyGenObject *gen){    return gen_send_ex(gen, NULL, 0);}

可以看到,唯一的区别就是send传递了参数,而next没有传递参数。这一点在小六的文章里也简略的带过。好吧,看函数gen_send_ex,在里面我print了一些日志,方便查看。

static PyObject *gen_send_ex(PyGenObject *gen, PyObject *arg, int exc){    PyThreadState *tstate = PyThreadState_GET();    /* 获取Frame */    PyFrameObject *f = gen->gi_frame;    PyObject *result;    if (gen->gi_running) {        fprintf(stderr, "gi init\n");        PyErr_SetString(PyExc_ValueError,                        "generator already executing");        return NULL;    }    if (f==NULL || f->f_stacktop == NULL) {        fprintf(stderr, "check stack\n");        /* Only set exception if called from send() */        if (arg && !exc)            PyErr_SetNone(PyExc_StopIteration);        return NULL;    }    if (f->f_lasti == -1) {        fprintf(stderr, "f->f_lasti\n");        /* 如果Generator初始化并且send第一个值不是None */        if (arg && arg != Py_None) {            fprintf(stderr, "something here\n");            PyErr_SetString(PyExc_TypeError,                            "can't send non-None value to a "                            "just-started generator");            return NULL;        }    } else {        /* 把参数arg push到frame的value stack中 */        fprintf(stderr, "frame\n");        if(arg) {            fprintf(stderr, "with arg\n");        }        result = arg ? arg : Py_None;        Py_INCREF(result);        *(f->f_stacktop++) = result;    }    fprintf(stderr, "here\n");    Py_XINCREF(tstate->frame);    assert(f->f_back == NULL);    f->f_back = tstate->frame;    gen->gi_running = 1;    /* 从Frame中取得yield的值 */    result = PyEval_EvalFrameEx(f, exc);    gen->gi_running = 0;    assert(f->f_back == tstate->frame);    Py_CLEAR(f->f_back);    if (result == Py_None && f->f_stacktop == NULL) {        fprintf(stderr, "here2\n");        Py_DECREF(result);        result = NULL;        /* Set exception if not called by gen_iternext() */        if (arg)            PyErr_SetNone(PyExc_StopIteration);    }    if (!result || f->f_stacktop == NULL) {        fprintf(stderr, "here3\n");        /* generator can't be rerun, so release the frame */        Py_DECREF(f);        gen->gi_frame = NULL;    }    fprintf(stderr, "return result\n");    return result;}

所以,这个时候我调用f.next()的时候,会出现什么情况呢?

f.next()f->f_lastiherecall herereturn result1

好吧,可以看到,首先会print一个f->f_lasti2,前面提到的两篇文章中就说明了这一点,这里简单的说一下f_lasti是一个最后执行的代码的一个offset,默认是-1,这里可以看到一个新创建的Generator必然offset是-1,所以会进到这个地方,但是又没有arg参数,所以函数就继续了。

然后走到here,仔细看这里就最后使用了PyEval_EvalFrameEx这个东西。从前面来看,PyEval_EvalFrameEx是一个Python的操作码(opcode)执行的一个大的循环,最终执行了YIELD_VALUE,实际上是fast_yield。具体的实现可以看ceval.c,我也没有细看。

这个时候,Generator操作的是自己的Frame对象,可以简单的当作是一段一段的执行代码,虽然我们不需要仔细的了解Python是如何操作的,只要我们有这个想象就可以了。并且从Generator Object初始化代码中可以了解,Generator Object自身有一个gi_frame成员,这就是Generator里常用的Frame。

所以,这个时候执行到了gen函数里的print call here,那么就输出了call here,这点很容易理解。当执行到这里了之后,遇见了yield关键字,ok,这个时候PyEval_EvalFrameEx执行了fast_yield,gi_frame并没有清空,然后返回了result,这里的result的值是一个PyIntObject。

这个时候Generator已经交出了代码的控制权,返回给了Python虚拟机,所以顺序就变成了return result->1这样。实际上可以想象,Generator就是一个代码运行的一个控制器,其操作的就是内部的Frame。

当第二次使用f.next()的时候,输出如下:

frameherecall herereturn result2

可以看到,这个时候Frame已经不是空了,因为已经执行了部分的gen的代码,可以想象一下gi_frame有这么一个指针3,停在了刚才yield那个地方,当调用next的时候,函数继续从刚才暂停的地方继续,从刚才暂停的堆栈里继续下去,然后又一次遇到了PyEval_EvalFrameEx,然后函数又继续运行,运行到下一个yield关键字之后,又交出了控制权,返回了result结果,于是变成了return result->2这样。

我个人简单的理解,有了Generator,有了自己的Frame,然后自己的Frame执行操作,最后使用完毕,再销毁Frame。send函数也同理,只不过是将传递的参数首先压入了Frame里的Value Stack罢了。


在最终销毁一个Generator对象时,执行代码如下:

static voidgen_dealloc(PyGenObject *gen){    PyObject *self = (PyObject *) gen;    _PyObject_GC_UNTRACK(gen);    if (gen->gi_weakreflist != NULL)        PyObject_ClearWeakRefs(self);    _PyObject_GC_TRACK(self);    if (gen->gi_frame != NULL && gen->gi_frame->f_stacktop != NULL) {        /* Generator暂停了, 所以可以关闭 */        Py_TYPE(gen)->tp_del(self);        if (self->ob_refcnt > 0)            /* 如果引用计数大于0,就复活这个对象 */            return;    }    _PyObject_GC_UNTRACK(self);    Py_CLEAR(gen->gi_frame);    Py_CLEAR(gen->gi_code);    PyObject_GC_Del(gen);}

其中Py_TYPE(gen)->tp_del(self);是调用了Generator对象结构体中的gen_del函数,最终又会走到gen_close

static PyObject *gen_close(PyGenObject *gen, PyObject *args){    PyObject *retval;    PyErr_SetNone(PyExc_GeneratorExit);    retval = gen_send_ex(gen, Py_None, 1);    if (retval) {        Py_DECREF(retval);        PyErr_SetString(PyExc_RuntimeError,                        "generator ignored GeneratorExit");        return NULL;    }    if (PyErr_ExceptionMatches(PyExc_StopIteration)        || PyErr_ExceptionMatches(PyExc_GeneratorExit))    {        PyErr_Clear();          /* ignore these errors */        Py_INCREF(Py_None);        return Py_None;    }    return NULL;}

再看看,最后还是调用了gen_send_ex


大概yield的实现就是这么理解吧,其实有很多细节的部分还没有解释清楚,因为我自己也不是很深的了解编程语言这种东西。我会慢慢更新,查漏补缺,再说像我这种垃圾水平,或者有什么改三观的改变也也有可能。

所以我觉得疯狂使用yield,一是没必要,因为不是所有情况下使用yield都是好理解的,另外就是我们的程序中,很多函数只调用一次也许就不再第二次调用了,如果大量的写yield,会存储过多的上下文,如果不考虑好如何回收内存的话,可能会有内存泄漏的问题。

    如果是普通的逻辑,比如1+1和读数据库,实际上没有太大的使用yield的必要。 ↩

    an integer offset into the bytecode of the last instructions executed. ↩

    Generator对象自身的。 ↩

Python yield

相关文章:

你感兴趣的文章:

标签云: