跳转至

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 变量表,但这样做确实是合法的,例如:

# 只能在Python2中运行
True, False = 0, 1

if False:
    print "hello"

但在 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 变量的具体例子,通过这个例子,我们可以观察对应的字节码是怎么样的。

# 全局变量的例子
global x
x = 0

def func():
    global x
    x = 1 

func()
print x   #this is 1

将上述代码保存成一个 py 文件,编译以后通过 show_file 来查看它的内容。

  2           0 LOAD_CONST               0 (0)
              3 STORE_GLOBAL             0 (x)
              ...
  9          22 LOAD_GLOBAL              0 (x)
             25 PRINT_ITEM          

这个文件所对应的字节码里有两个是我们尚未实现的: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 的代码:

# a.py
from b import foo 
x = 2 

foo()

b.py 的代码:

# b.py
x = 100 

def foo():
    print x

然后执行 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;
        ...
        }
    }
}

一切准备完毕以后,我们来看一个综合的测试用例。

def foo():
    return

if foo() is None:
    print(True)

在这个例子里,我们用到了函数定义、函数调用、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 的内建变量表有什么不同。欢迎你把你比对后的结果分享到评论区,也欢迎你把这节课的内容分享给其他朋友,我们下节课再见!

精选留言(3)
  • 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