作者在这集视频的开头说明了上一集犯的错误,他也很推荐去直接看python的官方文档,上一篇中他说的dis.dis的助记符前的数字含义是错的,我的文章里虽然写明是偏移量,但是我也没说清是什么的偏移量,其实这个就是代表当前指令在字节码的第几个字节,比如偏移为3就代表字节码中的第3个字节开始是这个指令,注意这个偏移是从0开始的,回顾上一篇文章,会发现前面几个偏移量是0、3、6、9,这个是因为这几个字节码除了指令自身占一个字节外,还有参数,如果该指令不支持参数,那么后面就不会留位置放参数,比如BINARY_ADD就是把栈顶的两个值拿出来加起来再放回栈顶,根本不需要参数,那么这个指令就只占一个字节,下一个字节就是下一条指令了
作者也指出他使用compile函数编译出来的对象不对,因此co_code也不对,我昨天虽然发现了这个错误,但是我也没仔细看官方文档中对于source这个参数的解释,这里放下原文看一眼:
Compile the source into a code or AST object. Code objects can be executed
by an exec
statement or evaluated by a call to eval()
.source can either be a Unicode string, a Latin-1 encoded string or an
AST object.Refer to the ast
module documentation for information on how to work
with AST objects.
我标红的地方其实写的很清楚了,source只能传入u码或者Latin1编码的源代码或者ast对象,因此我昨天做的是歪打正着了,我把文件打开以后把源代码字符串传进去了。而昨天错误的字节码是因为compile函数试着把”source.py”当成源代码来编译,因此显然编译出的结果也不对,但是吧,这玩意符合python语法,因此编译器并不会报错,而是给当作你在找一个source对象的py属性而通过了,就像这样:
这个写法是完全符合语法规则的
事实上编译器也确实按照咱们上述的语法尝试在找一个source对象内名为py的属性
这里有几个术语先放这,之后就不翻译了:
TOS top of the stack 栈顶 python是基于栈的设计,所以之后会大量出现这个术语
argument 参数
frame 帧 这个概念在python解释器中也是十分重要的,python在执行函数时需要独立的变量空间,帧就是用来做这个的,我们也可以管帧叫数据帧或者栈帧,每当开始执行程序时,一定会生成一个叫做global的帧,这个帧存放的都是全局变量,而当有函数被调用时,为了函数内能存在局部变量,又生成了这个函数专属的帧
call 调用 python的函数肯定有外面的其它代码调用它,并且函数执行完成以后要返回call它的地方
这里官方文档给列举了所有字节码的助记符,有需要直接查表即可,不要尝试去给全文背诵,没用
视频中提了一个问题,就是这个4 POP_TOP的值跑哪去了,其实这依然是因为python是基于栈设计的,当一个属性被调用,一定会被弹出栈,而至于有没有变量接收它那就另说了,如果我们写一个x = source.py,会发现在POP_TOP后还跟了一句STORE_NAME,也就是把弹出栈的数据给x变量,一旦解释器抛出异常,那么栈就会被清空
下面我们看python/ceval.c的681行,
PyEval_EvalFrame(PyFrameObject *f)
如果你跟我的源代码版本不一样,那么就自己搜一搜在哪行
这个地方实际上创建并返回了一个frame,这个frame实际上等价于一个函数,当这个frame执行完成后会返回call它的地方
我们再往下看926行,这里宏定义了很多关于栈操作的指令,这里都是为了方便使用而定义的,再往下看回mainloop,1179行,这里这么写的:
每当主循环执行到这里,就会拿下一条指令,如果有参数再把参数拿出来,HAS_ARG在include/opcode.h中定义:
其实就是说op大于等于HAVE_ARGUMENT的都是有参数的,这个值是多少呢?
其实是90,也就是说python的指令90号以下的是没有参数的,而90号以上的就有参数了,
ceval.c的1208行开始把头文件的所有指令作了具体的定义,这些指令到底能做什么都写在这里了,这里主要是组合了前面的最基础的栈操作,比如POP_TOP就是这样定义的:
这样也避免了源代码中的关于栈操作的混乱
下面作者简单提及了python的垃圾回收原理,也就是引用计数,我们总能听到一句话叫做python的变量更像是贴在值上,这个理解是没错的,我们也可以通过x = 1这一个简单例子来里姐:
实际上不像c语言等语言,在声明变量的时候就会开辟一片内存区域为未来存放值,恰恰相反,python先把1这个常量放在内存区域里,然后再声明一个变量,或者这里叫做指针更合适了,声明一个指针x指向常量1所在的内存区域,这样的设计为python语言带来了很多有意思的特性,比如最典型的这个例子:
按理说,把a的值赋值给b,那么应该会把数据给b拷贝一份,但是实际情况是b也像一张纸一样贴在了[1]这个列表上,因此a和b都指向了同一份数据,当这个被指的数据是常量时还好,如果指向可变的数据,就有可能因为其中的一个变量被误操作而导致整个数据被破坏。
但是python的垃圾回收机制也正是通过这个方式实现的,回收部分的代码会统计某个内存区域被多少个变量引用,如果这个值为0,则代表目前没有任何变量能访问到这个内存区域,那么垃圾回收就会把这块内存释放掉
下面敲一段代码吧:
这段代码可以让我们看到函数到底是怎么工作的,不过这里我们不用dis了,我们用一个在线的可视化debug网站来看:https://pythontutor.com/,这个网站进去以后把代码粘好,选python2.7,执行即可,当然也可以直接用我写好的,只需要进去即可:https://pythontutor.com/render.html#code=x%20%3D%2010%0Adef%20foo%28x%29%3A%0A%20%20%20%20y%20%3D%20x%20*%202%0A%20%20%20%20return%20bar%28y%29%0A%0Adef%20bar%28x%29%3A%0A%20%20%20%20y%20%3D%20x%20/%202%0A%20%20%20%20return%20y%0A%0Az%20%3D%20foo%28x%29&cumulative=false&curInstr=&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=2&rawInputLstJSON=%5B%5D&textReferences=false
通过可视化工具我们可以发现,python解释器在碰到定义函数时并不会直接进去执行,而只是声明了一个函数对象,并把函数名绑定在了函数上
而我们真正使用函数是在第10行,当foo函数被调用时,创建了一个独立于Global frame的frame:
那么我们再从dis的结果上对应一下:
偏移量4的地方看到了一个很熟悉的东西,叫做code object,这个东西在前面compile函数的时候就见过:
实际上这就是一段可以被解释器识别的指令对象,没什么特别的,这里偏移量4的位置解释器加载它只是为了和偏移量6和8的两个指令结合来绑定一个可调用的函数,其它的什么也没干,这个时候全局变量里只是记录了有一个叫做foo的变量,真正调用是在偏移量为24的CALL_FUNCTION,这个时候解释器循环才真正新建了一个叫做foo的frame,并开始使用新frame内的stack,当frame用完后一定会返回开始call这个函数的位置,也就是24,我们也可以发现后面就是正常的把值赋给z变量
到这里我突然有一个事情想不通了,解释器在新frame内是如何拿到全局变量的呢?
我们dis一下:
发现全局变量是用LOAD_GLOBAL拿到的