Python生成器(Generator)

python生成器是python编程的最独特的特性,在C、Java中完全没有对应的概念,前面学习的迭代器、推导式虽然也是python编程的神器,但在C、Java中有类似的东西,或者有可以替代的东西,但是生成器,在C和Java中完全没有相似或可替代的语法。

python因为GIL的原因,多线程的使用有很大的限制(或性能不佳),所以广泛的要使用协程,而python协程的基础便是生成器(与Go lang的协程不一样),所以生成器在python中非常的重要,是很多高级语法的基础,但因为生成器在其他语言中没有,所以也是非常难以理解的概念。学习过程中,需要对生成器的原理进行反复的琢磨。

生成器的定义

Python生成器是一种特殊的迭代器,可以逐个地产生元素,而不是一次性产生所有元素。生成器的工作方式与迭代器相似,可以通过for循环或者next()函数逐个获取生成器中的元素,而且生成器还支持惰性计算,即只有在需要时才会计算下一个元素。

生成器的语法使用yield关键字,yield用于定义生成器函数,生成器函数是一种特殊的函数,可以在迭代过程中逐步产生值,而不是一次性返回所有结果。

跟普通函数不同的是,生成器是一个返回迭代器的函数,只能用于迭代操作,更简单点理解生成器就是一个迭代器。这个迭代器通过调用next()或使用for循环进行迭代。

迭代器在执行的过程中,遇到yield语句时,函数执行过程会被暂停,将控制权返回给调用方,并保留函数的状态,并将 yield 后面的表达式作为当前迭代的值返回。当再次调用生成器时,生成器会从上次暂停的位置继续执行,直到遇到下一个yield语句。生成器函数的执行过程看起来就是不断地 执行->中断->执行->中断 的过程。

下面是一个简单的例子,展示如何使用生成器来实现一个斐波那契数列:

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

在这个例子中,fibonacci()函数是一个生成器,它使用yield语句来逐个产生斐波那契数列中的元素。在调用fibonacci()函数时,它会立即返回一个生成器对象,而不是执行函数体。然后,每次调用生成器对象的__next__()方法(或者使用next()函数)时,生成器会从上次暂停的位置继续执行,直到遇到下一个yield语句。因此,下面的代码可以打印出斐波那契数列中前10个数字:

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

f = fibonacci() #获得生成器
for i in range(10):
    print(next(f)) #执行生成器

’’’
0
1
1
2
3
5
8
13
21
34
‘’‘

生成器在处理大数据的任务,将大任务分解为一段一段进行处理,形成类似流的效果,可以提升处理效率,降低处理所需要的内存。

总的来看,生成器有以下几个特点:

  1. 语法上和函数类似:生成器函数和普通函数的定义相似,都使用def语句进行定义,差别在于,生成器使用yield语句返回值,而常规函数使用return语句返回值。
  2. 自动实现迭代器协议:生成器是一个特殊的迭代器,作为迭代器,解释器自动实现了迭代器协议,以便应用到迭代中(如for循环,sum函数)。由于生成器自动实现了迭代器协议,所以,我们可以调用它的next方法,并且,在没有值可以返回的时候,生成器自动产生StopIteration异常
  3. 执行过程的状态挂起:生成器使用yield语句返回一个值。yield语句挂起该生成器函数的状态,保留足够的信息,以便以后从它离开的地方继续执行。
     

生成器与函数的区别:

  • 生成器函数包含一个或多个 yield 语句。
  • 生成器函数在调用时,返回一个对象(迭代器) ,但不会立即开始执行。
  • 生成器函数自动实现迭代器的两个魔法方法,因此可以使用next()迭代这些项。
  • 生成器函数一旦创建,函数将暂停并将控制转移到调用方。
  • 生成器函数的局部变量及其状态在连续调用之间被记住。
  • 当生成器函数终止时,在进一步调用时自动引发 StopIteration。

这里有一个例子来说明上述所有要点。有一个名为 my_gen() 的生成器函数,它包含几个 yield 语句:

