这篇文章准确说是『Python 源码剖析』的读书笔记,整理完之后才发现很长,那就将就看吧。
1. 简单的例子
先从一个简单的例子说起,包含了两个文件 foo.py 和 demo.py
1 2 3 |
[foo.py] def add(a, b): return a + b |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
[demo.py] import foo a = [1, 'python'] a = 'a string' def func(): a = 1 b = 257 print(a + b) print(a) if __name__ == '__main__': func() foo.add(1, 2) |
执行这个程序
1 |
python demo.py |
输出结果
1 2 |
a string 258 |
同时,该文件目录多出一个 foo.pyc 文件
2. 背后的魔法
看完程序的执行结果,接下来开始一行行解释代码。
2.1 模块
Python 将 .py 文件视为一个 module,这些 module 中,有一个主 module,也就是程序运行的入口。在这个例子中,主 module 是 demo.py。
2.2 编译
执行 python demo.py
后,将会启动 Python 的解释器,然后将 demo.py 编译成一个字节码对象 PyCodeObject。
有的人可能会很好奇,编译的结果不应是 pyc 文件吗,就像 Java 的 class 文件,那为什么是一个对象呢,这里稍微解释一下。
在 Python 的世界中,一切都是对象,函数也是对象,类型也是对象,类也是对象(类属于自定义的类型,在 Python 2.2 之前,int, dict 这些内置类型与类是存在不同的,在之后才统一起来,全部继承自 object),甚至连编译出来的字节码也是对象,.pyc 文件是字节码对象(PyCodeObject)在硬盘上的表现形式。
在运行期间,编译结果也就是 PyCodeObject 对象,只会存在于内存中,而当这个模块的 Python 代码执行完
后,就会将编译结果保存到了 pyc 文件中,这样下次就不用编译,直接加载到内存中。pyc 文件只是 PyCodeObject 对象在硬盘上的表现形式。
这个 PyCodeObject 对象包含了 Python 源代码中的字符串,常量值,以及通过语法解析后编译生成的字节码指令。PyCodeObject 对象还会存储这些字节码指令与原始代码行号的对应关系,这样当出现异常时,就能指明位于哪一行的代码。
2.3 pyc 文件
一个 pyc 文件包含了三部分信息:Python 的 magic number、pyc 文件创建的时间信息,以及 PyCodeObject 对象。
magic number 是 Python 定义的一个整数值。一般来说,不同版本的 Python 实现都会定义不同的 magic number,这个值是用来保证 Python 兼容性的。比如要限制由低版本编译的 pyc 文件不能让高版本的 Python 程序来执行,只需要检查 magic number 不同就可以了。由于不同版本的 Python 定义的字节码指令可能会不同,如果不做检查,执行的时候就可能出错。
下面所示的代码可以来创建 pyc 文件,使用方法
1 |
python generate_pyc.py module_name |
例如
1 |
python generate_pyc.py demo |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
[generate_pyc.pyc] import imp import sys def generate_pyc(name): fp, pathname, description = imp.find_module(name) try: imp.load_module(name, fp, pathname, description) finally: if fp: fp.close() if __name__ == '__main__': generate_pyc(sys.argv[1]) |
2.4 字节码指令
为什么 pyc 文件也称作字节码文件?因为这些文件存储的都是一些二进制的字节数据,而不是能让人直观查看的文本数据。
Python 标准库提供了用来生成代码对应字节码的工具 dis
。dis 提供一个名为 dis 的方法,这个方法接收一个 code 对象,然后会输出 code 对象里的字节码指令信息。
1 2 3 4 |
s = open('demo.py').read() co = compile(s, 'demo.py', 'exec') import dis dis.dis(co) |
执行上面这段代码可以输出 demo.py 编译后的字节码指令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
1 0 LOAD_CONST 0 (-1) 3 LOAD_CONST 1 (None) 6 IMPORT_NAME 0 (foo) 9 STORE_NAME 0 (foo) 3 12 LOAD_CONST 2 (1) 15 LOAD_CONST 3 (u'python') 18 BUILD_LIST 2 21 STORE_NAME 1 (a) 4 24 LOAD_CONST 4 (u'a string') 27 STORE_NAME 1 (a) 6 30 LOAD_CONST 5 () 33 MAKE_FUNCTION 0 36 STORE_NAME 2 (func) 11 39 LOAD_NAME 1 (a) 42 PRINT_ITEM 43 PRINT_NEWLINE 13 44 LOAD_NAME 3 (__name__) 47 LOAD_CONST 6 (u'__main__') 50 COMPARE_OP 2 (==) 53 POP_JUMP_IF_FALSE 82 14 56 LOAD_NAME 2 (func) 59 CALL_FUNCTION 0 62 POP_TOP 15 63 LOAD_NAME 0 (foo) 66 LOAD_ATTR 4 (add) 69 LOAD_CONST 2 (1) 72 LOAD_CONST 7 (2) 75 CALL_FUNCTION 2 78 POP_TOP 79 JUMP_FORWARD 0 (to 82) >> 82 LOAD_CONST 1 (None) 85 RETURN_VALUE |
2.5 Python 虚拟机
demo.py 被编译后,接下来的工作就交由 Python 虚拟机来执行字节码指令了。Python 虚拟机会从编译得到的 PyCodeObject 对象中依次读入每一条字节码指令,并在当前的上下文环境
中执行这条字节码指令。我们的程序就是通过这样循环往复的过程才得以执行。
2.6 import 指令
demo.py 的第一行代码是 import foo
。import 指令用来载入一个模块,另外一个载入模块的方法是 from xx import yy
。用 from 语句的好处是,可以只复制需要的符号变量到当前的命名空间中(关于命名空间将在后面介绍)。
前文提到,当已经存在 pyc 文件时,就可以直接载入而省去编译过程。但是代码文件的内容会更新,如何保证更新后能重新编译而不入旧的 pyc 文件呢。答案就在 pyc 文件中存储的创建时间信息
。当执行 import 指令的时候,如果已存在 pyc 文件,Python 会检查创建时间是否晚于代码文件的修改时间,这样就能判断是否需要重新编译,还是直接载入了。如果不存在 pyc 文件,就会先将 py 文件编译。
2.7 绝对引入和相对引入
前文已经介绍了 import foo
这行代码。这里隐含了一个问题,就是 foo
是什么,如何找到 foo
。这就属于 Python 的模块引入规则,这里不展开介绍,可以参考 pep-0328。
2.8 赋值语句
接下来,执行到 a = [1, 'python']
,这是一条赋值语句,定义了一个变量 a,它对应的值是 [1, ‘python’]。这里要解释一下,变量是什么呢?
按照[维基百科](“https://en.wikipedia.org/wiki/Variable_(computer_science“) 的解释
变量是一个存储位置和一个关联的符号名字,这个存储位置包含了一些已知或未知的量或者信息。
变量实际上是一个字符串的符号,用来关联一个存储在内存中的对象。在 Python 中,会使用 dict(就是 Python 的 dict 对象)来存储变量符号(字符串)与一个对象的映射。
那么赋值语句实际上就是用来建立这种关联,在这个例子中是将符号 a
与一个列表对象 [1, 'python']
建立映射。
紧接着的代码执行了 a = 'a string'
,这条指令则将符号 a
与另外一个字符串对象 a string
建立了映射。今后对变量 a
的操作,将反应到字符串对象 a string
上。
2.9 def 指令
我们的 Python 代码继续往下运行,这里执行到一条 def func()
,从字节码指令中也可以看出端倪 MAKE_FUNCTION
。没错这条指令是用来创建函数的。Python 是动态语言,def 实际上是执行一条指令,用来创建函数(class 则是创建类的指令),而不仅仅是个语法关键字。函数并不是事先创建好的,而是执行到的时候才创建的。
def func()
将会创建一个名称为 func
的函数对象。实际上是先创建一个函数对象,然后将 func 这个名称符号绑定到这个函数上。
Python 中是无法实现 C 和 Java 中的重载的,因为重载要求函数名要相同,而参数的类型或数量不同,但是 Python 是通过变量符号(如这里的
func
)来关联一个函数,当我们用 def 语句再次创建一个同名的函数时,这个变量名就绑定到新的函数对象上了。
2.10 动态类型
继续看函数 func
里面的代码,这时又有一条赋值语句 a = 1
。变量 a
现在已经变成了第三种类型,它现在是一个整数了。那么 Python 是怎么实现动态类型的呢?答案就藏在具体存储的对象上。变量 a
仅仅只是一个符号(实际上是一个字符串对象),类型信息是存储在对象上的。在 Python 中,对象机制的核心是类型信息和引用计数(引用计数属于垃圾回收的部分)。
用 type(a),可以输出 a 的类型,这里是 int
b = 257
跳过,我们直接来看看 print(a + b)
,print 是输出函数,这里略过。这里想要探究的是 a + b
。
因为 a
和 b
并不存储类型信息,因此当执行 a + b
的时候就必须先检查类型,比如 1 + 2 和 “1” + “2” 的结果是不一样的。
看到这里,我们就可以想象一下执行一句简单的 a + b
,Python 虚拟机需要做多少繁琐的事情了。首先需要分别检查 a
和 b
所对应对象的类型,还要匹配类型是否一致(1 + “2” 将会出现异常),然后根据对象的类型调用正确的 +
函数(例如数值的 + 或字符串的 +),而 CPU 对于上面这条语句只需要执行 ADD 指令(还需要先将变量 MOV 到寄存器)。
2.11 命名空间 (namespace)
在介绍上面的这些代码时,还漏掉了一个关键的信息就是命名空间。在 Python 中,类、函数、module 都对应着一个独立的命名空间。而一个独立的命名空间会对应一个 PyCodeObject 对象,所以上面的 demo.py 文件编译后会生成两个 PyCodeObject,只是在 demo.py 这个 module 层的 PyCodeObject 中通过一个变量符号 func
嵌套了一个函数的 PyCodeObject。
命名空间的意义,就是用来确定一个变量符号到底对应什么对象。命名空间可以一个套一个地形成一条命名空间链,Python 虚拟机在执行的过程中,会有很大一部分时间消耗在从这条命名空间链中确定一个符号所对应的对象是什么。
在 Python中,命名空间是由一个 dict 对象实现的,它维护了(name,obj)这样的关联关系。
说到这里,再补充一下 import foo
这行代码会在 demo.py 这个模块的命名空间中,创建一个新的变量名 foo
,foo
将绑定到一个 PyCodeObject 对象,也就是 foo.py 的编译结果。
2.11.1 dir 函数
Python 的内置函数 dir 可以用来查看一个命名空间下的所有名字符号。一个用处是查看一个命名空间的所有属性和方法(这里的命名空间就是指类、函数、module)。
比如,查看当前的命名空间,可以使用 dir(),查看 sys 模块,可以使用 dir(sys)。
2.11.2 LEGB 规则
Python 使用 LEGB 的顺序来查找一个符号对应的对象
locals -> enclosing function -> globals -> builtins
locals,当前所在命名空间(如函数、模块),函数的参数也属于命名空间内的变量
enclosing,外部嵌套函数的命名空间(闭包中常见)
1 2 |