12月28, 2020

61. Python 是如何实现作用域的 (1)

前面说到 CodeObject 的结构,其中忽略了一个重要的问题:CodeObject 中记录了 names、contants 之类的信息。根据我们使用 Python 的经验来说,不可能整个 Python 程序的所有名称和常量都在一起,那样可就乱了套了,Python 通过 “命名空间(namespace)” 化整为零管理这一大堆对象。显然,一个稍微复杂一点的 Python 程序不太可能只有一个 CodeObject 这么简单。Python 是如何一层层组织这些命名空间的呢?提前预告一下,这些命名空间与作用域的实现息息相关。

一切从 AST 说起

image.png

从 Python 源码开始,一直到最终 VM 执行代码,可谓是一波三折,Python 在背后做了大量的工作,支撑这 Python 极高的易用性。命名空间出现的具体时间在逻辑上很难把握,因为从 AST 开始命名空间就已经呼之欲出了,缺的只是一个明确的准则。但在具体的实现上,可以大概确定在某个位置,命名空间就算是确定了。

让我们再回到 PyRun_FileExFlags 函数,希望你还记得它,只要在调试模式下,让 Python 执行一个简单的 Python 代码 test.py:

a = 1

def func():
    b = 1

func()

这一次,我们在代码中定义了一个函数 func,我们确定函数里会有一个新的命名空间,且看 Python 是如何实现它的。在调试模式下设置断点到 PyRun_FileExFlags,Python 会在这里对 test.py 进行解析、编译和执行。

PyObject *
PyRun_FileExFlags(FILE *fp, const char *filename_str, int start, PyObject *globals,
                  PyObject *locals, int closeit, PyCompilerFlags *flags)
{
    // 生成 AST
    mod = PyParser_ASTFromFileObject(fp, filename, NULL, start, 0, 0,
                                     flags, NULL, arena);
    // ··· ···

    // 运行 AST
    ret = run_mod(mod, filename, globals, locals, flags, arena);

    // ··· ···
}

创建 AST 的时候,我认为已经有命名空间的意思在里面,但还不够清晰,我们需要继续跟进到 run_mod 中。

// Python/pythonrun.c:1141
static PyObject *
run_mod(mod_ty mod, PyObject *filename, PyObject *globals, PyObject *locals,
            PyCompilerFlags *flags, PyArena *arena)
{
    // ··· ···

    // 编译 AST,还需要继续深入,才能找到的 namespace 的创世时刻
    co = PyAST_CompileObject(mod, filename, flags, -1, arena);

    // ··· ···

    // 在这里执行代码
    v = run_eval_code_obj(co, globals, locals);
    Py_DECREF(co);
    return v;
}


// Python/compile.c:L312
PyCodeObject *
PyAST_CompileObject(mod_ty mod, PyObject *filename, PyCompilerFlags *flags,
                   int optimize, PyArena *arena)
{
    // ··· ··

    // 构建符号表
    c.c_st = PySymtable_BuildObject(mod, filename, c.c_future);

    // ··· ···

    // 编译为 CodeObject
    co = compiler_mod(&c, mod);

    // ··· ···
}

image.png

一切都发生在最后的 PySymtable_BuildObject 函数中,有兴趣的朋友可以单步跟进这个函数。从该函数开始,有可能会进入递归调用,直到所有的代码层次都被解析完毕。

Scope(作用域或者代码块)

为了方便,首先将 test.py 源码的 AST 也一起给出:

image.png

对于 Python 执行 Python 源码文件这种形式,首先会创建一个类型为 Module 的顶级 ste (Symbol Table Entry),对应 AST 的根节点。

在解析的过程中,Python 也记录当前的递归深度和当前的 Scope。

  • 在执行 Python 源码文件时,整个文件被看作一个 Module,也就是最外一层,此时递归深度为 0;
  • 每当深入一层,递归深度 + 1, 反之上升一层递归深度就 - 1;
  • 对于某一层内所有的代码,它们都是 stmt(statement,语句)。

我用一张图将这些信息表达出来:

image.png

特别的:

  • Scope 用红色标注;
  • stmt 用绿色标注;
  • 实际上每一个 stmt 还会继续向下递归,拆分为 Name、Constant 之类,但是这里并没有展示这些细节;
  • 用蓝底灰字表示所处区域的递归深度。

Python 在进入一个新的 Scope 前,会调用 symtable_enter_block 函数,创建全新的 Scope,并且之后的所有解析都在这个 Scope 下进行,在退出 Scope 时执行 symtable_exit_block 函数,切换回上一层 Scope。

PySymtable_BuildObject 正式解析 AST 之前,先创建了一个顶级 Module scope,之后开始 深度优先 递归遍历 AST。在 Module 这一递归层内,所有语句都看作独立的 stmt,比如 a=1 和函数 func 的定义是完全对等的, 具体对 func 的解析则创建新 Scope, 并递归到函数正文中完成。当然,最后的 func() 也与之前的语句一样,都是 stmt。

什么时候会创建新的 Scope

源码中全局搜索 symtable_enter_block 的调用可知以下几种语句会进入新的 Scope:

  • FunctionDef_kind,函数定义语句;
  • ClassDef_kind,类定义语句;
  • AsyncFunctionDef_kind,Async 函数定义语句;
  • Lambda_kind, Lambda 定义语句;
  • symtable_handle_comprehension函数,作用尚不清楚,待研究。

基本上可以支持我们对 Python 语言作用域的使用经验。

这似乎已经足够说清楚 Python 的命名空间的实现了,事实上远不止如此,后面研究 Frame 的时候还会有一些新的发现。

本文链接:http://www.thinkinpython.com/post/deep_python_vm_61.html

-- EOF --