# 一个简单的生成器函数
def my_gen():
    n = 1
    print('第一次打印')
    # 生成器函数包含yield语句
    yield n

    n += 1
    print('第二次打印')
    yield n

    n += 1
    print('最后打印')
    yield n

运行结果:

# 返回一个生成器,但不会立即执行
a = my_gen()

# 使用next()函数进行迭代操作
next(a)
# 输出:第一次打印
# 输出:1

# 一旦生成器函数yield,函数会暂停并将控制转移到调用方

# 局部变量及其状态在连续调用之间被记住

next(a)
# 输出:第二次打印
# 输出:2

next(a)
# 输出:最后打印
# 输出:3

# 最终,函数终止。后续如果继续调用则会引发StopIteration异常。
next(a)  # 将引起 StopIteration 异常

在上面的例子中需要注意的一件有趣的事情是,变量 n 的值在每次调用之间都会被记住。

与普通函数不同,当函数 yield 时,局部变量不会被破坏。此外,生成器对象只能迭代一次。要重新启动进程,我们需要使用类似 a = my_gen()这样的代码创建另一个生成器对象。

最后要注意的是,我们可以直接使用 for 循环的生成器。

这是因为 for 循环接受一个迭代器,并使用 next() 函数对其进行迭代。当 StopIteration 被引发时,它自动结束。

# 一个简单的生成器函数
def my_gen():
    n = 1
    print('第一次打印')
    # 生成器函数包含yield语句
    yield n

    n += 1
    print('第二次打印')
    yield n

    n += 1
    print('最后打印')
    yield n


# 使用for循环遍历
for item in my_gen():
    print(item)

‘’'
第一次打印
1
第二次打印
2
最后打印
3
‘''

上面的例子用处不大,我们研究它只是为了搞清楚背后发生了什么。通常,生成器函数是通过具有适当终止条件的回路来实现的。

让我们再来看一个生成器的例子,它用来反转字符串。

# 生成器函数
def rev_str(my_str):
    length = len(my_str)
    for i in range(length - 1, -1, -1):
        yield my_str[i]


# 用来反转字符串的for循环
for char in rev_str("hello"):
    print(char)

‘’'
o
l
l
e
h
‘''

在这个例子中,我们使用 range()函数给 for 循环提供逆序的索引值。

注意: 这个生成器函数不仅可以处理字符串,还可以处理列表、元组等其他类型的可迭代对象。

 

生成器的原理

yield的原理涉及到Python解释器的C++的源代码,并不一定要完全弄懂这些源代码,只是需要理解其中的原理就可以了。

yield与return

生成器的关键是yield关键字,它与return有类似之处,就是都从函数内部执行中出来,并带给调用者返回值。

def foo():
    print('foo() run')
    return 1
f1 = foo;
print(f1)
print('-----')
print(f1())
print('-----')
print(f1())

‘’'

-----
foo() run
1
-----
foo() run
1
‘''

可以将上面的函数使用yield替换return。

def foo():
    print('foo() run')
    yield 1
f1 = foo(); #与函数不同,生成器需要执行才能得到
print(f1)
print('-----')
print(next(f1)) #使用next函数运行生成器
print('-----')
print(next(f1)) #StopIteration

‘’'

-----
foo() run
1
-----
Traceback (most recent call last):
…
    print(next(f1)) 
          ^^^^^^^^
StopIteration
‘’''

在上面的例子中,yield模拟了return的功能,但又几处不同:

  1. 普通函数执行的时候就是执行函数体,而生成器的函数在函数执行的时候,获得是生成器,并没有实际执行函数体;
  2. 函数可以反复的执行,但生成器在反复执行的时候,如果找不到下一个yield,或引发StopIteration异常。

可以把上面的生成器改造为:

def foo():
    print('foo() run')
    yield 1
    print('foo() continue to run!')
    yield 2
f1 = foo(); #与函数不同,生成器需要执行才能得到
print(f1)
print('-----')
print(next(f1)) #使用next函数运行生成器
print('-----')
print(next(f1)) 

