10 变量的作用域:哪些复杂规则是因函数而产生的?
你好,我是海纳。
上一节课我们实现了函数的基本功能,可以调用一个不带参数的函数,也可以正常地得到函数的返回值。引入函数以后,就有了新的命名空间(namespace),简单说就是函数内部定义的变量,在函数外部是访问不到的,这就产生了变量的作用域的问题。这一节课,我们就来实现函数的作用域。
变量的作用规则
在 Python 语言中,主要有四种类型的变量,按照作用域从小到大排列,分别是局部变量(Local)、闭包变量(Enclosing)、全局变量(Global)和内建变量(Builtin)。
例如以下三个例子:
global x
x = 0
def foo():
x = 2
def bar():
print(x)
def goo():
x = 3
print(x)
return bar, goo
def func():
global x
x = 1
func()
print(x) #this is 1
bar, goo = foo()
bar() # this is 2
goo() # this is 3
代码的注释里已经把结果标明了。
第 2 行定义了全局变量 x,但是在 goo 方法里,又定义了一个局部变量 x,那么第 11 行要打印 x 的值的时候,首先就会去局部变量表里查找。在 goo 方法里首先查到的是第 10 行定义的局部变量,所以这里就会打印 3。也就是说局部变量对全部变量 x 造成了覆盖。
在 func 方法中,我们明确地指定了要修改全局变量 x 的值,由原来的 0 改为1(第 16、17 行),这里是直接修改了全局变量表中的 x,而不是在局部变量表里创建新的变量。所以这会导致第 20 行打印全局变量时,输出为 1。
在 bar 方法里,虚拟机按照同样的查找顺序,先查找局部变量表,发现局部变量表里找不到 x,接下来就会去定义 bar 的上下文中去找,也就是 foo 的定义中。可以看到 foo 里已经定义了 x 为2。
bar 函数使用了 foo 函数中定义的局部变量,即使 bar 函数作为返回值被取出来以后(第 20 行),它仍然可以访问到 foo 函数中 x 的值。就好像 foo 中的局部变量已经与 bar 绑定在一起一样。人们把这种把函数外的变量与函数绑在一起的现象叫做Closure 或者 Enclosing,中文翻译为闭包。
可见,虚拟机在查找变量时,是按照局部变量 -> 闭包变量 -> 全局变量这样的顺序进行查找的。如果再加上语言内建变量,这种查找顺序就会被统称为 LEGB 规则。
当然,这个例子中缺少内建(Builtin)变量。Builtin 是 Python 内建变量表,在这个变量表里,常驻了很多 Python 语言的重要变量,例如 print 函数。
注意,Python 2 和Python 3 版本在处理语言内建变量时有所不同。在Python 2中,True 和 False,实际上是个变量。虽然我们几乎从来不去主动修改 Builtin 变量表,但这样做确实是合法的,例如:
但在 Python 3 中,这个例子就不能再通过编译了。
另外一个区别是,Python 2 中 print 是关键字,它会被翻译成 PRINT_ITEM 和 PRINT_NEWLINE 两条指令。但是这两条指令在 Python 3 中被废弃了,print 也从关键字变成了内建函数。所以在 Python 3 中,可以执行赋值操作,而 Python 2 则不行。
>>> say = print
>>> print = None
>>> print
>>> print("hello")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'NoneType' object is not callable
>>> say("hello")
hello
在详细地解释过 LEGB 规则以后,接下来我们就要思考如何在虚拟机中实现这些规则。
全局变量
实际上,局部变量已经实现好了,就在 FrameObject 中的 _locals 表里,函数执行过程中使用的局部变量都存储在这里。
所以,我们接下来需要实现的是 Global 变量的功能。先看一个 Global 变量的具体例子,通过这个例子,我们可以观察对应的字节码是怎么样的。
将上述代码保存成一个 py 文件,编译以后通过 show_file 来查看它的内容。
这个文件所对应的字节码里有两个是我们尚未实现的:STORE_GLOBAL 和 LOAD_GLOBAL。这两个字节码的作用都是操作全局变量,我们把局部变量放在了 FrameObject 的局部变量表里,而全局变量也放到 FrameObject 的全局变量表里。
接下来,就给 FrameObject 添加全局变量表。
// runtime/FrameObject.hpp
class FrameObject {
public:
...
Map<HiObject*, HiObject*>* _locals;
Map<HiObject*, HiObject*>* _globals;
public:
...
Map<HiObject*, HiObject*>* globals() { return _globals; }
};
简单推理就能知道,在同一个文件中所有的全局变量都会存放在同一个地方。所以这里需要一种机制,确保在同一个文件中定义的函数,它们所创建的 FrameObject 使用的是同一个全局变量列表。
有很多种设计方案可以达成这一目标,但为了更全面正确地实现全局变量功能,我们还需要研究跨文件的全局变量是如何工作的。
跨文件的全局变量
在同一个目录下,创建两个文件,分别命名为 a.py 和 b.py。
a.py 的代码:
b.py 的代码:
然后执行 a.py,得到的结果是 100,而不是 2。这说明函数所依赖的全局变量表是定义函数对象时,它所在的那个文件的全局变量,在这个例子中就是 b.py,而不是调用函数时所在文件的全局变量(a.py)。
换句话说,函数执行所依赖的全局变量是 MAKE_FUNCTION 时的,而不是 CALL_FUNCTION 时的。所以在执行 MAKE_FUNCTION 时,虚拟机就应该将 FrameObject 中的全局变量表打包进 FunctionObject,这样一来,无论函数对象在哪里被调用,它所使用的全局变量表都是定义该函数时所在文件的全局变量表。
实现这个功能最好的办法是,也为 FunctionObject 引入一个变量表。我们来看一下FunctionObject 的变化。
// runtime/functionObject.hpp
class FunctionObject : public HiObject {
private:
...
Map<HiObject*, HiObject*>* _globals;
public:
...
Map<HiObject*, HiObject*>* globals() { return _globals; }
void set_globals(Map<HiObject*, HiObject*>* x) { _globals = x; }
};
// runtime/interpreter.cpp
void Interpreter::run(CodeObject* codes) {
_frame = new FrameObject(codes);
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
FunctionObject* fo;
...
switch (op_code) {
...
case ByteCode::MAKE_FUNCTION:
v = POP();
fo = new FunctionObject(v);
fo->set_globals(_frame->globals());
PUSH(fo);
break;
...
}
}
}
上述代码中,在创建函数对象的时候,我们就把当前栈帧的全局变量表传递给了FunctionObject (第 26 行)。从此,无论这个函数被传递到哪里去执行,无论它的执行上下文中的全局变量表的内容是什么,这个函数一旦开始执行,它的全局变量表总会是它定义时的那个。
最后,在与变量表相关的逻辑里,还有几处需要修改的地方。
第一处是 FrameObject 的构造函数。
FrameObject::FrameObject (FunctionObject* func) {
...
_locals = new Map<HiObject*, HiObject*>();
_globals = func->_globals;
...
}
// this constructor is used for module only.
FrameObject::FrameObject(CodeObject* codes) {
...
_locals = new Map<HiObject*, HiObject*>();
_globals = _locals;
...
}
我们之前实现了两个 FrameObject 的构造函数,一个是用于调用函数时,为函数创建栈帧的。创建栈帧时,全局变量表就从 FunctionObject 中去取(第 4 行)。
还有一个构造函数是用于创建第一个栈帧的,它的输入参数是 CodeObject。在这个构造函数里,我们并没有创建一个新的全局变量表,而是让 _globals 与 _locals 指向了同一个对象(第 11 行)。这么做的原因是,在非函数上下文中,Python 的局部变量与全局变量的作用是一样的,只有调用函数时,创建了新的栈帧,才对局部变量和全局变量进行区分。
这样的设计可以保证在文件的模块中,全局变量表和局部变量是相同的,而且通过 FrameObject 和 FunctionObject 的相互传递,也保证了在同一个文件中定义的函数,所使用的全局变量表是同一个。
LOAD 指令
在实现了全局变量以后,接下来我们就可以实现 LOAD_GLOBAL 和 STORE_GLOBAL 指令了。顾名思义,这两个指令都是用于操作全局变量表的。它们的实现如下所示:
void Interpreter::run(CodeObject* codes) {
_frame = new FrameObject(codes);
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::LOAD_GLOBAL:
v = _frame->names()->get(op_arg);
w = _frame->globals()->get(v);
PUSH(w);
break;
case ByteCode::STORE_GLOBAL:
v = _frame->names()->get(op_arg);
_frame->globals()->put(v, POP());
break;
...
}
}
}
受影响的还有LOAD_NAME 指令。
LOAD_GLOBAL 只会去全局变量表里读取变量,但是 LOAD_NAME 却依赖于 LEGB 规则。也就是说,遇到 LOAD_NAME 时,执行器应该先去局部变量表里尝试读取变量,如果查找不到,再尝试去全局变量表里读取,如果还查找不到,就应该去 builtin 表里读取。这里没有考虑闭包变量的情况,这是因为在 Python 中,有专用的特殊字节码来处理闭包变量,我们会在后面的课程里实现相应的机制。
LOAD_NAME 的实现也要相应地发生变化。
void Interpreter::run(CodeObject* codes) {
_frame = new FrameObject(codes);
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::LOAD_NAME:
v = _frame->names()->get(op_arg);
w = _frame->locals()->get(v);
if (w != Universe::HiNone) {
PUSH(w);
break;
}
w = _frame->globals()->get(v);
if (w != Universe::HiNone) {
PUSH(w);
break;
}
PUSH(Universe::HiNone);
break;
...
}
}
}
builtin 变量
Python 虚拟机里有很多内建变量,这些变量不需要任何定义,赋值就可以直接使用了。例如 print、zip 等函数,以及 list、dict 等数据结构。
实际上,这些内建变量我们已经在虚拟机的内部实现中使用了。但如果想在 Python 代码里使用,我们还有一步工作,就是将这些变量在虚拟机的初始化阶段就放到 builtin 变量表中。
由于 builtin 变量表在整个虚拟机实例中只有一份,所以我们可以使用 static 关键字来修饰它,并且把它放在 Interpreter 类中。
// runtime/interpreter.hpp
class Interpreter {
private:
Map<HiObject*, HiObject*>* _builtins;
FrameObject* _frame;
...
};
// runtime/interpreter.cpp
Interpreter::Interpreter() {
_builtins = new Map<HiObject*, HiObject*>();
_builtins->put(new HiString("True"), Universe::HiTrue);
_builtins->put(new HiString("False"), Universe::HiFalse);
_builtins->put(new HiString("None"), Universe::HiNone);
_builtins->put(new HiString("print"), Universe::HiNone);
}
通过以上代码,我们就把 print 变量与内建的 None 对象联系起来了。当然,这里先把 print 变量放在内建变量表里并没有什么用,只是为了说明内建变量表的结构而已。等以后,我们实现了 native 方法,再来修改这里的实现,将真正实现打印功能的方法与 "print"
变量绑定在一起。
同时,None、True 和 False 也都被搬到内建变量表里了,虽然在 Python 3 时代,内建变量表里是否包含这三个变量已经不重要了,但我们为了逻辑实现的一致性,还是把它们放到这里了。
构建完内建变量表以后,虚拟机还要在 LOAD_NAME 里增加一些逻辑,当执行器在全局变量中查找失败以后,应该继续在 _builtins 表里查找。
void Interpreter::run(CodeObject* codes) {
_frame = new FrameObject(codes);
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::LOAD_NAME:
v = _frame->names()->get(op_arg);
w = _frame->locals()->get(v);
if (w != Universe::HiNone) {
PUSH(w);
break;
}
w = _frame->globals()->get(v);
if (w != Universe::HiNone) {
PUSH(w);
break;
}
w = _builtins->get(v);
if (w != Universe::HiNone) {
PUSH(w);
break;
}
PUSH(Universe::HiNone);
break;
...
}
}
}
一切准备完毕以后,我们来看一个综合的测试用例。
在这个例子里,我们用到了函数定义、函数调用、None、True 等内建变量,这些功能,虚拟机都已经实现了。
还有一个 is 比较是没有实现的。is 的比较和大于、小于这些比较的实现原理是完全一样的。我们只需要在 COMPARE_OP 的逻辑里增加 is 的比较操作就可以了。
#define HI_TRUE Universe::HiTrue
#define HI_FALSE Universe::HiFalse
...
void Interpreter::run(CodeObject* codes) {
_frame = new FrameObject(codes);
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::COMPARE_OP:
w = POP();
v = POP();
switch(op_arg) {
case ByteCode::IS:
if (v == w)
PUSH(HI_TRUE);
else
PUSH(HI_FALSE);
break;
case ByteCode::IS_NOT:
if (v == w)
PUSH(HI_TRUE);
else
PUSH(HI_FALSE);
break;
default:
printf("Error: Unrecognized compare op %d\n", op_arg);
}
break;
...
}
}
}
然后,这个测试就可以成功运行了。到这里,函数所使用的变量及其作用域,我们就全部介绍完了。
总结
引入函数以后,也引入了新的命名空间,从而产生了全局变量、局部变量、闭包变量、内建变量这些概念。
Python 按照一定的规则来访问变量,即先查找局部变量,如果找不到,就继续查找闭包变量,如果还是找不到,就继续查找全局变量、内建变量。这种访问规则,被称为 LEGB 规则。根据这个规则,我们添加了访问全局变量的机制,修改了 LOAD_NAME 的查找逻辑,并且准备好了内建变量表。
函数的功能进一步完善,但是当前函数还是不能接受参数。不能接受参数的函数就失去了它本来的意义。所以下节课我们就来实现函数的另一个重要功能:传递参数。
思考题
请你在 Python 的 REPL 环境中,使用 "print(dir(__builtins__))"
查看 CPython 中有哪些内建变量,还可以进一步对比一下 Python 2.7 和 Python 3.8 的内建变量表有什么不同。欢迎你把你比对后的结果分享到评论区,也欢迎你把这节课的内容分享给其他朋友,我们下节课再见!
- ifelse 👍(0) 💬(0)
学习打卡
2024-10-25 - Geek_66a783 👍(0) 💬(0)
LOAD_NAME字节码的实现是不是有点小问题。如果某个python变量的值恰好就是Universe::HiNone,那么这个时候直接返回它才是正确的行为。
2024-09-20 - Geek_66a783 👍(0) 💬(1)
全局变量那一块混入了python2.7的字节码
2024-09-20