Python中的迭代协议
Python是面向协议编程的,我们说的迭代协议一般是指__iter__
魔法函数。
迭代器是访问集合内元素的一种方式, 一般用来遍历数据
迭代器和以下标的访问方式不一样, 迭代器是不能返回的, 迭代器提供了一种惰性方式数据的方式
迭代器和可迭代对象的区别
可迭代对象是什么呢?迭代器和可迭代有什么区别?我们看下源码:
1 | from collections.abc import Iterable, Iterator |
从上面的源码中,我们看出可迭代对象是指实现了迭代协议__iter__
的对象,而迭代器除了实现了迭代协议__iter__
外还实现了魔法方法__next__
。
我们看下内置类型list是可迭代对象还是迭代器对象。
1 | In [5]: a = [1,2,3] |
我们看到list仅仅实现了__iter__
,因此list是可迭代对象而不是迭代器。
Python中内置了一个iter()函数,可以返回一个迭代器对象,它接受的参数是一个实现了__iter__()
方法的容器(也就是可迭代对象)。
1 | In [9]: a = iter(a) |
生成器函数的使用
这一小节我们将学习什么是生成器函数,及其使用。
函数里只要使用了yield
关键字就是生成器函数了
这里我们加上断点调试,发现生成器函数返回的不再是具体的值,而是一个生成器对象。
这里有小伙伴疑惑了,这个生成器对象应该在什么时候产生呢?
答案是:Python编译字节码的时候就产生了。
Python在运行之前会将代码编程字节码,在编译的时候发现生成器函数中存在 yield
关键字,就会把对象的yield
生成生成器对象。
生成的生成器函数我们如何使用呢?
因为生成器函数内部实现了可迭代协议,我们直接可以使用for循环。
1 | for aa in ge: |
传统函数只能使用一次return进行数据返回,而生成器函数可以使用多次yield
进行数据返回
1 | def gen_func(): |
使用yield
关键字使惰性求值和延迟求值成为了可能。(后面会学习到)
我们看下生成器函数的一个简单应用:
1 | # 生成一个菲波那切数列 查找指定值的和 |
我们看第二个和第三个函数,使用list记录会随着数值的增大而增加内存消耗,而使用yield
生成器则不会消耗内存。
生成器的原理
在理解生成器的原理之前我们先看下Python中函数的运行原理。
我们先生成两个函数
1 | def foo(): |
当我们运行上面的代码的时候,Python解释器python.exe
会用一个叫做 PyEval_EvalFramEx
(c函数)去执行foo函数, 这个函数首先会创建一个栈帧(stack frame),这个栈帧是一个上下文,是编译器用来实现过程/函数调用的一种数据结构。
Python中一切皆对象 栈帧也是对象 解释器会将代码块也编译成字节码对象
我们可以使用dis
查看函数代码编译成的字节码对象
1 | import dis |
上面字节码含义依次是加载全局,调用函数,从栈定抛出值,加载变量,返回。
在上面函数执行的过程是:
解释器在运行第一个函数foo
的之前会创建一个栈帧对象,并将代码块编译成一个字节码对象,然后在栈帧对象的上下文中运行字节码对象,当函数运行到foo
调用函数bar
的时候,又是一次函数的调用,首先还是会创建一个栈帧对象,然后将函数的控制权交给新建的这个栈帧,然后在新创建的栈帧上下文运行bar
函数对应的字节码。所有的栈帧都是分配在堆内存上,这就决定了栈帧可以独立于调用者存在
栈区(stack)— 由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
堆区(heap) — 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。
有关栈帧参考文章:
https://blog.csdn.net/Neil4/article/details/62416903
如何理解所有的栈帧都是分配在堆内存上,这就决定了栈帧可以独立于调用者存在这句话呢?
我们通过代码演示下:
1 | import inspect |
我们将函数bar
的栈帧赋值给全局变量,通过这个全局变量获得一些数据。
上述过程和静态语言有所不同,静态语言函数的调用是栈的形式,函数调用完成则栈全部清空。而动态语言是放在堆上的。
上面代码执行的过程包含一个递归的过程,当执行到foo
的时候回再次调用bar
。我们看下龟叔画的一张图:
上面这张图片就很形象的解释了上面代码整个调用过程。
我们了解了函数的执行过程对于我们了解生成器是很有帮助的,因为生成器正是利用了栈帧是保存在内存中这一特性。
下面我们在看一个生成器函数。
1 | def gen_func(): |
生成器对象时对PythonFrame进行了一层封装:
生成器对象在PyFrameObject
和PyCodeObject
上面又封装了PyGenObject
即Python生成器对象。
我们看下生成器对象的字节码:
1 | 2 0 LOAD_CONST 1 (1) |
我们可以从字节码中看到总共有两次YIELD_VALUE。
对于生成器对象,当我们每次调用的时候回运行到yield
关键字就停止。
我们可以使用Python生成器对象的fram获得最近执行代码运行的字节码位置和local变量。
1 | print(gen.gi_frame.f_lasti) |
输出上述结果是因为我们并没有调用生成器对象。
1 | next(gen) |
当我们调用一次的时候,即运行了一次yield
。这时运行到了字节码为2的位置,local变量为空。
1 | next(gen) |
当我们再调用一次的时候,即再运行了一次yield
。这是字节码运行到了12的位置,local在字节码为6的时候已经加载了值,所以local变量不再为空。
上述过程证明了对于生成器函数,使用yield
生成的生成器对象,我们可以通过PyGenObject
对函数的暂停和前进有了进一步的了解。因为我们可以通过变量获得函数具体执行到哪一步。这样就完成了整个函数的运行控制,通过yield
来暂停函数。生成器就是依赖这种方式实现的。
生成器对象分配在堆内存中和之前的函数调用栈帧一样,可以独立于函数的调用。对于生成器对象,每次调用都会生成一个栈帧对象,然后获得生成器对象,由生成器对象控制程序往前走。也就是说我们可以在任何地方,只要获得这个生成器对象,都可以恢复暂停它。这也是协程的基础。
生成器在UserList中的使用
很多同学可能都不知道什么是UserList(其实我也不知道🤷♀️),UserList其实是Python实现的List,可以用来解释List是如何实现的(Python内置的List是C语言实现的)。如果我们想自定义List可以继承UserList。
我们通过源码查看下实现原理
1 | from collections import UserList |
从源码中我们可以看到使用了yield
。每次调用next()函数会保存变量i
记录到何处位置。这样就完成了全部的遍历。
生成器如何读取大文件
该代码为老师实际工作遇到的一个需求,文件过大,文件内容全部为一行,有指定分隔符进行分割。
1 | def myreadlines(f, newline): |
本章收获颇多,加油ヾ(◍°∇°◍)ノ゙!