‘’'

-----
foo() run
1
-----
foo() continue to run!
2
‘''

 可以很明显的看出,当生成器执行到第一个yield返回后,在此调用next()函数,生成器是从第一个yield之后开始执行的,直到第二个yield返回为止,当第三次调用时,仍然会返回StopIteration异常

当然调用生成器更优雅的方法是使用for循环语句,它会自动判断 StopIteration并终止循环:

def foo():
    print('foo() run')
    yield 1
    print('foo() continue to run!')
    yield 2

f1 = foo(); #与函数不同,生成器需要执行才能得到
print(f1)
for f in f1:
    print('-----')
    print(f)

‘’'

foo() run
-----
1
foo() continue to run!
-----
2
‘''

生成器的创建原理

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

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

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

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

#CPython 3.10.4 的C源代码,仅仅作为学习原理的参考
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,相关源码如下:

#CPython 3.10.4 的C源代码,仅仅作为学习原理的参考
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;
}

最终 f1 = foo() 会生成下面的字节码

0 LOAD_NAME                0 (foo)
2 CALL_FUNCTION            0
4 STORE_NAME               1 (f1)

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

#CPython 3.10.4 的C源代码,仅仅作为学习原理的参考
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 解释器中的栈帧是在进程的堆区创建的,如此一来栈帧就完全是解释器控制的,即使解释器自己的物理栈帧结束了,只要不主动释放,代码块的栈帧依然会存在。

Python生成器(Generator)_第1张图片

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

#CPython 3.10.4 的C源代码,仅仅作为学习原理的参考
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解释器对yield进行了特殊处理,在处理包含yield的函数时,将该函数标记为生成器,实际并不执行该函数,而是返回该函数的生成器对象,在执行该生成器对象的时候,在遇到yield时,将函数的栈帧暂存在了生成器中,并且将控制权交还给调用者,再次调用生成器时,python恢复了函数调用的栈帧,让函数得以继续执行。

生成器的实现,主要是因为python是用软件来模拟硬件的行为,既然是软件,在实现的时候就可以添加很多功能,如独立的栈帧,yield返回的特殊控制等等。

其他创建生成器的方法

元组推导式

在介绍元组推导式的时候,我们简单介绍过,如果直接实用小括号构建元组推导式,返回的实际是一个生成器。如下面的例子,使用生成器表达式创建了一个生成器对象,它会逐个产生0到9的平方值。可以使用for循环遍历生成器对象,或者使用next()函数逐个获取生成器中的元素:

gen = (x**2 for x in range(10))

for i in gen:
    print(i)

# 或者使用 next() 函数
print(next(gen))
print(next(gen))
print(next(gen))

‘’'
0
1
4
9
16
25
36
49
64
81
0
1
4
‘''

当使用生成器时,需要注意生成器只能遍历一次,遍历完之后就会被消耗掉。因此如果需要多次遍历生成器,需要重新创建生成器对象。另外,生成器在遍历时也支持使用函数和条件语句等对元素进行过滤和操作,这些操作都是实时进行的,不会一次性将所有元素都存储在内存中。

下面是一个使用条件语句过滤生成器元素的例子:

gen = (x for x in range(10) if x % 2 == 0)

for i in gen:
    print(i)

 这个例子中,生成器对象gen会产生0到9中所有偶数。在for循环中,只有当元素满足x%2==0条件时,才会被输出。因此,这段代码只会输出0、2、4、6、8这些偶数。

a=sum([i for i in range(1000000000)])
print(a)

b=sum(i for i in range(1000000000))
print(b)

在上面的例子中,对于前一个表达式,在还没有看到最终结果时电脑如卡死了一般,对于后一个表达式,几乎没有什么内存占用。

itertools模块

itertools 模块是 Python 中内置的一个标准库,提供了许多生成器函数,用于生成各种类型的生成器。它可以用于创建常见的生成器类型,如循环、组合、分组等。

itertools模块后面会单独学习。

生成器高级语法

生成器组合

