diff --git "a/54.\346\267\261\345\272\246\350\247\243\345\257\206\350\231\232\346\213\237\346\234\272\347\232\204\346\211\247\350\241\214\347\216\257\345\242\203\357\274\232\346\240\210\345\270\247\345\257\271\350\261\241.html" "b/54.\346\267\261\345\272\246\350\247\243\345\257\206\350\231\232\346\213\237\346\234\272\347\232\204\346\211\247\350\241\214\347\216\257\345\242\203\357\274\232\346\240\210\345\270\247\345\257\271\350\261\241.html" index 63de68a..88fde82 100644 --- "a/54.\346\267\261\345\272\246\350\247\243\345\257\206\350\231\232\346\213\237\346\234\272\347\232\204\346\211\247\350\241\214\347\216\257\345\242\203\357\274\232\346\240\210\345\270\247\345\257\271\350\261\241.html" +++ "b/54.\346\267\261\345\272\246\350\247\243\345\257\206\350\231\232\346\213\237\346\234\272\347\232\204\346\211\247\350\241\214\347\216\257\345\242\203\357\274\232\346\240\210\345\270\247\345\257\271\350\261\241.html" @@ -387,25 +387,12 @@
我们是在第 4 行获取的栈帧,所以打印结果是 4。
int f_iblock
-用于跟踪 try / except / finally 代码块的层级深度,它记录了当前栈帧中活跃的 try 语句块的数量,每进入一个新的 try 语句块时加 1,离开 try 语句块时减 1。
-try: # f_iblock = 1
- try: # f_iblock = 2
- pass
- except:
- pass
-except: # f_iblock = 1
- pass
-finally: # f_iblock = 0
- pass
-
-f_iblock 对于虚拟机的异常捕获来说非常重要,可以在异常处理时确定当前代码在哪个 try 语句块内,帮助确定应该执行哪个 except 或 finally 子句,保证异常处理和清理代码能按正确的嵌套顺序执行。
+用于跟踪 try / except / finally 代码块的层级深度。具体等介绍异常捕获的时候再说,总之 f_iblock 对于虚拟机的异常捕获来说非常重要,可以在异常处理时确定当前代码在哪个 try 语句块内,帮助确定应该执行哪个 except 或 finally 子句,保证异常处理和清理代码能按正确的嵌套顺序执行。
char f_executing
当前栈帧是否仍在执行。
PyTryBlock f_blockstack[CO_MAXBLOCKS]
一个栈,用于追踪代码块,比如代码块的进入和退出,以及管理代码块的上下文信息。那么都支持哪些代码块呢?
我们是在第 4 行获取的栈帧,所以打印结果是 4。
int f_iblock
-用于跟踪 try / except / finally 代码块的层级深度,它记录了当前栈帧中活跃的 try 语句块的数量,每进入一个新的 try 语句块时加 1,离开 try 语句块时减 1。
-try: # f_iblock = 1
- try: # f_iblock = 2
- pass
- except:
- pass
-except: # f_iblock = 1
- pass
-finally: # f_iblock = 0
- pass
-
-f_iblock 对于虚拟机的异常捕获来说非常重要,可以在异常处理时确定当前代码在哪个 try 语句块内,帮助确定应该执行哪个 except 或 finally 子句,保证异常处理和清理代码能按正确的嵌套顺序执行。
+用于跟踪 try / except / finally 代码块的层级深度。具体等介绍异常捕获的时候再说,总之 f_iblock 对于虚拟机的异常捕获来说非常重要,可以在异常处理时确定当前代码在哪个 try 语句块内,帮助确定应该执行哪个 except 或 finally 子句,保证异常处理和清理代码能按正确的嵌套顺序执行。
char f_executing
当前栈帧是否仍在执行。
PyTryBlock f_blockstack[CO_MAXBLOCKS]
一个栈,用于追踪代码块,比如代码块的进入和退出,以及管理代码块的上下文信息。那么都支持哪些代码块呢?
我们可以通过函数的 __code__ 属性拿到底层对应的 PyCodeObject 对象,当然也可以获取里面的字段,下面就来演示一下,并详细介绍每个字段的含义。 PyObject_HEAD:对象的头部信息 我们看到 Python 真的一切皆对象,源代码编译之后的结果也是一个对象。 co_argcount:可以通过位置参数传递的参数个数 def foo(a, b, c=3): pass\nprint(foo.__code__.co_argcount) # 3 def bar(a, b, *args): pass\nprint(bar.__code__.co_argcount) # 2 def func(a, b, *args, c): pass\nprint(func.__code__.co_argcount) # 2 函数 foo 中的参数 a、b、c 都可以通过位置参数传递,所以结果是 3。而函数 bar 则是两个,这里不包括 *args。最后函数 func 显然也是两个,因为参数 c 只能通过关键字参数传递。 co_posonlyargcount:只能通过位置参数传递的参数个数,Python3.8 新增 def foo(a, b, c): pass\nprint(foo.__code__.co_posonlyargcount) # 0 def bar(a, b, /, c): pass\nprint(bar.__code__.co_posonlyargcount) # 2 注意:这里是只能通过位置参数传递的参数个数。对于 foo 而言,里面的三个参数既可以通过位置参数、也可以通过关键字参数传递,所以个数是 0。而函数 bar,里面的 a、b 只能通过位置参数传递,所以个数是 2。 co_kwonlyargcount:只能通过关键字参数传递的参数个数 def foo(a, b=1, c=2, *, d, e): pass\nprint(foo.__code__.co_kwonlyargcount) # 2 这里是 d 和 e,它们必须通过关键字参数传递。 co_nlocals:代码块中局部变量的个数,也包括参数 def foo(a, b, *args, c, **kwargs): name = \"xxx\" age = 16 gender = \"f\" c = 33 print(foo.__code__.co_varnames)\n\"\"\"\n('a', 'b', 'c', 'args', 'kwargs', 'name', 'age', 'gender')\n\"\"\"\nprint(foo.__code__.co_nlocals)\n\"\"\"\n8\n\"\"\" co_varnames 保存的是代码块的局部变量,显然 co_nlocals 就是它的长度。并且我们看到在编译之后,函数的局部变量就已经确定了,因为它们是静态存储的。 co_stacksize:执行该段代码块所需要的栈空间 def foo(a, b, c): name = \"xxx\" age = 16 gender = \"f\" c = 33 print(foo.__code__.co_stacksize) # 1 这个暂时不需要太关注,后续介绍栈帧的时候会详细说明。 co_flags:函数标识 先来提出一个问题: def some_func(): return \"hello world\" def some_gen(): yield return \"hello world\" print(some_func.__class__)\nprint(some_gen.__class__)\n\"\"\"\n\n\n\"\"\" print(some_func())\n\"\"\"\nhello world\n\"\"\"\nprint(some_gen())\n\"\"\"\n\n\"\"\" 调用 some_func 会将代码执行完毕,调用 some_gen 会返回生成器,但问题是这两者都是函数类型,为什么执行的时候会有不同的表现呢?可能有人觉得这还不简单,Python 具有词法作用域,由于 some_func 里面没有出现 yield 关键字,所以是普通函数,而 some_gen 里面出现了 yield,所以是生成器函数。 从源代码来看确实如此,但源代码是要编译成 PyCodeObject 对象的,在编译之后,函数内部是否出现 yield 关键字这一信息要怎么体现呢?答案便是通过 co_flags 字段。 然后解释器内部定义了一系列的标志位,通过和 co_flags 字段按位与,便可判断函数是否具备指定特征。常见的标志位如下: // Include/code.h // 函数参数是否包含 *args\n#define CO_VARARGS 0x0004\n// 函数参数是否包含 **kwargs\n#define CO_VARKEYWORDS 0x0008\n// 函数是否是内层函数\n#define CO_NESTED 0x0010\n// 函数是否是生成器函数\n#define CO_GENERATOR 0x0020\n// 函数是否是协程函数\n#define CO_COROUTINE 0x0080\n// 函数是否是异步生成器函数\n#define CO_ASYNC_GENERATOR 0x0200 我们实际测试一下,比如检测函数的参数类型: CO_VARARGS = 0x0004\nCO_VARKEYWORDS = 0x0008\nCO_NESTED = 0x0010 def foo(*args): pass def bar(): pass # 因为 foo 的参数包含 *args,所以和 CO_VARARGS 按位与的结果为真\n# 而 bar 的参数不包含 *args,所以结果为假\nprint(foo.__code__.co_flags & CO_VARARGS) # 4\nprint(bar.__code__.co_flags & CO_VARARGS) # 0 def foo(**kwargs): pass def bar(): pass print(foo.__code__.co_flags & CO_VARKEYWORDS) # 8\nprint(bar.__code__.co_flags & CO_VARKEYWORDS) # 0 def foo(): def bar(): pass return bar # foo 是外层函数,所以和 CO_NESTED 按位与的结果为假\n# foo() 返回的是内层函数,所以和 CO_NESTED 按位与的结果为真\nprint(foo.__code__.co_flags & CO_NESTED) # 0\nprint(foo().__code__.co_flags & CO_NESTED) # 16 当然啦,co_flags 还可以检测一个函数的类型。比如函数内部出现了 yield,那么它就是一个生成器函数,调用之后会得到一个生成器;使用 async def 定义,那么它就是一个协程函数,调用之后会得到一个协程。 这些在词法分析的时候就可以检测出来,编译之后会体现在 co_flags 字段中。 CO_GENERATOR = 0x0020\nCO_COROUTINE = 0x0080\nCO_ASYNC_GENERATOR = 0x0200 # 如果是生成器函数,那么 co_flags & 0x20 为真\ndef foo1(): yield\nprint(foo1.__code__.co_flags & 0x20) # 32 # 如果是协程函数,那么 co_flags & 0x80 为真\nasync def foo2(): pass\nprint(foo2.__code__.co_flags & 0x80) # 128\n# 显然 foo2 不是生成器函数,所以 co_flags & 0x20 为假\nprint(foo2.__code__.co_flags & 0x20) # 0 # 如果是异步生成器函数,那么 co_flags & 0x200 为真\nasync def foo3(): yield\nprint(foo3.__code__.co_flags & 0x200) # 512\n# 显然它不是生成器函数、也不是协程函数\n# 因此和 0x20、0x80 按位与之后,结果都为假\nprint(foo3.__code__.co_flags & 0x20) # 0\nprint(foo3.__code__.co_flags & 0x80) # 0 在判断函数种类时,这种方式是最优雅的。 co_firstlineno:代码块的起始位置在源文件中的哪一行 def foo(a, b, c): pass # 显然是文件的第一行\n# 或者理解为 def 所在的行\nprint(foo.__code__.co_firstlineno) # 1 如果函数出现了调用呢? def foo(): return bar def bar(): pass print(foo().__code__.co_firstlineno) # 4 如果执行 foo,那么会返回函数 bar,因此结果是 def bar(): 所在的行数。所以每个函数都有自己的作用域,以及 PyCodeObject 对象。 co_code:指令集,也就是字节码,它是一个 bytes 对象 def foo(a, b, c): name = \"satori\" age = 16 gender = \"f\" print(name, age, gender) # 字节码,一个 bytes 对象,它保存了要操作的指令\n# 但光有字节码是肯定不够的,还需要其它的静态信息\n# 显然这些信息连同字节码一样,都位于 PyCodeObject 中\nprint(foo.__code__.co_code)\n\"\"\"\nb'd\\x01}\\x03d\\x02}\\x04d\\x03}\\x05t\\x00|\\x03|\\x04|\\x05\\x83\\x03\\x01\\x00d\\x00S\\x00'\n\"\"\" co_consts:常量池,一个元组,保存代码块中创建的所有常量 def foo(): a = 122 + 1 b = \"hello\" c = (1, 2) d = [\"x\", \"y\"] e = {\"p\": \"k\"} f = {7, 8} print(foo.__code__.co_consts)\n\"\"\"\n(None, 123, 'hello', (1, 2), 'x', 'y', 'p', 'k', 7, 8)\n\"\"\" co_consts 里面出现的都是编译阶段可以确定的常量,而 [\"x\", \"y\"] 和 {\"p\": \"k\"} 没有出现,由此我们可以得出,列表和字典绝不是在编译阶段构建的。编译时,只是收集了里面的元素,然后等到运行时再去动态构建。 不过问题来了,在构建的时候解释器怎么知道是要构建列表、还是字典、亦或是其它的什么对象呢?所以这就依赖于字节码了,解释字节码的时候,会判断到底要构建什么样的对象。因此解释器执行的是字节码,核心逻辑都体现在字节码中,但是光有字节码还不够,它包含的只是程序的主干逻辑,至于变量、常量,则从符号表和常量池里面获取。 另外函数里面的变量 a 等于 122 + 1,但常量池里面却存储了 123,这个过程叫做常量折叠。常量之间的加减乘除,结果依旧是一个常量,编译阶段就会计算好。 co_names:符号表,一个元组,保存代码块中引用的其它作用域的变量 c = 1 def foo(a, b): print(a, b, c) d = (list, int, str) print(foo.__code__.co_names)\n\"\"\"\n('print', 'c', 'list', 'int', 'str')\n\"\"\" 虽然一切皆对象,但看到的都是指向对象的变量,所以 print, c, list, int, str 都是变量,它们都不在当前 foo 函数的作用域中。 co_varnames:符号表,一个元组,保存当前作用域中创建的局部变量 def foo(a, b, c): name = \"satori\" age = 16 gender = \"f\" print(name, age, gender) # 当前作用域中创建的变量,注意它和 co_names 的区别\n# co_varnames 保存的是当前作用域中创建的局部变量\n# 而 co_names 保存的是当前作用域中引用的其它作用域的变量\nprint(foo.__code__.co_varnames)\n\"\"\"\n('a', 'b', 'c', 'name', 'age', 'gender')\n\"\"\"\nprint(foo.__code__.co_varnames)\n\"\"\"\n('print',)\n\"\"\" co_cellvars:一个元组,保存外层函数的作用域中被内层函数引用的变量 co_freevars:一个元组,保存内层函数引用的外层函数的作用域中的变量 def foo(a, b, c): def bar(): print(a, b, c) return bar # co_cellvars:外层函数的作用域中被内层函数引用的变量\n# co_freevars:内层函数引用的外层函数的作用域中的变量\nprint(foo.__code__.co_cellvars)\nprint(foo.__code__.co_freevars)\n\"\"\"\n('a', 'b', 'c')\n()\n\"\"\"\n# foo 里面的变量 a、b、c 被内层函数 bar 引用了\n# 所以它的 co_cellvars 是 ('a', 'b', 'c')\n# 而 foo 不是内层函数,所以它的 co_freevars 是 () bar = foo(1, 2, 3)\nprint(bar.__code__.co_cellvars)\nprint(bar.__code__.co_freevars)\n\"\"\"\n()\n('a', 'b', 'c')\n\"\"\"\n# bar 引用了外层函数 foo 里面的变量 a、b、c\n# 所以它的 co_freevars 是 ('a', 'b', 'c')\n# 而 bar 已经是最内层函数了,所以它的 co_cellvars 是 () 当然目前的函数只嵌套了两层,但嵌套三层甚至更多层也是一样的。 def foo(a, b, c): def bar(d, e): print(a) def func(): print(b, c, d, e) return func return bar # 对于 foo 而言,它的内层函数就是 bar\n# 至于最里面的 func,由于定义在 bar 的内部,因此可以看做是 bar 函数体的一部分\n# 而 foo 里面的变量 a、b、c 都被内层函数引用了\nprint(foo.__code__.co_cellvars) # ('a', 'b', 'c')\nprint(foo.__code__.co_freevars) # () bar = foo(1, 2, 3)\n# 对于函数 bar 而言,它的内层函数就是 func\n# 而显然 bar 里面的变量 d 和 e 被 func 引用了\nprint(bar.__code__.co_cellvars) # ('d', 'e')\n# 然后 bar 引用了外层函数 foo 里面的 a、b、c\nprint(bar.__code__.co_freevars) # ('a', 'b', 'c')\n# 所以 co_cellvars 和 co_freevars 这两个字段的关系有点类似镜像 co_cellvars 和 co_freevars 在后续介绍闭包的时候会用到。 co_filename:代码块所在的文件的路径 # 文件名:main.py\ndef foo(): pass print(foo.__code__.co_filename)\n\"\"\"\n/Users/satori/Documents/testing_project/main.py\n\"\"\" 如果你无法使用 IDE,那么便可通过该字段查看函数定义在哪个文件中。 co_name:代码块的名字 def foo(): pass print(foo.__code__.co_name) # foo 对于函数来说,代码块的名字就是函数名。 co_lnotab:负责存储指令的偏移量和源代码行号之间的对应关系 PyCodeObject 是源代码编译之后的产物,虽然两者的结构千差万别,但体现出的信息是一致的。像源代码具有行号,那么编译成 PyCodeObject 之后,行号信息也应该要有专门的字段来维护,否则报错时我们就无法快速定位到行号。 def foo(): name = \"古明地觉\" hobby = ( \"sing\", \"dance\", \"rap\", \"🏀\" ) age = 16 我们通过 dis 模块反编译一下。 第一列数字表示行号,第二列数字表示字节码指令的偏移量,或者说指令在整个字节码指令集中的索引。我们知道字节码指令集就是一段字节序列,由 co_code 字段维护,并且每个指令都带有一个参数,所以偏移量(索引)为 0 2 4 6 8 ··· 的字节表示指令,偏移量为 1 3 5 7 9 ··· 的字节表示参数。 关于反编译的具体细节后续会说,总之一个字节码指令就是一个八位整数。对于当前函数来说,它的字节码偏移量和行号的对应关系如下: 偏移量和源代码行号的对应关系便由 co_lnotab(一个字节序列)维护,只不过 co_lnotab 并没有直接记录这些信息,而是记录的增量值。 (0, 1) 到 (0, 2):偏移量增加 0,行号增加 1; (0, 2) 到 (4, 3):偏移量增加 4,行号增加 1; (4, 3) 到 (8, 9):偏移量增加 4,行号增加 6; 所以 co_lnotab 便是 0 1 4 1 4 6,我们验证一下。 结果和我们分析的一样。 以上就是 PyCodeObject 里面的字段的含义,至于剩下的几个字段就无需关注了。","breadcrumbs":"51. Python 源文件编译之后会得到什么,它的结构是怎样的?和字节码又有什么联系? » PyCodeObject 字段解析","id":"227","title":"PyCodeObject 字段解析"},"228":{"body":"Python 解释器 = Python 编译器 + Python 虚拟机。 编译器先将 .py 源码文件编译成 PyCodeObject 对象,然后再交给虚拟机执行。 PyCodeObject 对象可以认为是源码文件的另一种等价形式,但经过编译,虚拟机可以更快速地执行。 为了避免每次都要对源文件进行编译,因此编译后的结果会序列化在 .pyc 文件中,如果源文件没有做改动,那么下一次执行时会直接从 .pyc 文件中读取。 Python 的函数、类、模块等,都具有各自的作用域,每个作用域对应一个独立的代码块,在编译时,Python 编译器会为每个代码块都创建一个 PyCodeObject 对象。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"51. Python 源文件编译之后会得到什么,它的结构是怎样的?和字节码又有什么联系? » 小结","id":"228","title":"小结"},"229":{"body":"上一篇文章我们介绍了 PyCodeObject 对象,但是还遗漏了一些内容,这里再单独补充一下。","breadcrumbs":"52. PyCodeObject 拾遗 » 楔子","id":"229","title":"楔子"},"23":{"body":"type 是所有类型对象的类型,我们称之为元类型或者元类,即 metaclass,当然它同时也是一个类型对象。下面看一下它的底层实现。 // Objects/typeobject.c PyTypeObject PyType_Type = { PyVarObject_HEAD_INIT(&PyType_Type, 0) \"type\", /* tp_name */ sizeof(PyHeapTypeObject), /* tp_basicsize */ sizeof(PyMemberDef), /* tp_itemsize */ (destructor)type_dealloc, /* tp_dealloc */ 0, /* tp_vectorcall_offset */ 0, /* tp_getattr */ 0, /* tp_setattr */ 0, /* tp_as_async */ (reprfunc)type_repr, /* tp_repr */ 0, /* tp_as_number */ 0, /* tp_as_sequence */ 0, /* tp_as_mapping */ 0, /* tp_hash */ (ternaryfunc)type_call, /* tp_call */ // ...\n}; 所有的类型对象加上元类都是由 PyTypeObject 这个结构体实例化得到的,所以它们内部的字段都是一样的。只不过传入的值不同,实例化之后得到的结果也不同,可以是 PyLong_Type、可以是 PyFloat_Type,也可以是这里的 PyType_Type。 再看一下里面的宏 PyVarObject_HEAD_INIT,它用来初始化引用计数、类型和 ob_size,其中类型被初始化成了 &PyType_Type。换句话说,PyType_Type 里面的 ob_type 字段指向的还是 PyType_Type,而对应 Python 的话,就是 type 的类型还是 type。 >>> type.__class__\n\n>>> type.__class__.__class__.__class__.__class__.__class__ is type\nTrue\n>>> type(type(type(type(type(type))))) is type\nTrue 显然不管套娃多少次,最终的结果都是 True,这也是符合预期的。","breadcrumbs":"6. 通过 type 和 object 之间的关联,进一步分析类型对象 » 类型对象的类型:PyType_Type","id":"23","title":"类型对象的类型:PyType_Type"},"230":{"body":"之前通过函数的 __code__ 属性获取了该函数的 PyCodeObject 对象,但是还有没有其它的方法呢?显然是有的,答案是通过内置函数 compile,不过在介绍 compile 之前,先介绍一下 eval 和 exec。 eval:传入一个字符串,然后把字符串里面的内容当做表达式。 a = 1\n# 所以 eval(\"a\") 就等价于 a\nprint(eval(\"a\")) # 1\nprint(eval(\"1 + 1 + 1\")) # 3 注意:eval 是有返回值的,返回值就是字符串里面的内容。所以 eval 接收的字符串里面一定是一个表达式,表达式计算之后是一个具体的值,比如 a = eval(\"1 + 2\"),等价于 a = 3。 但如果是语句的话,比如 a = eval(\"b = 3\"),这样等价于 a = (b = 3),显然这会出现语法错误。因此 eval 函数把字符串两边的引号剥掉之后,得到的一定是一个普通的值。 try: print(eval(\"xxx\"))\nexcept NameError as e: print(e) # name 'xxx' is not defined 此时等价于 print(xxx),但是 xxx 没有定义,所以报错。 # 此时是合法的,等价于 print('xxx')\nprint(eval(\"'xxx'\")) # xxx 以上就是 eval 函数,使用起来还是很方便的。 exec:传入一个字符串,把字符串里面的内容当成语句来执行,这个是没有返回值的,或者说返回值是 None。 # 相当于 a = 1\nexec(\"a = 1\") print(a) # 1 statement = \"\"\"\na = 123\nif a == 123: print(\"a 等于 123\")\nelse: print(\"a 不等于 123\")\n\"\"\"\nexec(statement) # a 等于 123 注意:a 等于 123 并不是 exec 返回的,而是把上面那坨字符串当成普通代码执行的时候 print 出来的。这便是 exec 的作用,将字符串当成语句来执行。 所以使用 exec 可以非常方便地创建多个变量。 import random for i in range(1, 5): exec(f\"a{i} = {random.randint(1, 100)}\") print(a1) # 72\nprint(a2) # 21\nprint(a3) # 38\nprint(a4) # 32 那么 exec 和 eval 的区别就显而易见了,eval 是要求字符串里面的内容能够当成一个值,并且该值就是 eval 函数的返回值。而 exec 则是直接执行里面的内容,返回值是 None。 print(eval(\"1 + 1\")) # 2\nprint(exec(\"1 + 1\")) # None # 相当于 a = 2\nexec(\"a = 1 + 1\")\nprint(a) # 2 try: # 相当于 a = 2,但很明显 a = 2 是一个语句 # 它无法作为一个值,因此放到 eval 里面就报错了 eval(\"a = 1 + 1\")\nexcept SyntaxError as e: print(e) # invalid syntax (, line 1) 还是很好区分的,但是 eval 和 exec 在生产中尽量要少用。另外,eval 和 exec 还可以接收第二个参数和第三个参数,我们在介绍名字空间的时候再说。 compile:关键来了,它执行后返回的就是一个 PyCodeObject 对象。 这个函数接收哪些参数呢? 参数一:当成代码执行的字符串 参数二:可以为这些代码起一个文件名 参数三:执行方式,支持三种,分别是 exec、single、eval 我们演示一下。 # exec:将源代码当做一个模块来编译\n# single:用于编译一个单独的 Python 语句(交互式)\n# eval:用于编译一个 eval 表达式\nstatement = \"a, b = 1, 2\"\n# 这里我们选择 exec,当成一个模块来编译\nco = compile(statement, \"古明地觉的编程教室\", \"exec\") print(co.co_firstlineno) # 1\nprint(co.co_filename) # 古明地觉的编程教室\nprint(co.co_argcount) # 0\n# 我们是以 a, b = 1, 2 这种方式赋值\n# 所以 (1, 2) 会被当成一个元组加载进来\n# 因此从这里可以看出,元组在编译阶段就已经确定好了\nprint(co.co_consts) # ((1, 2), None) statement = \"\"\"\na = 1\nb = 2\n\"\"\"\nco = compile(statement, \"\", \"exec\")\nprint(co.co_consts) # (1, 2, None)\nprint(co.co_names) # ('a', 'b') 我们后面在分析 PyCodeObject 的时候,会经常使用 compile 函数。 然后 compile 还可以接收一个 flags 参数,也就是第四个参数,它的默认值为 0,表示按照标准模式进行编译,就是之前说的那几步。 对文本形式的源代码进行分词,将其切分成一个个的 Token; 对 Token 进行语法解析,生成抽象语法树(AST); 将 AST 编译成 PyCodeObject 对象,简称 code 对象或者代码对象; 但如果将 flags 指定为 1024,那么 compile 函数在生成 AST 之后会直接停止,然后返回一个 _ast.Module 对象。 print( compile(\"a = 1\", \"\", \"exec\").__class__\n) # print( compile(\"a = 1\", \"\", \"exec\", flags=1024).__class__\n) # _ast 模块是和 Python 的抽象语法树相关的,那么问题来了,这个 _ast.Module 对象能够干什么呢?别着急,我们后续在介绍栈帧的时候说。不过由于抽象语法树比较底层,因此知道 compile 的前三个参数的用法即可。","breadcrumbs":"52. PyCodeObject 拾遗 » 内置函数 compile","id":"230","title":"内置函数 compile"},"231":{"body":"关于 Python 的字节码,是后面剖析虚拟机的重点,现在先来看一下。我们知道执行源代码之前会先编译得到 PyCodeObject 对象,里面的 co_code 字段指向了字节码序列,或者说字节码指令集。 虚拟机会根据这些指令集来进行一系列的操作(当然也依赖其它的静态信息),从而完成对程序的执行。关于指令,解释器定义了 100 多种,我们大致看一下。 // Include/opcode.h\n#define POP_TOP 1\n#define ROT_TWO 2\n#define ROT_THREE 3\n#define DUP_TOP 4\n#define DUP_TOP_TWO 5\n#define ROT_FOUR 6\n#define NOP 9\n#define UNARY_POSITIVE 10\n#define UNARY_NEGATIVE 11\n#define UNARY_NOT 12\n#define UNARY_INVERT 15\n#define BINARY_MATRIX_MULTIPLY 16\n#define INPLACE_MATRIX_MULTIPLY 17\n#define BINARY_POWER 19\n#define BINARY_MULTIPLY 20\n#define BINARY_MODULO 22\n#define BINARY_ADD 23\n#define BINARY_SUBTRACT 24\n#define BINARY_SUBSCR 25\n#define BINARY_FLOOR_DIVIDE 26\n#define BINARY_TRUE_DIVIDE 27\n#define INPLACE_FLOOR_DIVIDE 28\n#define INPLACE_TRUE_DIVIDE 29\n#define GET_AITER 50\n#define GET_ANEXT 51\n#define BEFORE_ASYNC_WITH 52\n#define BEGIN_FINALLY 53\n#define END_ASYNC_FOR 54\n#define INPLACE_ADD 55\n#define INPLACE_SUBTRACT 56\n#define INPLACE_MULTIPLY 57\n#define INPLACE_MODULO 59\n#define STORE_SUBSCR 60\n#define DELETE_SUBSCR 61\n#define BINARY_LSHIFT 62\n#define BINARY_RSHIFT 63\n#define BINARY_AND 64\n#define BINARY_XOR 65\n#define BINARY_OR 66\n#define INPLACE_POWER 67\n#define GET_ITER 68\n#define GET_YIELD_FROM_ITER 69\n#define PRINT_EXPR 70\n#define LOAD_BUILD_CLASS 71\n#define YIELD_FROM 72\n#define GET_AWAITABLE 73\n#define INPLACE_LSHIFT 75\n#define INPLACE_RSHIFT 76\n// ... 所谓字节码指令其实就是个整数,多个指令组合在一起便是字节码指令集(字节码序列),它是一个 bytes 对象。当然啦,指令集里面不全是指令,索引(偏移量)为偶数的字节表示指令,索引为奇数的字节表示指令参数,后续会细说。 然后我们可以通过反编译的方式查看每行 Python 代码都对应哪些操作指令。 # Python 的 dis 模块专门负责干这件事情\nimport dis def foo(a, b): c = a + b return c # 里面接收 PyCodeObject 对象\n# 当然函数也是可以的,会自动获取 co_code\ndis.dis(foo)\n\"\"\" 2 0 LOAD_FAST 0 (a) 2 LOAD_FAST 1 (b) 4 BINARY_ADD 6 STORE_FAST 2 (c) 3 8 LOAD_FAST 2 (c) 10 RETURN_VALUE\n\"\"\" 字节码反编译后的结果多么像汇编语言,其中第一列是源代码行号,第二列是字节码偏移量,第三列是字节码指令(也叫操作码),第四列是指令参数(也叫操作数)。Python 的字节码指令都是成对出现的,每个指令会带有一个指令参数。 另外查看字节码也可以使用 opcode 模块: from opcode import opmap opmap = {v: k for k, v in opmap.items()} def foo(a, b): c = a + b return c code = foo.__code__.co_code\nfor i in range(0, len(code), 2): print(\"操作码: {:<12} 操作数: {}\".format( opmap[code[i]], code[i+1] ))\n\"\"\"\n操作码: LOAD_FAST 操作数: 0\n操作码: LOAD_FAST 操作数: 1\n操作码: BINARY_ADD 操作数: 0\n操作码: STORE_FAST 操作数: 2\n操作码: LOAD_FAST 操作数: 2\n操作码: RETURN_VALUE 操作数: 0\n\"\"\" 总之字节码就是一段字节序列,转成列表之后就是一堆数字。偶数位置表示指令本身,而每个指令后面都会跟一个指令参数,也就是奇数位置表示指令参数。 所以指令本质上只是一个整数,而虚拟机会根据不同的指令执行不同的逻辑。说白了 Python 虚拟机执行字节码的逻辑就是把自己想象成一颗 CPU,并内置了一个巨型的 switch case 语句,其中每个指令都对应一个 case 分支。然后遍历整条字节码,拿到每一个指令和指令参数。接着对指令进行判断,不同的指令进入不同的 case 分支,执行不同的处理逻辑,直到字节码全部执行完毕或者程序出错。 关于执行字节码的具体流程,等介绍栈帧的时候细说。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"52. PyCodeObject 拾遗 » 字节码与反编译","id":"231","title":"字节码与反编译"},"232":{"body":"上一篇文章我们介绍了字节码,当时提到,py 文件在执行的时候会先被编译成 PyCodeObject 对象,并且该对象还会被保存到 pyc 文件中。 然而事实并不总是这样,有时当我们运行一个简单的程序时,并没有产生 pyc 文件。因此我们猜测:有些 Python 程序只是临时完成一些琐碎的工作,这样的程序仅仅只会运行一次,然后就不会再使用了,因此也就没有保存至 pyc 文件的必要。 如果我们在代码中加上了一个 import abc 这样的语句,再执行你就会发现解释器为 abc.py 生成了 pyc 文件,这就说明 import 语句会触发 pyc 的生成。 实际上,在运行过程中,如果碰到 import abc 这样的语句,那么 Python 会在设定好的 path 中寻找 abc.pyc 或者 abc.pyd 文件。但如果没有这些文件,而是只发现了 abc.py,那么会先将 abc.py 编译成 PyCodeObject,然后写入到 pyc 文件中。 接下来,再对 abc.pyc 进行 import 动作。对的,并不是编译成 PyCodeObject 对象之后就直接使用,而是先写到 pyc 文件里,然后再将 pyc 文件里面的 PyCodeObject 对象重新在内存中复制出来。 当然啦,触发 pyc 文件生成不仅可以通过 import,还可以通过 py_compile 模块手动生成。比如当前有一个 tools.py,代码如下。 a = 1\nb = \"你好啊\" 如何将其编译成 pyc 呢? import py_compile py_compile.compile(\"tools.py\") 查看当前目录的 __pycache__ 目录,会发现 pyc 已经生成了。 py文件名.cpython-版本号.pyc 便是编译之后的 pyc 文件名。","breadcrumbs":"53. 一文让你搞懂 pyc 文件 » pyc 文件的触发","id":"232","title":"pyc 文件的触发"},"233":{"body":"如果有一个现成的 pyc 文件,我们要如何导入它呢? from importlib.machinery import SourcelessFileLoader tools = SourcelessFileLoader( \"tools\", \"__pycache__/tools.cpython-38.pyc\"\n).load_module() print(tools.a) # 1\nprint(tools.b) # 你好啊 以上我们就成功手动导入了 pyc 文件。","breadcrumbs":"53. 一文让你搞懂 pyc 文件 » pyc 文件的导入","id":"233","title":"pyc 文件的导入"},"234":{"body":"pyc 文件在创建的时候都会往里面写入哪些内容呢? 1)magic number 这是解释器内部定义的一个值,不同版本的解释器会定义不同的 magic number,这个值是为了保证能够加载正确的 pyc,比如 Python3.8 不会加载 3.7 版本的 pyc。因为解释器在加载 pyc 文件的时候会检测该 pyc 的 magic number,如果和自身的 magic number 不一致,说明此 pyc 是由其它版本的解释器写入的,因此拒绝加载。 from importlib.util import MAGIC_NUMBER\nprint(MAGIC_NUMBER) # b'U\\r\\r\\n' with open(\"__pycache__/tools.cpython-38.pyc\", \"rb\") as f: magic_number = f.read(4)\nprint(magic_number) # b'U\\r\\r\\n' pyc 文件的前 4 个字节便是 magic number。 2)py 文件的最后修改时间 这个很好理解,在加载 pyc 的时候会比较源代码的实际修改时间和 pyc 文件中存储的修改时间。如果两者不相等,说明在生成 pyc 之后,源代码又被修改了,那么会重新编译并写入 pyc,而反之则会直接加载已存在的 pyc。 3)py 文件的大小 py 文件的大小也会被记录在 pyc 文件中。 4)PyCodeObject 对象 编译之后的 PyCodeObject 对象,这个不用说了,肯定是要存储的,并且是序列化之后再存储。 因此 pyc 文件的结构如下: 我们实际验证一下: import struct\nfrom importlib.util import MAGIC_NUMBER\nfrom datetime import datetime with open(\"__pycache__/tools.cpython-38.pyc\", \"rb\") as f: data = f.read() # 0 ~ 4 字节是 MAGIC NUMBER\nprint(data[: 4]) # b'U\\r\\r\\n'\nprint(MAGIC_NUMBER) # b'U\\r\\r\\n' # 4 ~ 8 字节是 4 个 \\x00\nprint(data[4: 8]) # b'\\x00\\x00\\x00\\x00' # 8 ~ 12 字节是 py 文件的最后修改时间(小端存储),一个时间戳\nts = struct.unpack(\" at 0x..., file \"tools.py\", line 1>\n\"\"\"\n# 查看常量池\nprint(code.co_consts) # (1, '你好啊', None) # 符号表\nprint(code.co_names) # ('a', 'b') 常量池和符号表都是正确的。","breadcrumbs":"53. 一文让你搞懂 pyc 文件 » pyc 文件都包含哪些内容","id":"234","title":"pyc 文件都包含哪些内容"},"235":{"body":"下面通过源码来查看 pyc 文件的写入过程,既然要写入,那么肯定要有文件句柄。 // Python/marshal.c // FILE 是 C 自带的文件句柄\n// 可以把 WFILE 看成是 FILE 的包装\ntypedef struct { FILE *fp; // 下面的字段在写入数据的时候会看到 int error; int depth; PyObject *str; char *ptr; char *end; char *buf; _Py_hashtable_t *hashtable; int version;\n} WFILE; 首先是写入 magic number、创建时间和文件大小,它们会调用 PyMarshal_WriteLongToFile 函数进行写入: // Python/marshal.c\nvoid\nPyMarshal_WriteLongToFile(long x, FILE *fp, int version)\n{ // magic number、创建时间和文件大小,只是一个 4 字节整数 // 因此使用 char[4] 来保存 char buf[4]; // 声明一个 WFILE 类型的变量 wf WFILE wf; // 内存初始化 memset(&wf, 0, sizeof(wf)); // 初始化内部字段 wf.fp = fp; // 文件句柄 wf.ptr = wf.buf = buf; // buf 数组首元素的地址 wf.end = wf.ptr + sizeof(buf); // buf 数组尾元素的地址 wf.error = WFERR_OK; wf.version = version; // 调用 w_long 将信息写到 wf 里面 // 写入的信息可以是 magic number、时间和文件大小 w_long(x, &wf); // 刷到磁盘上 w_flush(&wf);\n} 所以该函数只是初始化了一个 WFILE 对象,真正写入则是调用的 w_long。 // Python/marshal.c\nstatic void\nw_long(long x, WFILE *p)\n{ w_byte((char)( x & 0xff), p); w_byte((char)((x>> 8) & 0xff), p); w_byte((char)((x>>16) & 0xff), p); w_byte((char)((x>>24) & 0xff), p);\n} w_long 则是调用 w_byte 将 x 逐个字节地写到文件里面去。 当头信息写完之后,就该写 PyCodeObject 对象了,这个过程由 PyMarshal_WriteObjectToFile 函数负责。 // Python/marshal.c\nvoid\nPyMarshal_WriteObjectToFile(PyObject *x, FILE *fp, int version)\n{ char buf[BUFSIZ]; WFILE wf; memset(&wf, 0, sizeof(wf)); wf.fp = fp; wf.ptr = wf.buf = buf; wf.end = wf.ptr + sizeof(buf); wf.error = WFERR_OK; wf.version = version; if (w_init_refs(&wf, version)) return; /* caller mush check PyErr_Occurred() */ // 写入头信息由 PyMarshal_WriteLongToFile 负责,它内部会调用 w_long // 写入 PyCodeObject 由当前函数负责,它内部会调用 w_object w_object(x, &wf); w_clear_refs(&wf); w_flush(&wf);\n} 然后我们看一下 w_object 函数。 // Python/marshal.c\nstatic void\nw_object(PyObject *v, WFILE *p)\n{ char flag = '\\0'; p->depth++; if (p->depth > MAX_MARSHAL_STACK_DEPTH) { p->error = WFERR_NESTEDTOODEEP; } else if (v == NULL) { w_byte(TYPE_NULL, p); } else if (v == Py_None) { w_byte(TYPE_NONE, p); } else if (v == PyExc_StopIteration) { w_byte(TYPE_STOPITER, p); } else if (v == Py_Ellipsis) { w_byte(TYPE_ELLIPSIS, p); } else if (v == Py_False) { w_byte(TYPE_FALSE, p); } else if (v == Py_True) { w_byte(TYPE_TRUE, p); } else if (!w_ref(v, &flag, p)) w_complex_object(v, flag, p); p->depth--;\n} 可以看到 w_object 和 w_long 一样,本质上都是调用了 w_byte。当然 w_byte 只能写入一些简单数据,如果是列表、字典之类的数据,那么会调用 w_complex_object 函数,也就是代码中的最后一个 else if 分支。 w_complex_object 这个函数的源代码很长,我们看一下整体结构,具体逻辑就不贴了,后面会单独截取一部分进行分析。 // Python/marshal.c static void\nw_complex_object(PyObject *v, char flag, WFILE *p)\n{ Py_ssize_t i, n; // 如果是整数,执行整数的写入逻辑 if (PyLong_CheckExact(v)) { // ... } // 如果是浮点数,执行浮点数的写入逻辑 else if (PyFloat_CheckExact(v)) { // ... } // 如果是复数,执行复数的写入逻辑 else if (PyComplex_CheckExact(v)) { // ... } // 如果是字节序列,执行字节序列的写入逻辑 else if (PyBytes_CheckExact(v)) { // ... } // 如果是字符串,执行字符串的写入逻辑 else if (PyUnicode_CheckExact(v)) { // ... } // 如果是元组,执行元组的写入逻辑 else if (PyTuple_CheckExact(v)) { // ... } // 如果是列表,执行列表的写入逻辑 else if (PyList_CheckExact(v)) { // ... } // 如果是字典,执行字典的写入逻辑 else if (PyDict_CheckExact(v)) { // ... } // 如果是集合,执行集合的写入逻辑 else if (PyAnySet_CheckExact(v)) { // ... } // 如果是 PyCodeObject 对象,执行 PyCodeObject 对象的写入逻辑 else if (PyCode_Check(v)) { // ... } // 如果是 Buffer,执行 Buffer 的写入逻辑 else if (PyObject_CheckBuffer(v)) { // ... } else { W_TYPE(TYPE_UNKNOWN, p); p->error = WFERR_UNMARSHALLABLE; }\n} 源代码虽然长,但是逻辑非常单纯,就是对不同的对象、执行不同的写动作,然而其最终目的都是通过 w_byte 写到 pyc 文件中。了解完函数的整体结构之后,我们再看一下具体细节,看看它在写入对象的时候到底写入了哪些内容? // Python/marshal.c\nstatic void\nw_complex_object(PyObject *v, char flag, WFILE *p)\n{ // ...... else if (PyList_CheckExact(v)) { W_TYPE(TYPE_LIST, p); n = PyList_GET_SIZE(v); W_SIZE(n, p); for (i = 0; i < n; i++) { w_object(PyList_GET_ITEM(v, i), p); } } else if (PyDict_CheckExact(v)) { Py_ssize_t pos; PyObject *key, *value; W_TYPE(TYPE_DICT, p); /* This one is NULL object terminated! */ pos = 0; while (PyDict_Next(v, &pos, &key, &value)) { w_object(key, p); w_object(value, p); } w_object((PyObject *)NULL, p); } // ......\n} 以列表和字典为例,它们在写入的时候实际上写的是内部的元素,其它对象也是类似的。 def foo(): lst = [1, 2, 3] # 把列表内的元素写进去了\nprint( foo.__code__.co_consts\n) # (None, 1, 2, 3) 但很明显,如果只是将元素收集起来显然是不够的,否则 Python 在加载的时候怎么知道它是一个列表呢?所以在写入的时候不能光写数据,还要将类型信息也写进去。我们再看一下上面列表和字典的写入逻辑,里面都调用了 W_TYPE,它负责写入类型信息。 因此无论对于哪种对象,在写入具体数据之前,都会先调用 W_TYPE 将类型信息写进去。如果没有类型信息,那么当解释器加载 pyc 文件的时候,只会得到一坨字节流,而无法解析字节流中隐藏的结构和蕴含的信息。所以在往 pyc 文件里写入数据之前,必须先写入一个标识,比如 TYPE_LIST、TYPE_TUPLE、TYPE_DICT 等等,这些标识正是对应的类型信息。 如果解释器在 pyc 文件中发现了这样的标识,则预示着上一个对象结束,新的对象开始,并且也知道新对象是什么样的对象,从而也知道该执行什么样的构建动作。至于这些标识都是可以看到的,在底层已经定义好了。 到了这里可以看到,Python 对 PyCodeObject 对象的导出实际上是不复杂的。因为不管什么对象,最后都会归结为两种简单的形式,一种是数值写入,一种是字符串写入。 上面都是对数值的写入,比较简单,仅仅需要按照字节依次写入 pyc 即可。然而在写入字符串的时候,Python 设计了一种比较复杂的机制,有兴趣可以自己阅读源码,这里不再介绍。","breadcrumbs":"53. 一文让你搞懂 pyc 文件 » pyc 文件的写入","id":"235","title":"pyc 文件的写入"},"236":{"body":"最后再来说一下字节码混淆,我们知道 pyc 是可以反编译的,而且目前也有现成的工具。但这些工具会将每一个指令都解析出来,所以字节码混淆的方式就是往里面插入一些恶意指令(比如加载超出范围的数据),让反编译工具在解析的时候报错,从而失去作用。 但插入的恶意指令还不能影响解释器执行,因此还要插入一些跳转指令,从而让解释器跳过恶意指令。 混淆之后多了两条指令,其中偏移量为 8 的指令,参数为 255,表示加载常量池中索引为 255 的元素。如果常量池没有这么多元素,那么显然会发生索引越界,导致反编译的时候报错。 但对于解释器来说,是可以正常执行的,因为在执行到偏移量为 6 的指令时出现了一个相对跳转,直接跳到偏移量为 6 + 4 = 10 的指令了。 因此对于解释器执行来说,混淆前后是没有区别的,但对于反编译工具而言则无法正常工作,因为它会把每个指令都解析一遍。根据这个思路,我们可以插入很多很多的恶意指令,然后再利用跳转指令来跳过这些不合法的指令。当然混淆的手段并不止这些,我们还可以添加一些虚假的分支,然后在执行时跳转到真实的分支当中。 而这一切的目的,都是为了防止别人根据 pyc 文件反推出源代码。不过这种做法属于治标不治本,如果真的想要保护源代码的话,可以使用 Cython 将其编译成 pyd ,这是最推荐的做法。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"53. 一文让你搞懂 pyc 文件 » 字节码混淆","id":"236","title":"字节码混淆"},"237":{"body":"从现在开始,我们将剖析虚拟机运行字节码的原理。前面说了,Python 解释器可以分为两部分:Python 编译器和 Python 虚拟机。编译器将源代码编译成 PyCodeObject 对象之后,就由虚拟机接手整个工作。虚拟机会从 PyCodeObject 中读取字节码,并在当前的上下文中执行,直到所有的字节码都被执行完毕。 那么问题来了,既然源代码在经过编译之后,字节码指令以及静态信息都存储在 PyCodeObject 当中,那么是不是意味着虚拟机就在 PyCodeObject 对象上进行所有的动作呢? 很明显不是的,因为尽管 PyCodeObject 包含了关键的字节码指令以及静态信息,但有一个东西是没有包含、也不可能包含的,就是程序在运行时的执行环境,这个执行环境在 Python 里面就是栈帧。","breadcrumbs":"54. 深度解密虚拟机的执行环境:栈帧对象 » 楔子","id":"237","title":"楔子"},"238":{"body":"那什么是栈帧呢?我们举个例子。 name = \"古明地觉\" def some_func(): name = \"八意永琳\" print(name) some_func()\nprint(name) 上面的代码当中出现了两个 print(name),它们的字节码指令相同,但执行的效果却显然是不同的,这样的结果正是执行环境的不同所产生的。因为环境的不同,name 的值也不同。 因此同一个符号在不同环境中可能指向不同的类型、不同的值,必须在运行时进行动态捕捉和维护,这些信息不可能在 PyCodeObject 对象中被静态存储。 所以可以得出结论,虚拟机并不是在 PyCodeObject 对象上执行操作的,而是在栈帧对象上。虚拟机在执行时,会根据 PyCodeObject 对象动态创建出栈帧对象,然后在栈帧里面执行字节码。所以栈帧是虚拟机执行的上下文,执行时依赖的所有信息都存储在栈帧中。 然后对于上面的代码,我们可以大致描述一下流程: 首先基于模块的 PyCodeObject 创建一个栈帧,假设叫 A,所有的字节码都会在栈帧中执行,虚拟机可以从栈帧里面获取变量的值,也可以修改; 当发生函数调用的时候,这里是 some_func,那么虚拟机会在栈帧 A 之上,为 some_func 创建一个新的栈帧,假设叫 B,然后在栈帧 B 里面执行函数 some_func 的字节码指令; 在栈帧 B 里面也有一个名字为 name 的变量,但由于执行环境、或者说栈帧的不同,name 指向的对象也不同; 一旦函数 some_func 的字节码指令全部执行完毕,那么会将当前的栈帧 B 销毁(也可以保留),再回到调用者的栈帧中来。就像是递归一样,每当调用函数时,就会在当前栈帧之上创建一个新的栈帧,一层一层创建,一层一层返回;","breadcrumbs":"54. 深度解密虚拟机的执行环境:栈帧对象 » 栈帧:虚拟机的执行环境","id":"238","title":"栈帧:虚拟机的执行环境"},"239":{"body":"不难发现,Python 虚拟机执行字节码这个过程,就是在模拟操作系统运行可执行文件。比如: 程序加载 操作系统:加载可执行文件到内存,设置程序计数器。 Python 虚拟机:加载 .pyc 文件中的 PyCodeObject 对象,初始化字节码指令指针。 内存管理 操作系统:为进程分配内存空间,管理堆和栈。 Python 虚拟机:创建和管理 Python 对象,处理内存分配和垃圾回收。 指令执行 操作系统:CPU 逐条执行机器指令。 Python 虚拟机:虚拟机逐条执行字节码指令。 资源管理 操作系统:管理文件句柄、网络连接等系统资源。 Python 虚拟机:管理文件对象、套接字等 Python 级别的资源。 异常处理 操作系统:处理硬件中断和软件异常。 Python 虚拟机:捕获和处理 Python 异常。 我们简单地画一张示意图,来看看在一台普通的 x64 机器上,可执行文件是以什么方式运行的,在这里主要关注栈帧的变化。假设有三个函数,函数 f 调用了函数 g,函数 g 又调用了函数 h。 首先 CPU 有两个关键的寄存器,它们在函数调用和栈帧管理中扮演关键角色。 RSP(Stack Pointer):栈指针,指向当前栈帧的顶部,或者说最后一个入栈的元素。因此随着元素的入栈和出栈,RSP 会动态变化。由于地址从栈底到栈顶是逐渐减小的,所以 RSP 会随着数据入栈而减小,随着数据出栈而增大。当然不管 RSP 怎么变,它始终指向当前栈的顶部。 RBP(Base Pointer):基指针,指向当前栈帧的基址,它的作用是提供一个固定的参考点,用于访问当前函数的局部变量和参数。当新的帧被创建时,它的基址会保存上一个帧的基址,并由 RBP 指向。 我们用一段 C 代码来解释一下。 #include int add(int a, int b) { int c = a + b; return c;\n} int main() { int a = 11; int b = 22; int result = add(a, b); printf(\"a + b = %d\\n\", result);\n} 当执行函数 main 的时候,RSP 指向 main 栈帧的顶部,RBP 指向 main 栈帧的基址。然后在 main 里面又调用了函数 add,那么毫无疑问,系统会在地址空间中,在 main 的栈帧之上为 add 创建栈帧。然后让 RSP 指向 add 栈帧的顶部,RBP 指向 add 栈帧的基址,而 add 栈帧的基址保存了上一级栈帧(main 栈帧)的基址。 当函数 add 执行结束时,会销毁对应栈帧,再将 RSP 和 RBP 恢复为创建 add 栈帧之前的值,这样程序的执行流程就又回到了函数 main 里面,当然程序的运行空间也回到了函数 main 的栈帧中。 不难发现,通过两个 CPU 寄存器 RSP、RBP,以及栈帧中保存的上一级栈帧的基址,完美地维护了函数之间的调用链,这就是可执行文件在 x64 机器上的运行原理。 那么 Python 里面的栈帧是怎样的呢?","breadcrumbs":"54. 深度解密虚拟机的执行环境:栈帧对象 » 虚拟机和操作系统","id":"239","title":"虚拟机和操作系统"},"24":{"body":"Python 中有两个类型对象比较特殊,一个是站在类型金字塔顶端的 type,另一个是站在继承金字塔顶端的 object。看完了 type,再来看看 object。 由于 object 的类型是 type,那么在初始化 PyBaseObject_Type 的时候,它的 ob_type 一定也被设置成了 &PyType_Type。 我们看一下 PyBaseObject_Type 的具体实现,它同样定义在 Objects/typeobject.c 中。 类型对象在创建的时候,ob_type 字段都会被初始化成 &PyType_Type,而 object 也不例外,所以它的类型为 type,这个非常简单。但 type 的基类是 object,又是怎么一回事呢? 之前介绍类型对象的时候,我们说类型对象内部的 tp_base 指向继承的基类,那么对于 PyType_Type 来讲,它内部的 tp_base 肯定是 &PyBaseObject_Type,即 object。 但令我们吃鲸的是,它的 tp_base 居然是个 0,也就是说基类为空。 在 C 中,将指针变量赋值为 0 和赋值为 NULL 是等价的,因为 NULL 就是值为 0 的指针常量。 不是说 type 的基类是 object 吗?为啥 tp_base 是 0 呢。事实上如果你去看其它类型的话,会发现它们内部的 tp_base 也是 0。为 0 的原因就在于我们目前看到的类型对象还不够完善,因为 Python 的动态性,显然不可能在定义的时候就将所有字段属性都设置好、然后解释器一启动就得到我们平时使用的类型对象。 因此目前看到的类型对象还不是最终形态,有一部分字段属性是在解释器启动之后再动态完善的,而这个完善的过程被称为类型对象的初始化,它由函数 PyType_Ready 负责。 // Objects/typeobject.c int\nPyType_Ready(PyTypeObject *type)\n{ // ... // 注意这里的 type 是一个 C 函数的参数,不是 Python 里的 // 获取类型对象的基类(指针) base = type->tp_base; // 如果类型对象的 tp_base 为空,并且本身也不是 &PyBaseObject_Type // 那么就将它的 tp_base 设置为 &PyBaseObject_Type if (base == NULL && type != &PyBaseObject_Type) { base = type->tp_base = &PyBaseObject_Type; Py_INCREF(base); } // ...\n} 当解释器发现类对象还没有初始化时,会将其作为参数传递给 PyType_Ready,进行初始化。 初始化过程会做很多的工作,用于完善类型对象,而其中一项工作就是设置基类。如果发现类型对象的基类为空,那么就将基类设置为 object,因为在 Python3 里面新式类都要继承 object。当然啦,这个类不能是 object 本身,object 的基类是 None,因为继承链向上要有一个终点。 当 PyType_Ready 完成初始化之后,就得到我们平常使用的类型对象了,最终 PyType_Type 和 PyBaseObject_Type 的关系如下。 因此到目前为止,type 和 object 之间的恩怨纠葛算是真相大白了,总结一下: 1)和自定义类不同,内置的类不是由 type 实例化得到的,它们都是在底层预先定义好的,不存在谁创建谁。只是内置的类在定义的时候,它们的类型都被设置成了 type。这样不管是内置的类,还是自定义类,在调用时都可以执行 type 的 __call__ 函数,从而让它们的行为是一致的。 2)虽然内置的类在底层预定义好了,但还有一些瑕疵,因为有一部分逻辑无法以源码的形式体现,只能在解释器启动的时候再动态完善。而这个完善的过程,便包含了基类的填充,会将基类设置成 object。 所以 type 和 object 是同时出现的,它们的存在需要依赖彼此。首先这两者会以不完全体的形式定义在源码中,并且在定义的时候将 object 的类型设置成 type;然后当解释器启动的时候,再经过动态完善,进化成完全体,而进化的过程中会将 type 的基类设置成 object。 因此 object 的类型是 type,type 继承 object 就是这么来的。","breadcrumbs":"6. 通过 type 和 object 之间的关联,进一步分析类型对象 » 类型对象的基类:PyBaseObject_Type","id":"24","title":"类型对象的基类:PyBaseObject_Type"},"240":{"body":"相较于 x64 机器上看到的那个简简单单的栈帧,Python 的栈帧实际上包含了更多的信息。注:栈帧也是一个对象。 // Include/frameobject.h typedef struct _frame { PyObject_VAR_HEAD struct _frame *f_back; PyCodeObject *f_code; PyObject *f_builtins; PyObject *f_globals; PyObject *f_locals; PyObject **f_valuestack; PyObject **f_stacktop; PyObject *f_trace; char f_trace_lines; char f_trace_opcodes; PyObject *f_gen; int f_lasti; int f_lineno; int f_iblock; char f_executing; PyTryBlock f_blockstack[CO_MAXBLOCKS]; PyObject *f_localsplus[1];\n} PyFrameObject; 下面来解释一下里面的每个字段都是啥含义,不过在解释之前,我们要先知道如何在 Python 中获取栈帧对象。 import inspect def foo(): # 返回当前所在的栈帧 # 这个函数实际上是调用了 sys._getframe(1) return inspect.currentframe() frame = foo()\nprint(frame) \"\"\"\n\n\"\"\"\nprint(type(frame)) \"\"\"\n\n\"\"\" 我们看到栈帧的类型是 ,正如 PyCodeObject 对象的类型是 一样,这两个类没有暴露给我们,所以不可以直接使用。 同理,还有 Python 的函数,类型是 ,模块的类型是 。这些解释器都没有给我们提供,如果直接使用的话,那么 frame、code、function、module 只是几个没有定义的变量罢了,这些类我们只能通过这种间接的方式获取。 下面来看一下 PyFrameObject 里面每个字段的含义。 PyObject_VAR_HEAD 变长对象的头部信息,所以栈帧也是一个对象。 struct _frame *f_back 当前栈帧的上一级栈帧,也就是调用者的栈帧。所以 x64 机器是通过 RSP、RBP 两个指针维护函数的调用关系,而 Python 虚拟机则是通过栈帧的 f_back 字段。 import inspect def foo(): return inspect.currentframe() frame = foo()\nprint(frame)\n\"\"\"\n\n\"\"\"\n# foo 的上一级栈帧,显然对应的是模块的栈帧\nprint(frame.f_back)\n\"\"\"\n>\n\"\"\"\n# 相当于模块的上一级栈帧,显然是 None\nprint(frame.f_back.f_back)\n\"\"\"\nNone\n\"\"\" 因此通过栈帧,可以轻松地获取完整的函数调用链路,我们一会儿演示。 PyCodeObject *f_code 栈帧对象是在 PyCodeObject 之上构建的,所以它内部一定有一个字段指向 PyCodeObject,而该字段就是 f_code。 import inspect def e(): f() def f(): g() def g(): h() def h(): frame = inspect.currentframe() # 获取栈帧 func_names = [] # 只要 frame 不为空,就一直循环,并将函数名添加到列表中 while frame is not None: func_names.append(frame.f_code.co_name) frame = frame.f_back print(f\"函数调用链路:{' -> '.join(func_names[:: -1])}\") f()\n\"\"\"\n函数调用链路: -> f -> g -> h\n\"\"\" 模块 -> f -> g -> h,显然我们获取了整个调用链路,是不是很有趣呢? PyObject *f_builtins、*f_gloabls、*f_locals 这三者均表示名字空间,其中 f_gloabls 指向全局名字空间(一个字典),它是全局变量的容身之所。是的,Python 的全局变量是通过字典存储的,调用函数 globals 即可拿到该字典。 # 等价于 name = \"古明地觉\"\nglobals()[\"name\"] = \"古明地觉\" # 等价于 print(name)\nprint(globals()[\"name\"]) # 古明地觉 def foo(): import inspect return inspect.currentframe() frame = foo()\n# frame.f_globals 同样会返回全局名字空间\nprint(frame.f_globals is globals()) # True\n# 相当于创建了一个全局变量 age\nframe.f_globals[\"age\"] = 18\nprint(age) # 18 关于名字空间,我们后面会用专门的篇幅详细说明。 然后 f_locals 指向局部名字空间(一个字典),但和全局变量不同,局部变量不存在局部名字空间中,而是静态存储在数组中。该字段先有个印象,后续再详细说。 f_builtins 指向内置名字空间(一个字典),显然一些内置的变量都存在里面。 def foo(): import inspect return inspect.currentframe() frame = foo()\nprint(frame.f_builtins[\"list\"](\"abcd\"))\n\"\"\"\n['a', 'b', 'c', 'd']\n\"\"\" 和我们直接使用 list(\"abcd\") 是等价的。 PyObject **f_valuestack 指向运行时栈的栈底,关于什么是运行时栈,后续详细说明。 PyObject **f_stacktop 指向运行时栈的栈顶。 PyObject *f_trace 追踪函数,用于调试。 char f_trace_lines 是否为每一行代码调用追踪函数,当设置为真(非零值)时,每当虚拟机执行到一个新的代码行时,都会调用追踪函数。这允许调试器在每行代码执行时进行干预,比如设置断点、检查变量等。 char f_trace_opcodes 是否为每个字节码指令调用追踪函数,当设置为真时,虚拟机会在执行每个字节码指令之前调用追踪函数。这提供了更细粒度的控制,允许进行指令级别的调试。 所以不难发现,f_trace_lines 是行级追踪,对应源代码的每一行,通常用于普通的调试,如设置断点、单步执行等,并且开销相对较小。f_trace_opcodes 是指令级追踪,对应每个字节码指令,通常用于更深层次的调试,比如分析具体的字节码执行过程,并且开销较大。 import sys def trace_lines(frame, event, arg): print(f\"行号:{frame.f_lineno},文件名:{frame.f_code.co_filename}\") return trace_lines sys.settrace(trace_lines) 设置追踪函数一般需要通过 sys.settrace,不过不常用,了解一下即可。 PyObject *f_gen 是否是基于生成器的 PyCodeObject 构建的栈帧。 int f_lasti 上一条已执行完毕的指令在指令序列中的偏移量。 int f_lineno 获取该栈帧时的源代码行号。 import inspect def foo(): return inspect.currentframe() frame = foo()\nprint(frame.f_lineno) # 4 我们是在第 4 行获取的栈帧,所以打印结果是 4。 int f_iblock 用于跟踪 try / except / finally 代码块的层级深度,它记录了当前栈帧中活跃的 try 语句块的数量,每进入一个新的 try 语句块时加 1,离开 try 语句块时减 1。 try: # f_iblock = 1 try: # f_iblock = 2 pass except: pass except: # f_iblock = 1 pass\nfinally: # f_iblock = 0 pass f_iblock 对于虚拟机的异常捕获来说非常重要,可以在异常处理时确定当前代码在哪个 try 语句块内,帮助确定应该执行哪个 except 或 finally 子句,保证异常处理和清理代码能按正确的嵌套顺序执行。 char f_executing 当前栈帧是否仍在执行。 PyTryBlock f_blockstack[CO_MAXBLOCKS] 一个栈,用于追踪代码块,比如代码块的进入和退出,以及管理代码块的上下文信息。那么都支持哪些代码块呢? SETUP_LOOP:循环块(for / while) SETUP_EXCEPT:try / except 块 SETUP_FINALLY:try / finally 块 SETUP_WITH:with 语句块 SETUP_ASYNC_WITH:async with 语句块 PyObject *localsplus[1] 一个柔性数组,负责维护 \"局部变量 + cell 变量 + free 变量 + 运行时栈\",大小在运行时确定。 以上就是栈帧内部的字段,这些字段先有个印象,后续在剖析虚拟机的时候还会继续细说。 总之我们看到,PyCodeObject 并不是虚拟机的最终目标,虚拟机最终是在栈帧中执行的。每一个栈帧都会维护一个 PyCodeObject 对象,换句话说,每一个 PyCodeObject 对象都会隶属于一个栈帧。并且从 f_back 可以看出,虚拟机在实际执行时,会产生很多的栈帧对象,而这些对象会被链接起来,形成一条执行环境链表,或者说栈帧链表。 而这正是 x64 机器上栈帧之间关系的模拟,在 x64 机器上,栈帧之间通过 RSP 和 RBP 指针建立了联系,使得新栈帧在结束之后能够顺利地返回到旧栈帧中,而 Python 虚拟机则是利用 f_back 来完成这个动作。 当然,获取栈帧除了通过 inspect 模块之外,在捕获异常时,也可以获取栈帧。 def foo(): try: 1 / 0 except ZeroDivisionError: import sys # exc_info 返回一个三元组 # 分别是异常的类型、值、以及 traceback exc_type, exc_value, exc_tb = sys.exc_info() print(exc_type) # print(exc_value) # division by zero print(exc_tb) # # 调用 exc_tb.tb_frame 即可拿到异常对应的栈帧 # 另外这个 exc_tb 也可以通过下面这种方式获取 # except ZeroDivisionError as e; e.__traceback__ print(exc_tb.tb_frame.f_code.co_name) # foo print(exc_tb.tb_frame.f_back.f_code.co_name) # # 显然 tb_frame 是当前函数 foo 的栈帧 # 那么 tb_frame.f_back 就是整个模块对应的栈帧 # 而 tb_frame.f_back.f_back 显然就是 None 了 print(exc_tb.tb_frame.f_back.f_back) # None foo() 关于栈帧内部的字段的含义,我们就说完了。当然如果有些字段现在不是很理解,也没关系,随着不断地学习,你会豁然开朗。","breadcrumbs":"54. 深度解密虚拟机的执行环境:栈帧对象 » 栈帧的底层结构","id":"240","title":"栈帧的底层结构"},"241":{"body":"因为很多动态信息无法静态地存储在 PyCodeObject 对象中,所以 PyCodeObject 对象在交给虚拟机之后,虚拟机会在其之上动态地构建出 PyFrameObject 对象,也就是栈帧。 因此虚拟机是在栈帧里面执行的字节码,它包含了虚拟机在执行字节码时依赖的全部信息。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"54. 深度解密虚拟机的执行环境:栈帧对象 » 小结","id":"241","title":"小结"},"242":{"body":"在介绍栈桢的时候,我们看到了 3 个独立的名字空间:f_locals、f_globals、f_builtins。名字空间对 Python 来说是一个非常重要的概念,虚拟机的运行机制和名字空间有着非常紧密的联系。并且在 Python 中,与名字空间这个概念紧密联系在一起的还有名字、作用域这些概念,下面我们就来剖析这些概念是如何体现的。","breadcrumbs":"55. 名字空间:变量的容身之所 » 楔子","id":"242","title":"楔子"},"243":{"body":"在这个系列的最开始我们就说过,从解释器的角度来看,变量只是一个泛型指针 PyObject *,而从 Python 的角度来看,变量只是一个名字、或者说符号,用于和对象进行绑定的。 name = \"古明地觉\" 上面这个赋值语句其实就是将 name 和 \"古明地觉\" 绑定起来,让我们可以通过 name 这个符号找到对应的 PyUnicodeObject。因此定义一个变量,本质上就是建立名字和对象之间的映射关系。 另外我们说 Python 虽然一切皆对象,但拿到的都是指向对象的指针,因此创建函数和类,以及模块导入,同样是在完成名字和对象的绑定。 def foo(): pass class A(): pass 创建一个函数也相当于定义一个变量,会先根据函数体创建一个函数对象,然后将名字 foo 和函数对象绑定起来。所以函数名和函数体之间是分离的,同理类也是如此。 import os 导入一个模块,也是在定义一个变量。import os 相当于将名字 os 和模块对象绑定起来,通过 os 可以找到指定的模块对象。 当我们导入一个模块的时候,解释器是这么做的。 import os 等价于 os = __import__(\"os\"),可以看到本质上还是一个赋值语句。 import numpy as np 中的 as 语句同样是在定义变量,将名字 np 和对应的模块对象绑定起来,以后就可以通过 np 这个名字去获取指定的模块了。 总结:无论是普通的赋值语句,还是定义函数和类,亦或是模块导入,它们本质上都是在完成变量和对象的绑定。 name = \"古明地觉\" def foo(): pass class A(): pass import os\nimport numpy as np 里面的 name、foo、A、os、np,都只是一个变量,或者说名字、符号,然后通过名字可以获取与之绑定的对象。","breadcrumbs":"55. 名字空间:变量的容身之所 » 变量只是一个名字","id":"243","title":"变量只是一个名字"},"244":{"body":"正如上面所说,赋值语句、函数定义、类定义、模块导入,本质上只是完成了变量和对象之间的绑定,或者说我们创建了变量到对象的映射,通过变量可以获取对应的对象,而它们的容身之所就是名字空间。 所以名字空间是通过 PyDictObject 对象实现的,这对于映射来说简直再适合不过了。而前面介绍字典的时候,我们说字典是被高度优化的,原因就是虚拟机本身也重度依赖字典,从这里的名字空间即可得到体现。 当然,在一个模块内部,变量还存在可见性的问题,比如: x = 1 def foo(): x = 2 print(x) # 2 foo()\nprint(x) # 1 我们看到同一个变量名,打印的确是不同的值,说明指向了不同的对象,换句话说这两个变量是在不同的名字空间中被创建的。 名字空间本质上是一个字典,如果两者在同一个名字空间,那么由于 key 的不重复性,当执行 x = 2 的时候,会把字典里面 key 为 \"x\" 的 value 给更新成 2。但是在外面还是打印 1,这说明两者所在的不是同一个名字空间,打印的也就自然不是同一个 x。因此对于一个模块而言,内部可以存在多个名字空间,每一个名字空间都与一个作用域相对应。作用域可以理解为一段程序的正文区域,在这个区域里面定义的变量是有意义的,然而一旦出了这个区域,就无效了。 关于作用域这个概念,我们要记住:它仅仅是由源代码的文本所决定。在 Python 中,一个变量在某个位置是否起作用,是由它的文本位置决定的。 因此 Python 具有静态作用域(词法作用域),而名字空间则是作用域的动态体现,一个由程序文本定义的作用域在运行时会转化为一个名字空间、即一个 PyDictObject 对象。比如进入一个函数,显然会进入一个新的作用域,因此函数在执行时,会创建一个名字空间。 在介绍 PyCodeObject 的时候,我们说解释器在对源代码进行编译的时候,对于代码中的每一个 code block,都会创建一个 PyCodeObject 对象与之对应。而当进入一个新的名字空间、或者说作用域时,就算是进入一个新的 block 了。 而根据我们使用 Python 的经验,显然函数、类都是一个新的 block,解释器在执行的时候会为它们创建各自的名字空间。 所以名字空间是名字、或者说变量的上下文环境,名字的含义取决于名字空间。更具体的说,一个变量绑定的对象是不确定的,需要由名字空间来决定。位于同一个作用域的代码可以直接访问作用域中出现的名字,即所谓的直接访问;但不同的作用域,则需要通过访问修饰符 . 进行属性访问。 class A: x = 1 class B: y = 2 print(A.x) # 1 print(y) # 2 如果想在 B 里面访问 A 里面的内容,要通过 A.属性的方式,表示通过 A 来获取 A 里面的属性。但是访问 B 的内容就不需要了,因为都是在同一个作用域,所以直接访问即可。 访问名字这样的行为被称为名字引用,名字引用的规则决定了 Python 程序的行为。 x = 1 def foo(): x = 2 print(x) # 2 foo()\nprint(x) # 1 还是上面的代码,如果我们把函数里面的 x = 2 给删掉,意味着函数的作用域里面已经没有 x 这个变量了,那么再执行程序会有什么结果呢?从 Python 层面来看,显然是会寻找外部的 x。因此我们可以得到如下结论: 作用域是层层嵌套的; 内层作用域可以访问外层作用域; 外层作用域无法访问内层作用域,如果是把外层的 x = 1 给去掉,那么最后面的 print(x) 铁定报错; 查找元素会依次从当前作用域向外查找,也就是查找元素时,对应的作用域是按照从小往大、从里往外的方向前进的;","breadcrumbs":"55. 名字空间:变量的容身之所 » 作用域和名字空间","id":"244","title":"作用域和名字空间"},"245":{"body":"不光函数、类有自己的作用域,模块对应的源文件本身也有相应的作用域。比如: name = \"古明地觉\"\nage = 16 def foo(): return 123 class A: pass 这个文件本身也有自己的作用域,并且是 global 作用域,所以解释器在运行这个文件的时候,也会为其创建一个名字空间,而这个名字空间就是 global 名字空间,即全局名字空间。它里面的变量是全局的,或者说是模块级别的,在当前文件的任意位置都可以直接访问。 而 Python 也提供了 globals 函数,用于获取 global 名字空间。 name = \"古明地觉\" def foo(): pass print(globals())\n\"\"\"\n{..., 'name': '古明地觉', 'foo': }\n\"\"\" 里面的 ... 表示省略了一部分输出,我们看到创建的全局变量就在里面。而且 foo 也是一个全局变量,它指向一个函数对象。 注意:我们说函数内部是一个独立的 block,因此它会对应一个 PyCodeObject。然后在解释到 def foo 的时候,会根据 PyCodeObject 对象创建一个 PyFunctionObject 对象,然后将 foo 和这个函数对象绑定起来。 当后续调用 foo 的时候,再根据 PyFunctionObject 对象创建 PyFrameObject 对象、然后执行,至于具体细节留到介绍函数的时候再细说。总之,我们看到 foo 也是一个全局变量,全局变量都在 global 名字空间中。并且 global 名字空间全局唯一,它是程序运行时的全局变量和与之绑定的对象的容身之所。你在任何一个位置都可以访问到 global 名字空间,正如你在任何一个位置都可以访问全局变量一样。 另外我们思考一下,global 名字空间是一个字典,全局变量和对象会以键值对的形式存在里面。那如果我手动地往 global 名字空间里面添加一个键值对,是不是也等价于定义一个全局变量呢? globals()[\"name\"] = \"古明地觉\"\nprint(name) # 古明地觉 def foo1(): def foo2(): def foo3(): globals()[\"age\"] = 16 return foo3 return foo2 foo1()()()\nprint(age) # 16 我们看到确实如此,往 global 名字空间里面插入一个键值对完全等价于定义一个全局变量。并且 global 名字空间是唯一的,你在任何地方调用 globals() 得到的都是 global 名字空间,正如你在任何地方都可以访问到全局变量一样。 所以即使是在函数中给 global 名字空间添加一个键值对,也等价于定义一个全局变量。 问题来了,如果在函数里面,我们不获取 global 名字空间,怎么创建全局变量呢? name = \"古明地觉\" def foo(): global name name = \"古明地恋\" print(name) # 古明地觉\nfoo()\nprint(name) # 古明地恋 很简单,Python 为我们准备了 global 关键字,表示声明的变量是全局的。","breadcrumbs":"55. 名字空间:变量的容身之所 » global 名字空间","id":"245","title":"global 名字空间"},"246":{"body":"像函数和类拥有的作用域,我们称之为 local 作用域,在运行时会对应 local 名字空间,即局部名字空间。由于不同的函数具有不同的作用域,所以局部名字空间可以有很多个,但全局名字空间只有一个。 对于 local 名字空间来说,它也对应一个字典,显然这个字典就不是全局唯一的了。而如果想获取局部名字空间,Python 也提供了 locals 函数。 def foo(): name = \"古明地觉\" age = 17 return locals() def bar(): name = \"雾雨魔理沙\" age = 18 return locals() print(locals() == globals()) # True\nprint(foo()) # {'name': '古明地觉', 'age': 17}\nprint(bar()) # {'name': '雾雨魔理沙', 'age': 18} 对于模块来讲,它的 local 名字空间和 global 名字空间是一样的,也就是说,模块对应的栈桢对象里面的 f_locals 和 f_globals 指向的是同一个 PyDictObject 对象。但对于函数而言,局部名字空间和全局名字空间就不一样了,调用 locals() 是获取自身的局部名字空间,而不同函数的局部名字空间是不同的。但是 globals() 函数的调用结果是一样的,获取的都是全局名字空间,这也符合函数内不存在指定变量的时候会去找全局变量这一结论。 注:关于 local 名字空间,还有一个重要的细节,全局变量会存储在 global 名字空间中,但局部变量却并不存储在 local 名字空间中。函数有哪些局部变量在编译的时候就已经确定了,会被静态存储在数组中,关于这一点,后续会单独详细说明。","breadcrumbs":"55. 名字空间:变量的容身之所 » local 名字空间","id":"246","title":"local 名字空间"},"247":{"body":"Python 有一个所谓的 LGB 规则,指的是在查找一个变量时,会按照自身的 local 空间、外层的 global 空间、内置的 builtin 空间的顺序进行查找。 builtin 名字空间也是一个字典,当 local 名字空间、global 名字空间都查找不到指定变量的时候,会去 builtin 空间查找。而关于 builtin 空间的获取,Python 提供了一个模块。 # 等价于 __builtins__\nimport builtins\nprint(builtins is __builtins__) # True\nprint(builtins) # builtins 是一个模块,那么 builtins.__dict__ 便是 builtin 名字空间,也叫内置名字空间。 import builtins # builtins.list 表示从 builtin 名字空间中查找 list\n# 它等价于 builtins.__dict__[\"list\"]\n# 而如果只写 list,那么由于 local 空间、global 空间都没有\n# 因此最终还是会从 builtin 空间中查找\n# 但如果是 builtins.list,那么就不兜圈子了\n# 表示:\"builtin 空间,就从你这里获取了\"\nprint(builtins.list is list) # True # 将 builtin 空间的 dict 改成 123\nbuiltins.dict = 123\n# 那么此时获取的 dict 就是 123\nprint(dict + 456) # 579 # 如果是 str = 123,等价于创建全局变量 str = 123\nstr = 123\n# 显然影响的是 global 空间\nprint(str) # 123\n# builtin 空间则不受影响\nprint(builtins.str) # \nprint(builtins.__dict__[\"str\"]) # 这里提一下在 Python2 中,while 1 比 while True 要快,为什么? 因为 True 在 Python2 中不是关键字,所以它是可以作为变量名的。那么虚拟机在执行的时候就要先看 local 空间和 global 空间里有没有 True 这个变量,有的话使用我们定义的,没有的话再使用内置的 True。 而 1 是一个常量,直接加载就可以,所以 while True 多了符号查找这一过程。但是在 Python3 中两者就等价了,因为 True 在 Python3 中是一个关键字,也会直接作为一个常量来加载。","breadcrumbs":"55. 名字空间:变量的容身之所 » builtin 名字空间","id":"247","title":"builtin 名字空间"},"248":{"body":"记得之前介绍 exec 和 eval 的时候,我们说这两个函数里面还可以接收第二个参数和第三个参数,它们分别表示 global 名字空间、local 名字空间。 # 如果不指定,默认是当前所在的名字空间\n# 显然此时是全局名字空间\nexec(\"name = '古明地觉'\")\nprint(name) # 古明地觉 # 但我们也可以指定某个名字空间\nnamespace = {}\n# 比如将 namespace 作为全局名字空间\n# 另外这里没有指定第三个参数,也就是局部名字空间\n# 如果指定了第二个参数,但没有指定第三个参数\n# 那么第三个参数默认和第二个参数保持一致\nexec(\"name = 'satori'\", namespace)\nprint(namespace[\"name\"]) # satori 至于 eval 也是同理: namespace = {\"seq\": [1, 2, 3, 4, 5]}\ntry: print(eval(\"sum(seq)\"))\nexcept NameError as e: print(e) # name 'seq' is not defined\n# 告诉我们 seq 没有被定义\n# 如果将 namespace 作为名字空间\nprint(eval(\"sum(seq)\", namespace)) # 15 所以名字空间本质上就是一个字典,所谓的变量不过是字典里面的一个 key。为了进一步加深印象,再举个模块的例子: # 我们自定义一个模块吧\n# 首先模块也是一个对象,类型为 \n# 但底层没有将这个类暴露给我们,所以需要换一种方式获取\nimport sys\nModuleType = type(sys) # 以上就拿到了模块的类型对象,调用即可得到模块对象\n# 这里我们自定义一个类,继承 ModuleType\nclass MyModule(ModuleType): def __init__(self, module_name): self.module_name = module_name super().__init__(module_name) # 也可以定义一些其它的属性 def __str__(self): return f\"\" my_module = MyModule(\"自定义模块\")\nprint(my_module)\n\"\"\"\n\n\"\"\" # 此时的 my_module 啥也没有,我们为其添砖加瓦\nmy_module.__dict__[\"name\"] = \"古明地觉\"\nprint(my_module.name) # 古明地觉 # 给模块设置属性,本质上也是操作模块的属性字典,当然获取属性也是如此\n# 如果再和 exec 结合的话\ncode_string = \"\"\"\nage = 16\ndef foo(): return \"我是函数 foo\" from functools import reduce \"\"\"\n# 将属性设置在模块的属性字典里面\nexec(code_string, my_module.__dict__)\n# 然后我们获取它\nprint(my_module.age) # 16\nprint(my_module.foo()) # 我是函数 foo\nprint(my_module.reduce(int.__add__, range(101))) # 5050 # 是不是很神奇呢?由于 my_module 是一个模块对象\n# 我们还可以将它注入到 sys.modules 中,然后通过 import 获取\nsys.modules[\"俺滴模块\"] = my_module\nfrom 俺滴模块 import name, age, foo\nprint(name) # 古明地觉\nprint(age) # 16\nprint(foo()) # 我是函数 foo 怎么样,是不是很有意思呢?相信你对名字空间已经有了足够清晰的认识,它是变量和与之绑定的对象的容身之所。","breadcrumbs":"55. 名字空间:变量的容身之所 » exec 和 eval","id":"248","title":"exec 和 eval"},"249":{"body":"名字空间是 Python 的灵魂,它规定了一个变量应该如何查找,关于变量查找,下一篇文章来详细介绍,到时你会对名字空间有更加透彻的理解。 然后是作用域,所谓名字空间其实就是作用域的动态体现。整个 py 文件是一个作用域,也是全局作用域;定义函数、定义类、定义方法,又会创建新的作用域,这些作用域层层嵌套。那么同理,运行时的名字空间也是层层嵌套的,形成一条名字空间链。内层的变量对外层是不可见的,但外层的变量对内层是可见的。 然后全局名字空间是一个字典,它是唯一的,操作里面的键值对等价于操作全局变量;至于局部名字空间则不唯一,每一个函数都有自己的局部名字空间,但我们要知道函数内部在访问局部变量的时候是静态访问的(相关细节后续聊)。 还有内置名字空间,可以通过 __builtins__ 获取,但拿到的是一个模块,再获取它的属性字典,那么就是内置名字空间了。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"55. 名字空间:变量的容身之所 » 小结","id":"249","title":"小结"},"25":{"body":"至此,我们算是从解释器的角度完全理清了 Python 中对象之间的关系,用之前的一张图总结一下。 当然,目前还远远没有结束,后续还会针对内置的对象进行专门的剖析,如浮点数、整数、字符串、字节串、元组、列表、字典、集合等等,都会一点一点剖析。我们会从 Python 的角度介绍对象该怎么用,然后再看它的底层实现,最后再用 Python 代码进行验证,加深理解。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"6. 通过 type 和 object 之间的关联,进一步分析类型对象 » 小结","id":"25","title":"小结"},"250":{"body":"上一篇文章我们介绍了名字空间,并且知道了全局变量都存在 global 名字空间中,往 global 空间添加一个键值对相当于定义一个全局变量。那么问题来了,如果往函数的 local 空间里面添加一个键值对,是不是也等价于创建了一个局部变量呢? def foo(): locals()[\"name\"] = \"古明地觉\" try: print(name) except Exception as e: print(e) foo() # name 'name' is not defined 全局变量的创建是通过向字典添加键值对实现的,因为全局变量会一直变,需要使用字典来动态维护。 但对于函数来讲,内部的变量是通过静态方式存储和访问的,因为局部作用域中存在哪些变量在编译的时候就已经确定了,我们通过 PyCodeObject 的 co_varnames 即可获取内部都有哪些变量。 所以,虽然我们说变量查找遵循 LGB 规则,但函数内部的变量其实是静态访问的,不过完全可以按照 LGB 的方式理解。关于这方面的细节,后续还会细说。 因此名字空间是 Python 的灵魂,它规定了变量的作用域,使得 Python 对变量的查找变得非常清晰。","breadcrumbs":"56. 当查找一个变量时,虚拟机会进行哪些动作? » 楔子","id":"250","title":"楔子"},"251":{"body":"LGB 是针对 Python2.2 之前的,而从 Python2.2 开始,由于引入了嵌套函数,所以内层函数在找不到某个变量时应该先去外层函数找,而不是直接就跑到 global 空间里面找,那么此时的规则就是 LEGB。 x = 1 def foo(): x = 2 def bar(): print(x) return bar foo()()\n\"\"\"\n2\n\"\"\" 调用了内层函数 bar,如果按照 LGB 的规则来查找的话,由于函数 bar 的作用域没有 a,那么应该到全局里面找,打印的结果是 1 才对。 但我们之前说了,作用域仅仅是由文本决定的,函数 bar 位于函数 foo 之内,所以函数 bar 定义的作用域内嵌于函数 foo 的作用域之内。换句话说,函数 foo 的作用域是函数 bar 的作用域的直接外围作用域。所以应该先从 foo 的作用域里面找,如果没有那么再去全局里面找,而作用域和名字空间是对应的,所以最终打印了 2。 另外在调用 foo() 的时候,会执行函数 foo 中的 def bar(): 语句,这个时候解释器会将 a = 2 与函数 bar 捆绑在一起,然后返回,这个捆绑起来的整体就叫做闭包。 所以:闭包 = 内层函数 + 引用的外层作用域。 而这里显示的规则就是 LEGB,其中 E 表示 Enclosing,代表直接外围作用域。","breadcrumbs":"56. 当查找一个变量时,虚拟机会进行哪些动作? » LEGB 规则","id":"251","title":"LEGB 规则"},"252":{"body":"在初学 Python 时,估计很多人都会对下面的问题感到困惑。 x = 1 def foo(): print(x) foo()\n\"\"\"\n1\n\"\"\" 首先这段代码打印 1,这显然是没有问题的,不过下面问题来了。 x = 1 def foo(): print(x) x = 2 foo() 这段代码在执行 print(x) 的时候是会报错的,会抛出一个 UnboundLocalError: local variable 'x' referenced before assignment,意思是局部变量 x 在赋值之前就被使用了。 那么问题来了,在 print(x) 的下面加一个 x = 2,整体效果不应该是先打印全局变量 x,然后再创建一个局部变量 x 吗?为啥就报错了呢,相信肯定有人为此困惑。如果想弄明白这个错误的原因,需要深刻理解两点: 函数中的变量是静态存储、静态访问的,内部有哪些变量在编译的时候就已经确定; 局部变量在整个作用域内都是可见的; 在编译的时候,因为 x = 2 这条语句,所以知道函数中存在一个局部变量 x,那么查找的时候就会在当前局部作用域中查找,但还没来得及赋值,就 print(x) 了。换句话说,在打印 x 的时候,它还没有和某个具体的值进行绑定,所以报错:局部变量 x 在赋值之前就被使用了。 但如果没有 x = 2 这条语句则不会报错,因为知道局部作用域中不存在 x 这个变量,所以会找全局变量 x,从而打印 1。 更有趣的东西隐藏在字节码当中,我们可以通过反汇编来查看一下: import dis x = 1 def foo(): print(x) dis.dis(foo)\n\"\"\" 6 0 LOAD_GLOBAL 0 (print) 2 LOAD_GLOBAL 1 (x) 4 CALL_FUNCTION 1 6 POP_TOP 8 LOAD_CONST 0 (None) 10 RETURN_VALUE\n\"\"\" def bar(): print(x) x = 2 dis.dis(bar) \"\"\" 19 0 LOAD_GLOBAL 0 (print) 2 LOAD_FAST 0 (x) 4 CALL_FUNCTION 1 6 POP_TOP 20 8 LOAD_CONST 1 (2) 10 STORE_FAST 0 (x) 12 LOAD_CONST 0 (None) 14 RETURN_VALUE\n\"\"\" 第二列的序号代表字节码指令的偏移量,我们看偏移量为 2 的指令,函数 foo 对应的指令是 LOAD_GLOBAL,意思是在 global 空间中查找 x。而函数 bar 的指令是 LOAD_FAST,表示在数组中静态查找 x,但遗憾的是,此时 x 还没有和某个值进行绑定。 因此结果说明 Python 采用了静态作用域策略,在编译的时候就已经知道变量藏身于何处。而且这个例子也表明,一旦函数内有了对某个变量的赋值操作,它会在整个作用域内可见,因为编译时就已经确定。换句话说,会遮蔽外层作用域中相同的名字。 我们看一下函数 foo 和函数 bar 的符号表。 x = 1 def foo(): print(x) def bar(): print(x) x = 2 print(foo.__code__.co_varnames) # ()\nprint(bar.__code__.co_varnames) # ('x',) 在编译的时候,就知道函数 bar 里面存在局部变量 x。 如果想修复这个错误,可以用之前说的 global 关键字,将变量 x 声明为全局的。 x = 1 def bar(): global x # 表示变量 x 是全局变量 print(x) x = 2 bar() # 1\nprint(x) # 2 但这样的话,会导致外部的全局变量被修改,如果不想出现这种情况,那么可以考虑直接获取全局名字空间。 x = 1 def bar(): print(globals()[\"x\"]) x = 2 bar() # 1\nprint(x) # 1 这样结果就没问题了,同样的,类似的问题也会出现在嵌套函数中。 def foo(): x = 1 def bar(): print(x) x = 2 return bar foo()() 执行内层函数 bar 的时候,print(x) 也会出现 UnboundLocalError,如果想让它不报错,而是打印外层函数中的 x,该怎么做呢?Python 同样为我们准备了一个关键字:nonlocal。 def foo(): x = 1 def bar(): # 使用 nonlocal 的时候,必须是在内层函数里面 nonlocal x print(x) x = 2 return bar foo()() # 1 如果 bar 里面是 global x,那么表示 x 是全局变量,当 foo()() 执行完毕之后,会创建一个全局变量 x = 2。但这里不是 global,而是 nonlocal,表示 x 是外部作用域中的变量,因此会打印 foo 里面的变量 x。 当然啦,既然声明为 nonlocal,那么 foo 里面的 x 肯定会受到影响。 import inspect frame = None def foo(): globals()[\"frame\"] = inspect.currentframe() x = 1 def bar(): nonlocal x # print(x) x = 2 return bar bar = foo()\n# 打印 foo 的局部变量,此时变量 x 的值为 1\nprint(frame.f_locals)\n\"\"\"\n{'bar': .bar at 0x7fbe3b8664c0>, 'x': 1}\n\"\"\"\n# 调用内层函数 bar\nbar()\n# 此时 foo 的局部变量 x 的值变成了 2\nprint(frame.f_locals)\n\"\"\"\n{'bar': .bar at 0x7fbe3b8664c0>, 'x': 2}\n\"\"\" 不过由于 foo 是一个函数,调用内层函数 bar 的时候,外层函数 foo 已经结束了,所以不管怎么修改它里面的变量,都无所谓了。 另外上面的函数只嵌套了两层,即使嵌套很多层也是可以的。 import inspect frame = None def a(): def b(): globals()[\"frame\"] = inspect.currentframe() x = 123 def c(): def d(): def e(): def f(): nonlocal x print(x) x = 456 return f return e return d return c return b b = a()\nc = b()\nd = c()\ne = d()\nf = e()\nprint(frame.f_locals)\n\"\"\"\n{'c': .b..c at 0x7fbe3b82d670>, 'x': 123}\n\"\"\"\n# 调用函数 f 的时候,打印的是函数 b 里面的变量 x\n# 当然,最后也会修改它\nf()\n\"\"\"\n123\n\"\"\"\n# 可以看到 x 变成了 456\nprint(frame.f_locals)\n\"\"\"\n{'c': .b..c at 0x7fbe3b82d670>, 'x': 456}\n\"\"\" 不难发现,在嵌套多层的情况下,会采用就近原则。如果函数 d 里面也定义了变量 x,那么函数 f 里面的 nonlocal x 表示的就是函数 d 里面的局部变量 x。","breadcrumbs":"56. 当查找一个变量时,虚拟机会进行哪些动作? » global 表达式","id":"252","title":"global 表达式"},"253":{"body":"当我们访问某个变量时,会按照 LEGB 的规则进行查找,而属性查找也是类似的,本质上都是到名字空间中查找一个名字所引用的对象。但由于属性查找限定了范围,所以要更简单,比如 a.xxx,就是到 a 里面去找属性 xxx,这个规则是不受 LEGB 作用域限制的,就是到 a 里面查找,有就是有,没有就是没有。 import numpy as np # 在 np 指向的对象(模块)中查找 array 属性\nprint(np.array([1, 2, 3]))\n\"\"\"\n[1 2 3]\n\"\"\"\n# 本质上就是去 np 的属性字典中查找 key = \"array\" 对应的 value\nprint(np.__dict__[\"array\"]([11, 22, 33]))\n\"\"\"\n[11 22 33]\n\"\"\" class Girl: name = \"古明地觉\" age = 16 print(Girl.name, Girl.age)\n\"\"\"\n古明地觉 16\n\"\"\"\nprint(Girl.__dict__[\"name\"], Girl.__dict__[\"age\"])\n\"\"\"\n古明地觉 16\n\"\"\" 需要补充一点,我们说属性查找会按照 LEGB 规则,但这必须限制在自身所在的模块内,如果是多个模块就不行了。举个例子,假设有两个 py 文件,内容如下: # girl.py\nprint(name) # main.py\nname = \"古明地觉\"\nfrom girl import name 关于模块的导入我们后续会详细说,总之执行 main.py 的时候报错了,提示变量 name 没有被定义,但问题是 main.py 里面定义了变量 name,为啥报错呢? 很明显,因为 girl.py 里面没有定义变量 name,所以导入 girl 的时候报错了。因此结论很清晰了,变量查找虽然是 LEGB 规则,但不会越过自身所在的模块。print(name) 在 girl.py 里面,而变量 name 定义在 main.py 里面,在导入时不可能跨过 girl.py 的作用域去访问 main.py 里的 name,因此在执行 from girl import name 的时候会抛出 NameError。 虽然每个模块内部的作用域规则有点复杂,因为要遵循 LEGB;但模块与模块的作用域之间则划分得很清晰,就是相互独立。 关于模块,我们后续会详细说。总之通过属性操作符 . 的方式,本质上都是去指定的名字空间中查找对应的属性。","breadcrumbs":"56. 当查找一个变量时,虚拟机会进行哪些动作? » 属性查找","id":"253","title":"属性查找"},"254":{"body":"自定义的类里面如果没有 __slots__,那么这个类的实例对象会有一个属性字典,和名字空间的概念是等价的。 class Girl: def __init__(self): self.name = \"古明地觉\" self.age = 16 g = Girl()\nprint(g.__dict__) # {'name': '古明地觉', 'age': 16} # 对于查找属性而言, 也是去属性字典中查找\nprint(g.name, g.__dict__[\"name\"]) # 古明地觉 古明地觉 # 同理设置属性, 也是更改对应的属性字典\ng.__dict__[\"gender\"] = \"female\"\nprint(g.gender) # female 当然模块也有属性字典,本质上和类的实例对象是一致的,因为模块本身就是一个实例对象。 print(__builtins__.str) # \nprint(__builtins__.__dict__[\"str\"]) # 另外这个 __builtins__ 位于 global 名字空间里面,然后获取 global 名字空间的 globals 又是一个内置函数,于是一个神奇的事情就出现了。 print(globals()[\"__builtins__\"].globals()[\"__builtins__\"]. globals()[\"__builtins__\"].globals()[\"__builtins__\"]. globals()[\"__builtins__\"].globals()[\"__builtins__\"] ) # print(globals()[\"__builtins__\"].globals()[\"__builtins__\"]. globals()[\"__builtins__\"].globals()[\"__builtins__\"]. globals()[\"__builtins__\"].globals()[\"__builtins__\"].list(\"abc\") ) # ['a', 'b', 'c'] global 名字空间和 builtin 名字空间,都保存了指向彼此的指针,所以不管套娃多少次,都是可以的。","breadcrumbs":"56. 当查找一个变量时,虚拟机会进行哪些动作? » 属性空间","id":"254","title":"属性空间"},"255":{"body":"整个内容很好理解,关键的地方就在于局部变量,它是静态存储的,编译期间就已经确定。而在访问局部变量时,也是基于数组实现的静态查找,而不是使用字典。 关于 local 空间,以及如何使用数组实现静态查找,我们后面还会详细说。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"56. 当查找一个变量时,虚拟机会进行哪些动作? » 小结","id":"255","title":"小结"},"256":{"body":"当解释器启动后,首先会进行运行时环境的初始化,注意这里的运行时环境,它和之前说的执行环境有很大不同,运行时环境是一个全局的概念,而执行环境是一个栈帧。 关于运行时环境的初始化是一个很复杂的过程,涉及到 Python 进程、线程的创建,类型对象的完善等非常多的内容,我们暂时先不讨论。这里就假设初始化动作已经完成,我们已经站在了虚拟机的门槛外面,只需要轻轻推动第一张骨牌,整个执行过程就像多米诺骨牌一样,一环扣一环地展开。 在介绍字节码的时候我们说过,解释器可以看成是:编译器+虚拟机,编译器负责将源代码编译成 PyCodeObject 对象,而虚拟机则负责执行。所以我们的重点就是虚拟机是怎么执行 PyCodeObject 对象的?整个过程是什么,掌握了这些,你对虚拟机会有一个更深的理解。","breadcrumbs":"57. 虚拟机是怎么执行字节码的?背后都经历了哪些过程 » 楔子","id":"256","title":"楔子"},"257":{"body":"在介绍栈帧的时候我们说过,Python 是一门动态语言,一个变量指向什么对象需要在运行时才能确定,这些信息不可能静态存储在 PyCodeObject 对象中。所以虚拟机在运行时会基于 PyCodeObject 对象动态创建出栈帧对象,然后在栈帧里面执行字节码。而创建栈帧,主要使用以下两个函数: // Python/ceval.c /* 基于 PyCodeObject、全局名字空间、局部名字空间,创建栈帧 * 参数非常简单,所以它一般适用于模块这种参数不复杂的场景 * 前面说了,模块也会对应一个栈帧,并且它位于栈帧链的最顶层 */\nPyObject *\nPyEval_EvalCode(PyObject *co, PyObject *globals, PyObject *locals)\n{ return PyEval_EvalCodeEx(co, globals, locals, (PyObject **)NULL, 0, (PyObject **)NULL, 0, (PyObject **)NULL, 0, NULL, NULL);\n} /* 相比 PyEval_EvalCode 多了很多的参数 * 比如里面有位置参数以及个数,关键字参数以及个数 * 还有默认参数以及个数,闭包等等,显然它用于函数等复杂场景 */\nPyObject *\nPyEval_EvalCodeEx(PyObject *_co, PyObject *globals, PyObject *locals, PyObject *const *args, int argcount, PyObject *const *kws, int kwcount, PyObject *const *defs, int defcount, PyObject *kwdefs, PyObject *closure)\n{ return _PyEval_EvalCodeWithName(_co, globals, locals, args, argcount, kws, kws != NULL ? kws + 1 : NULL, kwcount, 2, defs, defcount, kwdefs, closure, NULL, NULL);\n} 我们看到 PyEval_EvalCode 也是调用了 PyEval_EvalCodeEx,后者是通用逻辑,只不过为模块创建栈帧时,参数非常简单,所以又封装了 PyEval_EvalCode 函数。 当然啦,上面这两个函数最终都会调用 _PyEval_EvalCodeWithName 函数,创建并初始化栈帧对象,我们来看一下该函数内部的逻辑。 // Python/ceval.c PyObject *\n_PyEval_EvalCodeWithName(PyObject *_co, PyObject *globals, PyObject *locals, PyObject *const *args, Py_ssize_t argcount, PyObject *const *kwnames, PyObject *const *kwargs, Py_ssize_t kwcount, int kwstep, PyObject *const *defs, Py_ssize_t defcount, PyObject *kwdefs, PyObject *closure, PyObject *name, PyObject *qualname)\n{ PyCodeObject* co = (PyCodeObject*)_co; PyFrameObject *f; PyObject *retval = NULL; PyObject **fastlocals, **freevars; // ... // 调用 _PyFrame_New_NoTrack 函数创建栈帧 f = _PyFrame_New_NoTrack(tstate, co, globals, locals); if (f == NULL) { return NULL; } fastlocals = f->f_localsplus; freevars = f->f_localsplus + co->co_nlocals; // ... // 调用 PyEval_EvalFrameEx 在栈帧中执行字节码 retval = PyEval_EvalFrameEx(f,0); fail: assert(tstate != NULL); if (Py_REFCNT(f) > 1) { Py_DECREF(f); _PyObject_GC_TRACK(f); } else { ++tstate->recursion_depth; Py_DECREF(f); --tstate->recursion_depth; } return retval;\n} 这个函数的逻辑比较长,但做的事情很简单。 调用 _PyFrame_New_NoTrack 函数创建栈帧,并初始化内部字段。 栈帧创建完毕之后,里面的字段都是初始值,所以还要基于当前的 PyCodeObject 对象、位置参数、关键字参数、参数个数等信息修改栈帧字段,而省略掉的大部分代码就是在负责相关逻辑。以上这两步组合起来,就是我们之前说的基于 PyCodeObject 对象构建栈帧对象。 栈帧字段设置完毕之后,调用 PyEval_EvalFrameEx 函数,在栈帧中执行字节码。 当然,PyEval_EvalFrameEx 也不是整个流程的终点,它内部还调用了一个函数。 // Python/ceval.c PyObject *\nPyEval_EvalFrameEx(PyFrameObject *f, int throwflag)\n{ // interp 表示进程状态对象,它的 eval_frame 字段被设置为 _PyEval_EvalFrameDefault // 这个 _PyEval_EvalFrameDefault 函数便是虚拟机运行的核心,是一个代码量超级多的函数 PyInterpreterState *interp = _PyInterpreterState_GET_UNSAFE(); return interp->eval_frame(f, throwflag);\n} 所以整个流程很清晰了,我们画一张图。 所以 _PyEval_EvalFrameDefault 函数是虚拟机运行的核心,该函数较为复杂,我们会在下一篇文章中分析它的具体实现。至于本篇文章就先从宏观的角度来描述一下虚拟机执行字节码的流程,并对之前的内容做一个补充,将背后涉及的概念阐述一遍,这样后续看源码的时候也会事半功倍。 首先栈帧中有一个 f_code 字段,它指向 PyCodeObject 对象,该对象的 co_code 字段则保存着字节码指令序列。而虚拟机执行字节码就是从头到尾遍历整个 co_code,对指令逐条执行的过程。 另外也不要觉得字节码指令(简称指令)有多神秘,说白了它就是个 uint8 整数,而一个程序肯定会包含多条指令,它们整体便构成了指令集,或者说指令序列。那么显然,使用 bytes 对象来表示指令序列再合适不过了,如果站在 C 的角度,则就是一个普普通通的字符数组,一条指令就是一个字符、或者说一个整数。 当然指令序列里面包含的不仅仅是指令,还有指令参数,因为每个指令都会带一个参数。因此索引为 0 2 4 6 8 ··· 的整数表示指令,索引为 1 3 5 7 9 ··· 的整数表示指令参数。 我们用 Python 来演示一下: code_string = \"\"\"\na = 1\nb = 2\nc = a + b\n\"\"\" code_object = compile(code_string, \"\", \"exec\")\n# 查看常量池\nprint(code_object.co_consts)\n\"\"\"\n(1, 2, None)\n\"\"\"\n# 查看符号表\nprint(code_object.co_names)\n\"\"\"\n('a', 'b', 'c')\n\"\"\" 这些都比较简单,再来看一下反编译的结果,直接 dis.dis(code_object) 即可。 /* 常量池:(1, 2, None) * 符号表:('a', 'b', 'c') */ // 第一列表示源代码行号\n// 第二列表示指令的偏移量\n// 第三列表示指令,在 C 中它们都是宏,对应一个整数\n// 第四列表示指令参数\n// 第五列是 dis 模块为了方便我们阅读而补充的提示信息 // 指令:LOAD_CONST,指令参数:0\n// 表示从常量池中加载索引为 0 的常量,并压入运行时栈(关于运行时栈,一会儿详细说明)\n// 索引为 0 的常量显然是 1,而括号里面的提示信息显示的也是 1\n2 0 LOAD_CONST 0 (1)\n// 指令:STORE_NAME,指令参数:0\n// 表示从符号表中加载索引为 0 的符号,显然结果是 \"a\"\n// 然后弹出运行时栈的栈顶元素,显然是上一条指令压入的 1\n// 将 \"a\" 和 1 组成键值对,存储在当前的名字空间中\n// 到此 a = 1 这条语句便完成了,或者说完成了变量和值的绑定 2 STORE_NAME 0 (a) // 从常量池中加载索引为 1 的常量(结果是 2),并压入运行时栈 3 4 LOAD_CONST 1 (2)\n// 从符号表中加载索引为 1 的符号(结果是 \"b\")\n// 然后从栈顶弹出元素 2,将 \"b\" 和 2 绑定起来 6 STORE_NAME 1 (b) // 加载符号表中索引为 0 的符号对应的值,并压入运行时栈 4 8 LOAD_NAME 0 (a)\n// 加载符号表中索引为 1 的符号对应的值,并压入运行时栈 10 LOAD_NAME 1 (b)\n// 将运行时栈的两个元素弹出,并执行加法运算\n// 运算之后,再将结果 a + b 压入运行时栈 12 BINARY_ADD\n// 从符号表中加载索引为 2 的符号,结果是 \"c\" // 将运行时栈的栈顶元素弹出,这里是 a + b 的运算结果\n// 然后进行绑定,完成 c = a + b 这条赋值语句 14 STORE_NAME 2 (c)\n// 从常量池中加载索引为 2 的元素并返回,有一个隐式的 return None 16 LOAD_CONST 2 (None) 18 RETURN_VALUE 这些指令的源码实现后续都会说,但是不难发现,程序的主干逻辑都体现在字节码中,而依赖的信息则由其它字段来维护。所谓执行源代码,其实就是虚拟机执行编译之后的字节码,通过遍历 co_code,对不同的指令执行不同的逻辑。 然后我们基于上面这些输出信息,看看能否将字节码指令集还原出来,当然在还原之前首先要知道这些指令代表的数值是多少。 下面我们来进行还原。 \"\"\" 0 LOAD_CONST 0 (1) 2 STORE_NAME 0 (a) 4 LOAD_CONST 1 (2) 6 STORE_NAME 1 (b) 8 LOAD_NAME 0 (a)\n10 LOAD_NAME 1 (b)\n12 BINARY_ADD\n14 STORE_NAME 2 (c)\n16 LOAD_CONST 2 (None)\n18 RETURN_VALUE\n\"\"\"\nBINARY_ADD = 23\nRETURN_VALUE = 83\nSTORE_NAME = 90\nLOAD_CONST = 100\nLOAD_NAME = 101 codes = [ # a = 1 LOAD_CONST, 0, STORE_NAME, 0, # b = 2 LOAD_CONST, 1, STORE_NAME, 1, # c = a + b LOAD_NAME, 0, # 加载 a LOAD_NAME, 1, # 加载 b BINARY_ADD, 0, # 计算 a + b STORE_NAME, 2, # 和变量 c 绑定 # 所有代码块都隐式地包含了一个 return None LOAD_NAME, 2, RETURN_VALUE, 0\n]\nprint(bytes(codes))\n\"\"\"\nb'd\\x00Z\\x00d\\x01Z\\x01e\\x00e\\x01\\x17\\x00Z\\x02e\\x02S\\x00'\n\"\"\" 那么字节码是不是我们还原的这个样子呢?来对比一下。 >>> code_object.co_code\nb'd\\x00Z\\x00d\\x01Z\\x01e\\x00e\\x01\\x17\\x00Z\\x02d\\x02S\\x00' 结果是一样的,到此相信你对 Python 源代码的执行过程应该有更深的了解了,简单来讲,其实就是以下几个步骤。 1)源代码被编译成 PyCodeObject 对象,该对象的 co_code 字段指向字节码指令序列,它包含了程序执行的主干逻辑,剩余字段则保存常量池、符号表等其它静态信息。 2)虚拟机在 PyCodeObject 对象的基础上构建栈桢对象。 3)虚拟机在栈桢对象内部执行字节码(帧评估),具体流程就是遍历指令集和,根据不同指令执行不同的处理逻辑,而这一过程便由 _PyEval_EvalFrameDefault 函数负责完成。","breadcrumbs":"57. 虚拟机是怎么执行字节码的?背后都经历了哪些过程 » 虚拟机的运行框架","id":"257","title":"虚拟机的运行框架"},"258":{"body":"之前一直提到一个概念,叫运行时栈,那什么是运行时栈呢?别急,我们先来回顾一下栈桢的基本结构。 大部分字段都很好理解,因为之前通过 Python 代码演示过。但有几个字段是虚拟机用于执行指令的,后续会遇到,所以这里再拿出来解释一下。 f_lasti 上一条刚执行完的字节码指令的偏移量,因为每个指令要带一个参数,所以当虚拟机要执行偏移量为 n 的指令时,那么 f_lasti 就是 n - 2。当然,如果字节码还没有开始执行,那么 f_lasti 为 -1。 f_localsplus 一个柔性数组,它的内存大小被分为 4 个部分。 注:f_localsplus 是一个数组,所以它是一段连续的内存,只不过按照用途被分成了 4 个部分。如果用新一团团长丁伟的说法:每个部分之间是鸡犬相闻,但又老死不相往来。 然后再着重强调一下运行时栈,虚拟机在执行字节码指令时高度依赖它,因为一个指令只能带一个参数,那么剩余的参数就必须通过运行时栈给出。比如 a = 1 会对应两条字节码:LOAD_CONST 和 STORE_NAME。 STORE_NAME 的作用是从符号表中获取符号,或者说变量名,然后和值绑定起来。而要加载符号,那么必须要知道它在符号表中的索引,显然这可以通过指令参数给出,但问题是与之绑定的值怎么获取?毫无疑问,要通过运行时栈。因此 LOAD_CONST 将值读取进来之后,还要压入运行时栈,然后 STORE_NAME 会将值从运行时栈中弹出,从而完成符号(变量)和值的绑定。 关于运行时栈,我们再看个复杂的例子: 偏移量为 6 的指令表示要构建一个字典,指令参数 2 表示构建的字典的长度为 2,但问题是字典的键值对在什么地方?显然它们已经被提前压入了运行时栈,执行 BUILD_CONST_KEY_MAP 的时候直接弹出即可。 所以这就是运行时栈的作用,如果某个指令需要 n 个参数,那么其中的 n - 1 个必须要提前压入运行时栈,然后在该指令执行的时候依次弹出,因为一个指令只能带一个参数。 f_stacktop 一个指针,指向运行时栈的栈顶。由于运行时栈存储的都是 PyObject *,所以 f_stacktop 的类型是 PyObject **。当然在源码中没有直接操作 f_stacktop 字段,而是定义了一个变量 stack_pointer,它初始等于 f_stacktop。后续操作的都是 stack_pointer,当然操作完之后,还要重新赋值给 f_stacktop。 所以 stack_pointer 始终指向运行时栈的栈顶,而元素的入栈和出栈,显然都是通过操作 stack_pointer 完成的。 执行 *stack_pointer++ = v,一个元素就入栈了。 执行 v = *--stack_pointer,一个元素就出栈了。 而随着元素的入栈和出栈,运行时栈的栈顶、或者说 stack_pointer 也在不断变化,但无论如何,stack_pointer 始终指向运行时栈的栈顶。当然啦,由于栈顶发生变化,后续还要对 f_stacktop 进行更新。 f_valuestack 一个指针,指向运行时栈的栈底。 另外我们说 f_localsplus 数组被分成了 4 份,最后一份给了运行时栈,因此虽然我们称之为栈,但它其实就是一个数组,而且还是数组的一部分。 而对于数组而言,内存地址从左往右是增大的。 这是 f_localsplus 的示意图,灰色区域表示运行时栈之前的部分,这里我们只看运行时栈,目前栈里面有两个元素,stack_pointer 指向栈顶。 这时如果要添加一个元素 3,那么直接 *stack_pointer++ = 3 即可。 如果要将栈顶元素弹出,那么执行 v = *--stack_pointer 即可。 还是比较清晰的,不过还没结束,我们还要继续探讨运行时栈。","breadcrumbs":"57. 虚拟机是怎么执行字节码的?背后都经历了哪些过程 » 什么是运行时栈","id":"258","title":"什么是运行时栈"},"259":{"body":"相信你已经知道什么是运行时栈了,说白了它就是参数的容身之所,比如虚拟机在执行 a + b 的时候,通过指令和指令参数可以判断这是一个加法操作,但在执行加法的时候,加号两边的值要怎么获取呢?这时候就需要一个栈来专门保存相应的参数。在执行加法之前,先将 a 和 b 压入栈中,等执行加法的时候,再将 a 和 b 从栈里面弹出来即可,非常简单。 然后再来看看和运行时栈相关的一些宏,并加深对运行时栈的理解。 // Python/ceval.c #define STACK_LEVEL() ((int)(stack_pointer - f->f_valuestack))\n#define EMPTY() (STACK_LEVEL() == 0)\n#define TOP() (stack_pointer[-1])\n#define SECOND() (stack_pointer[-2])\n#define THIRD() (stack_pointer[-3])\n#define FOURTH() (stack_pointer[-4])\n#define PEEK(n) (stack_pointer[-(n)])\n#define SET_TOP(v) (stack_pointer[-1] = (v))\n#define SET_SECOND(v) (stack_pointer[-2] = (v))\n#define SET_THIRD(v) (stack_pointer[-3] = (v))\n#define SET_FOURTH(v) (stack_pointer[-4] = (v))\n#define SET_VALUE(n, v) (stack_pointer[-(n)] = (v))\n#define BASIC_STACKADJ(n) (stack_pointer += n)\n#define BASIC_PUSH(v) (*stack_pointer++ = (v))\n#define BASIC_POP() (*--stack_pointer) #define PUSH(v) BASIC_PUSH(v)\n#define POP() BASIC_POP()\n#define STACK_GROW(n) BASIC_STACKADJ(n)\n#define STACK_SHRINK(n) BASIC_STACKADJ(-n)\n#define EXT_POP(STACK_POINTER) (*--(STACK_POINTER)) 宏还是比较多的,我们来逐一介绍。假设目前运行时栈内部有三个元素,从栈底到栈顶分别是整数 1、2、3,那么运行时栈的结构就是下面这样。 f_localsplus 数组被分成 4 个区域,运行时栈占据最后一个,因此图中的灰色区域便是运行时栈之前的内存。由于我们是研究运行时栈,所以这部分区域后续就不再画了。 然后看一下这些和运行时栈相关的宏都是干嘛的。 STACK_LEVEL() 返回运行时栈的元素个数,显然直接让栈顶指针和栈底指针相减就完事了。 #define STACK_LEVEL() ((int)(stack_pointer - f->f_valuestack)) 所以 STACK_LEVEL() 是会动态变化的,因为 stack_pointer 会动态变化。 记得之前在介绍 PyCodeObject 时,我们说它内部的 co_stacksize 字段表示执行代码块所需要的栈空间,而这个栈空间就是运行时栈的长度。当然也可以理解为要想执行这段代码块,后续创建栈桢时,应该给 f_localsplus 数组中表示运行时栈的区域分配能存储多少个元素的内存。 比如 co_stacksize 是 1,那么表示应该给运行时栈分配能存储 1 个元素的内存,即运行时栈的长度为 1。 STACK_LEVEL() 表示当前运行时栈已存储的元素数量,而 co_stacksize 表示运行时栈的长度,即最多能存储多少个元素。 我们通过反编译的方式,实际演示一下。 import dis def some_func(): a = 1 b = 2 c = 3 # 这里只保留字节码指令\ndis.dis(some_func)\n\"\"\"\nLOAD_CONST # 将元素压入运行时栈,栈里的元素个数为 1\nSTORE_FAST # 将元素从栈顶弹出,栈里的元素个数为 0\nLOAD_CONST # 将元素压入运行时栈,栈里的元素个数为 1\nSTORE_FAST # 将元素从栈顶弹出,栈里的元素个数为 0 LOAD_CONST # 将元素压入运行时栈,栈里的元素个数为 1 STORE_FAST # 将元素从栈顶弹出,栈里的元素个数为 0 LOAD_CONST # 将元素压入运行时栈,栈里的元素个数为 1\nRETURN_VALUE # 将元素从栈顶弹出,栈里的元素个数为 0\n\"\"\" # 也就是说,运行时栈只要能容纳一个元素,即可执行这段代码\nprint(some_func.__code__.co_stacksize) # 1 我们再来看个例子。 import dis def some_func(): a = 1 b = 2 c = 3 lst = [a, b, c] dis.dis(some_func)\n\"\"\"\nLOAD_CONST # 将元素压入运行时栈,栈里的元素个数为 1 STORE_FAST # 将元素从栈顶弹出,栈里的元素个数为 0 LOAD_CONST # 将元素压入运行时栈,栈里的元素个数为 1 STORE_FAST # 将元素从栈顶弹出,栈里的元素个数为 0 LOAD_CONST # 将元素压入运行时栈,栈里的元素个数为 1 STORE_FAST # 将元素从栈顶弹出,栈里的元素个数为 0 LOAD_FAST # 将元素压入运行时栈,栈里的元素个数为 1\nLOAD_FAST # 将元素压入运行时栈,栈里的元素个数为 2 LOAD_FAST # 将元素压入运行时栈,栈里的元素个数为 3\nBUILD_LIST # 将栈里的三个元素弹出,构建列表并入栈(此时元素个数为 1)\nSTORE_FAST # 将元素从栈顶弹出,栈里的元素个数为 0 LOAD_CONST # 将元素压入运行时栈,栈里的元素个数为 1\nRETURN_VALUE # 将元素从栈顶弹出,栈里的元素个数为 0\n\"\"\" # 不难看出,要想执行这段代码,运行时栈要能容纳 3 个元素\nprint(some_func.__code__.co_stacksize) # 3 相信你现在应该理解 co_stacksize 的作用了,它表示运行时栈最多能容纳多少个元素,也就是运行时栈的长度。以上面代码为例,由于最多会压入 3 个元素,所以运行时栈的长度就是 3,即最多能容纳 3 个元素。并且这个长度在编译之后就已经确定了,因为可以通过遍历指令集静态计算出来。 我们画一张图描述一下上面的代码在执行时,运行时栈的变化过程。 整个过程应该很清晰,当然上面只是运行时栈的变化,f_localsplus 中存储局部变量的内存区域也在变化。另外如果代码块位于全局作用域,那么变化的就是全局名字空间,相关细节后续详细展开。 EMPTY() #define EMPTY() (STACK_LEVEL() == 0) 判断运行时栈是否为空,显然只需判断运行时栈的元素个数是否为 0 即可。 TOP() #define TOP() (stack_pointer[-1]) 查看当前运行时栈的栈顶元素。 SECOND() #define SECOND() (stack_pointer[-2]) 查看从栈顶元素开始的第二个元素。 THIRD() #define THIRD() (stack_pointer[-3]) 查看从栈顶元素开始的第三个元素。 FOURTH() #define FOURTH() (stack_pointer[-4]) 查看从栈顶元素开始的第四个元素。 PEEK(n) #define PEEK(n) (stack_pointer[-(n)]) 查看从栈顶元素开始的第 n 个元素。 SET_TOP(v) #define SET_TOP(v) (stack_pointer[-1] = (v)) 将当前运行时栈的栈顶元素设置成 v。 SET_SECOND(v) #define SET_SECOND(v) (stack_pointer[-2] = (v)) 将从栈顶元素开始的第二个元素设置成 v。 SET_THIRD(v) #define SET_THIRD(v) (stack_pointer[-3] = (v)) 将从栈顶元素开始的第三个元素设置成 v。 SET_FOURTH(v) #define SET_FOURTH(v) (stack_pointer[-4] = (v)) 将从栈顶元素开始的第四个元素设置成 v。 SET_VALUE(n, v) #define SET_VALUE(n, v) (stack_pointer[-(n)] = (v)) 将从栈顶元素开始的第 n 个元素设置成 v。 PUSH(v) #define PUSH(v) BASIC_PUSH(v)\n#define BASIC_PUSH(v) (*stack_pointer++ = (v)) 往运行时栈中压入一个元素,并且压入之后,栈中已存储的元素个数一定不超过 co_stacksize。假设当前栈里有一个元素 1,然后添加一个元素 2。 Python 的变量都是一个指针,所以 stack_pointer 是一个二级指针,它永远指向栈顶位置,只不过栈顶位置会变。另外要注意:运行时栈的内存一开始就申请好了,初始状态下,里面的元素全部为 NULL。而往栈里面压入一个元素,其实就是修改 stack_pointer 指向的内存单元,然后执行 stack_pointer++。 POP() #define POP() BASIC_POP()\n#define BASIC_POP() (*--stack_pointer) 弹出栈顶元素,注意它和 TOP 的区别,TOP 是返回栈顶元素,但不弹出。 stack_pointer 指向栈顶位置,所以它向栈底移动一个位置,就相当于元素被弹出了。 关于弹出元素需要做一个说明,所谓弹出元素本质上就是将 stack_pointer 向栈底移动一个位置。我们看一下上图,一开始栈里面有两个元素,分别是整数 1 和整数 2,当然准确来说应该是指向它们的指针,但为了描述方便,我们就用对象本身代替了。 然后执行 POP(),将整数 2 弹出,但我们发现 POP() 之后,整数 2 还在栈里面。关于这一点很好理解,因为 stack_pointer 始终指向栈顶位置,而它向栈底移动了一个位置,那么整数 2 就已经不是栈顶元素了。当下一个元素入栈时,会把整数 2 替换掉。因此虽然整数 2 还在运行时栈里面,但和不在没有任何区别,此时我们依旧认为整数 2 被弹出了。 不过在后续的文章中,在画运行时栈的时候,我们也会这么画。 为了阅读清晰,stack_pointer 之后的元素就不写了。另外还要注意一点,运行时栈的内存一开始就已经申请好了,是固定的,弹出元素只是改变栈顶指针 stack_pointer 的指向,而内存区域的大小是不变的。 当然这些内容都比较简单,但为了避免出现歧义,这里单独解释一下。 STACK_GROW(n) #define STACK_GROW(n) BASIC_STACKADJ(n)\n#define BASIC_STACKADJ(n) (stack_pointer += n) 改变运行时栈的栈顶,注:运行时栈的大小是固定的,但栈顶是由 stack_pointer 决定的。 那么问题来了,假设要往运行时栈压入两个元素,分别是 2、3,该怎么做呢?首先肯定可以通过 PUSH 实现。 PUSH(2);\nPUSH(3); 但如果不让你用 PUSH,该怎么做呢? STACK_GROW(2);\n// 设置元素\nSET_VALUE(1, 3); // stack_pointer[-1] = 3\nSET_VALUE(2, 2); // stack_pointer[-2] = 2 两种做法都是可以的。 STACK_SHRINK(n) #define STACK_SHRINK(n) BASIC_STACKADJ(-n)\n#define BASIC_STACKADJ(n) (stack_pointer += n) 它的作用和 STACK_GROWN 正好相反。 注意:STACK_SHRINK(3) 之后,stack_pointer 和 f_valuestack 都指向了运行时栈的栈底,同时也是栈顶。还是之前说的,栈空间是固定的,但栈顶会随着元素的入栈和出栈而动态变化。 另外,对于当前示例来说,如果你不关注栈里的元素的话,那么执行 STACK_SHRINK(3) 和执行三次 POP() 是等价的。当然不管是哪种情况,最终栈里的元素都还是 1、2、3,因为弹出元素只是改变栈顶指针 stack_pointer 的指向,而不会修改栈里的元素。当然啦,既然栈顶是由 stack_pointer 决定的,而它目前指向了栈底位置,所以我们可以认为此时栈是空的。 这么画似乎更清晰一些,但我们要知道这背后的整个过程。另外还要再次强调,运行时栈只是数组 f_localsplus 的一部分,并且是最后一部分,所以它的内存空间事先就申请好了,里面的每个元素都是 NULL。所谓添加元素,就是修改 stack_pointer 指针指向的内存,然后 stack_pointer++。所谓弹出元素,就是 stack_pointer--,然后返回 stack_pointer 指向的内存。 以上就是运行时栈的一些宏,后续阅读源码的时候,会经常遇到。","breadcrumbs":"57. 虚拟机是怎么执行字节码的?背后都经历了哪些过程 » 运行时栈的一些宏","id":"259","title":"运行时栈的一些宏"},"26":{"body":"本篇文章来聊一聊对象的创建,一个对象是如何从无到有产生的呢? >>> n = 123\n>>> n\n123 比如在终端中执行 n = 123,一个整数对象就创建好了,但它的背后都发生了什么呢?带着这些疑问,开始今天的内容。","breadcrumbs":"7. 当创建一个 Python 对象时,背后都经历了哪些过程? » 楔子","id":"26","title":"楔子"},"260":{"body":"本篇文章我们就从宏观的角度介绍了虚拟机执行字节码的流程,说白了虚拟机就是把自己当成一个 CPU,在栈桢中执行指令。通过遍历字节码指令集,对不同的指令执行不同的处理逻辑。 然后是运行时栈,因为一个指令只能带一个参数,那么剩余的参数就需要通过运行时栈给出。比如 LOAD_CONST 指令,它在加载完常量之后,会将常量压入运行时栈,然后 STORE_NAME 或 STORE_FAST 指令再将常量从运行时栈的顶部弹出,并和某个符号(变量)绑定起来。 关于这些指令,我们后面会详细说。 最后我们介绍了运行时栈的一些宏,因为执行指令的时候会反复操作运行时栈,所以底层封装了很多的宏。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"57. 虚拟机是怎么执行字节码的?背后都经历了哪些过程 » 小结","id":"260","title":"小结"},"261":{"body":"上一篇文章我们介绍了虚拟机是怎么执行字节码指令的,并且还介绍了运行时栈,以及操作运行时栈的一些宏。相信你对字节码执行的整个流程应该有了清晰的认识,那么接下来我们就深入到源码中,进一步考察执行过程。","breadcrumbs":"58. 深入源码,进一步考察字节码的执行流程 » 楔子","id":"261","title":"楔子"},"262":{"body":"之前说了,虚拟机就是把自己当成一个 CPU,在栈帧中执行字节码,面对不同的字节码指令,执行不同的处理逻辑。 具体实现由 Python/ceval.c 中的 _PyEval_EvalFrameDefault 函数负责,该函数超级长,并且里面还包含了大量的宏,这些宏完全可以定义在其它的文件中。像我们之前介绍的操作运行时栈的宏,也定义在 _PyEval_EvalFrameDefault 函数里面了。所以为了方便大家理解,我决定先介绍里面出现的宏,等宏说完了之后再看具体的逻辑。 #ifdef HAVE_COMPUTED_GOTOS #ifndef USE_COMPUTED_GOTOS #define USE_COMPUTED_GOTOS 1 #endif\n#else #if defined(USE_COMPUTED_GOTOS) && USE_COMPUTED_GOTOS #error \"Computed gotos are not supported on this compiler.\" #endif #undef USE_COMPUTED_GOTOS #define USE_COMPUTED_GOTOS 0\n#endif // 如果使用 \"计算跳转\",导入静态跳转表\n#if USE_COMPUTED_GOTOS\n/* Import the static jump table */\n#include \"opcode_targets.h\" 里面出现了一个关键词:计算跳转,这是什么意思呢? 首先 _PyEval_EvalFrameDefault(后续简称为帧评估函数)的代码量虽然很大,但它的核心不难理解,就是循环遍历字节码指令集,处理每一条指令。而当一条指令执行完毕时,虚拟机会有以下三种动作之一: 停止循环、退出帧评估函数,当执行的指令为 RETURN_VALUE、YIELD_VALUE 等。 执行指令的过程中出错了,比如执行 GET_ITER 指令,但对象不具备可迭代的性质。那么要进行异常处理(或者直接抛出异常),然后退出帧评估函数。 执行下一条指令。 前面两种动作没啥好说的,关键是第三种,如何执行下一条指令。首先虚拟机内部有一个巨型的 switch 语句,伪代码如下: int opcode;\nint oparg; for (;;) { // 循环遍历指令集,获取指令和指令参数 opcode = ...; // 指令 oparg = ...; // 指令参数 // 执行对应的处理逻辑 switch (opcode) { case LOAD_CONST: 处理逻辑; case LOAD_FAST: 处理逻辑; case LOAD_FAST: 处理逻辑; case BUILD_LIST: 处理逻辑; case DICT_UPDATE: 处理逻辑; // ... }\n} 一个 case 分支,对应一个字节码指令的实现,由于指令非常多,所以这个 switch 语句也非常庞大。然后遍历出的指令,会进入这个 switch 语句进行匹配,执行相应的处理逻辑。所以循环遍历 co_code 得到字节码指令,然后交给内部的 switch 语句、执行匹配到的 case 分支,如此周而复始,最终完成了对整个 Python 程序的执行。 其实到这里,你应该已经了解了帧评估函数的整体结构。说白了就是将自己当成一个 CPU,在栈帧中执行一条条指令,而执行过程中所依赖的常量、变量等,则由栈帧的其它字段来维护。因此在虚拟机的执行流程进入了那个巨大的 for 循环,并取出第一条字节码指令交给里面的 switch 语句之后,第一张多米诺骨牌就已经被推倒,命运不可阻挡的降临了。一条接一条的指令如同潮水般涌来,浩浩荡荡,横无际涯。 虽然在概念上很好理解,但很多细节被忽略掉了,本篇文章就将它们深挖出来。还是之前的问题,当一个指令执行完毕时,怎么执行下一条指令。 估计有人对这个问题感到奇怪,在 case 分支的内部加一行 continue 进行下一轮循环不就行了吗?没错,这种做法是行得通的,但存在性能问题。因为 continue 会跳转到 for 循环所在位置,所以遍历出下一条指令之后,会再次进入 switch 语句进行匹配。尽管逻辑上是正确的,但 switch 里面有数百个 case 分支,如果每来一个指令,都要顺序匹配一遍的话,那么效率必然不高。 而事实上整个字节码指令集是已知的,所以不管执行哪个指令,我们都可以提前得知它的下一个指令,只需将指针向后偏移两个字节即可。 那么问题来了,既然知道下一条要执行的指令是什么,那么在当前指令执行完毕时,可不可以直接跳转到下一条指令对应的 case 分支中呢? 答案是可以的,这个过程就叫做计算跳转,通过标签作为值即可实现。关于什么是标签作为值,我们用一段 C 代码解释一下。 #include void label_as_value(int jump) { int num = 4; void *label; switch (num) { case 1: printf(\"%d\\n\", 1); break; // 在 case 2 分支里面定义了一个标签叫 two case 2: two: { printf(\"%d\\n\", 2); break; } // 在 case 3 分支里面定义了一个标签叫 three case 3: three: { printf(\"%d\\n\", 3); break; } case 4: printf(\"%d\\n\", 4); // 如果参数 jump 等于 2,保存 two 标签的地址 // 如果参数 jump 等于 3,保存 three 标签的地址 if (jump == 2) label = &&two; else if (jump == 3) label = &&three; // 跳转到指定标签 goto *label; default: break; }\n} int main() { label_as_value(2); // 4 // 2 label_as_value(3); // 4 // 3\n} 由于变量 num 等于 4,所以会进入 case 4 分支,在里面有一个 goto *label。如果你对 C 不是特别熟悉的话,估计会有些奇怪,觉得不应该是 goto label 吗?如果是 goto label,那么需要显式地定义一个名为 label 的标签,但这里并没有。我们的目的是跳转到 two 标签或 three 标签,具体跳转到哪一个,则由参数控制。因此可以使用 && 运算符,这是 GCC 的一个扩展特性,叫做标签作为值,它允许我们获取标签的地址作为一个值。 所以在开头声明了一个 void *label,然后让 label 保存标签地址,再通过 goto *label 跳转到指定标签。由于 *label 代表哪个标签是在运行时经过计算才能知晓,因此称为计算跳转(在运行时动态决定跳转目标)。 注意:goto *&&标签名 属于高级的、非标准的 C 语言用法。 那么毫无疑问,解释器也一定为处理指令的 case 分支定义了相应的标签,并拿到了这些标签的地址。没错,这些标签地址位于 Python/opcode_targets.h 中,这个 opcode_targets.h 就是上面的帧评估函数导入的头文件。 每个指令的处理逻辑都会对应一个标签,这些标签的地址全部保存在了数组中,执行帧评估函数时导入进来即可。这里可能有人会问,导入数组时,它里面的标签都还没有定义吧。确实如此,不过没关系,对于 C 来说,标签只要定义了,那么它在函数的任何一个位置都可以使用。 假设要执行的下一条指令为 opcode,那么就会跳转到 *opcode_targets[opcode],因此我们有理由相信,指令和 opcode_targets 数组的索引之间存在某种关联。而这种关联也很好想,opcode_targets[opcode] 指向的标签,其内部的逻辑就是用来处理 opcode 指令的,我们来验证一下。 #define LOAD_CONST 100\n#define LOAD_NAME 101 比如 LOAD_CONST 的值为 100,那么 opcode_targets[100] 肯定会指向 TARGET_LOAD_CONST 标签;LOAD_NAME 的值为 101,那么 opcode_targets[101] 肯定会指向 TARGET_LOAD_NAME 标签。 结果没有问题,其它指令也是一样的,通过计算跳转,可以直接 goto 到指定的标签。 好,我们总结一下,首先帧评估函数内部有一个巨型的 switch,每一个指令的处理逻辑都对应一个 case 分支,由于指令有一百多个,所以 case 分支也有一百多个。而当指令进入 switch 后,显然会顺序匹配这一百多个 case 分支,找到符合条件的那一个。 整个过程的逻辑是没问题的,但效率上还可以更进一步优化,因为整个字节码指令集是已知的,既然都提前知道了下一条待处理的指令是什么,那完全可以直接跳转到对应的 case 分支中。所以每个 case 分支都会对应一个标签,我们看一下源码。 这个 TARGET 是一个宏,也定义在帧评估函数中。 #define TARGET(op) \\ op: \\ TARGET_##op // 所以 case TARGET(LOAD_CONST): 展开之后就会变成\n// case LOAD_CONST: TARGET_LOAD_CONST: 所以在指令的名称前加一个 TARGET_ 就是对应的标签,比如下一条要执行的指令是 YIELD_VALUE,它等于 86,那么 opcode_targets[86] 就等于 &&TARGET_YIELD_VALUE,指向的标签内部便是 YIELD_VALUE 的处理逻辑,至于其它指令也是同理。 因此读取完下一条指令之后,就不用跳转到开头重新走一遍 switch 了。而是将指令作为索引,从 opcode_targets 拿到标签地址直接跳转即可,并且跳转后的标签内部的逻辑就是用来处理该指令的。 所以底层为每个指令的处理逻辑都定义了一个标签,而标签的地址在数组中的索引,和要处理的指令本身是相等的。 不过要想实现计算跳转,需要 GCC 支持标签作为值这一特性,即 goto *标签地址,至于标签地址是哪一个标签的地址,则在运行时动态计算得出。比如 opcode_targets[opcode] 指向哪个标签无从得知,这取决于 opcode 的值。 goto 标签:静态跳转,标签需要显式地定义好,跳转位置在编译期间便已经固定。 goto *标签地址:动态跳转(计算跳转),跳转位置不固定,可以是已有标签中的任意一个。至于具体是哪一个,需要在运行时经过计算才能确定。 以上就是计算跳转,我们继续往下说。 // 如果使用了计算跳转\n#define FAST_DISPATCH() \\ { \\ if (!_Py_TracingPossible(ceval) && !PyDTrace_LINE_ENABLED()) { \\ f->f_lasti = INSTR_OFFSET(); /* 将当前指令的偏移量赋值给 f_lasti */ \\ NEXTOPARG(); /* 获取下一条指令 */ \\ goto *opcode_targets[opcode]; /* 跳转到对应的标签中 */ \\ } \\ goto fast_next_opcode; \\ } #define DISPATCH() \\ { \\ if (!_Py_atomic_load_relaxed(eval_breaker)) { \\ FAST_DISPATCH(); \\ } \\ continue; \\ } #define TARGET(op) \\ op: \\ TARGET_##op // 如果不使用计算跳转\n#define TARGET(op) op\n#define FAST_DISPATCH() goto fast_next_opcode\n#define DISPATCH() continue 每条指令在执行的最后,都会调用 DISPATCH() 或 FAST_DISPATCH(),我们看一下源码。 如果不使用计算跳转,那么 DISPATCH() 就等价于 continue,直接进行下一轮 for 循环,然后进入 switch。而 FAST_DISPATCH() 会跳转到 fast_next_opcode 标签,该标签定义在 for 循环的里面,switch 的外面,所以它虽然不用从 for 循环的位置开始执行,但依然要走一遍完整的 switch。另外由于不使用计算跳转,那么 case 分支里的标签也就没意义了,所以 case TARGET(op) 就等价于 case op。 如果使用计算跳转,那么就是之前说的那样,在指令执行完之后(并且没有中断请求)会调用 NEXTOPARG() 获取下一条指令,然后通过 goto *opcode_targets[opcode] 实现计算跳转,直接跳到下一条指令对应的 case 分支中,从而省去了匹配的时间。 好,我们继续往下看。 // 获取元组 v 中索引为 i 的元素\n#define GETITEM(v, i) PyTuple_GetItem((v), (i)) /* 在遍历字节码指令序列时,会用到以下两个变量 * first_instr:永远指向字节码指令序列的第一条指令 * next_instr:永远指向下一条待执行(或正在执行)的字节码指令 * 另外由于每条字节码指令都会带有一个参数 * 所以 first_instr 和 next_instr 的类型都是 _Py_CODEUNIT *,即 uint16_t * * 其中前 8 位表示指令,后 8 位表示指令参数 */ // 在调用 NEXTOPARG() 之前,next_instr 指向正在执行的字节码指令\n// 如果调用了 NEXTOPARG(),那么 next_instr 就会指向下一条待执行的字节码指令\n// 该宏计算的显然就是它和第一条指令(或者说字节码指令序列的起始位置)之间的偏移量\n#define INSTR_OFFSET() \\ (sizeof(_Py_CODEUNIT) * (int)(next_instr - first_instr)) // 获取 next_instr 指向的 uint16 的前 8 位和后 8 位,也就是拿到指令和指令参数\n// 然后执行 next_instr++\n#define NEXTOPARG() do { \\ _Py_CODEUNIT word = *next_instr; \\ opcode = _Py_OPCODE(word); \\ oparg = _Py_OPARG(word); \\ next_instr++; \\ } while (0) 通过 INSTR_OFFSET 和 NEXTOPARG,我们介绍了两个指针变量:first_instr 和 next_instr,虚拟机就是通过它们来完成遍历的。 #define JUMPTO(x) (next_instr = first_instr + (x) / sizeof(_Py_CODEUNIT))\n#define JUMPBY(x) (next_instr += (x) / sizeof(_Py_CODEUNIT)) 这两个指令等到介绍 if 控制流的时候再说,不过相信你也能猜到它是做什么的,if 控制流的某个分支如果不满足条件,就会跳到下一个分支。而这个跳转过程是怎么实现的呢?显然要借助于这里的 JUMPTO 和 JUMPBY。 // 指令预测\n#if defined(DYNAMIC_EXECUTION_PROFILE) || USE_COMPUTED_GOTOS\n#define PREDICT(op) if (0) goto PRED_##op\n#else\n#define PREDICT(op) \\ do{ \\ _Py_CODEUNIT word = *next_instr; \\ opcode = _Py_OPCODE(word); \\ if (opcode == op){ \\ oparg = _Py_OPARG(word); \\ next_instr++; \\ goto PRED_##op; \\ } \\ } while(0)\n#endif\n#define PREDICTED(op) PRED_##op: PREDICT 宏和指令预测相关,后续介绍 if 控制流的时候再说。 关于宏就说到这里,至于剩下的一些宏,暂时就先不用看了,我们在后续的部分才会用到它们。 好,既然宏说完了,接下来我们可以看整个帧评估函数都做些什么了,代码有删减。 PyObject* _Py_HOT_FUNCTION\n_PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag)\n{ // 初始化一些变量,它们的含义等赋值的时候再说 PyObject **stack_pointer; const _Py_CODEUNIT *next_instr; int opcode; int oparg; PyObject **fastlocals, **freevars; PyObject *retval = NULL; _PyRuntimeState * const runtime = &_PyRuntime; PyThreadState * const tstate = _PyRuntimeState_GetThreadState(runtime); struct _ceval_runtime_state * const ceval = &runtime->ceval; _Py_atomic_int * const eval_breaker = &ceval->eval_breaker; PyCodeObject *co; int instr_ub = -1, instr_lb = 0, instr_prev = -1; const _Py_CODEUNIT *first_instr; PyObject *names; PyObject *consts; _PyOpcache *co_opcache; // ... // 省略了一堆的宏定义,就是我们上面刚介绍的 // ... // 检查是否超过递归深度限制 if (Py_EnterRecursiveCall(\"\")) return NULL; // tstate->frame 保存当前正在执行的栈桢,所以将 f 赋值给 tstate->frame // 至于之前的 tstate->frame,则保存在 f.f_back 字段中(在创建栈桢 f 的时候就完成了) tstate->frame = f; // 如果启用追踪机制 if (tstate->use_tracing) { // tstate->c_tracefunc 对应 Python 里的 sys.settrace // 如果不为空,那么进行调用 // 该函数可以监控每行代码的执行,因此一般用于调试器 if (tstate->c_tracefunc != NULL) { if (call_trace_protected(tstate->c_tracefunc, tstate->c_traceobj, tstate, f, PyTrace_CALL, Py_None)) { goto exit_eval_frame; } } // tstate->c_profilefunc 对应 Python 里的 sys.setprofile // 如果不为空,那么进行调用,该函数主要用于性能分析 if (tstate->c_profilefunc != NULL) { if (call_trace_protected(tstate->c_profilefunc, tstate->c_profileobj, tstate, f, PyTrace_CALL, Py_None)) { goto exit_eval_frame; } } } // DTrace 是一个强大的动态追踪工具 // 这里检测是否启用了 DTrace 的函数入口探针 if (PyDTrace_FUNCTION_ENTRY_ENABLED()) // 如果启用了 DTrace,则记录函数进入的事件 dtrace_function_entry(f); // 获取栈桢内部的关键字段 co = f->f_code; names = co->co_names; consts = co->co_consts; fastlocals = f->f_localsplus; freevars = f->f_localsplus + co->co_nlocals; assert(PyBytes_Check(co->co_code)); assert(PyBytes_GET_SIZE(co->co_code) <= INT_MAX); assert(PyBytes_GET_SIZE(co->co_code) % sizeof(_Py_CODEUNIT) == 0); assert(_Py_IS_ALIGNED(PyBytes_AS_STRING(co->co_code), sizeof(_Py_CODEUNIT))); // 注意这里的 first_instr,上面已经介绍了,它指向字节码指令序列的起始位置,或者说第一条指令 first_instr = (_Py_CODEUNIT *) PyBytes_AS_STRING(co->co_code); assert(f->f_lasti >= -1); // 初始状态下,next_instr 和 first_instr 相等 next_instr = first_instr; if (f->f_lasti >= 0) { assert(f->f_lasti % sizeof(_Py_CODEUNIT) == 0); next_instr += f->f_lasti / sizeof(_Py_CODEUNIT) + 1; } stack_pointer = f->f_stacktop; assert(stack_pointer != NULL); f->f_stacktop = NULL; f->f_executing = 1; // 进入主循环,在这个 for 循环里面一会儿就会看到那个巨型的 switch main_loop: // 遍历字节码指令集,处理每一条指令 for (;;) { // stack_pointer 是栈顶指针,f_valuestack 是栈底指针 // 由于 Python 的运行时栈是基于数组实现的,所以从栈底到栈顶,地址是增大的 // 因此 stack_pointer 一定大于等于 f_valuestack assert(stack_pointer >= f->f_valuestack); // STACK_LEVEL() 一定小于等于运行时栈的长度,之前说过的 assert(STACK_LEVEL() <= co->co_stacksize); // 线程状态对象里面没有异常产生 assert(!_PyErr_Occurred(tstate)); // 检测是否有待处理的中断(比如信号、GIL 释放请求等) if (_Py_atomic_load_relaxed(eval_breaker)) { opcode = _Py_OPCODE(*next_instr); /* 如果指令是以下之一,那么忽略中断,直接跳到 fast_next_opcode 标签进行处理 * SETUP_FINALLY:try / finally 语句的开始 * SETUP_WITH:with 语句的开始 * BEFORE_ASYNC_WITH:async with 语句的开始 * YIELD_FROM:yield from 表达式 */ // 这种设计主要是为了确保在某些关键操作(如资源管理、异常处理、异步操作)的开始阶段不被中断信号打断 // 从而保证这些操作的正确性和可靠性,进而保证 Python 程序的稳定性和可预测性 if (opcode == SETUP_FINALLY || opcode == SETUP_WITH || opcode == BEFORE_ASYNC_WITH || opcode == YIELD_FROM) { goto fast_next_opcode; } // 使用原子操作检查是否有待处理的信号 // 如果有待处理的信号,那么调用 handle_signals 函数处理它们 // 这个机制允许 Python 程序响应外部事件和系统信号,同时保证执行的正确性 if (_Py_atomic_load_relaxed(&ceval->signals_pending)) { if (handle_signals(runtime) != 0) { goto error; } } // 通过原子操作检查是否有待处理的调用需要执行,calls_to_do 是一个计数器,表示待处理的调用的数量 // 如果有待处理的调用,那么执行 make_pending_calls 函数 // pending calls 主要用于垃圾回收(GC)、异步 IO 回调、定时器事件等 // 这个机制是 Python 运行时系统的重要组成部分,允许虚拟机在主循环中处理各种异步任务和周期性任务 // 确保各种后台任务能够得到及时处理,并且不需要使用额外的线程和复杂的调度机制 if (_Py_atomic_load_relaxed(&ceval->pending.calls_to_do)) { if (make_pending_calls(runtime) != 0) { goto error; } } // 通过原子操作检查是否有释放 GIL 的请求,如果有,那么该线程就要释放 GIL if (_Py_atomic_load_relaxed(&ceval->gil_drop_request)) { // 将当前线程状态设置为 NULL,因为要发生切换了(关于 GIL,后续会单独介绍) if (_PyThreadState_Swap(&runtime->gilstate, NULL) != tstate) { Py_FatalError(\"ceval: tstate mix-up\"); } // 释放 GIL,给其它线程一个机会,不能让某一个线程一直霸占着 // 如果开启了多线程,那么当释放 GIL 的那一刻,就会被其它线程获取 drop_gil(ceval, tstate); // GIL 释放之后,还要再次获取,但 GIL 已经被其它线程拿走了 // 所以会触发操作系统内核的线程调度机制,进入阻塞状态,等待 GIL 再度回到自己手中 // 因此不难发现,如果有 n 个线程,那么其中的 n - 1 个会陷入阻塞,等待获取 GIL // 而一旦持有 GIL 的线程执行了 drop_gil 函数,将 GIL 释放了 // 那么这 n - 1 个线程当中就会有一个线程拿到 GIL 并解除阻塞,然后开始执行字节码 // 至于释放 GIL 的线程,则会尝试再次获取 GIL,但会因为获取不到而陷入阻塞(已经被其它线程拿走了) take_gil(ceval, tstate); // 检查是否需要快速退出线程(比如在解释器关闭时) exit_thread_if_finalizing(runtime, tstate); // 到这里说明 take_gil 返回了(即阻塞状态解除),也意味着拿到了 GIL,那么要恢复线程状态 if (_PyThreadState_Swap(&runtime->gilstate, tstate) != NULL) { Py_FatalError(\"ceval: orphan tstate\"); } } // 检测线程状态中是否存在异步的异常 if (tstate->async_exc != NULL) { PyObject *exc = tstate->async_exc; tstate->async_exc = NULL; UNSIGNAL_ASYNC_EXC(ceval); _PyErr_SetNone(tstate, exc); Py_DECREF(exc); goto error; } } // 以上是一些中断检测逻辑,如果执行顺利,那么会走到这里 fast_next_opcode: // 保存上一条已执行完毕的字节码的偏移量 f->f_lasti = INSTR_OFFSET(); // 如果启用了 DTrace 行追踪,那么记录行级别的执行信息 if (PyDTrace_LINE_ENABLED()) maybe_dtrace_line(f, &instr_lb, &instr_ub, &instr_prev); // 检查是否需要执行行级追踪,如果追踪功能可用并且设置了追踪函数,那么执行 // 这是 Python 调试和性能分析功能的核心部分,使得像 pdb 这样的调试器能够逐行执行代码 if (_Py_TracingPossible(ceval) && tstate->c_tracefunc != NULL && !tstate->tracing) { int err; // 保存当前栈指针 f->f_stacktop = stack_pointer; // 调用行追踪函数 err = maybe_call_line_trace(tstate->c_tracefunc, tstate->c_traceobj, tstate, f, &instr_lb, &instr_ub, &instr_prev); // 追踪函数可能改变帧的状态,需要重新加载,并更新栈指针 JUMPTO(f->f_lasti); if (f->f_stacktop != NULL) { stack_pointer = f->f_stacktop; f->f_stacktop = NULL; } if (err) goto error; } // 这个宏前面介绍了,它会获取下一条待处理的指令和指令参数 NEXTOPARG(); // 进入 dispatch_opcode 标签 dispatch_opcode: // 下面这些宏主要用于指令追踪和性能分析,简单了解一下就好\n#ifdef DYNAMIC_EXECUTION_PROFILE // 如果启用了动态执行性能分析\n#ifdef DXPAIRS // 如果启用了指令对分析 dxpairs[lastopcode][opcode]++; // 记录相邻指令对的出现次数 lastopcode = opcode; // 更新上一个指令\n#endif dxp[opcode]++; // 记录单个指令的执行次数\n#endif #ifdef LLTRACE // 如果启用了低级追踪,并且追踪开关打开,那么打印偏移量、指令、指令参数等信息 if (lltrace) { if (HAS_ARG(opcode)) { printf(\"%d: %d, %d\\n\", f->f_lasti, opcode, oparg); } else { printf(\"%d: %d\\n\", f->f_lasti, opcode); } }\n#endif // 好的,关键来了,我们终于来到了这个巨型的 switch // 一个指令对应一个 case 分支,里面包含了该指令的处理逻辑 // 因为有一百多个 case 分支,所以这个 switch 语句的代码量高达 2300 多行 // 当然啦,也仅仅只是代码量大,但逻辑很单纯,就是定义了一百多条指令的处理逻辑嘛 switch (opcode) { // NOP 指令的处理逻辑,另外还记得这个 TARGET 宏吗?如果开启了计算跳转,那么分支内部还会定义一个标签 // 此时 case TARGET(NOP) 会展开成 case NOP: TARGET_NOP: case TARGET(NOP): { FAST_DISPATCH(); } // LOAD_FAST 指令的处理逻辑 case TARGET(LOAD_FAST): { PyObject *value = GETLOCAL(oparg); if (value == NULL) { format_exc_check_arg(tstate, PyExc_UnboundLocalError, UNBOUNDLOCAL_ERROR_MSG, PyTuple_GetItem(co->co_varnames, oparg)); goto error; } Py_INCREF(value); PUSH(value); FAST_DISPATCH(); } // LOAD_CONST 指令的处理逻辑 case TARGET(LOAD_CONST): { PREDICTED(LOAD_CONST); PyObject *value = GETITEM(consts, oparg); Py_INCREF(value); PUSH(value); FAST_DISPATCH(); } // STORE_FAST 指令的处理逻辑 case TARGET(STORE_FAST): { PREDICTED(STORE_FAST); PyObject *value = POP(); SETLOCAL(oparg, value); FAST_DISPATCH(); } // POP_TOP 指令的处理逻辑 case TARGET(POP_TOP): { PyObject *value = POP(); Py_DECREF(value); FAST_DISPATCH(); } // ... // ... // ... // MAKE_FUNCTION 指令的处理逻辑 case TARGET(MAKE_FUNCTION): { // ... PUSH((PyObject *)func); DISPATCH(); } // BUILD_SLICE 指令的处理逻辑 case TARGET(BUILD_SLICE): { // ... if (slice == NULL) goto error; DISPATCH(); } // FORMAT_VALUE 指令的处理逻辑 case TARGET(FORMAT_VALUE): { // ... DISPATCH(); } // EXTENDED_ARG 指令的处理逻辑 case TARGET(EXTENDED_ARG): { int oldoparg = oparg; NEXTOPARG(); oparg |= oldoparg << 8; goto dispatch_opcode; } /* 这些指令内部的具体逻辑,我们后续会聊 */ // 如果执行到这里,说明上面的 case 分支都没有匹配到,意味着出现了一个未知的指令 // 那么打印错误信息:unknown opcde,不过基本不会发生,除非你刻意构造一个不存在的指令\n#if USE_COMPUTED_GOTOS _unknown_opcode:\n#endif default: fprintf(stderr, \"XXX lineno: %d, opcode: %d\\n\", PyFrame_GetLineNumber(f), opcode); _PyErr_SetString(tstate, PyExc_SystemError, \"unknown opcode\"); goto error; } // 到这里,switch 语句块就结束了 // 这个位置永远不可能到达,因为在每条指令的处理逻辑的最后,要么调用 DISPATCH(),要么 goto error // 调用 DISPATCH() 会去执行下一条指令,goto error 会跳转到下面的 error 标签 // 当然这里的 Py_UNREACHABLE() 有没有都无所谓,但加上之后会让程序显得更加严谨 Py_UNREACHABLE(); // 如果字节码指令在执行时出错了,那么会设置异常,然后跳转到 error 标签\nerror:\n// 以下是错误处理的防御性代码,用于确保在发生错误时总是设置了适当的异常 // 记得之前介绍过异常的本质,其实就是解释器内部在执行时发现逻辑出问题了(比如索引超出范围)\n// 那么会将异常(比如 IndexError)设置在回溯栈中,并立即返回一个表示错误的哨兵值\n// 当解释器将返回值传递给 Python 时,会发现返回值为 NULL,知道出异常了\n// 于是会将回溯栈里的异常输出到 stderr 当中,就是我们在终端中看到的那一坨红色的东西,然后结束进程\n// 但如果解释器发现回溯栈里面没有异常,那么会额外设置一个 SystemError: error return without exception set\n// 意思就是:\"明明发生错误了,为什么回溯栈里面没有设置异常呢?\",一般这个问题会在用 C 编写扩展模块的时候遇到 #ifdef NDEBUG if (!_PyErr_Occurred(tstate)) { _PyErr_SetString(tstate, PyExc_SystemError, \"error return without exception set\"); }\n#else // 当然如果没有定义 NDEBUG 宏的话,那么就会展开成一个 assert 断言 // 对于解释器本身来说,像这种 assert 断言都是成立的,否则底层源码写的就有问题 assert(_PyErr_Occurred(tstate));\n#endif // 报错时,要生成 traceback,即回溯栈,关于 traceback,等介绍异常捕获的时候再说 PyTraceBack_Here(f); // 执行追踪函数,用于调试器捕获异常、追踪异常、以及异常处理的监控和分析等 // 在使用 pdb 调试器时,这个机制允许调试器捕获和显示异常信息 if (tstate->c_tracefunc != NULL) call_exc_trace(tstate->c_tracefunc, tstate->c_traceobj, tstate, f); // 这里和异常捕获相关,我们后续再聊 exception_unwind: while (f->f_iblock > 0) { // ... } break; } // 到这里,外层的 for 循环就结束了,显然会有两种情况 // 要么字节码都执行完毕了,要么出异常了,但不管是哪种,都意味着要退出栈桢了 assert(retval == NULL); assert(_PyErr_Occurred(tstate)); // 到这里说明要退出栈桢了,如果运行时栈里面还有元素的话,那么要清空\nexit_returning: while (!EMPTY()) { PyObject *o = POP(); Py_XDECREF(o); } // 生成器在 yield 时的追踪处理逻辑\n// 另外像这些追踪函数可以不用太关注,都是用于调试和性能分析的\nexit_yielding: if (tstate->use_tracing) { if (tstate->c_tracefunc) { if (call_trace_protected(tstate->c_tracefunc, tstate->c_traceobj, tstate, f, PyTrace_RETURN, retval)) { Py_CLEAR(retval); } } if (tstate->c_profilefunc) { if (call_trace_protected(tstate->c_profilefunc, tstate->c_profileobj, tstate, f, PyTrace_RETURN, retval)) { Py_CLEAR(retval); } } } // 帧评估函数退出时的清理代码\nexit_eval_frame: // 如果启用了 DTrace,记录函数返回事件 if (PyDTrace_FUNCTION_RETURN_ENABLED()) dtrace_function_return(f); // 退出递归调用,与之前的 Py_EnterRecursiveCall() 相对应 Py_LeaveRecursiveCall(); // 标记帧不再处于执行状态 f->f_executing = 0; // 当调用一个函数时,会在当前帧的基础上创建新的帧,并将执行权交给新的帧 // 当函数执行完毕时,会销毁栈桢,并将执行权还给上一级栈帧(即调用者的帧),这个过程叫做栈桢回退 // 显然这里要将 f->back 赋值给 tstate->frame,即回退到上一级栈桢 tstate->frame = f->f_back; // 检查返回值的有效性,确保返回值符合 Python 的调用约定 return _Py_CheckFunctionResult(NULL, retval, \"PyEval_EvalFrameEx\"); } 以上就是帧评估函数的源码逻辑,总的来说并不难理解,其核心就是通过 for 循环遍历字节码指令集,将遍历出的指令交给内部的 switch 语句,执行对应的 case 分支。当匹配到的 case 分支执行完毕时,会有以下三种情况: 停止循环、退出帧评估函数,当执行的指令为 RETURN_VALUE、YIELD_VALUE 等。 执行指令的过程中出错了,跳转到 error 标签,然后进行异常处理(或者直接抛出异常)。 执行下一条指令,如果开启了计算跳转,那么会精确跳转到下一条指令的处理逻辑中,否则会跳转到 fast_next_opcode 标签的所在位置、或者 for 循环的所在位置。但不管如何,虚拟机接下来的动作就是获取下一条字节码指令和指令参数,完成对下一条指令的执行。 所以通过 for 循环一条一条遍历 co_code 中的字节码指令,然后交给内部的 switch 语句、执行对应的 case 分支,如此周而复始,最终完成了对整个 Python 程序的执行。 相信到此刻你已经彻底了解了 Python 执行引擎的整体结构。说白了虚拟机就是将自己当成一个 CPU,在栈帧中一条条的执行指令,而执行过程中所依赖的常量、变量等,则由栈帧的其它字段来维护。","breadcrumbs":"58. 深入源码,进一步考察字节码的执行流程 » 源码解析字节码指令的执行过程","id":"262","title":"源码解析字节码指令的执行过程"},"263":{"body":"光看源码还是有点枯燥的,下面我们来写一段简单的代码,然后反编译,并通过画图来演示虚拟机是如何执行字节码的。 code = \"\"\"\\\nchinese = 89\nmath = 99\nenglish = 91\navg = (chinese + math + english) / 3\n\"\"\" # 将上面的代码以模块的方式进行编译\nco = compile(code, \"my_module\", \"exec\")\n# 查看常量池\nprint(co.co_consts) # (89, 99, 91, 3, None)\n# 查看符号表\nprint(co.co_names) # ('chinese', 'math', 'english', 'avg') 在编译的时候,常量和符号(变量)都会被静态收集起来。然后我们反编译一下看看字节码,直接通过 dis.dis(co) 即可,结果如下: 1 0 LOAD_CONST 0 (89) 2 STORE_NAME 0 (chinese) 2 4 LOAD_CONST 1 (99) 6 STORE_NAME 1 (math) 3 8 LOAD_CONST 2 (91) 10 STORE_NAME 2 (english) 4 12 LOAD_NAME 0 (chinese) 14 LOAD_NAME 1 (math) 16 BINARY_ADD 18 LOAD_NAME 2 (english) 20 BINARY_ADD 22 LOAD_CONST 3 (3) 24 BINARY_TRUE_DIVIDE 26 STORE_NAME 3 (avg) 28 LOAD_CONST 4 (None) 30 RETURN_VALUE 上面每一列的含义之前说过,这里再重复一下。 第一列是源代码的行号; 第二列是指令的偏移量,或者说该指令在整个字节码指令序列中的索引。因为每条指令后面都跟着一个参数,所以偏移量是 0 2 4 6 8 ...; 第三列是字节码指令,简称指令,它们在宏定义中代表整数; 第四列是字节码指令参数,简称指令参数、或者参数,不同的指令参数的含义不同; 第五列是 dis 模块给我们额外提供的信息,一会儿说; 我们从上到下依次解释每条指令都干了什么? 0 LOAD_CONST:表示加载一个常量(指针),并压入运行时栈。后面的指令参数 0 表示从常量池中加载索引为 0 的常量,至于 89 则表示加载的常量是 89。所以最后面的括号里面的内容实际上起到的是一个提示作用,告诉你加载的对象是什么。 2 STORE_NAME:表示将 LOAD_CONST 加载的常量用一个名字绑定起来,放在所在的名字空间中。后面的 0 (chinese) 则表示使用符号表中索引为 0 的名字(符号),且名字为 \"chinese\"。 所以像 chinese = 89 这种简单的赋值语句,会对应两条字节码指令。 然后 4 LOAD_CONST、6 STORE_NAME 和 8 LOAD_CONST、10 STORE_NAME 的作用显然和上面是一样的,都是加载一个常量,然后将某个符号和常量绑定起来,并放在名字空间中。 12 LOAD_NAME:加载一个变量,并压入运行时栈,而后面的 0 (chinese) 表示加载符号表中索引为 0 的变量的值,然后这个变量叫 chinese。14 LOAD_NAME 也是同理,将符号表中索引为 1 的变量的值压入运行时栈,并且变量叫 math。此时栈里面有两个元素,从栈底到栈顶分别是 chinese 和 math。 16 BINARY_ADD:将上面两个变量从运行时栈弹出,然后执行加法操作,并将结果压入运行时栈。 18 LOAD_NAME:将符号表中索引为 2 的变量 english 的值压入运行时栈,此时栈里面有两个元素,从栈底到栈顶分别是 chinese + math 的返回结果和 english。 20 BINARY_ADD:将运行时栈里的两个元素弹出,然后执行加法操作,并将结果压入运行时栈。此时栈里面有一个元素,就是 chinese + math + english 的返回结果。 22 LOAD_CONST:将常量 3 压入运行时栈,此时栈里面有两个元素; 24 BINARY_TRUE_DIVIDE:将运行时栈里的两个元素弹出,然后执行除法操作,并将结果压入运行时栈,此时栈里面有一个元素; 26 STORE_NAME:将元素从运行时栈里面弹出,并用符号表中索引为 3 的变量 avg 和它绑定起来,然后放在名字空间中。 28 LOAD_CONST:将常量 None 压入运行时栈,然后通过 30 RETURN_VALUE 将其从栈中弹出、并返回。 所以 Python 虚拟机就是把自己想象成一个 CPU,在栈帧中一条条执行字节码指令,当指令执行完毕或执行出错时,停止执行。 我们通过几张图展示一下上面的过程,为了阅读方便,这里将相应的源代码再贴一份。 chinese = 89\nmath = 99\nenglish = 91\navg = (chinese + math + english) / 3 之前说了,模块也有自己的作用域,并且是全局作用域,所以虚拟机也会为它创建栈帧。而在代码还没有执行的时候,栈帧就已经创建好了,整个布局如下。 f_localsplus 下面的箭头方向,代表运行时栈从栈底到栈顶的方向。 这里再强调一下 f_localsplus 字段,它是一个柔性数组。虽然声明的时候写着长度为 1,但实际使用时,长度不受限制,和 Go 语言不同,C 数组的长度不属于类型的一部分。然后 f_localsplus 在逻辑上被分成了四份,分别用于局部变量、cell 变量、free 变量、运行时栈,由于当前示例中的代码是以模块的方式编译的,里面所有的变量都是全局变量,而且也不涉及闭包啥的,所以这里就把 f_localsplus 理解为运行时栈即可。 接下来就开始执行字节码了,next_instr 指向下一条待执行的字节码指令,显然初始状态下,下一条待执行的指令就是第一条指令。 于是虚拟机开始执行 0 LOAD_CONST,该指令表示将常量加载进运行时栈,而要加载的常量在常量池中的索引,由指令参数表示。 在源码中,指令对应的变量是 opcode,指令参数对应的变量是 oparg。 case TARGET(LOAD_CONST): { PREDICTED(LOAD_CONST); // 调用元组的 GETITEM 方法,从常量池中加载索引为 oparg 的对象 // 当然啦,为了描述方便我们称之为对象,但其实是指向对象的指针 PyObject *value = GETITEM(consts, oparg); // 增加引用计数 Py_INCREF(value); // 压入运行时栈 PUSH(value); FAST_DISPATCH();\n} 该指令的参数为 0,所以会将常量池中索引为 0 的元素 89 压入运行时栈,执行完之后,栈帧的布局就变成了下面这样: 接着虚拟机执行 2 STORE_NAME 指令,从符号表中获取索引为 0 的符号、即 chinese。然后将栈顶元素 89 弹出,再将符号 chinese 和整数对象 89 绑定起来保存到 local 名字空间中。 case TARGET(STORE_NAME): { // 从符号表中加载索引为 oparg 的符号 // 符号本质上就是一个 PyUnicodeObject 对象 // 这里就是字符串 \"chinese\" PyObject *name = GETITEM(names, oparg); // 从运行时栈的栈顶弹出元素 // 显然是上一步压入的 89 PyObject *v = POP(); // 获取名字空间 namespace PyObject *ns = f->f_locals; int err; // 如果没有名字空间则报错,设置异常 if (ns == NULL) { _PyErr_Format(tstate, PyExc_SystemError, \"no locals found when storing %R\", name); Py_DECREF(v); goto error; } // 将符号和对象绑定起来放在 ns 中 // 名字空间是一个字典,PyDict_CheckExact 负责检测 ns 是否为字典,等价于 type(ns) is dict // 除此之外,还有 PyDict_Check(ns),它等价于 isinstance(ns, dict) if (PyDict_CheckExact(ns)) // 通过字典的特定类型 API 将键值对 \"chinese\": 89 设置到字典中 err = PyDict_SetItem(ns, name, v); else // 走到这里说明 type(ns) 不是 dict,那么它应该继承 dict // 通过泛型 API 设置元素 err = PyObject_SetItem(ns, name, v); // 对象的引用计数减 1,因为从运行时栈中弹出了 Py_DECREF(v); // 如果 err != 0,证明设置元素出错了,跳转至 error 标签 if (err != 0) goto error; // 调用 DISPATCH() 执行下一条指令,如果没有开启计算跳转,那么它就相当于一个 continue DISPATCH();\n} 执行完之后,栈帧的布局就变成了下面这样: 此时运行时栈为空,local 名字空间多了个键值对。 同理剩余的两个赋值语句也是类似的,只不过指令参数不同,比如 6 STORE_NAME 加载的是符号表中索引为 1 的符号,10 STORE_NAME 加载的是符号表中索引为 2 的符号,分别是 math 和 english。它们执行完之后,栈桢布局如下: 然后 12 LOAD_NAME 和 14 LOAD_NAME 负责将符号表中索引为 0 和 1 的变量的值压入运行时栈: case TARGET(LOAD_NAME): { // 从符号表 co_names 中加载索引为 oparg 的变量(符号) // 但是注意:全局变量是通过字典存储的 // 所以这里的 name 只是一个字符串罢了,比如 \"chinese\" // 然后还要再根据这个字符串从字典里面查找对应的 value PyObject *name = GETITEM(names, oparg); // 对于模块来说,f->f_locals 和 f->f_globals 指向同一个字典 PyObject *locals = f->f_locals; PyObject *v; // local 名字空间一定不为 NULL if (locals == NULL) { _PyErr_Format(tstate, PyExc_SystemError, \"no locals when loading %R\", name); goto error; } // 如果 type(locals) is dict 为真 if (PyDict_CheckExact(locals)) { // 根据 name 获取 value,所以 print(chinese) 本质上就是下面这样 // print(locals[\"chinese\"]) v = PyDict_GetItemWithError(locals, name); if (v != NULL) { Py_INCREF(v); } else if (_PyErr_Occurred(tstate)) { goto error; } } // 否则说明 type(locals) is dict 为假,但 isinstance(locals, dict) 为真 else { // 通过泛型 API 获取元素 v = PyObject_GetItem(locals, name); if (v == NULL) { if (!_PyErr_ExceptionMatches(tstate, PyExc_KeyError)) goto error; _PyErr_Clear(tstate); } } // 如果 v 等于 NULL,说明 local 空间不存在 if (v == NULL) { // 那么从全局名字空间(global 名字空间)获取 v = PyDict_GetItemWithError(f->f_globals, name); // 如果 v 不等于 NULL,说明获取到了 if (v != NULL) { Py_INCREF(v); } // 否则说明 global 空间也不存在指定的 key // 这里检测一下是否有异常产生,有的话跳转到 error 标签 else if (_PyErr_Occurred(tstate)) { goto error; } // local 空间和 global 空间都没有,那么该去 builtin 空间查找了 else { // 逻辑和上面是类似的,如果查找不到,跳转到 error 标签,否则增加引用计数 if (PyDict_CheckExact(f->f_builtins)) { v = PyDict_GetItemWithError(f->f_builtins, name); if (v == NULL) { if (!_PyErr_Occurred(tstate)) { format_exc_check_arg( tstate, PyExc_NameError, NAME_ERROR_MSG, name); } goto error; } Py_INCREF(v); } else { v = PyObject_GetItem(f->f_builtins, name); if (v == NULL) { if (_PyErr_ExceptionMatches(tstate, PyExc_KeyError)) { format_exc_check_arg( tstate, PyExc_NameError, NAME_ERROR_MSG, name); } goto error; } } } } // 压入运行时栈 PUSH(v); DISPATCH();\n} 上面两条指令执行完之后,栈帧的布局就变成了下面这样: 接下来执行 16 BINARY_ADD,它会将栈里的两个元素弹出,然后执行加法操作,最后再将结果入栈。 当然上面这种说法是为了方便理解,其实虚拟机真正执行的时候,只会弹出一个元素,而另一个元素只是使用 TOP() 进行查看,但不弹出。等结果计算完毕之后,再将栈顶元素替换掉。 所以本质上,和弹出两个元素、再将计算结果入栈是一样的。 case TARGET(BINARY_ADD): { // 从栈顶弹出元素,这里是 99(变量 math) PyObject *right = POP(); // math 弹出之后,chinese 就成为了新的栈顶元素 // 这里的 TOP() 则是获取栈顶元素 89(变量 chinese) PyObject *left = TOP(); // 用于保存两者的和 PyObject *sum; // 如果是字符串,执行专门的函数 if (PyUnicode_CheckExact(left) && PyUnicode_CheckExact(right)) { sum = unicode_concatenate(tstate, left, right, f, next_instr); } // 否则通过泛型 API 进行计算 else { sum = PyNumber_Add(left, right); Py_DECREF(left); } // 减少元素的引用计数 Py_DECREF(right); // 将栈顶元素替换成 sum SET_TOP(sum); if (sum == NULL) goto error; DISPATCH();\n} BINARY_ADD 指令执行完之后,栈帧的布局就变成了下面这样: 然后 18 LOAD_NAME 负责将符号表中索引为 2 的变量 english 的值压入运行时栈,而指令 20 BINARY_ADD 则是继续执行加法操作,并将结果设置在栈顶,然后 22 LOAD_CONST 将常量 3 再压入运行时栈。 这三条指令执行之后,运行时栈的变化如下: 接着是 24 BINARY_TRUE_DIVIDE,它的逻辑和 BINARY_ADD 类似,只不过一个执行除法,一个执行加法。 case TARGET(BINARY_TRUE_DIVIDE): { // 从栈顶弹出元素,显然是 3 PyObject *divisor = POP(); // 查看栈顶元素,此时栈顶元素变成了 279 PyObject *dividend = TOP(); // 调用 PyNumber_TrueDivide,执行 279 / 3 PyObject *quotient = PyNumber_TrueDivide(dividend, divisor); // 减少引用计数 Py_DECREF(dividend); Py_DECREF(divisor); // 将栈顶元素替换成 279 / 3 的计算结果 SET_TOP(quotient); if (quotient == NULL) goto error; DISPATCH();\n} 当 24 BINARY_TRUE_DIVIDE 执行完之后,运行时栈如下: 然后 26 STORE_NAME 将栈顶元素 93.0 弹出,并将符号表中索引为 3 的变量 avg 和它绑定起来,放到名字空间中。因此最终栈帧关系图如下: 以上就是虚拟机对这几行代码的执行流程,整个过程就像 CPU 执行指令一样。 我们再用 Python 代码描述一遍上面的逻辑: # LOAD_CONST 将 89 压入栈中,STORE_NAME 将 89 从栈中弹出\n# 并将符号 \"chinese\" 和 89 绑定起来,放在名字空间中\nchinese = 89\nprint( {k: v for k, v in locals().items() if not k.startswith(\"__\")}\n) # {'chinese': 89} math = 99\nprint( {k: v for k, v in locals().items() if not k.startswith(\"__\")}\n) # {'chinese': 89, 'math': 99} english = 91\nprint( {k: v for k, v in locals().items() if not k.startswith(\"__\")}\n) # {'chinese': 89, 'math': 99, 'english': 91} avg = (chinese + math + english) / 3\nprint( {k: v for k, v in locals().items() if not k.startswith(\"__\")}\n) # {'chinese': 89, 'math': 99, 'english': 91, 'avg': 93.0} 现在你是不是对虚拟机执行字节码有更深的了解了呢?当然字节码指令非常多,不止我们上面看到的那几个。你可以随便写一些代码,然后分析一下它的字节码指令是什么。","breadcrumbs":"58. 深入源码,进一步考察字节码的执行流程 » 通过反编译查看字节码","id":"263","title":"通过反编译查看字节码"},"264":{"body":"到此,我们就深入源码,考察了虚拟机执行字节码的流程,帧评估函数虽然很长,也有那么一些复杂,但是核心逻辑不难理解。就是把自己当成一个 CPU,在栈帧中执行字节码指令。 下一篇文章我们来介绍一下常见的几个指令,并探讨不同的变量赋值语句的背后原理。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"58. 深入源码,进一步考察字节码的执行流程 » 小结","id":"264","title":"小结"},"265":{"body":"前面我们剖析了字节码的执行流程,本来应该接着介绍一些常见指令的,但因为有几个指令涉及到了局部变量,所以我们单独拿出来说。与此同时,我们还要再度考察一下 local 名字空间,它的背后还隐藏了很多内容。 我们知道函数的参数和函数内部定义的变量都属于局部变量,均是通过静态方式访问的。 x = 123 def foo1(): global x a = 1 b = 2 # co_nlocals 会返回局部变量的个数\n# a 和 b 是局部变量,x 是全局变量,因此是 2\nprint(foo1.__code__.co_nlocals) # 2 def foo2(a, b): pass print(foo2.__code__.co_nlocals) # 2 def foo3(a, b): a = 1 b = 2 c = 3 print(foo3.__code__.co_nlocals) # 3 无论是参数还是内部新创建的变量,本质上都是局部变量。 按照之前的理解,当访问一个全局变量时,会去访问 global 名字空间(也叫全局名字空间)。 那么问题来了,当操作函数的局部变量时,是不是也等价于操作其内部的 local 名字空间(局部名字空间)呢?我们往下看。","breadcrumbs":"59. 局部变量是怎么实现静态查找的,它和 local 名字空间又有什么联系呢? » 楔子","id":"265","title":"楔子"},"266":{"body":"之前我们说过 Python 变量的访问是有规则的,会按照本地、闭包、全局、内置的顺序去查找,也就是 LEGB 规则,所以在查找变量时,local 名字空间应该是第一选择。 但不幸的是,虚拟机在为调用的函数创建栈帧对象时,这个至关重要的 local 名字空间并没有被创建。因为栈帧的 f_locals 字段和 f_globals 字段分别指向了局部名字空间和全局名字空间,而创建栈帧时 f_locals 被初始化成了 NULL,所以并没有创建局部名字空间。这里可能有人会有疑问,因为印象之中函数是有 local 空间的吧。 import inspect # 模块的栈帧\nframe = inspect.currentframe()\n# 对于模块而言,局部名字空间和全局名字空间是同一个字典\nprint(frame.f_locals is frame.f_globals) # True\n# 当然啦,局部名字空间和全局名字空间也可以通过内置函数获取\nprint( frame.f_locals is locals() is frame.f_globals is globals()\n) # True # 但对于函数而言就不一样了\ndef foo(): name = \"古明地觉\" return inspect.currentframe() frame = foo()\n# global 名字空间全局唯一\n# 无论是获取栈帧的 f_globals,还是调用 globals(),得到的都是同一份字典\nprint(frame.f_globals is globals()) # True\n# 但每个函数都有自己独立的局部名字空间\nprint(frame.f_locals) # {'name': '古明地觉'} # 咦,不是说局部名字空间被初始化为 NULL 吗?\n# 那么在 Python 里面获取的话,结果应该是个 None 才对啊\n# 关于这一点,我们稍后会解释 总之对于函数而言,在创建栈帧时,它的 f_locals 被初始化为 NULL。那么问题来了,局部变量到底存储在什么地方呢?当然,由于变量只是一个名字(符号),而局部变量的名字都存储在符号表中,所以更严谨的说法是,局部变量的值存储在什么地方? 在介绍虚拟机执行字节码的时候我们说过,当函数被调用时,虚拟机会为其创建一个栈帧。栈帧是虚拟机的执行环境,包含了执行时所依赖的上下文,而栈帧内部有一个字段叫 f_localsplus,它是一个数组。 这个数组虽然是一段连续内存,但在逻辑上被分成了 4 份,其中局部变量便存储在 f_localsplus 的第一份空间中。现在我们明白了,局部变量是静态存储在数组中的。 我们举个例子。 def foo(a, b): c = a + b print(c) 它的字节码如下: // 加载局部变量 a,压入运行时栈\n2 0 LOAD_FAST 0 (a) // 加载局部变量 b,压入运行时栈 2 LOAD_FAST 1 (b) // 将 a 和 b 从栈中弹出,然后做加法运算,再将结果压入栈中 4 BINARY_ADD // 加载符号 c,弹出栈顶元素(a + b 的运算结果) // 然后将两者绑定起来,完成赋值语句 c = a + b 6 STORE_FAST 2 (c) // 加载变量 print,优先从 global 空间中加载 // 如果 global 空间里面没有,那么再从 builtin 空间中加载\n3 8 LOAD_GLOBAL 0 (print) // 加载局部变量 c 10 LOAD_FAST 2 (c) // 执行 print(c),并将返回值压入栈中,print 的返回值是 None 12 CALL_FUNCTION 1 // 弹出栈顶的返回值,因为没有用变量保存,所以会直接丢弃 14 POP_TOP // 隐式的 return None 16 LOAD_CONST 0 (None) 18 RETURN_VALUE 注意里面的 LOAD_FAST 和 STORE_FAST,这两个指令对应的逻辑如下。 case TARGET(LOAD_FAST): { // 通过宏 GETLOCAL 获取局部变量的值 PyObject *value = GETLOCAL(oparg); // 如果值为 NULL,抛出 UnboundLocalError if (value == NULL) { format_exc_check_arg(tstate, PyExc_UnboundLocalError, UNBOUNDLOCAL_ERROR_MSG, PyTuple_GetItem(co->co_varnames, oparg)); goto error; } // 增加引用计数并压入运行时栈 Py_INCREF(value); PUSH(value); FAST_DISPATCH();\n} case TARGET(STORE_FAST): { PREDICTED(STORE_FAST); // 获取栈顶元素 PyObject *value = POP(); // 通过宏 SETLOCAL 创建局部变量 SETLOCAL(oparg, value); FAST_DISPATCH();\n} 所以 LOAD_FAST 和 STORE_FAST 分别负责加载和创建局部变量,而核心就是里面的两个宏:GETLOCAL、SETLOCAL,这两个宏也是定义在帧评估函数里面的。 // Python/ceval.c // 在帧评估函数中,fastlocals 会被赋值为 f->f_localsplus\n#define GETLOCAL(i) (fastlocals[i]) #define SETLOCAL(i, value) do { PyObject *tmp = GETLOCAL(i); \\ GETLOCAL(i) = value; \\ Py_XDECREF(tmp); } while (0)\n/* 这里额外再补充一个关于 C 语言的知识点 * 我们看到宏 SETLOCAL 展开之后的结果是 do {...} while (0) * do while 循环会先执行 do 里面的循环体,然后再判断条件是否满足 * 因此从效果上来说,执行 do {...} while (0) 和直接执行 ... 是等价的 * 那么问题来了,既然效果等价,为啥还要再套一层 do while 呢 * 其实原因很简单,如果宏在展开之后会生成多条语句,那么这些语句要成为一个整体 * 另外由于 C 程序的语句要以分号结尾,所以在调用宏时,我们也会习惯性地在结尾加上分号 * 因此我们希望有这样一种结构,能同时满足以下要求: * 1)可以将多条语句包裹起来,作为一个整体; * 2)程序的语义不能发生改变; * 3)在语法上,要以分号结尾; * 显然 do while 完美满足以上三个要求,只需将 while 里的条件设置为 0 即可 * 并且当编译器看到 while (0) 时,也会进行优化,去掉不必要的循环控制结构 * 因此以后看到 do {...} while (0) 时,不要觉得奇怪,这是宏的一个常用技巧 */ 我们看到操作局部变量,就是在基于索引操作数组 f_localsplus,显然这个过程比操作字典要快。尽管字典是经过高度优化的,但显然再怎么优化,也不可能快过数组的静态操作。 所以此时我们对局部变量的藏身之处已经了然于心,它们就存放在栈帧的 f_localsplus 字段中,而之所以没有使用 local 名字空间的原因也很简单。因为函数内部的局部变量在编译时就已经确定了,个数是不会变的,因此编译时也能确定局部变量占用的内存大小,以及访问局部变量的字节码指令应该如何访问内存。 def foo(a, b): c = a + b print(c) print( foo.__code__.co_varnames\n) # ('a', 'b', 'c') 比如变量 c 位于符号表中索引为 2 的位置,这在编译时就已确定。 当创建变量 c 时,只需修改数组 f_localsplus 中索引为 2 的元素即可。 当访问变量 c 时,只需获取数组 f_localsplus 中索引为 2 的元素即可。 这个过程是基于数组索引实现的静态查找,所以操作局部变量和操作全局变量有着异曲同工之妙。操作全局变量本质上是基于 key 操作字典的 value,其中 key 是变量的名称,value 是变量的值;而操作局部变量本质上是基于索引操作数组 f_localsplus 的元素,这个索引就是变量名在符号表中的索引,对应的数组元素就是变量的值。 所以我们说 Python 的变量其实就是个名字,或者说符号,到这里是不是更加深刻地感受到了呢? 但对于局部变量来说,如果想实现静态查找,显然要满足一个前提:变量名在符号表中的索引和与之绑定的值在 f_localsplus 中的索引必须是一致的。毫无疑问,两者肯定是一致的,并且索引是多少在编译阶段便已经确定,会作为指令参数保存在字节码指令序列中。 好,到此可以得出结论,虽然虚拟机为函数实现了 local 名字空间(初始为 NULL),但在操作局部变量时却没有使用它,原因就是为了更高的效率。当然还有所谓的 LEGB,都说变量查找会遵循这个规则,但我们心里清楚,局部变量其实是静态访问的,不过完全可以按照 LEGB 的方式来理解。","breadcrumbs":"59. 局部变量是怎么实现静态查找的,它和 local 名字空间又有什么联系呢? » 如何访问(创建)一个局部变量","id":"266","title":"如何访问(创建)一个局部变量"},"267":{"body":"先来看一下全局名字空间: x = 1 def foo(): globals()[\"x\"] = 2 foo()\nprint(x) # 2 global 空间全局唯一,在 Python 层面上就是一个字典,在任何地方操作该字典,都相当于操作全局变量,即使是在函数内部。因此在执行完 foo() 之后,全局变量 x 就被修改了。但 local 名字空间也是如此吗?我们尝试一下。 def foo(): x = 1 locals()[\"x\"] = 2 print(x) foo() # 1 我们按照相同的套路,却并没有成功,这是为什么?原因就是上面解释的那样,函数内部有哪些局部变量在编译时就已经确定了,查询的时候是从数组 f_localsplus 中静态查找的,而不是从 local 名字空间中查找。 然后我们打印一下 local 名字空间,看看里面都有哪些内容。 def foo(): name = \"satori\" print(locals()) age = 17 print(locals()) gender = \"female\" print(locals()) foo()\n\"\"\"\n{'name': 'satori'}\n{'name': 'satori', 'age': 17}\n{'name': 'satori', 'age': 17, 'gender': 'female'}\n\"\"\" 我们看到打印 locals() 居然也会显示内部的局部变量,相信聪明如你已经猜到 locals() 是怎么回事了。符号表里面存储了局部变量的符号(或者说名字),f_localsplus 里面存储了局部变量的值,当执行 locals() 的时候,会基于符号表和 f_localsplus 创建一个字典出来。 def foo(): name = \"satori\" age = 17 gender = \"female\" print(locals()) # 符号表:保存了函数中创建的局部变量的名字\nprint(foo.__code__.co_varnames)\n\"\"\"\n('name', 'age', 'gender')\n\"\"\"\n# 调用函数时会创建栈帧,局部变量的值都保存在 f_localsplus 里面\n# 并且符号表中变量名的顺序和 f_localsplus 中变量值的顺序是一致的\nf_localsplus = [\"satori\", 17, \"female\"]\n# 这里就用一个列表来模拟了 我们来看一下变量的创建。 由于符号 name 位于符号表中索引为 0 的位置,那么执行 name = \"satori\" 时,就会将 \"satori\" 放在 f_localsplus 中索引为 0 的位置。 由于符号 age 位于符号表中索引为 1 的位置,那么执行 age = 17 时,就会将 17 放在 f_localsplus 中索引为 1 的位置。 由于符号 gender 位于符号表中索引为 2 的位置,那么执行 gender = \"female\" 时,就会将 \"female\" 放在 f_localsplus 中索引为 2 的位置。 后续在访问变量的时候,比如访问变量 age,由于它位于符号表中索引为 1 的位置,那么就会通过 f_localsplus[1] 获取它的值,这些符号对应的索引都是在编译阶段确定的。所以在运行时才能实现静态查找,指令 LOAD_FAST 和 STORE_FAST 都是基于索引来静态操作底层数组。 我们用一张图来描述这个过程: 符号表负责存储局部变量的名字,f_localsplus 负责存储局部变量的值(里面的元素初始为 NULL),而在给局部变量赋值的时候,本质上就是将值写在了 f_localsplus 中。并且变量名在符号表中的索引,和变量值在 f_localsplus 中的索引是一致的,因此操作局部变量本质上就是在操作 f_localsplus 数组。至于 locals() 或者说局部名字空间,它是基于符号表和 f_localsplus 动态创建的,为了方便我们获取已存在的局部变量,执行 locals() 会临时创建一个字典(只会创建一次)。 所以我们通过 locals() 获取局部名字空间之后,访问里面的局部变量是可以的,只不过此时将静态访问变成了动态访问。 def foo(): name = \"satori\" # 会从 f_localsplus 中静态查找 print(name) # 先基于已有的变量和值创建一个字典 # 然后通过字典实现变量的动态查找 print(locals()[\"name\"]) foo()\n\"\"\"\nsatori\nsatori\n\"\"\" 两种方式都是可以的,但基于 locals() 来访问,在效率上明显会低一些。 另外基于 locals() 访问一个变量是可以的,但无法创建一个变量。 def foo(): name = \"satori\" locals()[\"age\"] = 17 try: print(age) except NameError as e: print(e) foo()\n\"\"\"\nname 'age' is not defined\n\"\"\" 局部变量是静态存储在数组里的,locals() 只是做了一个拷贝而已。往局部名字空间里面添加一个键值对,不等于创建一个局部变量,因为局部变量不是从它这里查找的,因此代码中打印 age 报错了。但如果外部还有一个全局变量 age 的话,那么会打印全局变量 age。 然后再补充一点,我们说全局名字空间在任何地方都是唯一的,而对于函数而言,它的局部名字空间在整个函数内部也是唯一的。不管调用 locals 多少次,拿到的都是同一个字典。 def foo(): name = \"satori\" # 执行 locals() 的时候,内部只有一个键值对 d = locals() print(d) # {'name': 'satori'} # 再次获取,此时有两个键值对 print(locals()) # {'name': 'satori', 'd': {...}} # 但两者的 id 相同,因为一个函数只有一个局部名字空间 # 不管调用多少次 locals(),拿到的都是同一个字典 print(id(d) == id(locals())) # True foo() 所以 locals() 和 globals() 指向的名字空间都是唯一的,只不过 locals() 是在某个函数内部唯一,而 globals() 在所有地方都唯一。 因此局部名字空间初始为 NULL,但在第一次执行 locals() 时,会以符号表中的符号作为 key,f_localsplus 中的值作为 value,创建一个字典作为函数的局部名字空间。而后续再执行 locals() 的时候,由于名字空间已存在,就不会再次创建了,直接基于当前的局部变量对字典进行更新即可。 def foo(): # 创建一个字典,由于当前还没有定义局部变量,因此是空字典 print(locals()) # {} # 往局部名字空间添加一个键值对 locals()[\"a\"] = \"b\" print(locals()) # {'a': 'b'} # 定义一个局部变量 name = \"satori\" # 由于局部名字空间已存在,因此不会再次创建 # 直接将局部变量的名字作为 key、值作为 value,拷贝到字典中 print(locals()) # {'a': 'b', 'name': 'satori'} foo() 注意:虽然局部名字空间里面存在 \"a\" 这个 key,但 a 这个局部变量是不存在的。","breadcrumbs":"59. 局部变量是怎么实现静态查找的,它和 local 名字空间又有什么联系呢? » 解密 local 名字空间","id":"267","title":"解密 local 名字空间"},"268":{"body":"目前我们已经知道 local 名字空间是怎么创建的了,也熟悉了它的特性,下面通过源码来看一下它的构建过程。 // Python/bltinmodule.c static PyObject *\nbuiltin_locals_impl(PyObject *module)\n{ PyObject *d; // Python 内置函数的源码实现位于 bltinmodule.c 中 // 这里又调用了 PyEval_GetLocals d = PyEval_GetLocals(); Py_XINCREF(d); return d;\n} // Python/ceval.c\nPyObject *\nPyEval_GetLocals(void)\n{ // 获取线程状态对象 PyThreadState *tstate = _PyThreadState_GET(); // 拿到当前栈桢 PyFrameObject *current_frame = _PyEval_GetFrame(tstate); if (current_frame == NULL) { _PyErr_SetString(tstate, PyExc_SystemError, \"frame does not exist\"); return NULL; } // 调用 PyFrame_FastToLocalsWithError 创建 local 名字空间 // 并赋值给 current_frame->f_locals if (PyFrame_FastToLocalsWithError(current_frame) < 0) { return NULL; } assert(current_frame->f_locals != NULL); // 返回 current_frame->f_locals return current_frame->f_locals;\n} // Objects/frameobject.c\nint\nPyFrame_FastToLocalsWithError(PyFrameObject *f)\n{ PyObject *locals, *map; PyObject **fast; PyCodeObject *co; Py_ssize_t j; Py_ssize_t ncells, nfreevars; // 栈桢不能为空 if (f == NULL) { PyErr_BadInternalCall(); return -1; } // 获取局部名字空间 locals = f->f_locals; // 如果为 NULL,那么创建一个新字典,作为名字空间 // 所以局部名字空间只会创建一次,后续不会再创建 if (locals == NULL) { locals = f->f_locals = PyDict_New(); if (locals == NULL) return -1; } // 获取 PyCodeObject 对象 co = f->f_code; // 拿到内部的符号表(一个元组),里面保存了函数局部变量的名字 map = co->co_varnames; if (!PyTuple_Check(map)) { PyErr_Format(PyExc_SystemError, \"co_varnames must be a tuple, not %s\", Py_TYPE(map)->tp_name); return -1; } // 获取 f_localsplus,它里面保存了局部变量的值 // 只不过除了局部变量的值之外,还保存了其它的 fast = f->f_localsplus; // 那么 f_localsplus 里面到底有多少个局部变量的值呢?显然这要基于符号表来判断 // co_varnames 里面保存了多少个符号,f_localsplus 里面就保存了多少个局部变量的值 j = PyTuple_GET_SIZE(map); if (j > co->co_nlocals) // 理论上符号表的长度和局部变量的个数(co_nlocals)是相等的 // 但如果超过了 co_nlocals,那么让它等于 co_nlocals j = co->co_nlocals; // 如果 co_nlocals 大于 0,证明存在局部变量,那么调用 map_to_dict // 将 co_varnames 和 f_localsplus 里的元素组成键值对,添加到局部名字空间中 if (co->co_nlocals) { // 相当于 locals.update(zip(co_varnames, f_localsplus)) if (map_to_dict(map, j, locals, fast, 0) < 0) return -1; } // 如果里面有 cell 变量和 free 变量的话,也会添加到局部名字空间中 // 关于 cell 变量和 free 变量,由于它们和闭包相关,所以等介绍闭包的时候再说 ncells = PyTuple_GET_SIZE(co->co_cellvars); nfreevars = PyTuple_GET_SIZE(co->co_freevars); if (ncells || nfreevars) { if (map_to_dict(co->co_cellvars, ncells, locals, fast + co->co_nlocals, 1)) return -1; if (co->co_flags & CO_OPTIMIZED) { if (map_to_dict(co->co_freevars, nfreevars, locals, fast + co->co_nlocals + ncells, 1) < 0) return -1; } } return 0;\n} 可以看到,源码的实现逻辑和我们之前分析的是一样的。 变量 map 是符号表 co_varnames,保存了局部变量的名字; 变量 fast 是 f_localsplus,保存了局部变量的值; 变量 j 是局部变量的个数; 变量 locals 是局部名字空间; 然后将它们作为参数,传递给 map_to_dict 函数,该函数内部会进行遍历,按照顺序将变量名和变量值依次添加到局部名字空间中。然后我们再来看一下 map_to_dict 函数,它内部有一处细节非常之关键。 // Objects/frameobject.c static int\nmap_to_dict(PyObject *map, Py_ssize_t nmap, PyObject *dict, PyObject **values, int deref)\n{ Py_ssize_t j; assert(PyTuple_Check(map)); assert(PyDict_Check(dict)); assert(PyTuple_Size(map) >= nmap); // nmap 表示局部变量的个数 for (j=0; j < nmap; j++) { // 从符号表中获取符号(变量名) PyObject *key = PyTuple_GET_ITEM(map, j); // values 就是 f_localsplus,然后获取变量的值 PyObject *value = values[j]; assert(PyUnicode_Check(key)); // 闭包变量相关,等介绍闭包的时候再说,由于当前不存在闭包,所以先忽略掉 if (deref && value != NULL) { assert(PyCell_Check(value)); value = PyCell_GET(value); } // 注意这一步:检测 value 是否等于 NULL,那么问题来了,什么时候 value 会等于 NULL 呢? // 之前说了,局部变量有哪些在编译的时候就确定了,保存在符号表中,局部变量的值保存在 f_localsplus 中 // 在局部变量还没有赋值的时候,它在 f_localsplus 中对应的值就是 NULL // 而给一个局部变量赋值,本质上就是在修改 f_localsplus // 假设一个函数的符号表为 (\"a\", \"b\", \"c\"),在给变量 c 赋值之前调用了 locals() // 那么在获取变量 c 对应的值时,拿到的就是一个 NULL if (value == NULL) { // 如果某个符号对应的值为 NULL,则说明在获取名字空间时,该变量还没有被赋值 // 那么当该符号在 local 空间中已存在时,还要将它删掉。这一步非常关键,它的作用我们稍后会说 if (PyObject_DelItem(dict, key) != 0) { if (PyErr_ExceptionMatches(PyExc_KeyError)) PyErr_Clear(); else return -1; } } // 如果 value 不等于 NULL,说明变量已经完成了和某个值的绑定,于是将它们组成键值对拷贝到 local 空间中 else { if (PyObject_SetItem(dict, key, value) != 0) return -1; } } return 0;\n} 以上就是 local 名字空间的获取过程在源码层面的体现。","breadcrumbs":"59. 局部变量是怎么实现静态查找的,它和 local 名字空间又有什么联系呢? » local 名字空间的创建过程","id":"268","title":"local 名字空间的创建过程"},"269":{"body":"我们再来搭配 exec 关键字,结果会更加明显。首先 exec 函数可以将一段字符串当成代码来执行,并将执行结果体现在当前的名字空间中。 def foo(): print(locals()) # {} exec(\"x = 1\") print(locals()) # {'x': 1} try: print(x) except NameError as e: print(e) # name 'x' is not defined foo() 尽管 locals() 变了,但是依旧访问不到 x,因为虚拟机并不知道 exec(\"x = 1\") 是创建一个局部变量,它只知道这是一个函数调用。 事实上 exec 会作为一个独立的编译单元来执行,并且有自己的作用域。 所以 exec(\"x = 1\") 执行完之后,效果就是改变了局部名字空间,里面多了一个 \"x\": 1 键值对。但关键的是,局部变量 x 不是从局部名字空间中查找的,exec 终究还是错付了人。而由于函数 foo 对应的 PyCodeObject 对象的符号表中并没有 x 这个符号,所以报错了。 补充:exec 默认影响的是 local 名字空间,如果在执行时发现 local 名字空间为 NULL,那么会自动创建一个。所以调用 exec 也可以创建名字空间(当它为 NULL 时)。 exec(\"x = 1\")\nprint(x) # 1 如果放在模块里面是可以的,因为模块的 local 名字空间和 global 名字空间指向同一个字典,所以 global 名字空间会多一个 key 为 \"x\" 的键值对。而全局变量是从 global 名字空间中查找的,所以这里没有问题。 def foo(): # 此时 exec 影响的是 global 名字空间 exec(\"x = 123\", globals()) # 所以这里不会报错, 但此时的 x 不是局部变量, 而是全局变量 print(x) foo()\nprint(x)\n\"\"\"\n123\n123\n\"\"\" 可以给 exec 指定要影响的名字空间,代码中 exec 影响的是全局名字空间,打印的 x 也是全局变量。","breadcrumbs":"59. 局部变量是怎么实现静态查找的,它和 local 名字空间又有什么联系呢? » local 名字空间与 exec 函数","id":"269","title":"local 名字空间与 exec 函数"},"27":{"body":"前面我们介绍了 Python 对象在底层的数据结构,知道了 Python 底层是通过 PyObject 实现了对象的多态。所以我们先来分析一下 Python 为什么慢? 在 Python 中创建一个对象,会分配内存并进行初始化,然后用一个 PyObject * 指针来维护这个对象,当然所有对象都是如此。因为指针是可以相互转化的,所以变量在保存一个对象的指针时,会将指针转成 PyObject * 之后再交给变量保存。 因此在 Python 中,变量的传递(包括函数的参数传递)实际上传递的都是泛型指针 PyObject *。这个指针具体指向什么类型的对象我们并不知道,只能通过其内部的 ob_type 字段进行动态判断,而正是因为这个 ob_type,Python 实现了多态机制。 比如 a.pop(),我们不知道 a 指向的对象是什么类型,它可能是列表、也可能是字典,或者是我们实现了 pop 方法的自定义类的实例对象。至于它到底是什么类型,只能通过 ob_type 动态判断。 如果 a 的 ob_type 为 &PyList_Type,那么 a 指向的对象就是列表,于是会调用 list 类型中定义的 pop 操作。如果 a 的 ob_type 为 &PyDict_Type,那么 a 指向的对象就是字典,于是会调用 dict 类型中定义的 pop 操作。所以变量 a 在不同的情况下,会表现出不同的行为,这正是 Python 多态的核心所在。 再比如列表,它内部的元素也都是 PyObject *,因为类型要保持一致,所以对象的指针不能直接存(因为类型不同),而是需要统一转成泛型指针 PyObject * 之后才可以存储。当我们通过索引获取到该指针进行操作的时候,也会先通过 ob_type 判断它的类型,看它是否支持指定的操作。所以操作容器内的某个元素,和操作一个变量并无本质上的区别,它们都是 PyObject *。 从这里我们也能看出 Python 为什么慢了,因为有一部分时间浪费在类型和属性的查找上面。 以变量 a + b 为例,这个 a 和 b 指向的对象可以是整数、浮点数、字符串、列表、元组、以及实现了 __add__ 方法的类的实例对象。因为 Python 的变量都是 PyObject *,所以它可以指向任意的对象,这就意味着 Python 无法做基于类型的优化。 底层在执行 a + b 时,首先要通过 ob_type 判断变量指向的对象是什么类型,这在 C 的层面需要一次属性查找。然后 Python 将每一个算术操作都抽象成了一个魔法方法,所以实例相加时要在类型对象中找到该方法对应的函数指针,这又是一次属性查找。找到了之后将 a、b 作为参数传递进去,这会产生一次函数调用,将对象维护的值拿出来进行运算,然后根据相加的结果创建一个新的对象,再将新的对象的指针转成 PyObject * 之后返回。 所以一个简单的加法运算,Python 内部居然做了这么多的工作,要是再放到循环里面,那么上面的步骤要重复 N 次。而对于 C 来讲,由于已经规定好了类型,所以 a + b 在编译之后就是一条简单的机器指令,因此两者在效率上差别很大。 当然我们不是来吐槽 Python 效率的问题,因为任何语言都有擅长的一面和不擅长的一面,这里只是通过回顾前面的知识来解释为什么 Python 效率低。因此当别人问你 Python 为什么效率低的时候,希望你能从这个角度来回答它,主要就两点: Python 无法基于类型做优化; Python 对象基本都存储在堆上; 建议不要一上来就谈 GIL,那是在多线程情况下才需要考虑的问题。而且我相信大部分觉得 Python 慢的人,都不是因为 Python 无法利用多核才觉得慢的。","breadcrumbs":"7. 当创建一个 Python 对象时,背后都经历了哪些过程? » Python 为什么这么慢","id":"27","title":"Python 为什么这么慢"},"270":{"body":"我们说 exec 的执行效果会体现在 local 名字空间中,但是需要考虑变量名冲突的问题。举个例子: def foo(): exec(\"x = 1\") print(locals()[\"x\"]) foo()\n\"\"\"\n1\n\"\"\" def bar(): exec(\"x = 1\") print(locals()[\"x\"]) x = 123 bar()\n\"\"\"\nTraceback (most recent call last): File ..... bar() File ..... print(locals()[\"x\"])\nKeyError: 'x'\n\"\"\" 这是什么情况?函数 bar 只是多了一行赋值语句,为啥就报错了呢?要想搞懂这个问题,首先要明确两点: 1)函数的局部变量在编译的时候就已经确定,并存储在对应的 PyCodeObject 对象的符号表 (co_varnames) 中,这是由语法规则所决定的; 2)函数内的局部变量在其整个作用域范围内都是可见的; 对于 foo 函数来说,exec 执行完之后相当于往 local 名字空间中添加一个键值对,这没有问题。对于 bar 函数而言也是如此,在执行完 exec(\"x = 1\") 之后,local 名字空间中也会存在 \"x\": 1 这个键值对,但下面执行 locals() 的时候又把字典更新了。因为局部变量可以在函数的任意位置创建,或者修改,所以每一次执行 locals() 的时候,都会遍历符号表和 f_localsplus,组成键值对将原来的字典更新一遍。 在 bar 函数里面有一行 x = 123,所以知道函数里面存在局部变量 x,符号表里面也会有 \"x\" 这个符号,这是在编译时就确定的。但我们是在 x = 123 之前调用的 locals,所以此时符号 x 在 f_localsplus 中对应的值还是一个 NULL,没有指向一个合法的 PyObject。换句话说就是,知道里面存在局部变量 x,但是还没有来得及赋值。 然后在更新名字空间的时候,如果发现值是个 NULL,那么就把名字空间中该变量对应的键值对给删掉。 我们回顾一下 map_to_dict 函数: 所以 bar 函数执行 locals()[\"x\"] 的时候,会先获取名字空间,原本里面是有 \"x\": 1 这个键值对的。但因为赋值语句 x = 123 的存在,导致符号表里面存在 \"x\" 这个符号,但执行 locals() 的时候又尚未完成赋值,所以值为 NULL,于是又把这个键值对给删掉了。所以执行 locals()[\"x\"] 的时候,出现了 KeyError。 因为局部名字空间体现的是局部变量的值,而调用 locals 的时候,局部变量 x 还没有被创建。所以 locals() 里面不应该存在 key 为 \"x\" 的键值对,于是会将它删除。 我们将名字空间打印一下: def foo(): # 创建局部名字空间,并写入键值对 \"x\": 1 # 此时名字空间为 {\"x\": 1} exec(\"x = 1\") # 获取名字空间,会进行更新 # 但当前不存在局部变量,所以名字空间仍是 {\"x\": 1} print(locals()) def bar(): # 创建局部名字空间,并写入键值对 \"x\": 1 # 此时名字空间为 {\"x\": 1} exec(\"x = 1\") # 获取名字空间,会进行更新 # 由于里面存在局部变量 x,但尚未赋值 # 于是将字典中 key 为 \"x\" 的键值对给删掉 # 所以名字空间变成了 {} print(locals()) x = 123 foo() # {'x': 1}\nbar() # {} 上面代码中,局部变量的创建发生在 exec 之后,如果发生在 exec 之前也是相同的结果。 def foo(): exec(\"x = 2\") print(locals()) foo() # {'x': 2} def bar(): x = 1 exec(\"x = 2\") print(locals()) bar() # {'x': 1} 在 exec(\"x = 2\") 执行之后,名字空间也变成了 {\"x\": 2}。但从源码中我们看到,每次调用 locals,都会遍历符号表和 f_localsplus,对字典进行更新,所以在 bar 函数里面获取名字空间的时候,又把 \"x\" 对应的 value 给更新回来了。 当然这是在变量冲突的情况下,会保存真实存在的局部变量的值。但如果不冲突,比如 bar 函数里面是 exec(\"y = 2\"),那么 locals() 里面就会存在两个键值对。但只有 x 才是真正的局部变量,而 y 则不是。 将 exec(\"x = 2\") 换成 locals()[\"x\"] = 2 也是一样的效果,它们都是往局部名字空间中添加一个键值对,但不会创建一个局部变量。","breadcrumbs":"59. 局部变量是怎么实现静态查找的,它和 local 名字空间又有什么联系呢? » 变量名冲突的问题","id":"270","title":"变量名冲突的问题"},"271":{"body":"当 Python 中混进一只薛定谔的猫…… ,这是猫哥在 19 年更新的一篇文章,里面探讨的内容我们本文的主题是重叠的。猫哥在文章中举了几个疑惑重重的例子,看看用上面学到的内容能不能合理地解释。 # 例 0\ndef foo(): exec('y = 1 + 1') z = locals()['y'] print(z) foo()\n# 输出:2 # 例 1\ndef foo(): exec('y = 1 + 1') y = locals()['y'] print(y) foo()\n# 报错:KeyError: 'y' 以上是猫哥文章中举的示例,首先例 0 很简单,因为 exec 影响了所在的局部名字空间,里面存在 \"y\": 2 这个键值对。至于里面的变量 z 则不影响,因为我们获取的是 \"y\" 这个 key 对应的 value。 但例 1 则不同,因为 Python 在语法解析的时候发现了 y = ... 这样的赋值语句,那么它在编译的时候就知道函数里面存在 y 这个局部变量,并写入符号表中。既然符号表中存在,那么调用 locals 的时候就会对它进行更新。但是对 y 赋值是发生在调用 locals 之后,所以在调用 locals 的时候,y 的值还是一个 NULL,也就是变量还没有赋值。所以会将名字空间中的 \"y\": 2 这个键值对给删掉,于是报出 KeyError 错误。 再来看看猫哥文章的例 2: # 例 2\ndef foo(): y = 1 + 1 y = locals()['y'] print(y) foo()\n# 输出:2 locals() 是对真实存在的局部变量的一个拷贝,在调用 locals 之前 y 就已经创建好了。符号表里面有 \"y\",f_localsplus 里面有一个数值 2,所以调用 locals() 的时候,会得到 {\"y\": 2},因此函数执行正常。 猫哥文章的例 3: # 例3\ndef foo(): exec('y = 1 + 1') boc = locals() y = boc['y'] print(y) foo()\n# KeyError: 'y' 这个例3 和例1 是一样的,只不过用变量 boc 将局部名字空间保存起来了。执行 exec 的时候,会创建局部名字空间,写入键值对 \"y\": 2。但调用 locals 的时候,发现函数内部存在局部变量 y 并且还尚未赋值,于是又会将 \"y\": 2 这个键值对给删掉,因此 boc 变成了一个空字典。 所以在执行 y = boc[\"y\"] 的时候会出现 KeyError。 猫哥文章的例 4: # 例4\ndef foo(): boc = locals() exec('y = 1 + 1') y = boc['y'] print(y) foo()\n# 输出:2 显然在调用 locals 的时候,会返回一个空字典,因为此时的局部变量都还没有赋值。但需要注意的是:boc 已经指向了局部名字空间(字典),而局部名字空间在一个函数里面也是唯一的。所以 exec(\"y = 1 + 1\") 执行之后,会往局部名字空间里面写入一个键值对,而变量 boc 指向的字典也会发生改变,因为是同一个字典,所以程序正常执行。 猫哥文章的例 5: # 例5\ndef foo(): boc = locals() exec('y = 1 + 1') print(locals()) y = boc['y'] print(y) foo()\n# {'boc': {...}} # KeyError: 'y' 首先在执行 boc = locals() 之后,boc 会指向一个空字典,然后 exec 函数执行之后会往字典里面写入一个键值对 \"y\": 2。如果在 exec 执行之后,直接执行 y = boc[\"y\"],那么代码是没有问题的,但问题是执行之前插入了一个 print(locals())。 我们说过,当调用 locals 的时候,会对名字空间进行更新,然后返回更新之后的名字空间。由于函数内部存在 y = ... 这样的赋值语句,所以符号表中就存在 \"y\" 这个符号,于是会进行更新。但更新的时候,发现 y 还没有被赋值,于是又将字典中的键值对 \"y\": 2 给删掉了。 由于局部名字空间只有一份,所以 boc 指向的字典也会发生改变,换句话说在 print(locals()) 之后,boc 就指向了一个空字典,因此执行 y = boc[\"y\"] 时会出现 KeyError。","breadcrumbs":"59. 局部变量是怎么实现静态查找的,它和 local 名字空间又有什么联系呢? » 薛定谔的猫","id":"271","title":"薛定谔的猫"},"272":{"body":"以上我们就探讨了 local 名字空间相关的内容,它是一个字典,是对真实存在的局部变量的一个拷贝,每当我们调用 locals,都会拷贝一次(但字典只会存在一份)。 然后函数的局部变量都是静态存储的,编译时就已经确定,无法在运行时动态添加。我们往局部名字空间里面添加键值对,并不等价于创建局部变量。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"59. 局部变量是怎么实现静态查找的,它和 local 名字空间又有什么联系呢? » 小结","id":"272","title":"小结"},"273":{"body":"前面我们考察了虚拟机执行字节码指令的原理,那么本篇文章就来看看这些指令对应的逻辑是怎样的,每个指令都做了哪些事情。当然啦,由于字节码指令有一两百个,我们没办法逐一分析,这里会介绍一些常见的。至于其它的指令,会随着学习的深入,慢慢揭晓。 介绍完常见指令之后,我们会探讨 Python 赋值语句的背后原理,并分析它们的差异。","breadcrumbs":"60. 剖析字节码指令,以及 Python 赋值语句的原理 » 楔子","id":"273","title":"楔子"},"274":{"body":"有一部分指令出现的频率极高,非常常用,我们来看一下。 LOAD_CONST:加载一个常量; LOAD_FAST:在局部作用域中加载一个局部变量; LOAD_GLOBAL:在局部作用域中加载一个全局变量或内置变量; LOAD_NAME:在全局作用域中加载一个全局变量或内置变量; STORE_FAST:在局部作用域中定义一个局部变量,来建立和某个对象之间的映射关系; STORE_GLOBAL:在局部作用域中定义一个使用 global 关键字声明的全局变量,来建立和某个对象之间的映射关系; STORE_NAME:在全局作用域中定义一个全局变量,来建议和某个对象之间的映射关系; 我们举例说明: import dis name = \"古明地觉\" def foo(): age = 16 print(age) global name print(name) name = \"古明地恋\" dis.dis(foo)\n\"\"\" 6 0 LOAD_CONST 1 (16) 2 STORE_FAST 0 (age) 7 4 LOAD_GLOBAL 0 (print) 6 LOAD_FAST 0 (age) 8 CALL_FUNCTION 1 10 POP_TOP 9 12 LOAD_GLOBAL 0 (print) 14 LOAD_GLOBAL 1 (name) 16 CALL_FUNCTION 1 18 POP_TOP 10 20 LOAD_CONST 2 ('古明地恋') 22 STORE_GLOBAL 1 (name) 24 LOAD_CONST 0 (None) 26 RETURN_VALUE\n\"\"\" 我们看到 age = 16 对应两条字节码指令。 LOAD_CONST:加载一个常量,这里是 16; STORE_FAST:在局部作用域中创建一个局部变量,这里是 age; print(age) 对应四条字节码指令。 LOAD_GLOBAL:在局部作用域中加载一个全局变量或内置变量,这里是 print; LOAD_FAST:在局部作用域中加载一个局部变量,这里是 age; CALL_FUNCTION:函数调用; POP_TOP:从栈顶弹出返回值; print(name) 对应四条字节码指令。 LOAD_GLOBAL:在局部作用域中加载一个全局变量或内置变量,这里是 print; LOAD_GLOBAL:在局部作用域中加载一个全局变量或内置变量,这里是 name; CALL_FUNCTION:函数调用; POP_TOP:从栈顶弹出返回值; name = \"古明地恋\" 对应两条字节码指令。 LOAD_CONST:加载一个常量,这里是 \"古明地恋\"; STORE_GLOBAL:在局部作用域中创建一个 global 关键字声明的全局变量,这里是 name; 这些指令非常常见,因为它们和常量、变量的加载,以及变量的定义密切相关,你写的任何代码在反编译之后都少不了它们的身影。 注:不管加载的是常量、还是变量,得到的永远是指向对象的指针。","breadcrumbs":"60. 剖析字节码指令,以及 Python 赋值语句的原理 » 常用指令","id":"274","title":"常用指令"},"275":{"body":"这里再通过变量赋值感受一下字节码的执行过程,首先关于变量赋值,你平时是怎么做的呢? 这些赋值语句背后的原理是什么呢?我们通过字节码来逐一回答。 1)a, b = b, a 的背后原理是什么? 想要知道背后的原理,查看它的字节码是我们最好的选择。 1 0 LOAD_NAME 0 (b) 2 LOAD_NAME 1 (a) 4 ROT_TWO 6 STORE_NAME 1 (a) 8 STORE_NAME 0 (b) 10 LOAD_CONST 0 (None) 12 RETURN_VALUE 里面关键的就是 ROT_TWO 指令,虽然我们还没看这个指令,但也能猜出来它负责交换栈里面的两个元素。假设 a 和 b 的值分别为 22、33,看一下运行时栈的变化过程。 示意图还是很好理解的,关键就在于 ROT_TWO 指令,它是怎么交换元素的呢? case TARGET(ROT_TWO): { // 获取栈顶元素 PyObject *top = TOP(); // 获取从栈顶开始的第二个元素(栈底元素) PyObject *second = SECOND(); // 将栈顶元素设置为 second,将栈的第二个元素设置为 top // 完成两个元素之间的交换 SET_TOP(second); SET_SECOND(top); FAST_DISPATCH();\n} 执行 ROT_TWO 指令之前,栈里有两个元素,栈顶元素是 a,栈底元素是 b。执行 ROT_TWO 指令之后,栈顶元素是 b,栈底元素是 a。然后后面的两个 STORE_NAME 会将栈里面的元素 b、a 依次弹出,赋值给 a、b,从而完成变量交换。 2)a, b, c = c, b, a 的背后原理是什么? 老规矩,还是查看字节码,因为一切真相都隐藏在字节码当中。 1 0 LOAD_NAME 0 (c) 2 LOAD_NAME 1 (b) 4 LOAD_NAME 2 (a) 6 ROT_THREE 8 ROT_TWO 10 STORE_NAME 2 (a) 12 STORE_NAME 1 (b) 14 STORE_NAME 0 (c) 16 LOAD_CONST 0 (None) 18 RETURN_VALUE 整个过程和 a, b = b, a 是相似的,首先 LOAD_NAME 将变量 c、b、a 依次压入栈中。由于栈先入后出的特性,此时栈的三个元素按照顺序(从栈顶到栈底)分别是 a、b、c。然后是 ROT_THREE 和 ROT_TWO,毫无疑问,这两个指令执行完之后,会将栈的三个元素调换顺序,也就是将 a、b、c 变成 c、b、a。最后 STORE_NAME 将栈的三个元素 c、b、a 依次弹出,分别赋值给 a、b、c,从而完成变量的交换。 因此核心就在 ROT_THREE 和 ROT_TWO 上面,由于后者上面已经说过了,所以我们看一下 ROT_THREE。 case TARGET(ROT_THREE): { PyObject *top = TOP(); PyObject *second = SECOND(); PyObject *third = THIRD(); SET_TOP(second); SET_SECOND(third); SET_THIRD(top); FAST_DISPATCH();\n} 栈顶元素是 top、栈的第二个元素是 second、栈的第三个元素是 third,然后将栈顶元素设置为 second、栈的第二个元素设置为 third、栈的第三个元素设置为 top。所以栈里面的 a、b、c 在经过 ROT_THREE 之后就变成了 b、c、a,显然这还不是正确的结果。于是继续执行 ROT_TWO,将栈的前两个元素进行交换,执行完之后就变成了 c、b、a。 假设 a、b、c 的值分别为 \"a\"、\"b\"、\"c\",整个过程如下: 对于多元赋值来说,解释器的做法是固定的,首先按照从左往右的顺序,将等号右边的变量依次压入栈中,然后在栈里面对元素做处理,最后再将栈里的元素弹出,仍旧按照从左往右的顺序,依次赋值给等号左边的变量。 另外这里为了交换栈里的三个元素,使用了两个指令,但其实一个指令就够了,只需将栈顶元素和栈底元素进行交换即可,因为中间的元素是不需要动的。而在之后的版本中,官方优化了这个逻辑。 3)a, b, c, d = d, c, b, a 的背后原理是什么?它和上面提到的 1)和 2)有什么区别呢? 我们还是看一下字节码。 1 0 LOAD_NAME 0 (d) 2 LOAD_NAME 1 (c) 4 LOAD_NAME 2 (b) 6 LOAD_NAME 3 (a) 8 BUILD_TUPLE 4 10 UNPACK_SEQUENCE 4 12 STORE_NAME 3 (a) 14 STORE_NAME 2 (b) 16 STORE_NAME 1 (c) 18 STORE_NAME 0 (d) 20 LOAD_CONST 0 (None) 22 RETURN_VALUE 将等号右边的变量,按照从左往右的顺序,依次压入栈中,但此时没有直接将栈里面的元素做交换,而是构建一个元组。因为往栈里面压入了四个元素,所以 BUILD_TUPLE 后面的 oparg 是 4,表示构建长度为 4 的元组。 case TARGET(BUILD_TUPLE): { // 元素从栈顶到栈底依次是 a、b、c、d PyObject *tup = PyTuple_New(oparg); if (tup == NULL) goto error; // 将元素依次弹出,弹出的顺序也是 a、b、c、d // 但是注意循环,元素是从后往前设置的 // 所以 item[3], item[2], item[1], item[0] = a, b, c, d while (--oparg >= 0) { PyObject *item = POP(); PyTuple_SET_ITEM(tup, oparg, item); } // 将元组 item 压入栈中,元组为 (d, c, b, a) PUSH(tup); DISPATCH();\n} 此时栈里面只有一个元素,指向一个元组。接下来是 UNPACK_SEQUENCE,负责对序列进行解包,它的指令参数也是 4,表示要解包的序列的长度为 4,我们来看看它的逻辑。 case TARGET(UNPACK_SEQUENCE): { PREDICTED(UNPACK_SEQUENCE); // seq:从栈里面弹出的元组 (d, c, b, a) // item:用于遍历元素 // items:指向一个 PyObject * 类型的数组 PyObject *seq = POP(), *item, **items; if (PyTuple_CheckExact(seq) && PyTuple_GET_SIZE(seq) == oparg) { // 获取元组内部的 ob_item 字段,元素就存储在它指向的数组中 items = ((PyTupleObject *)seq)->ob_item; // 遍历内部的每一个元素,并依次压入栈中 // 由于是从后往前遍历的,所以遍历的元素依次是 a b c d // 但在压入栈中之后,元素从栈顶到栈底就变成了 d c b a while (oparg--) { item = items[oparg]; Py_INCREF(item); PUSH(item); } } else if (PyList_CheckExact(seq) && PyList_GET_SIZE(seq) == oparg) { // 该指令同样适用于列表,逻辑一样(一会儿会看到) items = ((PyListObject *)seq)->ob_item; while (oparg--) { item = items[oparg]; Py_INCREF(item); PUSH(item); } } // ... Py_DECREF(seq); DISPATCH();\n} 最后 STORE_NAME 将 d c b a 依次弹出,赋值给变量 a b c d,从而完成变量交换。所以当交换的变量多了之后,不会直接在运行时栈里面操作,而是将栈里面的元素挨个弹出,构建元组;然后再按照指定顺序,将元组里面的元素重新压到栈里面。 假设变量 a b c d 的值分别为 1 2 3 4,我们画图来描述一下整个过程。 不管是哪一种做法,Python 在进行变量交换时所做的事情是不变的,核心分为三步走。首先将等号右边的变量,按照从左往右的顺序,依次压入栈中;然后对运行时栈里面元素的顺序进行调整;最后再将运行时栈里面的元素挨个弹出,还是按照从左往右的顺序,再依次赋值给等号左边的变量。 只不过当变量不多时,调整元素位置会直接基于栈进行操作;而当达到四个时,则需要额外借助于元组。 然后多元赋值也是同理,比如 a, b, c = 1, 2, 3,看一下它的字节码。 1 0 LOAD_CONST 0 ((1, 2, 3)) 2 UNPACK_SEQUENCE 3 4 STORE_NAME 0 (a) 6 STORE_NAME 1 (b) 8 STORE_NAME 2 (c) 10 LOAD_CONST 1 (None) 12 RETURN_VALUE 元组直接作为一个常量被加载进来了,然后解包,再依次赋值。 4)a, b, c, d = d, c, b, a 和 a, b, c, d = [d, c, b, a] 有区别吗? 答案是没有区别,两者在反编译之后对应的字节码指令只有一处不同。 1 0 LOAD_NAME 0 (d) 2 LOAD_NAME 1 (c) 4 LOAD_NAME 2 (b) 6 LOAD_NAME 3 (a) 8 BUILD_LIST 4 10 UNPACK_SEQUENCE 4 12 STORE_NAME 3 (a) 14 STORE_NAME 2 (b) 16 STORE_NAME 1 (c) 18 STORE_NAME 0 (d) 20 LOAD_CONST 0 (None) 22 RETURN_VALUE 前者是 BUILD_TUPLE,现在变成了 BUILD_LIST,其它部分一模一样,并且解包用的依旧是 UNPACK_SEQUENCE 指令,所以两者的效果是相同的。当然啦,由于元组的构建比列表快一些,因此还是推荐第一种写法。 5)a = b = c = 123 背后的原理是什么? 如果变量 a、b、c 指向的值相同,比如都是 123,那么便可以通过这种方式进行链式赋值。那么它背后是怎么做的呢? 1 0 LOAD_CONST 0 (123) 2 DUP_TOP 4 STORE_NAME 0 (a) 6 DUP_TOP 8 STORE_NAME 1 (b) 10 STORE_NAME 2 (c) 12 LOAD_CONST 1 (None) 14 RETURN_VALUE 出现了一个新的字节码指令 DUP_TOP,只要搞清楚它的作用,事情就简单了。 case TARGET(DUP_TOP): { // 获取栈顶元素,注意是获取、不是弹出 // TOP:查看元素,POP:弹出元素 PyObject *top = TOP(); // 增加指向对象的引用计数 Py_INCREF(top); // 压入栈中 PUSH(top); FAST_DISPATCH();\n} 所以 DUP_TOP 干的事情就是将栈顶元素拷贝一份,再重新压到栈里面。另外不管链式赋值语句中有多少个变量,模式都是一样的。 我们以 a = b = c = d = e = 123 为例: 1 0 LOAD_CONST 0 (123) 2 DUP_TOP 4 STORE_NAME 0 (a) 6 DUP_TOP 8 STORE_NAME 1 (b) 10 DUP_TOP 12 STORE_NAME 2 (c) 14 DUP_TOP 16 STORE_NAME 3 (d) 18 STORE_NAME 4 (e) 20 LOAD_CONST 1 (None) 22 RETURN_VALUE 将常量压入运行时栈,然后拷贝一份,赋值给 a;再拷贝一份,赋值给 b;再拷贝一份,赋值给 c;再拷贝一份,赋值给 d;最后自身赋值给 e。 当然啦,虽然 Python 一切皆对象,但拿到的都是指向对象的指针,所以这里拷贝的是指针。 以上就是链式赋值的秘密,其实没有什么好神奇的,就是将栈顶元素进行拷贝,再依次赋值。但是这背后有一个坑,就是给变量赋的值不能是可变对象,否则容易造成 BUG。 a = b = c = {} a[\"ping\"] = \"pong\"\nprint(a) # {'ping': 'pong'}\nprint(b) # {'ping': 'pong'}\nprint(c) # {'ping': 'pong'} 虽然 Python 一切皆对象,但对象都是通过指针来间接操作的。所以 DUP_TOP 是将字典的地址拷贝一份,而字典只有一个,因此最终 a、b、c 会指向同一个字典。 6)a is b 和 a == b 的区别是什么? is 用于判断两个变量是不是引用同一个对象,也就是保存的对象的地址是否相等;而 == 则是判断两个变量引用的对象是否相等,等价于 a.__eq__(b) 。 Python 的变量在 C 看来只是一个指针,因此两个变量是否指向同一个对象,等价于 C 中的两个指针存储的地址是否相等; 而 Python 的 ==,则需要调用 PyObject_RichCompare,来比较它们指向的对象所维护的值是否相等。 这两个语句的字节码指令是一样的,唯一的区别就是指令 COMPARE_OP 的参数不同。 // a is b 1 0 LOAD_NAME 0 (a) 2 LOAD_NAME 1 (b) 4 COMPARE_OP 8 (is) 6 POP_TOP 8 LOAD_CONST 0 (None) 10 RETURN_VALUE // a == b 1 0 LOAD_NAME 0 (a) 2 LOAD_NAME 1 (b) 4 COMPARE_OP 2 (==) 6 POP_TOP 8 LOAD_CONST 0 (None) 10 RETURN_VALUE 我们看到指令参数一个是 8、一个是 2,然后是 COMPARE_OP 指令的背后逻辑: case TARGET(COMPARE_OP): { // 弹出栈顶元素,这里是 b PyObject *right = POP(); // 显然 left 就是 a,因为 b 被弹出之后,a 就成为了新的栈顶元素 PyObject *left = TOP(); // 进行比较,比较结果为 res PyObject *res = cmp_outcome(tstate, oparg, left, right); // 减少 left 和 right 引用计数 Py_DECREF(left); Py_DECREF(right); // 将栈顶元素替换为 res SET_TOP(res); if (res == NULL) goto error; // 指令预测,暂时不用管,等介绍 if 控制流的时候再说 PREDICT(POP_JUMP_IF_FALSE); PREDICT(POP_JUMP_IF_TRUE); DISPATCH();\n} 所以逻辑很简单,核心就在 cmp_outcome 函数中。 // Python/ceval.c\nstatic PyObject *\ncmp_outcome(PyThreadState *tstate, int op, PyObject *v, PyObject *w)\n{ int res = 0; // op 就是 COMPARE_OP 指令的参数 switch (op) { // PyCmp_IS 是一个枚举变量,等于 8,定义在 Include/opcode.h 中 // 而 is 关键字,在 C 的层面就是一个 == 判断 case PyCmp_IS: res = (v == w); break; // is not 则对应 != case PyCmp_IS_NOT: res = (v != w); break; // in 关键字 case PyCmp_IN: res = PySequence_Contains(w, v); if (res < 0) return NULL; break; // not in 关键字 case PyCmp_NOT_IN: res = PySequence_Contains(w, v); if (res < 0) return NULL; res = !res; break; // except 关键字 case PyCmp_EXC_MATCH: if (PyTuple_Check(w)) { Py_ssize_t i, length; length = PyTuple_Size(w); for (i = 0; i < length; i += 1) { PyObject *exc = PyTuple_GET_ITEM(w, i); if (!PyExceptionClass_Check(exc)) { _PyErr_SetString(tstate, PyExc_TypeError, CANNOT_CATCH_MSG); return NULL; } } } else { if (!PyExceptionClass_Check(w)) { _PyErr_SetString(tstate, PyExc_TypeError, CANNOT_CATCH_MSG); return NULL; } } res = PyErr_GivenExceptionMatches(v, w); break; default: // 剩下的走 PyObject_RichCompare 逻辑 // 这是一个函数调用,比较对象维护的值是否相等 return PyObject_RichCompare(v, w, op); } v = res ? Py_True : Py_False; Py_INCREF(v); return v;\n} 我们实际举个栗子: a = 3.14\nb = float(\"3.14\")\nprint(a is b) # False\nprint(a == b) # True a 和 b 都是 3.14,两者是相等的,但不是同一个对象。 反过来也是如此,如果 a is b 成立,那么 a == b 也不一定成立。可能有人好奇,a is b 成立说明 a 和 b 指向的是同一个对象,那么 a == b 表示该对象和自己进行比较,结果应该始终是相等的呀,为啥也不一定成立呢?以下面两种情况为例: class Girl: def __eq__(self, other): return False g = Girl()\nprint(g is g) # True\nprint(g == g) # False __eq__ 返回 False,此时虽然是同一个对象,但是两者不相等。 import math\nimport numpy as np a = float(\"nan\")\nb = math.nan\nc = np.nan\nprint(a is a, a == a) # True False\nprint(b is b, b == b) # True False\nprint(c is c, c == c) # True False nan 是一个特殊的浮点数,意思是 not a number(不是一个数字),用于表示空值。而 nan 和所有数字的比较结果均为 False,即使是和它自身比较。 但需要注意的是,在使用 == 进行比较的时候虽然是不相等的,但如果放到容器里面就不一定了。举个例子: import numpy as np lst = [np.nan, np.nan, np.nan]\nprint(lst[0] == np.nan) # False\nprint(lst[1] == np.nan) # False\nprint(lst[2] == np.nan) # False\n# lst 里面的三个元素和 np.nan 均不相等 # 但是 np.nan 位于列表中,并且数量是 3\nprint(np.nan in lst) # True\nprint(lst.count(np.nan)) # 3 出现以上结果的原因就在于,元素被放到了容器里,而容器的一些 API 在比较元素时会先判定它们存储的对象的地址是否相同,即:是否指向了同一个对象。如果是,直接认为相等;否则,再去比较对象维护的值是否相等。可以理解为先进行 is 判断,如果结果为 True,直接判定两者相等;如果 is 操作的结果不为 True,再去进行 == 判断。 因此 np.nan in lst 的结果为 True,lst.count(np.nan) 的结果是 3,因为它们会先比较对象的地址。地址相同,则直接认为对象相等。 在用 pandas 做数据处理的时候,nan 是一个非常容易坑的地方。 提到 is 和 ==,那么问题来了,在和 True、False、None 比较时,是用 is 还是用 == 呢?由于 True、False、None 它们不仅是关键字,而且也被看做是一个常量,最重要的是它们都是单例的,所以我们应该用 is 判断。 另外 is 在底层只需要一个 == 即可完成,但 Python 的 ==,在底层则需要调用 PyObject_RichCompare 函数。因此 is 在速度上也更有优势,== 操作肯定比函数调用要快。 补充:判断对象是否相等,底层有两个常用的函数,分别是 PyObject_RichCompare 和 PyObject_RichCompareBool。 PyObject_RichCompare 是直接比较对象的值是否相等。而 PyObject_RichCompareBool 会先比较地址是否相等(即是否是同一个对象),如果是同一个对象,那么直接认为相等,否则再调用 PyObject_RichCompare 判断值是否相等。 对于容器的一些 API,在比较对象是否相等时,调用的都是 PyObject_RichCompareBool。","breadcrumbs":"60. 剖析字节码指令,以及 Python 赋值语句的原理 » 变量赋值的具体细节","id":"275","title":"变量赋值的具体细节"},"276":{"body":"以上我们就分析了常见的几个指令,以及变量赋值的底层逻辑,怎么样,是不是对 Python 有更深的理解了呢。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"60. 剖析字节码指令,以及 Python 赋值语句的原理 » 小结","id":"276","title":"小结"},"277":{"body":"前面我们分析了虚拟机执行字节码的原理,并且也介绍了不少指令,但这些指令都是从上往下顺序执行的,不涉及任何的跳转。而像流程控制语句,比如 if、for、while、try 等等,它们在执行时会发生跳转,因此 Python 底层一定还存在相应的跳转指令。 那么从现在开始,就来分析一下这些流程控制语句的实现原理,本文先来介绍 if 语句。","breadcrumbs":"61. 流程控制语句 if 是怎么实现的? » 楔子","id":"277","title":"楔子"},"278":{"body":"if 语句应该是最简单也是最常用的流程控制语句,那么它的字节码是怎么样的呢?当然这里的 if 语句指的是 if elif else 整体,里面的某个条件叫做该 if 语句的分支。 我们看一下 if 语句的字节码长什么样子。 import dis code_string = \"\"\"\nscore = 90 if score >= 85: print(\"Good\") elif score >= 60: print(\"Normal\") else: print(\"Bad\")\n\"\"\" dis.dis(compile(code_string, \"\", \"exec\")) 反编译得到的字节码指令比较多,我们来慢慢分析。另外为了阅读方便,源代码行号就不显示了。 // 加载常量 90 并压入运行时栈 0 LOAD_CONST 0 (90) // 加载符号表中索引为 0 的符号 \"score\",弹出运行时栈的栈顶元素 90 // 然后将两者绑定起来,存放在当前的名字空间中 2 STORE_NAME 0 (score) // 加载变量 score 4 LOAD_NAME 0 (score) // 加载常量 85 6 LOAD_CONST 1 (85) // 进行比较,操作符是 >=,这个指令之前介绍过的 8 COMPARE_OP 5 (>=) // 如果比较结果为 False,就进行跳转,从名字也能看出指令的含义 // 那么跳转到什么地方呢?指令参数 22 表示跳转到偏移量为 22 的指令 // 很明显,就是当前分支的下一个分支。关于具体是怎么跳转的,一会儿说 10 POP_JUMP_IF_FALSE 22 // 如果走到这里说明没有跳转,当前分支的条件为真,那么开始执行该分支内部的逻辑 // 以下 4 条指令对应 print(\"Good\") 12 LOAD_NAME 1 (print) 14 LOAD_CONST 2 ('Good') 16 CALL_FUNCTION 1 18 POP_TOP // if 语句只有一个分支会被执行,如果执行了某个分支,那么整个 if 语句就结束了 // 于是向前跳转 26 个偏移量,来到偏移量为 48 的指令 20 JUMP_FORWARD 26 (to 48) // 对应 score >= 60\n>> 22 LOAD_NAME 0 (score) 24 LOAD_CONST 3 (60) 26 COMPARE_OP 5 (>=) // 如果比较结果为假,跳转到偏移量为 40 的指令 28 POP_JUMP_IF_FALSE 40 // 以下 4 条指令对应 print(\"Normal\") 30 LOAD_NAME 1 (print) 32 LOAD_CONST 4 ('Normal') 34 CALL_FUNCTION 1 36 POP_TOP // 向前跳转 8 个偏移量,来到偏移量为 48 的指令 38 JUMP_FORWARD 8 (to 48) // 最后一个是 else 分支,而 else 分支没有判断条件\n>> 40 LOAD_NAME 1 (print) 42 LOAD_CONST 5 ('Bad') 44 CALL_FUNCTION 1 46 POP_TOP // 到这里说明 if 语句结束了,而下面也没有代码了,于是返回 // 每个代码块对应的指令的最后都有一个 return\n>> 48 LOAD_CONST 6 (None) 50 RETURN_VALUE 我们看到字节码偏移量之前有几个 >> 这样的符号,显然这是 if 语句中的每一个分支开始的地方。 经过分析,整个 if 语句的字节码指令还是很简单的。就是从上到下依次判断每一个分支,如果某个分支条件成立,就执行该分支的代码,执行完毕后结束整个 if 语句;否则跳转到下一个分支。 显然核心就在于 POP_JUMP_IF_FALSE 指令,我们看一下它的逻辑。","breadcrumbs":"61. 流程控制语句 if 是怎么实现的? » if 字节码","id":"278","title":"if 字节码"},"279":{"body":"COMPARE_OP 执行完之后会将比较的结果压入运行时栈,而 POP_JUMP_IF_FALSE 指令则是将结果从栈顶弹出并判断真假。如果为假,那么跳到下一个分支,否则执行此分支的代码。 case TARGET(POP_JUMP_IF_FALSE): { PREDICTED(POP_JUMP_IF_FALSE); // 从栈顶弹出比较结果 PyObject *cond = POP(); int err; // 如果 cond is True,说明当前分支的条件成立,那么执行下一条指令 if (cond == Py_True) { Py_DECREF(cond); FAST_DISPATCH(); } // 如果 cond is False,那么通过 JUMPTO 跳转到 if 语句的下一个分支 // 关于 JUMPTO 一会儿介绍 if (cond == Py_False) { Py_DECREF(cond); JUMPTO(oparg); FAST_DISPATCH(); } // 到这里说明 cond 不是布尔值,那么调用 PyObject_IsTrue 并判断结果是否为真 // PyObject_IsTrue(cond):等价于 Python 的 bool(cond) is True err = PyObject_IsTrue(cond); Py_DECREF(cond); // 如果 cond 的布尔值为真,那么返回 1,此时什么也不做 // 最后会调用 DISPATCH(),去执行下一条指令 if (err > 0) ; // 如果 cond 的布尔值为假,那么返回 0,跳转到下一个 if 分支 else if (err == 0) JUMPTO(oparg); else goto error; DISPATCH();\n} 逻辑不难理解,但是里面出现了判断对象布尔值的函数,我们补充一下。 // Objects/object.c // 等价于 Python 的 bool(v) is True\nint\nPyObject_IsTrue(PyObject *v)\n{ Py_ssize_t res; // 如果 v 本身就是布尔值 True,返回 1 if (v == Py_True) return 1; // 如果 v 本身就是布尔值 False,返回 0 if (v == Py_False) return 0; // 如果 v 是 None,返回 0 if (v == Py_None) return 0; // 如果 v 是数值型对象,并且实现了 nb_bool(对应 __bool__) // 那么调用,如果结果不为 0,返回 1,否则返回 0 else if (v->ob_type->tp_as_number != NULL && v->ob_type->tp_as_number->nb_bool != NULL) res = (*v->ob_type->tp_as_number->nb_bool)(v); // 如果 v 是映射型对象,并且实现了 mp_length(对应 __len__) // 那么调用,返回对象的长度 else if (v->ob_type->tp_as_mapping != NULL && v->ob_type->tp_as_mapping->mp_length != NULL) res = (*v->ob_type->tp_as_mapping->mp_length)(v); // 如果 v 是序列型对象,并且实现了 sq_length(对应 __len__) // 那么调用,返回对象的长度 else if (v->ob_type->tp_as_sequence != NULL && v->ob_type->tp_as_sequence->sq_length != NULL) res = (*v->ob_type->tp_as_sequence->sq_length)(v); // 如果以上条件都不满足,直接返回 1,比如自定义类的实例对象(默认为真) else return 1; // 如果 res > 0 返回 1,否则返回 0 return (res > 0) ? 1 : Py_SAFE_DOWNCAST(res, Py_ssize_t, int);\n} // not 底层也调用了 PyObject_IsTrue\nint\nPyObject_Not(PyObject *v)\n{ int res; // 如果 v 是真,res == 1,那么 res == 0 结果是 0 // 如果 v 是假,res == 0,那么 res == 0 结果是 1 // 相当于取反 res = PyObject_IsTrue(v); if (res < 0) return res; return res == 0;\n} // Objects/boolobject.c\nstatic PyObject *\nbool_new(PyTypeObject *type, PyObject *args, PyObject *kwds)\n{ // 是一个 Python 类,这里的 bool_new 便是它的构造函数 PyObject *x = Py_False; long ok; // 不接收关键字参数 if (!_PyArg_NoKeywords(\"bool\", kwds)) return NULL; // 只接收 0 ~ 1 个参数,如果不传,那么默认返回 False if (!PyArg_UnpackTuple(args, \"bool\", 0, 1, &x)) return NULL; // 调用 PyObject_IsTrue,所以我们说 if v 和 if bool(v) 是等价的 // 因为当 v 不是布尔值时,if v 对应的指令内部会调用 PyObject_IsTrue // 而 bool(v) 也会调用 PyObject_IsTrue,所以两者是等价的 ok = PyObject_IsTrue(x); if (ok < 0) return NULL; // 调用 PyBool_FromLong 创建布尔值,ok 为 1 返回 True,为 0 返回 False return PyBool_FromLong(ok);\n} PyObject *PyBool_FromLong(long ok)\n{ PyObject *result; if (ok) result = Py_True; else result = Py_False; Py_INCREF(result); return result;\n} 相信你现在明白了为什么 if 后面不跟布尔值也是可以的,因为有一个 C 函数 PyObject_IsTrue,可以判断任意对象的真假。如果 if 后面跟着的不是布尔值,那么会自动调用该函数。另外由于 bool(v) 也会调用该函数,所以 if v 和 if bool(v) 是等价的。 注:没有 PyObject_IsFalse。 说完了 POP_JUMP_IF_FALSE 指令,再补充一个和它相似的指令叫 POP_JUMP_IF_TRUE,它表示当比较结果为真时,跳到下一个分支,否则执行当前分支的代码。可能有人觉得,这不对吧,比较结果为真,难道不应该执行当前分支的逻辑吗?所以 POP_JUMP_IF_TRUE 指令似乎本身就是矛盾的。 仔细想想你应该能够猜到原因,答案就是使用了 not。 import dis code_string = \"\"\"\nif 2 > 1: print(\"古明地觉\")\n\"\"\"\n# 只打印部分字节码\ndis.dis(compile(code_string, \"\", \"exec\"))\n\"\"\" 0 LOAD_CONST 0 (2) 2 LOAD_CONST 1 (1) 4 COMPARE_OP 4 (>) 6 POP_JUMP_IF_FALSE 16\n\"\"\" code_string = \"\"\"\nif not 2 > 1: print(\"古明地觉\")\n\"\"\"\ndis.dis(compile(code_string, \"\", \"exec\"))\n\"\"\" 0 LOAD_CONST 0 (2) 2 LOAD_CONST 1 (1) 4 COMPARE_OP 4 (>) 6 POP_JUMP_IF_TRUE 16\n\"\"\" 正常情况下如果比较结果为 False,则跳转到 if 语句的下一个分支,所以 POP_JUMP_IF_FALSE 指令是合理的。至于 POP_JUMP_IF_TRUE 指令从逻辑上似乎就不该存在,因为它和 if 语句本身是相矛盾的。但现在我们明白了,该指令其实是为 not 关键字准备的。如果比较结果为真,那么 not 取反就是假,于是跳转到 if 语句的下一个分支,所以整个逻辑依旧是正确的。 当然这里只有一个 not,即使有很多个 not 也是可以的,尽管这没太大意义。 import dis # 这里有 4 个 not,因为是偶数个,两两相互抵消\n# 所以结果等价于 if 2 > 1\ncode_string = \"\"\"\nif not not not not 2 > 1: print(\"古明地觉\")\n\"\"\"\n# 只打印部分字节码\ndis.dis(compile(code_string, \"\", \"exec\"))\n\"\"\" 0 LOAD_CONST 0 (2) 2 LOAD_CONST 1 (1) 4 COMPARE_OP 4 (>) 6 POP_JUMP_IF_FALSE 16\n\"\"\" # 这里有 5 个 not,因为是奇数个,两两相互抵消之后还剩下一个\n# 所以结果等价于 if not 2 > 1\ncode_string = \"\"\"\nif not not not not not 2 > 1: print(\"古明地觉\")\n\"\"\"\ndis.dis(compile(code_string, \"\", \"exec\"))\n\"\"\" 0 LOAD_CONST 0 (2) 2 LOAD_CONST 1 (1) 4 COMPARE_OP 4 (>) 6 POP_JUMP_IF_TRUE 16\n\"\"\" 然后再看一下 POP_JUMP_IF_TRUE 指令的内部逻辑,显然它和 POP_JUMP_IF_FALSE 是类似的。 case TARGET(POP_JUMP_IF_TRUE): { PREDICTED(POP_JUMP_IF_TRUE); // 弹出栈顶元素 PyObject *cond = POP(); int err; // 如果 cond is False,那么 not 之后就是 True // 所以当前 if 分支成立,于是执行下一条指令 if (cond == Py_False) { Py_DECREF(cond); FAST_DISPATCH(); } // 如果 cond is True,那么 not 之后就是 False // 因此跳转到下一个分支 if (cond == Py_True) { Py_DECREF(cond); JUMPTO(oparg); FAST_DISPATCH(); } // 说明 cond 不是布尔值,那么通过 PyObject_IsTrue 判断是否为真 // 为真返回 1,为假返回 0,出现错误的话返回 -1(基本不会发生) err = PyObject_IsTrue(cond); Py_DECREF(cond); // 如果 err > 0,说明布尔值为真,但还要进行 not 取反,因此最终整体为假 // 所以会跳转到下一个分支 if (err > 0) { JUMPTO(oparg); } // 如果 err == 0,说明布尔值为假,那么 not 取反之后整体为真 // 因此会执行当前分支内的逻辑,所以此处什么也不用做,直接 DISPATCH() 到下一条指令即可 else if (err == 0) ; else goto error; DISPATCH();\n} 以上就是 POP_JUMP_IF_FALSE 和 POP_JUMP_IF_TRUE 的内部逻辑,可以说非常简单。","breadcrumbs":"61. 流程控制语句 if 是怎么实现的? » POP_JUMP_IF_FALSE","id":"279","title":"POP_JUMP_IF_FALSE"},"28":{"body":"然后来说一说 Python 的 C API,这个非常关键。首先 Python 解释器听起来很高大上,但按照陈儒老师的说法,它不过就是用 C 语言写出的一个开源软件,从形式上和其它软件并没有本质上的不同。 比如你在 Windows 系统中打开 Python 的安装目录,会发现里面有一个二进制文件 python.exe 和一个动态库文件 python38.dll。二进制文件负责执行,动态库文件则包含了相应的依赖,当然编译的时候也可以把动态库里的内容统一打包到二进制文件中,不过大部分软件在开发时都会选择前者。 既然解释器是用 C 写的,那么在执行时肯定会将 Python 代码翻译成 C 代码,这是毫无疑问的。比如创建一个列表,底层就会创建一个 PyListObject 实例,比如调用某个内置函数,底层会调用对应的 C 函数。 所以如果你想搞懂 Python 代码的执行逻辑或者编写 Python 扩展,那么就必须要清楚解释器提供的 API 函数。而按照通用性来划分的话,这些 API 可以分为两种。 泛型 API; 特定类型 API; 泛型 API 顾名思义,泛型 API 和参数类型无关,属于抽象对象层。这类 API 的第一个参数是 PyObject *,可以处理任意类型的对象,API 内部会根据对象的类型进行区别处理。 而且泛型 API 的名称也是有规律的,格式为 PyObject_###,我们举例说明。 所以泛型 API 一般以 PyObject_ 开头,第一个参数是 PyObject *,表示可以处理任意类型的对象。 特定类型 API 顾名思义,特定类型 API 和对象的类型是相关的,属于具体对象层,只能作用在指定类型的对象上面。因此不难发现,每种类型的对象,都有属于自己的一组特定类型 API。 // 通过 C 的 double 创建 PyFloatObject\nPyObject* PyFloat_FromDouble(double v); // 通过 C 的 long 创建 PyLongObject\nPyObject* PyLong_FromLong(long v);\n// 通过 C 的 char * 创建 PyLongObject\nPyObject* PyLong_FromString(const char *str, char **pend, int base) 以上就是解释器提供的两种 C API,了解完之后我们再来看看对象是如何创建的。","breadcrumbs":"7. 当创建一个 Python 对象时,背后都经历了哪些过程? » Python 的 C API","id":"28","title":"Python 的 C API"},"280":{"body":"指令跳转是由 JUMPTO 实现的,它内部的逻辑长啥样呢?并且跳转除了 JUMPTO 之外,还有一个 JUMPBY,这两者有啥区别呢? // Python/ceval.c #define JUMPTO(x) (next_instr = first_instr + (x) / sizeof(_Py_CODEUNIT))\n#define JUMPBY(x) (next_instr += (x) / sizeof(_Py_CODEUNIT)) 字节码指令的遍历是通过 next_instr 实现的,如果将指令执行的方向代表前进的方向。 JUMPTO(x):表示从头开始向前跳转 x 个偏移量。 JUMPBY(x):表示从当前指令所在的位置向前跳转 x 个偏移量。 所以 JUMPTO 表示绝对跳转,JUMPBY 表示相对跳转。不难发现,JUMPTO 既可以向前跳转(偏移量增大),也可以向后跳转(偏移量减小);而 JUMPBY 只能向前跳转。 假设参数为 n,当前指令的偏移量为 m。对于 JUMPTO 而言,跳转之后的偏移量始终为 2n,如果 m < 2n 就是向前跳转,m > 2n 就是向后跳转。但对于 JUMPBY 而言,由于它是从当前待执行的指令开始跳转的,所以只能向前跳转(偏移量增大)。 另外在看字节码指令的时候,我们还看到了一个 JUMP_FORWARD 指令,当某个分支执行完毕之后,会直接跳转到 if 语句结束的下一条指令。并且不同分支对应的 JUMP_FORWARD 指令的参数是不同的,所以它内部一定使用了相对跳转。 case TARGET(JUMP_FORWARD): { JUMPBY(oparg); FAST_DISPATCH();\n} 我们分析的没错,它内部就是调用了一个 JUMPBY,因为终点相同、但跳转的偏移量不同,所以只能是相对跳转。","breadcrumbs":"61. 流程控制语句 if 是怎么实现的? » JUMPTO","id":"280","title":"JUMPTO"},"281":{"body":"通过引入计算跳转,可以避免不必要的匹配。因为整个指令集合是已知的,这就说明某条指令在执行时,便可知道它的下一条指令是什么。所以当前指令处理完后,可以直接跳转到下一条指令对应的处理逻辑中,这就是计算跳转。但如果不使用计算跳转,那么每次读取到指令后,都要进入 switch,顺序匹配一百多个 case 分支,找到匹配成功的那一个。 因此使用计算跳转可以避免不必要的匹配,既然提前知道下一条指令是啥了,那么直接精确跳转就行,无需多走一遍 switch。不过要想实现计算跳转,需要 GCC 支持标签作为值,即 goto *label_addr 用法,由于 label_addr 是一个标签地址,那么解引用之后就是标签了。至于具体会跳转到哪一个标签,取决于 label_addr 保存了哪一个标签的地址,因此这种跳转是动态的,在运行时决定跳转目标。 goto 标签:静态跳转,标签需要显式地定义好,跳转位置在编译期间便已经固定。 goto *标签地址:动态跳转(计算跳转),跳转位置不固定,可以是已有标签中的任意一个。至于具体是哪一个,需要在运行时经过计算才能确定。 虚拟机为每个指令的处理逻辑都定义了一个标签,对于计算跳转来说,goto 的结果是 *标签地址,这个地址是运行时计算得出的。我们举个例子,随便看一段字节码指令集。 比如当前正在执行 LOAD_FAST 指令,那么下一条指令可以是 STORE_FAST、LOAD_FAST 以及 BUILD_LIST 等。当开启计算跳转时: 如果下一条指令是 STORE_FAST,那么之后就会跳转到 STORE_FAST 对应的标签; 如果下一条指令是 LOAD_FAST,那么之后就会跳转到 LOAD_FAST 对应的标签; 如果下一条指令是 BUILD_LIST,那么之后就会跳转到 BUILD_LIST 对应的标签; 所以在运行时判断指令的值,获取对应的标签,从而实现精确跳转,这就是计算跳转。当然这些内容在剖析虚拟机执行字节码时已经说过了,这里再回顾一下。 接下来说一说指令预测,不难发现,如果是计算跳转,那么指令预测功能貌似没啥用,因为总是能精确跳转到下一条指令对应的标签中。没错,指令预测只有在不使用计算跳转的情况下有用,那什么是指令预测呢? 在不使用计算跳转时,goto 后面必须是一个静态的标签,跳转位置在编译阶段便已经固定,换句话说一个指令执行完毕后要跳转到哪一个标签是写死的,不能保证跳转后的标签正好对应下一条指令的处理逻辑。比如 LOAD_FAST 的下一条指令可以是 STORE_FAST 和 BUILD_LIST,那么应该跳转到哪一个指令对应的标签中呢? 正因为这种不确定性,绝大部分指令在执行完毕后都会直接跳转到 fast_next_opcode 标签,然后顺序匹配 case 分支。 但也有那么几个指令,由于彼此的关联性很强,很多时候都是成对出现的,面对这样的指令,虚拟机会进行预测。比如 A 和 B 两个指令的关联性很强,尽管 A 的下一条指令除了是 B 之外,也有可能是其它指令,但 B 出现的概率是最大的,因此虚拟机会预测下一条指令是 B 指令。于是在执行完 A 指令之后,会验证自己的预测是否正确,即检测下一条指令是否是 B 指令。如果预测对了,可以实现精确跳转,如果预测错了,就只能回到 switch 语句逐一匹配 case 分支了。 总结一下:指令在执行时,它的下一条指令是已知的,但是不固定,有多种可能。如果不使用计算跳转,由于 goto 后面必须是一个写死的标签,而下一条指令却不固定,那么只能选择进入 switch、顺序匹配 case 分支。但也有那么几对指令,关联性很强,虽然不能保证百分百,但值得做一次尝试,这便是指令预测。 当然啦,如果使用计算跳转,情况则不一样了,此时压根用不到指令预测。因为 goto 后面是 *标签地址,而地址是可以动态获取的。由于所有标签的地址都保存在了一个数组中,不管接下来要处理哪一条指令,都可以获取到对应的标签地址,实现精确跳转。 好,关于指令预测我们已经知道是啥了,那么在源码层面又是如何体现的呢?在 POP_JUMP_IF_FALSE 指令中,我们看到有这么一行逻辑。 里面有一个宏 PREDICTED。 // Python/ceval.c\n#define PREDICTED(op) PRED_##op: 这个宏展开之后又是一个标签,由于调用时结尾加了分号,所以这还是一个空标签。整体效果如下: 那么展开成一个标签有什么用呢?首先肯定是为了跳转,至于具体过程我们再看一下 COMPARE_OP 指令就明白了。 COMPARE_OP 指令上面已经介绍了,它会对两个对象进行比较,并将比较结果压入运行时栈。之后它做了指令预测,并且还预测了两次,因为虚拟机认为 COMPARE_OP 执行完之后大概率会执行 POP_JUMP_IF_FALSE 或 POP_JUMP_IF_TRUE,所以做了一个预测。而相关逻辑位于 PREDICT 中,看一下它长什么样子。 // Python/ceval.c // 如果开启计算跳转,那么指令预测不生效,因为本身就知道该跳转到哪个指令对应的标签\n#if defined(DYNAMIC_EXECUTION_PROFILE) || USE_COMPUTED_GOTOS\n#define PREDICT(op) if (0) goto PRED_##op\n#else\n// 如果不开启计算跳转,那么会比较预测的指令和实际的指令是否相等\n// 所以 COMPARE_OP 指令处理逻辑里面的 PREDICT(POP_JUMP_IF_FALSE)\n// 就是在判断下一条指令是不是自己预测的 POP_JUMP_IF_FALSE\n// 如果是,说明预测成功,那么 goto PRED_POP_JUMP_IF_FALSE\n// 否则说明预测失败,那么会执行 DISPATCH(),然后 goto 到 switch 语句所在位置\n#define PREDICT(op) \\ do{ \\ _Py_CODEUNIT word = *next_instr; \\ opcode = _Py_OPCODE(word); \\ if (opcode == op){ \\ oparg = _Py_OPARG(word); \\ next_instr++; \\ goto PRED_##op; \\ } \\ } while(0)\n#endif 以上便是指令预测,说白了就是如果指令 A 和指令 B 具有极高的关联性(甚至百分百),那么执行完 A 指令后会判断下一条指令是不是 B。如果是,那么直接跳转即可,就省去了匹配 case 分支的时间,如果不是,那就只能挨个匹配了。 因为是静态跳转,goto 后面的标签是写死的,编译阶段就确定了,所以只有那种关联度极高的指令才会开启预测功能,因为预测成功的概率比较高。但如果指令 A 的下一条指令有多种可能(假设有 6 种),并且每种指令出现的概率还差不多,那么这时不管预测哪一个,成功的概率都只有 1/6。显然这就不叫预测了,这是在掷骰子,因此对于这样的指令,虚拟机不会为它开启预测功能。 比如 LOAD_FAST 的下一个指令可以是 STORE_FAST、LOAD_FAST、BUILD_LIST 等等,不管预测哪一种,成功的概率都不是特别高,因此它没有进行指令预测。 所以就一句话:只有 A 和 B 两个指令的关联度极高的时候,执行 A 之后才会预测下一条指令是否是 B。预测成功直接跳转,预测失败执行 DISPATCH(),跳转到 fast_next_opcode 标签,进入 switch 语句。 但如果使用了计算跳转,情况就不一样了,此时不会开启指令预测,或者说指令预测里的逻辑会变得无效。 很明显,使用计算跳转后,PREDICT(op) 不会产生任何效果,因此也可以理解为没有开启指令预测。而之所以不用预测,是因为执行 DISPATCH() 的时候,本身就可以精确跳转到指定位置。","breadcrumbs":"61. 流程控制语句 if 是怎么实现的? » 指令预测","id":"281","title":"指令预测"},"282":{"body":"本篇文章我们就分析了 if 语句的实现原理,总的来说不难理解。依旧是在栈桢中执行字节码,只是多了一个指令跳转罢了,至于怎么跳转、跳转到什么地方,全部都体现在字节码中。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"61. 流程控制语句 if 是怎么实现的? » 小结","id":"282","title":"小结"},"283":{"body":"在介绍 if 语句的时候,我们看到了最基本的控制流,其核心就是跳转。但是 if 只能向前跳转,而接下来介绍的 for、while 循环,指令是可以回退的,也就是向后跳转。","breadcrumbs":"62. 流程控制语句 for、while 是怎么实现的? » 楔子","id":"283","title":"楔子"},"284":{"body":"我们看一个简单的 for 循环的字节码。 import dis code_string = \"\"\"\nlst = [1, 2]\nfor item in lst: print(item)\n\"\"\" dis.dis(compile(code_string, \"\", \"exec\")) 反编译之后,字节码指令如下。 // 加载常量 1,压入运行时栈 0 LOAD_CONST 0 (1) // 加载常量 2,压入运行时栈 2 LOAD_CONST 1 (2) // 将运行时栈的元素弹出,构建长度为 2 的列表,并压入栈中 4 BUILD_LIST 2 // 将上一步构建的列表从栈顶弹出,并用符号 lst 与之绑定 // 到此 lst = [1, 2] 便完成了 6 STORE_NAME 0 (lst) // 从全局名字空间中加载 lst 8 LOAD_NAME 0 (lst) // 获取对应的迭代器,即 iter(lst) 10 GET_ITER // 开始 for 循环,将里面的元素依次迭代出来 // 如果迭代结束,向前跳转 12 个偏移量,来到偏移量为 26 的指令\n>> 12 FOR_ITER 12 (to 26) // 到这里说明上一步迭代出元素了 // 用符号 item 和迭代出的元素进行绑定 14 STORE_NAME 1 (item) // 对应 print(item) 16 LOAD_NAME 2 (print) 18 LOAD_NAME 1 (item) 20 CALL_FUNCTION 1 22 POP_TOP // 到此,一次遍历就完成了,那么跳转到偏移量为 12 的指令,进行下一轮循环 // 注意:上面的 FOR_ITER 指令和这里的 JUMP_ABSOLUTE 指令的参数都是 12 // 但它们有着不同,FOR_ITER 指令的参数 12 表示从当前位置向前跳转 12 个偏移量 // 而 JUMP_ABSOLUTE 指令的参数 12 表示跳转到偏移量为 12 个位置(或者说从开头跳转 12 个偏移量) 24 JUMP_ABSOLUTE 12\n>> 26 LOAD_CONST 2 (None) 28 RETURN_VALUE 我们直接从 10 GET_ITER 开始看起,首先 for 循环遍历的对象必须是可迭代对象,然后会调用它的 __iter__ 方法,得到迭代器。再不断地调用迭代器的 __next__ 方法,一步一步将里面的值全部迭代出来,当出现 StopIteration 异常时,for 循环捕捉,最后退出。 另外,我们说 Python 里面是先有值,后有变量,for 循环也不例外。循环的时候,先将迭代器中的元素迭代出来,然后再让变量 item 指向。因此包含 10 个元素的迭代器,需要迭代 11 次才能结束。因为 for 循环事先是不知道迭代 10 次就能结束的,它需要再迭代一次,发现没有元素可以迭代、并捕获抛出的 StopIteration 之后,才能结束。 for 循环遍历可迭代对象时,会先拿到对应的迭代器,那如果遍历的就是一个迭代器呢?答案是依旧调用 __iter__,只不过由于本身就是一个迭代器,所以返回的还是其本身。 将元素迭代出来之后,就开始执行 for 循环体的逻辑了。 执行完一轮循环之后,通过 JUMP_ABSOLUTE 跳转到字节码偏移量为 12、也就是 FOR_ITER 的位置开始下一次循环。这里我们发现它没有跳到 GET_ITER 那里,所以可以得出结论,for 循环在遍历的时候只会创建一次迭代器。 下面来看指令对应的具体逻辑: case TARGET(GET_ITER): { // 获取栈顶元素,即上一步压入的列表指针 PyObject *iterable = TOP(); // 调用 PyObject_GetIter,获取对应的迭代器 // 这个函数在介绍迭代器的时候已经说过了 // 等价于 iter = type(iterable).__iter__(iterable) PyObject *iter = PyObject_GetIter(iterable); Py_DECREF(iterable); // 将迭代器 iter 设置为栈顶元素 SET_TOP(iter); if (iter == NULL) goto error; // 指令预测,解释器认为下一条指令大概率是 FOR_ITER 或 CALL_FUNCTION PREDICT(FOR_ITER); PREDICT(CALL_FUNCTION); DISPATCH();\n} 当创建完迭代器之后,就正式进入 for 循环了。所以从 FOR_ITER 开始,进入了虚拟机层面上的 for 循环。 源代码中的 for 循环,在虚拟机层面也一定对应着一个相应的循环控制结构。因为无论进行怎样的变换,都不可能在虚拟机层面利用顺序结构来实现源码层面上的循环结构,这也可以看作是程序的拓扑不变性。 因此源代码是宏观的,虚拟机执行字节码是微观的,尽管两者的层级不同,但本质上等价的,是程序从一种形式到另一种形式的等价转换。 我们来看一下 FOR_ITER 指令对应的具体实现: case TARGET(FOR_ITER): { PREDICTED(FOR_ITER); // 从栈顶获取迭代器对象(指针) PyObject *iter = TOP(); // 调用迭代器类型对象的 tp_iternext,将迭代器内的元素迭代出来 PyObject *next = (*iter->ob_type->tp_iternext)(iter); // 如果 next != NULL,说明迭代到元素了,那么压入运行时栈 if (next != NULL) { PUSH(next); PREDICT(STORE_FAST); PREDICT(UNPACK_SEQUENCE); DISPATCH(); } // 否则说明迭代出现异常 if (_PyErr_Occurred(tstate)) { // 如果异常还不是 StopIteration,那么跳转到 error 标签 if (!_PyErr_ExceptionMatches(tstate, PyExc_StopIteration)) { goto error; } else if (tstate->c_tracefunc != NULL) { call_exc_trace(tstate->c_tracefunc, tstate->c_traceobj, tstate, f); } // 否则说明是 StopIteration,那么证明迭代完毕,将异常清空 _PyErr_Clear(tstate); } // 迭代结束了,但运行时栈里面还有一个迭代器对象 // 那么要将它弹出,因此这里执行了 STACK_SHRINK(1) STACK_SHRINK(1); Py_DECREF(iter); // 跳转到 for 循环结束后的下一条指令 // 当前的指令为:12 FOR_ITER 12 (to 26) // 所以会通过 JUMPBY 实现一个相对跳转 // 从当前位置向前跳转 12 个偏移量,来到偏移量为 26 的指令 JUMPBY(oparg); PREDICT(POP_BLOCK); DISPATCH();\n} 在执行 FOR_ITER 的时候,如果迭代器没有耗尽,那么会迭代出元素,压入运行时栈,然后调用 DISPATCH() 去执行下一条指令。当一轮循环结束后,还要进行指令回退,从字节码中也看到了,for 循环遍历一次之后,会再次跳转到 FOR_ITER,而跳转所使用的指令就是 JUMP_ABSOLUTE,从名字也能看出这个指令会使用绝对跳转。 case TARGET(JUMP_ABSOLUTE): { PREDICTED(JUMP_ABSOLUTE); // 跳转到偏移量为 oparg 的指令 JUMPTO(oparg);\n#if FAST_LOOPS FAST_DISPATCH();\n#else DISPATCH();\n#endif\n} 之前介绍过 JUMPTO 和 JUMPBY 两个宏, #define JUMPTO(x) (next_instr = first_instr + (x) / sizeof(_Py_CODEUNIT))\n#define JUMPBY(x) (next_instr += (x) / sizeof(_Py_CODEUNIT)) 这两个宏都表示跳转 x 个偏移量,但 JUMPTO 是从头开始跳转,所以只要 x 固定,那么跳转位置就始终是固定的。而 JUMPBY 表示从当前位置开始跳转,所以位置不同,跳转的结果也不同。 然后天下没有不散的宴席,随着迭代的进行,for 循环总有退出的那一刻,而这个退出的动作只能落在 FOR_ITER 的身上。在 FOR_ITER 指令执行的过程中,如果遇到了 StopIteration,就意味着迭代结束了。这个结果将导致虚拟机会将迭代器从运行时栈中弹出,同时执行一个 JUMPBY 动作,向前跳跃,在字节码的层面是向下,也就是偏移量增大的方向。","breadcrumbs":"62. 流程控制语句 for、while 是怎么实现的? » for 控制流","id":"284","title":"for 控制流"},"285":{"body":"看完了 for,再来看看 while,并且我们还要分析两个关键字:break、continue。 import dis code_string = \"\"\"\na = 0\nwhile a < 10: a += 1 if a == 5: continue if a == 7: break print(a)\n\"\"\" dis.dis(compile(code_string, \"\", \"exec\")) 看一下它的指令: // a = 0 0 LOAD_CONST 0 (0) 2 STORE_NAME 0 (a) // 比较 a < 10\n>> 4 LOAD_NAME 0 (a) 6 LOAD_CONST 1 (10) 8 COMPARE_OP 0 (<) // 如果 a < 10 为假,说明循环结束 // 跳转到偏移量为 50 的指令,内部会使用绝对跳转 10 POP_JUMP_IF_FALSE 50 // 到这里说明 while 条件成立,进入循环体 // 执行 a += 1 12 LOAD_NAME 0 (a) 14 LOAD_CONST 2 (1) 16 INPLACE_ADD 18 STORE_NAME 0 (a) // 比较 a == 5 20 LOAD_NAME 0 (a) 22 LOAD_CONST 3 (5) 24 COMPARE_OP 2 (==) // 如果 a == 5 为假,跳转到偏移量为 30 的指令 26 POP_JUMP_IF_FALSE 30 // 否则说明 a == 5 为真,执行 continue // 由于 continue 是立即进入下一轮循环 // 所以直接跳转到偏移量为 4 的指令,即 while 循环的开始位置 // 所以在虚拟机的层面,continue 就是一个跳转指令 28 JUMP_ABSOLUTE 4 // 比较 a == 7\n>> 30 LOAD_NAME 0 (a) 32 LOAD_CONST 4 (7) 34 COMPARE_OP 2 (==) // 如果 a == 7 为假,跳转到偏移量为 40 的指令 36 POP_JUMP_IF_FALSE 40 // 否则说明 a == 7 为真,执行 break // 因此直接跳转到偏移量为 50 的位置,即 while 循环结束后的下一条指令 38 JUMP_ABSOLUTE 50 // print(a)\n>> 40 LOAD_NAME 1 (print) 42 LOAD_NAME 0 (a) 44 CALL_FUNCTION 1 46 POP_TOP // 到这里说明一轮循环结束了,那么跳转到偏移量为 4 的位置,即 while 循环的开始位置 48 JUMP_ABSOLUTE 4 // 隐式的 return None\n>> 50 LOAD_CONST 5 (None) 52 RETURN_VALUE 有了 for 循环,再看 while 循环就简单多了,整体逻辑和 for 高度相似,当然里面还结合了 if。 刚才说了,尽管源代码和字节码的层级不同,但本质上是等价的,是程序从一种形式到另一种形式的等价转换。在源码中能看到的,在字节码当中也能看到。比如源代码中的 continue 会跳转到循环所在位置,那么在字节码中自然也会对应一个跳转指令。","breadcrumbs":"62. 流程控制语句 for、while 是怎么实现的? » while 控制流","id":"285","title":"while 控制流"},"286":{"body":"以上我们就探讨了 Python 的两种循环,总的来说没什么难度,本质上还是跳转。只不过有时会通过 JUMPTO 进行绝对跳转,有时会通过 JUMPBY 进行相对跳转。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"62. 流程控制语句 for、while 是怎么实现的? » 小结","id":"286","title":"小结"},"287":{"body":"程序在运行的过程中,总是会不可避免地产生异常,此时为了让程序不中断,必须要将异常捕获掉。如果能提前得知可能会发生哪些异常,建议使用精确捕获,如果不知道会发生哪些异常,则使用 Exception 兜底。 另外异常也可以用来传递信息,比如生成器。 def gen(): yield 1 yield 2 return \"result\" g = gen()\nnext(g)\nnext(g)\ntry: next(g)\nexcept StopIteration as e: print(f\"返回值: {e.value}\") # 返回值: result 如果想要拿到生成器的返回值,我们需要让它抛出 StopIteration,然后进行捕获,再通过 value 属性拿到返回值。所以,Python 是将生成器的返回值封装到了异常里面。 之所以举这个例子,目的是想说明,异常并非是让人嗤之以鼻的东西,它也可以作为信息传递的载体。特别是在 Java 语言中,引入了 checked exception,方法的所有者还可以声明自己会抛出什么异常,然后调用者对异常进行处理。在 Java 程序启动时,抛出大量异常都是司空见惯的事情,并在相应的调用堆栈中将信息完整地记录下来。至此,Java 的异常不再是异常,而是一种很普遍的结构,从良性到灾难性都有所使用,异常的严重性由调用者来决定。 虽然在 Python 里面,异常还没有达到像 Java 异常那么高的地位,但使用频率也是很高的,下面我们就来剖析一下异常是怎么实现的?","breadcrumbs":"63. 异常是怎么实现的?虚拟机是如何将异常抛出去的? » 楔子","id":"287","title":"楔子"},"288":{"body":"Python 解释器 = Python 编译器 + Python 虚拟机,所以异常可以由编译器抛出,也可以由虚拟机剖出。如果是编译器抛出的异常,那么基本上都是 SyntaxError,即语法错误。 try: >>>\nexcept Exception as e: print(e) 比如上面这段代码,你会发现异常捕获根本没用,因为这是编译阶段就发生的错误,而异常捕获是在运行时进行的。当然语法不对属于低级错误,所以不会留到运行时。 然后是运行时产生的异常: try: 1 / 0\nexcept ZeroDivisionError: print(\"Division by zero\") 像这种语法正确,但程序执行时因逻辑出现问题而导致的异常,是可以被捕获的。对于我们来说,关注的显然是运行时产生的异常,比如 TypeError、IndexError 等等。 那么问题来了,异常本质上是什么呢?我们以列表为例,看看 IndexError 是怎么产生的。 lst = [1, 2, 3]\nprint(lst[3])\n\"\"\"\nIndexError: list index out of range\n\"\"\" 列表的最大索引是 2,但我们访问了索引为 3 的元素,虚拟机就知道不能再执行下去了,否则会访问非法内存。因此虚拟机的做法是:输出异常信息,结束进程。我们通过源码来验证一下: 在获取列表元素时发现索引不合法,就知道要抛出 IndexError 了,于是将异常写入到回溯栈中,并返回 NULL。正常情况下,返回值应该指向一个合法的对象,如果为 NULL,证明出现异常了。 此时虚拟机会将回溯栈里的异常抛出来(就是我们在控制台看到的那一抹鲜红),然后结束进程,这就是异常的本质。当然异常也是一个 Python 对象,虚拟机在退出前,会写入到 stderr 中。","breadcrumbs":"63. 异常是怎么实现的?虚拟机是如何将异常抛出去的? » 异常的本质是什么?","id":"288","title":"异常的本质是什么?"},"289":{"body":"当我们用 C 编写 Python 扩展时,如果想设置异常的话,该怎么做呢?首先设置异常之前,我们要知道有哪些异常。在 pyerrors.h 中,虚拟机内置了大量的异常,另外 Python 一切皆对象,因此异常也是一个对象。 有了异常之后,怎么写入呢?关于异常写入,底层也提供了相应的 C API。 PyErr_SetNone:设置异常,不包含提示信息。 PyErr_SetObject:设置异常,包含提示信息(Python 字符串)。 PyErr_SetString:设置异常,包含提示信息(C 字符串)。 PyErr_Occurred:检测回溯栈中是否有异常产生。 PyErr_Clear:将回溯栈中的异常清空,相当于 Python 的异常捕获。 PyErr_Fetch:将回溯栈中的异常清空,同时拿到它的 exc_type、exc_value、exc_tb。 PyErr_Restore:基于 exc_type、exc_value、exc_tb 设置异常。 我们以 PyErr_Restore 为例,看看异常的具体设置过程。 // Python/errors.c // PyErr_SetObject、PyErr_SetString 等等,最终都会调用 PyErr_Restore\nvoid\nPyErr_Restore(PyObject *type, PyObject *value, PyObject *traceback)\n{ // 获取线程状态对象 PyThreadState *tstate = _PyThreadState_GET(); _PyErr_Restore(tstate, type, value, traceback);\n} // 将异常设置在线程状态对象中\nvoid\n_PyErr_Restore(PyThreadState *tstate, PyObject *type, PyObject *value, PyObject *traceback)\n{ PyObject *oldtype, *oldvalue, *oldtraceback; if (traceback != NULL && !PyTraceBack_Check(traceback)) { Py_DECREF(traceback); traceback = NULL; } // 获取线程状态对象中已存在的异常(可能为空) oldtype = tstate->curexc_type; oldvalue = tstate->curexc_value; oldtraceback = tstate->curexc_traceback; // 将新异常设置在线程状态对象中 tstate->curexc_type = type; tstate->curexc_value = value; tstate->curexc_traceback = traceback; // 减少旧异常的引用计数 Py_XDECREF(oldtype); Py_XDECREF(oldvalue); Py_XDECREF(oldtraceback);\n} 注意这里的 PyThreadState 对象,它是与线程相关的,但它只是线程信息的一个抽象描述,而真实的线程及状态肯定是由操作系统来维护和管理的。 但虚拟机在运行的时候总需要另外一些与线程相关的状态和信息,比如是否发生了异常等等,而这些信息显然操作系统是没办法提供的。而 PyThreadState 对象正是 Python 为线程准备的、在虚拟机层面保存线程状态信息的对象(后面简称线程状态对象、或者线程对象)。 当前活动线程(OS 原生线程)对应的 PyThreadState 对象可以通过 PyThreadState_GET 获得,在得到了线程状态对象之后,就将异常信息存放在里面。 关于线程相关的内容,后续会详细说。","breadcrumbs":"63. 异常是怎么实现的?虚拟机是如何将异常抛出去的? » 异常写入的一些 C API","id":"289","title":"异常写入的一些 C API"},"29":{"body":"创建对象可以使用泛型 API,也可以使用特定类型 API,比如创建一个浮点数。 使用泛型 API 创建 PyObject* pi = PyObject_New(PyObject, &PyFloat_Type); 通过泛型 API 可以创建任意类型的对象,因为该类 API 和类型无关。那么问题来了,解释器怎么知道要给对象分配多大的内存呢? 在介绍类型对象的时候我们提到,对象的内存大小、支持哪些操作等等,都属于元信息,而元信息会存在对应的类型对象中。其中 tp_basicsize 和 tp_itemsize 负责指定实例对象所需的内存空间。 // Include/objimpl.h // 创建定长对象\n#define PyObject_New(type, typeobj) \\ ( (type *) _PyObject_New(typeobj) )\n// 创建变长对象\n#define PyObject_NewVar(type, typeobj, n) \\ ( (type *) _PyObject_NewVar((typeobj), (n)) )\n/* 所以 PyObject* pi = PyObject_New(PyObject, &PyFloat_Type) 等价于如下 * PyObject* pi = (PyObject *)_PyObject_New(&PyFloat_Type) */ 所以实际申请内存的动作由 _PyObject_New 和 _PyObject_NewVar 负责,看看它的逻辑。 // Objects/object.c\nPyObject *\n_PyObject_New(PyTypeObject *tp)\n{ PyObject *op; // 通过 PyObject_Malloc 为对象申请内存,大小为 _PyObject_SIZE(tp) op = (PyObject *) PyObject_MALLOC(_PyObject_SIZE(tp)); if (op == NULL) return PyErr_NoMemory(); // 设置对象的类型和引用计数 return PyObject_INIT(op, tp);\n} PyVarObject *\n_PyObject_NewVar(PyTypeObject *tp, Py_ssize_t nitems)\n{ PyVarObject *op; const size_t size = _PyObject_VAR_SIZE(tp, nitems); // 通过 PyObject_Malloc 为对象申请内存,大小为 _PyObject_VAR_SIZE(tp, nitems) op = (PyVarObject *) PyObject_MALLOC(size); if (op == NULL) return (PyVarObject *)PyErr_NoMemory(); // 设置对象的类型、引用计数和 ob_size return PyObject_INIT_VAR(op, tp, nitems);\n} // Include/objimpl.h\n#define _PyObject_SIZE(typeobj) ( (typeobj)->tp_basicsize ) #define _PyObject_VAR_SIZE(typeobj, nitems) \\ _Py_SIZE_ROUND_UP((typeobj)->tp_basicsize + \\ (nitems)*(typeobj)->tp_itemsize, \\ SIZEOF_VOID_P)\n/* 类型对象的 tp_basicsize 字段表示它的实例对象的基础大小,即底层结构体的大小 * 对于像浮点数这种不可变的定长对象来说,显然大小就等于 PyFloat_Type 的 tp_basicsize * * 如果对象内部可以容纳指定数量的元素,比如元组,那么 tp_itemsize 便是每个元素的大小 * 对于元组来说,它的大小等于 tp_basicsize + 元素个数 * tp_itemsize,并且按照 8 字节对齐 */ 以上便是泛型 API 创建对象的流程,但泛型 API 属于通用逻辑,而内置类型的实例对象一般会采用特定类型 API 创建。 使用特定类型 API 创建 // 创建浮点数,值为 2.71\nPyObject* e = PyFloat_FromDouble(2.71);\n// 创建一个可以容纳 5 个元素的元组\nPyObject* tpl = PyTuple_New(5);\n// 创建一个可以容纳 5 个元素的列表\n// 当然这是初始容量,列表还可以扩容\nPyObject* lst = PyList_New(5); 和泛型 API 不同,使用特定类型 API 只能创建指定类型的对象,因为这种 API 是和类型绑定的。比如我们可以用 PyDict_New 创建一个字典,但不可能创建一个集合出来。 如果使用特定类型 API,那么可以直接分配内存。因为内置类型的实例对象,它们的定义在底层都是写死的,解释器对它们了如指掌,因此可以直接分配内存并初始化。 比如通过 e = 2.71 创建一个浮点数,解释器看到 2.71 就知道要创建 PyFloatObject 结构体实例,那么申请多大内存呢?显然是 sizeof(PyFloatObject),直接计算一下结构体实例的大小即可。 // Include/floatobject.h\ntypedef struct { // ob_refcnt 占 8 字节,ob_type 也占 8 字节 PyObject_HEAD // 占 8 字节 double ob_fval;\n} PyFloatObject; 由于 PyFloatObject 只是在 PyObject 的基础上引入了一个 double 字段,用于维护浮点数的值,所以一个 PyFloatObject 实例的大小为 24 字节。既然内存大小知道,那么直接分配就可以了,分配之后再将 ob_refcnt 初始化为 1、将 ob_type 设置为 &PyFloat_Type、将 ob_fval 设置为 2.71 即可。 同理可变对象也是一样,因为字段都是固定的,容纳的元素个数也可以根据赋的值得到,所以内部的所有字段占用了多少内存可以算出来,因此也是可以直接分配内存的。 还是那句话,解释器对内置的数据结构了如指掌,因为这些结构在底层都是定义好的,源码直接写死了。所以解释器根本不需要借助类型对象去创建实例对象,它只需要在实例对象创建完毕之后,再将 ob_type 设置为指定的类型即可(让实例对象和类型对象建立联系)。 所以采用特定类型 API 创建实例的速度会更快,但这只适用于内置的数据结构,而我们自定义类的实例对象显然没有这个待遇。假设通过 class Person: 定义了一个类,那么在实例化的时候,显然不可能通过 PyPerson_New 去创建,因为底层压根就没有这个 API。这种情况下创建 Person 的实例对象就需要 Person 这个类型对象了,因此自定义类的实例对象如何分配内存、如何进行初始化,需要借助对应的类型对象。 总的来说,Python 内部创建一个对象有两种方式: 通过特定类型 API,适用于内置数据结构,即内置类型的实例对象。 通过调用类型对象去创建(底层会调用泛型 API),多用于自定义类型。","breadcrumbs":"7. 当创建一个 Python 对象时,背后都经历了哪些过程? » 对象是如何创建的","id":"29","title":"对象是如何创建的"},"290":{"body":"帧评估函数里面有一个巨型的 switch,负责执行字节码指令,如果执行出错,那么跳转到 error 标签。 如果在执行指令的时候出现了异常,那么会跳转到 error 这里,否则会跳转到其它地方。另外当出现异常时,会在线程状态对象中将异常信息记录下来,包括异常类型、异常值、回溯栈(traceback),这个 traceback 就是在 error 标签中调用 PyTraceBack_Here 创建的。 另外可能有人不清楚 traceback 是做什么的,我们举个 Python 的例子。 def h(): 1 / 0 def g(): h() def f(): g() f()\n\"\"\"\nTraceback (most recent call last): File \"/Users/.../main.py\", line 10, in f() File \"/Users/.../main.py\", line 8, in f g() File \"/Users/.../main.py\", line 5, in g h() File \"/Users/.../main.py\", line 2, in h 1 / 0\nZeroDivisionError: division by zero\n\"\"\" 这是脚本运行时产生的错误输出,我们看到了函数调用的信息:比如在源代码的哪一行调用了哪一个函数,那么这些信息是从何而来的呢?没错,显然是 traceback 对象。虚拟机在处理异常的时候,会创建 traceback 对象,在该对象中记录栈帧的信息。虚拟机利用该对象来将栈帧链表中每一个栈帧的状态进行可视化,可视化的结果就是上面输出的异常信息。 而且我们发现输出的信息也是一个链状的结构,因为每一个栈帧都会对应一个 traceback 对象,这些 traceback 对象之间也会组成一个链表。 所以当虚拟机开始处理异常的时候,它首先的动作就是创建 traceback 对象,用于记录异常发生时活动栈帧的状态。创建方式是通过 PyTraceBack_Here 函数,它接收一个栈帧作为参数。 // Python/traceback.c\nint\nPyTraceBack_Here(PyFrameObject *frame)\n{ // 获取当前的异常对象,拿到它的 exc_type、exc_val、exc_tb PyObject *exc, *val, *tb, *newtb; PyErr_Fetch(&exc, &val, &tb); // 创建新的 traceback 对象,并和旧的 traceback 对象组成链表 newtb = _PyTraceBack_FromFrame(tb, frame); if (newtb == NULL) { _PyErr_ChainExceptions(exc, val, tb); return -1; } // 将异常设置在线程状态对象中 // 并且异常的 exc_type 和 exc_val 保持不变,但 traceback 是新的 traceback PyErr_Restore(exc, val, newtb); Py_XDECREF(tb); return 0;\n} 那么这个 traceback 对象究竟长什么样呢? // Include/cpython/traceback.h\ntypedef struct _traceback { PyObject_HEAD struct _traceback *tb_next; struct _frame *tb_frame; int tb_lasti; int tb_lineno;\n} PyTracebackObject; 里面有一个 tb_next,所以很容易想到 traceback 也是一个链表结构。其实 traceback 对象的链表结构跟栈帧对象的链表结构是同构的、或者说一一对应的,即一个栈帧对象对应一个 traceback 对象。","breadcrumbs":"63. 异常是怎么实现的?虚拟机是如何将异常抛出去的? » traceback 是什么?","id":"290","title":"traceback 是什么?"},"291":{"body":"在 PyTraceBack_Here 函数中我们看到,traceback 对象是通过 _PyTraceBack_FromFrame 创建的,那么秘密就隐藏在这个函数中。 // Python/traceback.c\n_PyTraceBack_FromFrame(PyObject *tb_next, PyFrameObject *frame)\n{ assert(tb_next == NULL || PyTraceBack_Check(tb_next)); assert(frame != NULL); return tb_create_raw((PyTracebackObject *)tb_next, frame, frame->f_lasti, PyFrame_GetLineNumber(frame));\n} static PyObject *\ntb_create_raw(PyTracebackObject *next, PyFrameObject *frame, int lasti, int lineno)\n{ PyTracebackObject *tb; if ((next != NULL && !PyTraceBack_Check(next)) || frame == NULL || !PyFrame_Check(frame)) { PyErr_BadInternalCall(); return NULL; } // 为 traceback 对象申请内存 tb = PyObject_GC_New(PyTracebackObject, &PyTraceBack_Type); if (tb != NULL) { // 设置属性 Py_XINCREF(next); tb->tb_next = next; Py_XINCREF(frame); tb->tb_frame = frame; tb->tb_lasti = lasti; tb->tb_lineno = lineno; PyObject_GC_Track(tb); } return (PyObject *)tb;\n} tb_next 将两个 traceback 连接了起来,不过这个和栈帧的 f_back 正好相反,f_back 指向的是上一个栈帧,而 tb_next 指向的是下一个 traceback。 另外在 traceback 中,还通过 tb_frame 字段和对应的 PyFrameObject 对象建立了联系,当然还有最后执行完毕时的字节码偏移量、以及在源代码中对应的行号。","breadcrumbs":"63. 异常是怎么实现的?虚拟机是如何将异常抛出去的? » traceback 创建","id":"291","title":"traceback 创建"},"292":{"body":"traceback 的创建我们知道了,那么它和栈帧对象是怎么联系起来的呢?我们还以之前的代码为例,来解释一下。 def h(): 1 / 0 def g(): h() def f(): g() f() 当执行到函数 h 的 1 / 0 这行代码时,底层会执行 BINARY_TRUE_DIVIDE 指令。 case TARGET(BINARY_TRUE_DIVIDE): { PyObject *divisor = POP(); PyObject *dividend = TOP(); // 调用了数值型对象的泛型 API PyObject *quotient = PyNumber_TrueDivide(dividend, divisor); Py_DECREF(dividend); Py_DECREF(divisor); SET_TOP(quotient); if (quotient == NULL) goto error; DISPATCH();\n} // Objects/abctract.c\nPyObject *\nPyNumber_TrueDivide(PyObject *v, PyObject *w)\n{ return binary_op(v, w, NB_SLOT(nb_true_divide), \"/\");\n} #define NB_SLOT(x) offsetof(PyNumberMethods, x)\n// 最终会执行 (&PyLong_Type) -> tp_as_methods -> nb_true_divide\n// 即 long_true_divice 函数,看一下它的逻辑 // Objects/longobject.c\nstatic PyObject *\nlong_true_divide(PyObject *v, PyObject *w)\n{ // ... a_size = Py_ABS(Py_SIZE(a)); b_size = Py_ABS(Py_SIZE(b)); negate = (Py_SIZE(a) < 0) ^ (Py_SIZE(b) < 0); // 如果除数为 0,设置 ZeroDivisionError if (b_size == 0) { PyErr_SetString(PyExc_ZeroDivisionError, \"division by zero\"); goto error; } // ... error: return NULL;\n} 由于除数为 0,因此会通过 PyErr_SetString 设置一个异常进去,最终将异常类型、异常值、以及 traceback 保存到线程状态对象中。然后跳转到 error 标签,注意:当前是 long_true_divide 的 error 标签,然后会返回 NULL。 当 long_true_divide 返回 NULL 时,那么变量 quotient 拿到的就是 NULL,由于没有指向一个合法的 PyObject,虚拟机就意识到发生异常了,这时候会跳转到 error 标签。注意:这个 error 标签是帧评估函数里的 error 标签。 在里面会先取出线程状态对象中已有的 traceback 对象,然后以函数 h 的栈帧为参数,创建一个新的 traceback 对象,将两者通过 tb_next 关联起来。最后,再替换掉线程状态对象里面的 traceback 对象。 在虚拟机意识到有异常抛出,并创建了 traceback 之后,它会在当前栈帧中寻找 try except 语句,来执行开发人员指定的异常捕捉动作。如果没有找到,那么虚拟机将退出当前的活动栈帧,并沿着栈帧链回退到上一个栈帧(这里是函数 g 的栈帧),在上一个栈帧中寻找 try except 语句。 就像我们之前说的,函数调用会创建栈帧,当函数执行完毕或者出现异常时,会回退到上一级栈帧。一层一层创建、一层一层返回。至于回退的这个动作,则是在 _PyEval_EvalFrameDefault 的最后完成。 当出现异常时,虚拟机会进入 exception_unwind 标签寻找异常捕获逻辑,相关细节下一篇文章再说,这里就让它抛出去。然后来到 exit_returning 标签,将运行时栈清空。最后进入 exit_eval_frame 标签,将当前线程状态对象中的活跃栈帧设置为上一级栈帧,从而完成栈帧回退的动作。 当栈帧回退时,会进入函数 g 的栈帧,由于返回值为 NULL,所以知道自己调用的函数 h 的内部发生异常了(否则返回值一定会指向一个合法的 PyObject),那么继续寻找异常捕获语句。对于当前这个例子来说,显然是找不到的,于是会从线程状态对象中取出已有的 traceback 对象(函数 h 的栈帧对应的 traceback),然后以函数 g 的栈帧为参数,创建新的 traceback 对象,再将两者通过 tb_next 关联起来,并重新设置到线程状态对象中。 异常会沿着栈帧链进行反向传播,函数 h 出现的异常被传播到了函数 g 中,显然接下来函数 g 要将异常传播到函数 f 中。因为函数 g 在无法捕获异常时,那么返回值也是 NULL,而函数 f 看到返回值为 NULL 时,同样会去寻找异常捕获语句。但是找不到,于是会从线程状态对象中取出已有的 traceback 对象(此时是函数 g 的栈帧对应的 traceback),然后以函数 f 的栈帧为参数,创建新的 traceback 对象,再将两者通过 tb_next 关联起来,并重新设置到线程状态对象中。 最后再传播到模块对应的栈帧中,如果还无法捕获发生的异常,那么虚拟机就要将异常抛出来了。 这个沿着栈帧链不断回退的过程我们称之为栈帧展开,在栈帧展开的过程中,虚拟机不断地创建与各个栈帧对应的 traceback,并将其链接成链表。 由于没有异常捕获,那么接下来会调用 PyErr_Print。然后在 PyErr_Print 中,虚拟机取出维护的 traceback 链表,并进行遍历,将里面的信息逐个输出到 stderr 当中,最终就是我们在 Python 中看到的异常信息。 并且打印顺序是:.py文件、函数f、函数g、函数h。因为每一个栈帧对应一个 traceback,而栈帧又是往后退的,因此显然会从 .py文件对应的 traceback 开始打印,然后通过 tb_next 找到函数 f 对应的 traceback,依次下去。当异常信息全部输出完毕之后,解释器就结束运行了。 因此从链路的开始位置到结束位置,将整个调用过程都输出出来,可以很方便地定位问题出现在哪里。 另外,虽然 traceback 一直在更新(因为要对整个调用链路进行追踪),但是异常类型和异常值始终是不变的,就是函数 h 中抛出的 ZeroDivisionError: division by zero。","breadcrumbs":"63. 异常是怎么实现的?虚拟机是如何将异常抛出去的? » 栈帧展开","id":"292","title":"栈帧展开"},"293":{"body":"以上就是虚拟机抛异常的过程,异常在 Python 里面也是一个对象,和其它的实例对象并无本质区别。 exc = StopIteration(\"迭代结束了\")\nprint(exc.value) # 迭代结束了\nprint(exc.args) # ('迭代结束了',) exc = IndexError(\"索引越界了\")\nprint(exc.args) # ('索引越界了',) exc = Exception(\"不知道是啥异常,总之出问题了\")\nprint(exc.args) # ('不知道是啥异常,总之出问题了',) # 异常都有一个 args 属性,以元组的形式保存传递的参数 所谓抛出异常,就是将错误信息输出到 stderr 中,然后停止进程。并且除了虚拟机内部会抛出异常之外,我们还可以使用 raise 关键字手动引发一个异常。 def judge_score(score: int): if score > 100 or score < 0: raise ValueError(\"Score must be between 0 and 100\") 站在虚拟机的角度,score 取任何值都是合理的,但对于我们来说,希望 score 位于 0 ~ 100。那么当 score 不满足 0 ~ 100 时,就可以手动 raise 一个异常。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"63. 异常是怎么实现的?虚拟机是如何将异常抛出去的? » 小结","id":"293","title":"小结"},"3":{"body":"本文就说到这里,赶快下载 Python 3.8 源码,来和我一起学习 Python 吧(ヾ(◍°∇°◍)ノ゙)。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"1. CPython 源码长什么样子? » 小结","id":"3","title":"小结"},"30":{"body":"lst = [] 和 lst = list() 都会创建一个空列表,但这两种方式有什么区别呢? 我们说创建实例对象可以通过解释器提供的特定类型 API,用于内置类型;也可以通过实例化类型对象去创建,既可用于自定义类型,也可用于内置类型。 # 通过特定类型 API 创建\n>>> lst = [] >>> lst\n[]\n# 通过调用类型对象创建\n>>> lst = list() >>> lst\n[] 还是那句话,解释器对内置数据结构了如指掌,并且做足了优化。 看到 123,就知道创建 PyLongObject 实例; 看到 2.71,就知道创建 PyFloatObject 实例; 看到 ( ),就知道创建 PyTupleObject 实例; 看到 [ ],就知道创建 PyListObject 实例; ······ 这些都会使用特定类型 API 去创建,直接为结构体申请内存,然后设置引用计数和类型,所以使用 [ ] 创建列表是最快的。但如果使用 list() 创建列表,那么就产生了一个调用,要进行参数解析、类型检测、创建栈帧、销毁栈帧等等,所以开销会大一些。 import time start = time.perf_counter()\nfor _ in range(10000000): lst = []\nend = time.perf_counter()\nprint(end - start) \"\"\"\n0.2144167000001289\n\"\"\" start = time.perf_counter()\nfor _ in range(10000000): lst = list()\nend = time.perf_counter()\nprint(end - start) \"\"\"\n0.4079916000000594\n\"\"\" 通过 [ ] 的方式创建一千万次空列表需要 0.21 秒,但通过 list() 的方式创建一千万次空列表需要 0.40 秒,主要就在于 list() 是一个调用,而 [ ] 会直接被解析成 PyListObject,因此 [ ] 的速度会更快一些。 所以对于内置类型的实例对象而言,使用特定类型 API 创建要更快一些。而且事实上通过类型对象去创建的话,会先调用 tp_new,然后在 tp_new 内部还是调用了特定类型 API。 比如: 创建列表:可以是 list()、也可以是 [ ]; 创建元组:可以是 tuple()、也可以是 ( ); 创建字典:可以是 dict()、也可以是 { }; 前者是通过类型对象创建的,后者是通过特定类型 API 创建的。对于内置类型的实例对象而言,我们推荐使用特定类型 API 创建,会直接解析为对应的 C 一级数据结构,因为这些结构在底层都是已经实现好了的,可以直接用。而无需通过诸如 list() 这种调用类型对象的方式来创建,因为它们内部最终还是使用了特定类型 API,相当于多绕了一圈。 不过以上都是针对内置类型,而自定义的类型就没有这个待遇了,它的实例对象只能通过调用它自己创建。比如 Person 这个类,解释器不可能事先定义一个 PyPersonObject 然后将 API 提供给我们,所以我们只能通过调用 Person 来创建它的实例对象。 另外内置类型被称为静态类,它和它的实例对象在底层已经定义好了,无法动态修改。我们自定义的类型被称为动态类,它是在解释器运行的过程中动态构建的,所以我们可以对其进行动态修改。 事实上 Python 的动态性、GIL 等特性,都是解释器在将字节码翻译成 C 代码时动态赋予的,而内置类型在编译之后已经是指向 C 一级的数据结构,因此也就丧失了相应的动态性。不过与之对应的就是效率上的提升,因为运行效率和动态性本身就是鱼与熊掌的关系。","breadcrumbs":"7. 当创建一个 Python 对象时,背后都经历了哪些过程? » [] 和 list(),应该使用哪种方式","id":"30","title":"[] 和 list(),应该使用哪种方式"},"31":{"body":"以上我们就简单分析了 Python 对象的创建过程,当然这只是一个开头,其背后还隐藏了大量的细节,我们后续会慢慢说。 下一篇文章来聊一聊,对象是如何被调用的。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"7. 当创建一个 Python 对象时,背后都经历了哪些过程? » 小结","id":"31","title":"小结"},"32":{"body":"在上一篇文章中,我们分析了对象是如何创建的,主要有两种方式,一种是通过特定类型 API,另一种是通过调用类型对象。 对于内置类型的实例对象而言,这两种方式都是支持的,比如列表,我们既可以通过 [ ] 创建,也可以通过 list() 创建,前者是列表的特定类型 API,后者是调用类型对象。但对于自定义类的实例对象而言,我们只能通过调用类型对象的方式来创建。 而一个对象如果可以被调用,那么这个对象就是 callable,否则就不是 callable。那么问题来了,如果一个对象是 callable,那么它都具有哪些特征呢? 从 Python 的角度看,如果对象是 callable,那么它的类型对象一定实现了 __call__ 函数; 从解释器的角度看,如果对象是 callable,那么它的类型对象的 tp_call 字段一定不为空。","breadcrumbs":"8. 当调用一个 Python 对象时,背后都经历了哪些过程? » 楔子","id":"32","title":"楔子"},"33":{"body":"调用 int 可以创建一个整数,调用 str 可以创建一个字符串,调用 tuple 可以创建一个元组,调用自定义的类也可以创建出相应的实例对象,这就说明类型对象是可调用的,也就是 callable。既然类型对象可调用,那么类型对象的类型对象(type)内部一定实现了 __call__ 函数。 # int 可以调用,那么它的类型对象、也就是元类(type)一定实现了 __call__ 函数\nprint(hasattr(type, \"__call__\")) # True # 而调用一个对象,等价于调用其类型对象的 __call__ 函数\n# 所以 int(2.71) 实际上就等价于如下\nprint(type.__call__(int, 2.71)) # 2 我们说 int、str、float 这些都是类型对象(简单来说就是类),而 123、\"你好\"、2.71 是其对应的实例对象,这些都没问题。但如果相对 type 而言,int、str、float 是不是又成了实例对象呢?因为它们的类型都是 type。 所以 class 具有二象性: 如果站在实例对象(如:123、\"你好\"、2.71)的角度上,它是类型对象; 如果站在 type 的角度上,它是实例对象; 同理,由于 type 的类型还是 type,那么 type 既是 type 的类型对象,type 也是 type 的实例对象。虽然这里描述的有一些绕,但应该不难理解,而为了避免后续的描述出现歧义,这里我们做一个申明: 整数、浮点数、字符串、列表等等,我们称之为实例对象 int、float、str、dict,以及自定义的类,我们称之为类型对象 type 虽然也是类型对象,但我们称它为元类 由于 type 的内部定义了 __call__ 函数,那么说明类型对象都是可调用的,因为调用类型对象就是调用元类 type 的 __call__ 函数。而实例对象能否调用就不一定了,这取决于它的类型对象是否定义了 __call__ 函数,因为调用一个对象,本质上是调用其类型对象内部的 __call__ 函数。 class A: pass a = A()\n# 因为自定义的类 A 里面没有 __call__\n# 所以 a 是不可以被调用的\ntry: a()\nexcept Exception as e: # 告诉我们 A 的实例对象无法被调用 print(e) # 'A' object is not callable # 如果我们给 A 设置了一个 __call__\ntype.__setattr__(A, \"__call__\", lambda self: \"这是__call__\")\n# 发现可以调用了\nprint(a()) # 这是__call__ 这就是动态语言的特性,即便在类创建完毕之后,依旧可以通过 type 进行动态设置,而这在静态语言中是不支持的。所以 type 是所有类的元类,它控制了自定义类的生成过程,因此 type 这个古老而又强大的类可以让我们玩出很多新花样。 但对于内置的类,type 是不可以对其动态增加、删除或者修改属性的,因为内置的类在底层是静态定义好的。从源码中我们看到,这些内置的类、包括元类,它们都是 PyTypeObject 对象,在底层已经被声明为全局变量了,或者说它们已经作为静态类存在了。所以 type 虽然是所有类型对象的类型,但只有面对自定义的动态类,type 才具有对属性进行增删改的能力。 而且在上一篇文章中我们也解释过,Python 的动态性是解释器将字节码翻译成 C 代码的时候赋予的,因此给类对象动态设置属性只适用于动态类,也就是在 py 文件中使用 class 关键字定义的类。而对于静态类,它们在编译之后已经是指向 C 一级的数据结构了,不需要再被解释器解释了,因此解释器自然也就无法在它们身上动手脚,毕竟彪悍的人生不需要解释。 try: type.__setattr__(dict, \"ping\", \"pong\")\nexcept Exception as e: print(e) \"\"\" can't set attributes of built-in/extension type 'dict' \"\"\" try: type.__setattr__(list, \"ping\", \"pong\")\nexcept Exception as e: print(e) \"\"\" can't set attributes of built-in/extension type 'list' \"\"\" 同理其实例对象亦是如此,静态类的实例对象也不可以动态设置属性: lst = list()\ntry: lst.name = \"古明地觉\"\nexcept Exception as e: print(e) # 'list' object has no attribute 'name' 在介绍 PyTypeObject 结构体的时候我们说过,静态类的实例对象可以绑定哪些属性,已经写死在 tp_members 字段里面了。","breadcrumbs":"8. 当调用一个 Python 对象时,背后都经历了哪些过程? » 从 Python 的角度看对象的调用","id":"33","title":"从 Python 的角度看对象的调用"},"34":{"body":"如果一个对象可以被调用,那么它的类型对象中一定要有 tp_call,更准确的说是 tp_call 字段的值是一个具体的函数指针,而不是 0。由于 PyList_Type 是可以调用的,这就说明 PyType_Type 内部的 tp_call 是一个函数指针,这在 Python 的层面我们已经验证过了,下面再来通过源码看一下。 在创建 PyType_Type 的时候,PyTypeObject 内部的 tp_call 字段被设置成了 type_call。所以当我们调用 PyList_Type 的时候,会执行 type_call 函数。 因此 list() 在 C 的层面上等价于: (&PyList_Type)->ob_type->tp_call(&PyList_Type, args, kwargs);\n// 即:\n(&PyType_Type)->tp_call(&PyList_Type, args, kwargs);\n// 而在创建 PyType_Type 的时候,给 tp_call 字段传递的是 type_call\n// 因此最终等价于\ntype_call(&PyList_Type, args, kwargs) 如果用 Python 来演示这一过程的话: # 以 list(\"abcd\") 为例,它等价于\nlst1 = list.__class__.__call__(list, \"abcd\")\n# 等价于\nlst2 = type.__call__(list, \"abcd\")\nprint(lst1) # ['a', 'b', 'c', 'd']\nprint(lst2) # ['a', 'b', 'c', 'd'] 这就是 list() 的秘密,相信其它类型在实例化的时候是怎么做的,你已经知道了,做法是相同的。 # dct = dict([(\"name\", \"古明地觉\"), (\"age\", 17)])\ndct = dict.__class__.__call__( dict, [(\"name\", \"古明地觉\"), (\"age\", 17)]\n)\nprint(dct) # {'name': '古明地觉', 'age': 17} # buf = bytes(\"hello world\", encoding=\"utf-8\")\nbuf = bytes.__class__.__call__( bytes, \"hello world\", encoding=\"utf-8\"\n)\nprint(buf) # b'hello world' 当然,目前还没有结束,我们还需要看一下 type_call 的源码实现。","breadcrumbs":"8. 当调用一个 Python 对象时,背后都经历了哪些过程? » 从解释器的角度看对象的调用","id":"34","title":"从解释器的角度看对象的调用"},"35":{"body":"调用类型对象,本质上会调用 type.__call__,在底层对应 type_call 函数,因为 PyType_Type 的 tp_call 字段被设置成了 type_call。当然调用 type 也是如此,因为 type 的类型还是 type。 那么这个 type_call 都做了哪些事情呢? // Objects/typeobject.c static PyObject *\ntype_call(PyTypeObject *type, PyObject *args, PyObject *kwds)\n{ // 参数 type 表示类型对象或者元类,假设调用的是 list,那么它就是 &PyList_Type // 参数 args 和 kwds 表示位置参数和关键字参数,args 是元组,kwds 是字典 // 指向创建的实例对象,当然也可能是类型对象,取决于参数 type // 如果参数 type 表示元类,那么 obj 会指向类型对象,并且是自定义的动态类 // 如果参数 type 表示类型对象,那么 obj 会指向实例对象 PyObject *obj; // 执行类型对象(也可能是元类)的 tp_new,也就是 __new__ // 如果不存在,那么会报错,而在 Python 中见到的报错信息就是这里指定的 if (type->tp_new == NULL) { PyErr_Format(PyExc_TypeError, \"cannot create '%.100s' instances\", type->tp_name); return NULL; } obj = type->tp_new(type, args, kwds); // 检测调用是否正常,如果调用正常,那么 obj 一定指向一个合法的 PyObject // 而如果 obj 为 NULL,则表示执行出错,此时解释器会抛出异常 obj = _Py_CheckFunctionResult((PyObject*)type, obj, NULL); if (obj == NULL) return NULL; // 这里要做一个额外判断: // 如果参数 type 是 &PyType_Type,也就是 Python 中的元类 // 那么它可以接收一个位置参数(查看对象类型),也可以接收三个位置参数(创建自定义类) // 所以当 type 是 &PyType_Type,位置参数的个数为 1,并且没有传递关键字参数时 // 那么表示查看对象的类型,此时直接返回 obj 即可 if (type == &PyType_Type && PyTuple_Check(args) && PyTuple_GET_SIZE(args) == 1 && (kwds == NULL || (PyDict_Check(kwds) && PyDict_GET_SIZE(kwds) == 0))) return obj; // 到这里说明不是查看对象类型,而是创建类或者实例 // 如果参数 type 是元类,那么表示创建类,此时 args 的长度必须为 3 // 如果参数 type 是类型对象,那么表示创建实例对象 // 所以为了描述方便,我们就假设参数 type 是类型对象,但我们知道它也可以是元类 // 总之到这里 __new__ 已经执行完了,那么之后该干啥了?显然是执行 __init__,但需要先做一个检测 // 如果 __new__ 返回的实例对象的类型不是当前类型,那么直接返回,不再执行 __init__ // 比如自定义 class A,那么在 __new__ 里面应该返回 A 的实例对象,但我们故意返回个 123 // 由于返回值的类型不是当前类型,那么不再执行初始化函数 __init__ if (!PyType_IsSubtype(Py_TYPE(obj), type)) return obj; // 走到这里说明类型一致,那么执行 __init__,将 obj、args、kwds 一起传过去 type = Py_TYPE(obj); if (type->tp_init != NULL) { int res = type->tp_init(obj, args, kwds); if (res < 0) { assert(PyErr_Occurred()); Py_DECREF(obj); obj = NULL; } else { assert(!PyErr_Occurred()); } } // 返回创建的对象 obj,当然准确来说是对象的泛型指针 // 因为 Python 虽然一切皆对象,但我们拿到的都是对象的泛型指针 // 只是有时为了描述方便,我们会说成是对象,这一点我们心里清楚就好 return obj;\n} 所以整个过程就三步: 如果传递的是元类,并且只有一个参数,那么直接返回对象的类型; 否则先调用 tp_new 为实例对象申请内存; 再调用 tp_init(如果有)进行初始化,设置对象属性; 所以这对应了 Python 中的 __new__ 和 __init__,其中 __new__ 负责为实例对象开辟一份内存,然后返回指向对象的指针,并且该指针会自动传递给 __init__ 中的 self。 class Girl: def __new__(cls, name, age): print(\"__new__ 方法执行啦\") # 调用 object.__new__(cls) 创建 Girl 的实例对象 # 然后该对象的指针会自动传递给 __init__ 中的 self return object.__new__(cls) def __init__(self, name, age): print(\"__init__ 方法执行啦\") self.name = name self.age = age g = Girl(\"古明地觉\", 16)\nprint(g.name, g.age)\n\"\"\"\n__new__ 方法执行啦\n__init__ 方法执行啦\n古明地觉 16\n\"\"\" __new__ 里面的参数要和 __init__ 里面的参数保持一致,因为会先执行 __new__ ,然后解释器再将 __new__ 的返回值和传递的参数组合起来一起传给 __init__。因此从这个角度上讲,设置属性完全可以在 __new__ 里面完成。 class Girl: def __new__(cls, name, age): self = object.__new__(cls) self.name = name self.age = age return self g = Girl(\"古明地觉\", 16)\nprint(g.name, g.age)\n\"\"\"\n古明地觉 16\n\"\"\" 这样也是没问题的,不过 __new__ 一般只负责创建实例,设置属性应该交给 __init__ 来做,毕竟一个是构造函数、一个是初始化函数,各司其职。另外由于 __new__ 里面不负责初始化,那么它的参数除了 cls 之外,一般都会写成 *args 和 **kwargs。 然后再回过头来看一下 type_call 中的这两行代码: tp_new 应该返回该类型对象的实例对象,而且一般情况下我们是不重写 __new__ 的,会默认执行 object 的 __new__。但如果我们重写了,那么必须要手动返回 object.__new__(cls)。可如果我们不返回,或者返回其它的话,会怎么样呢? class Girl: def __new__(cls, *args, **kwargs): print(\"__new__ 方法执行啦\") instance = object.__new__(cls) # 打印看看 instance 到底是个啥 print(\"instance:\", instance) print(\"type(instance):\", type(instance)) # 正确做法是将 instance 返回 # 但是我们不返回,而是返回一个整数 123 return 123 def __init__(self, name, age): print(\"__init__ 方法执行啦\") g = Girl()\n\"\"\"\n__new__ 方法执行啦\ninstance: <__main__.Girl object at 0x0000019A2B7270A0>\ntype(instance): \n\"\"\" 这里面有很多可以说的点,首先就是 __init__ 里面需要两个参数,但是我们没有传,却还不报错。原因就在于这个 __init__ 压根就没有执行,因为 __new__ 返回的不是 Girl 的实例对象。 通过打印 instance,我们知道了 object.__new__(cls) 返回的就是 cls 的实例对象,而这里的 cls 就是 Girl 这个类本身。所以我们必须要返回 instance,才会自动执行相应的 __init__。 我们在外部来打印一下创建的实例对象吧,看看结果: class Girl: def __new__(cls, *args, **kwargs): return 123 def __init__(self, name, age): print(\"__init__ 方法执行啦\") g = Girl()\nprint(g)\n\"\"\"\n123\n\"\"\" 我们看到打印的结果是 123,所以再次总结一下 tp_new 和 tp_init 之间的区别,当然也对应 __new__ 和 __init__ 的区别: tp_new:为实例对象申请内存,底层会调用 tp_alloc,至于对象的大小则记录在 tp_basicsize 字段中,而在 Python 里面则是调用 object.__new__(cls),然后一定要将实例对象返回; tp_init:tp_new 的返回值会自动传递给 self,然后为 self 绑定相应的属性,也就是进行实例对象的初始化; 但如果 tp_new 返回的对象的类型不对,比如 type_call 的第一个参数接收的是 &PyList_Type,但 tp_new 返回的却是 PyTupleObject *,那么此时就不会执行 tp_init。对应上面的 Python 代码就是,Girl 的 __new__ 应该返回 Girl 的实例对象(指针)才对,但却返回了整数,因此类型不一致,不会执行 __init__。 所以都说类在实例化的时候会先调用 __new__,再调用 __init__,相信你应该知道原因了,因为在源码中先调用 tp_new,再调用 tp_init。所以源码层面表现出来的,和我们在 Python 层面看到的是一样的。","breadcrumbs":"8. 当调用一个 Python 对象时,背后都经历了哪些过程? » type_call 源码解析","id":"35","title":"type_call 源码解析"},"36":{"body":"到此,我们就从 Python 和解释器两个层面剖析了对象是如何调用的,更准确的说,我们是从解释器的角度对 Python 层面的知识进行了验证,通过 tp_new 和 tp_init 的关系,来了解 __new__ 和 __init__ 的关系。 当然对象调用还不止目前说的这么简单,更多的细节隐藏在了幕后。后续我们会循序渐进,一点点地揭开它的面纱,并且在这个过程中还会不断地学习到新的东西。比如说,实例对象在调用方法的时候会自动将实例本身作为参数传递给 self,那么它为什么会传递呢?解释器在背后又做了什么工作呢?这些在之后的文章中都会详细说明。 欢迎大家关注我的公众号:古明地觉的编程教室。 如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。","breadcrumbs":"8. 当调用一个 Python 对象时,背后都经历了哪些过程? » 小结","id":"36","title":"小结"},"37":{"body":"之前我们提到了泛型 API,这类 API 的特点是可以处理任意类型的对象,举个例子。 // 返回对象的长度\nPyObject_Size\n// 返回对象的某个属性的值\nPyObject_GetAttr\n// 返回对象的哈希值\nPyObject_Hash\n// 将对象转成字符串后返回\nPyObject_Str 对应到 Python 代码中,就是下面这个样子。 # PyObject_Size\nprint(len(\"古明地觉\"))\nprint(len([1, 2, 3]))\n\"\"\"\n4\n3\n\"\"\" # PyObject_GetAttr\nprint(getattr(\"古明地觉\", \"lower\"))\nprint(getattr([1, 2, 3], \"append\"))\nprint(getattr({}, \"update\"))\n\"\"\"\n\n\n\n\"\"\" # PyObject_Hash\nprint(hash(\"古明地觉\"))\nprint(hash(2.71))\nprint(hash(123))\n\"\"\"\n8152506393378233203\n1637148536541722626\n123\n\"\"\" # PyObject_Str\nprint(str(\"古明地觉\"))\nprint(str(object()))\n\"\"\"\n古明地觉\n