有时候你需要把两个生成器组合成一个新的生成器,比如:

gen_1 = (i for i in range(0,3))
gen_2 = (i for i in range(6,9))

def new_gen():
    for x in gen_1:
        yield x
    for y in gen_2:
        yield y

for x in new_gen():
    print(x)

# 输出:
# 0
# 1
# 2
# 6
# 7
# 8

这种组合迭代的形式不太方便,因此 Python 3.3 引入新语法 yield from 后,可以改成这样:

def new_gen():
    yield from gen_1
    yield from gen_2

它代替了 for 循环,迭代并返回生成器的值。

yield from 感觉上像是语法糖,不过它主要的应用场景是在协程中,这里就不展开探讨了。

管道生成器

生成器可以形成管道的效果:

假设我们有一个生成器,它生成 Fibonacci 斐波拉契数列中的数字。我们还有另一个平方数的生成器。

如果我们要求出斐波拉契数列中数字的平方和,我们可以通过以下方法,将生成器函数的输出结合在一起来实现。

# 斐波拉契数列生成器
def fibonacci_numbers(nums):
    x, y = 0, 1
    for _ in range(nums):
        x, y = y, x+y
        yield x

# 平方和生成器
def square(nums):
    for num in nums:
        yield num**2

print(sum(square(fibonacci_numbers(10))))

‘’'
4895
‘''

send()

既然生成器允许我们暂停控制流并返回数据,也可以通过send()给生成器传递数据,调用send()后,其实不用再调用next()了,可以认为send()内部已经调用了一次next():

def gen():
    count = 0
    while True:
        count += (yield count)

yield 变成个表达式了,并且生成器调用者可以通过 send()向生成器传递数据:

>>> g = gen()
>>> g.send(None)
0
>>> g.send(1)
1
>>> g.send(2)
3
>>> g.send(5)
8

稍微要注意的是首次调用时,必须要先执行一次 next() 或者 .send(None) 使生成器到达 yield 位置,send(None)  类似于 next 可以进入生成器中去执行代码。

throw()

throw() 允许用生成器抛出异常,像这样:

def my_gen():
    count = 0
    while True:
        yield count
        count += 1

gen = my_gen()
for i in gen:
    print(i)
    if i == 3:
        gen.throw(ValueError('The number is 3...'))

# 输出:
# 0
# 1
# 2
# 3
# ValueError: The number is 3...

这在任何需要捕获异常的领域都很有用。

close()

close() 可以停止生成器,比如把上面的例子改改:

def my_gen():
    count = 0
    while True:
        yield count
        count += 1

gen = my_gen()
for i in gen:
    print(i)
    if i == 3:
        gen.close()

这次就不会抛出异常了,而是在迭代完数字 3 之后,生成器就顺利地停止了。

生成器的应用

读取大文件

在处理大型文件时,可以使用生成器逐行读取文件内容,而不是一次性读取整个文件。这样可以避免占用过多的内存,尤其是当文件非常大时,使用生成器可以大大提高程序的性能。

下面是一个读取文件的例子,它会打印出文件中所有包含指定关键字的行:

def read_file(filename, keyword):
    with open(filename, 'r') as f:
        for line in f:
            if keyword in line:
                yield line.strip()

在这个例子中,read_file()函数使用yield语句逐行读取文件内容,并在每一行包含指定关键字时,产生这一行的内容。然后可以使用for循环遍历生成器对象,依次获取每一行包含指定关键字的内容:

for line in read_file('example.txt', 'python'):
    print(line)

这段代码会打印出文件example.txt中所有包含关键字'python'的行。

无限序列

理论上存储无限序列需要无限的空间,这是不可能的。

但是由于生成器一次只生成一个值,因此它可用于表示无限数据。(理论上)

比如生成所有偶数:

def all_even():
    n = 0
    while True:
        yield n
        n += 2

even = all_even()
for i in even:
    print(i)

这个程序将无限的运行下去,直到你手动打断它。

你可能感兴趣的:(python,开发语言)