跳转至

25 模块和库:构筑现代软件系统的基础材料

你好,我是海纳。

在之前的课程里,我们分别实现了函数的功能,面向对象的基础设施以及自动内存管理功能。这一节课将开始一个新的主题,那就是模块和库。

在现在的编程语言中,模块和库是最重要的组成部分,它决定了某一门语言的流行程度。例如 Java、Perl 等语言都有丰富的扩展库,可以方便地实现各种功能。

Python 语言也不例外,甚至可以说,Python 的成功正是由于它的丰富多样的功能库。功能库既要容易开发维护,也要容易部署传播,这就需要在语言虚拟机的层面进行全面的设计。

今天我们就来研究 Python 中的块和模块是如何定义、组织和实现的。我们的目标并不是实现一个完备的,功能强大的运行时库(Runtime Library),而是重在介绍虚拟机为实现模块和库的功能,提供了哪些能力。

实现 import 语句

在 Python 中,库是以模块为单位进行组织的,一个库由一个或者多个模块组成。导入一个库,其实就是导入它的模块,导入模块使用的语句是 import。我们通过一个例子来说明 import 语句的用法。

# test_import.py
import test_func
print(test_func.fact(5))

# test_func.py
print("loading func module")

def fact(n):
    if n == 0:
        return 1
    else:
        return n * fact(n-1)

在同一个目录下新建两个文件,一个名为 test_import.py,另一个名为 test_func.py,通过代码你可以看到它们的内容。

把这两个文件编译成 pyc 文件,然后使用 show_file 工具查看。可以看到 test_func 文件并没什么特别,其中的字节码都是我们已经实现过了的。重点在于 test_import 文件。它的字节码我列在下面了,你可以看一下。

  1           0 LOAD_CONST               0 (0)
              2 LOAD_CONST               1 (None)
              4 IMPORT_NAME              0 (test_func)
              6 STORE_NAME               0 (test_func)

  2           8 LOAD_NAME                1 (print)
             10 LOAD_NAME                0 (test_func)
             12 LOAD_METHOD              2 (fact)
             14 LOAD_CONST               2 (5)
             16 CALL_METHOD              1
             18 CALL_FUNCTION            1
             20 POP_TOP
             22 LOAD_CONST               1 (None)
             24 RETURN_VALUE

上述字节码中,第 3 行出现了一个新的指令:

IMPORT_NAME,它的操作数是 names 列表的序号,所对应的字符串是 test_func。IMPORT_NAME 的执行结果也是一个虚拟机对象,它会被放在操作数栈上,然后通过 STORE_NAME 把它赋值给了 test_func 变量(第 4 行)。这个虚拟机对象就是我们这节课要实现的 ModuleObject。

在第 1 行和第 2 行往栈上加载了两个常量,这两个常量也是供 IMPORT_NAME 使用的,我们暂时还用不着它,这里就先不管了。接下来,LOAD_METHOD 这条指令从 ModuleObject 中加载了 fact 对象到栈上。fact 是一个函数对象,它可以被调用。

实现 ModuleObject

我们要在虚拟机里增加一个代表模块的类,它是 import 语句的执行结果,还要支持 LOAD_METHOD 方法。先来定义 ModuleObject,它也是一个普通的对象,所以也需要继承 HiObject。

class ModuleKlass : public Klass {
private:
    static ModuleKlass* _instance;
    ModuleKlass();

public:
    static ModuleKlass* get_instance();
    void initialize();
};

class ModuleObject : public HiObject {
private:
    HiString*   _mod_name;
 
public:
    ModuleObject(HiDict* x);
    static ModuleObject* import_module(HiObject* mod_name);

    void put(HiObject* x, HiObject* y);
    HiObject* get(HiObject* x);
};

和其他所有的类型一样,ModuleObject 也要有自己所对应的 ModuleKlass。ModuleKlass 是一个单例类,并且只定义了 oops_do 和 size 两个虚方法用来支持 GC。

void ModuleKlass::initialize() {
    HiDict* dict = HiDict::new_instance();
    set_klass_dict(dict);
    set_name(HiString::new_instance("module"));
    (new HiTypeObject())->set_own_klass(this);

    add_super(ObjectKlass::get_instance());
    order_supers();
}

size_t ModuleKlass::size() {
    return sizeof(ModuleObject);
}

void ModuleKlass::oops_do(OopClosure* f, HiObject* obj) {
    f->do_oop((HiObject**)&obj->as<ModuleObject>()->_mod_name);
}

ModuleKlass 没有什么特别的实现,这些逻辑在前面新建各种内建类型的时候都曾经遇见过。ModuleObject 很像一个字典,它支持以键值对的方式向里面添加元素,也支持以 key 查找相关元素。相比于 FunctionObject 等比较复杂的对象,ModuleObject 的定义更简洁。

ModuleObject 中有一个 static 方法 import_module 用来加载模块,还有一个方法是 extend,可以用于合并两个模块的内容。extend 方法和字典的合并非常相似,这里就不再详细解释了,下面我们来重点实现加载模块的功能。

实现加载模块功能

我们先来分析加载模块的时候,虚拟机做了哪些动作。

第一步,是要找到这个模块所对应的文件,比如当执行 import 语句的时候,虚拟机会在执行文件的相同目录下,查找对应的 pyc 文件。Python 虚拟机还可以加载 py 文件,并在加载之前,把它编译成 pyc 文件。但我们的虚拟机里不打算支持编译的能力,所以我们就直接查找 pyc 文件就可以了。

第二步,加载文件并且执行。Python 的 import 语句和 Java 的大不相同,Java 的 import 只是用于编译时引入符号,而 Python 中却会执行要加载的模块。比如,在这节课开始的例子里,test_func 模块里包含了一条 print 语句。在 import 的时候,这条语句是会被执行的。

同样的道理,被加载的模块中用于定义类、函数、变量的语句都会被执行,执行的结果就是创建了一个新的命名空间。这个新的命名空间就是我们这节课所实现的 ModuleObject。

你可以看一下具体的实现。首先,要实现 import_module 方法。

HiObject* ModuleObject::search_file(Handle<HiObject*> x) {
    Handle<HiList*> paths = Interpreter::get_instance()->search_path();
    for (int i = 0; i < paths->length(); i++) {
        Handle<HiString*> path = paths->get(i)->as<HiString>(); 
        path = path->add(x)->as<HiString>();

        Handle<HiString*> name = path->add(ST(pyc))->as<HiString>();
        name->print();
        printf("\n");
        if (access(name->value(), R_OK) == 0) {
            return import_pyc(x->as<HiString>(), name);
        }
    }

    return Universe::HiNone;
}

ModuleObject* ModuleObject::import_pyc(HiString* raw_mod_name, HiString* file_name) {
    BufferedInputStream stream(file_name->value());
    BinaryFileParser parser(&stream);

    Handle<HiString*> mod_name(raw_mod_name);
    Handle<CodeObject*> mod_code = parser.parse();
    Handle<HiDict*> mod_dict = Interpreter::get_instance()->run_mod(mod_code, mod_name);
    return new ModuleObject(mod_dict);
}

ModuleObject* ModuleObject::import_module(HiObject* x) {
    Handle<HiObject*> mod_name(x);
    HiObject* mod = search_file(x);

    if (mod == Universe::HiNone) {
        printf("No module named ");
        mod_name->print();
        printf("\n");
        assert(false);
        return nullptr;
    }

    return mod->as<ModuleObject>();
}

import_module 的核心逻辑就是调用 search_file 在 Python 的库目录下搜索模块(第 30 行)。search_file 通过遍历所有的 Python 库目录,去查找对应的 pyc 文件。通过 access 方法来判断文件是否存在。如果文件存在,就说明找到了目标文件,那就可以通过调用 import_pyc 来加载模块(第 10 至 12 行)。在 import_pyc 方法中,需要执行一遍加载的模块。

在虚拟机刚开始创建的时候,我们通过 BufferedInputStream 读入字节码文件,并通过 BinaryFileParser 把字节码文件解析成 CodeObject。这里做了同样的事情(第 19 至 23行)。

然后虚拟机调用 run_mod 方法,执行模块,并将执行的结果,也就是一个变量表,通过返回值的形式传递出来(第 24 行)。然后使用这个返回值创建 ModuleObject(第 25 行)。

接下来,再实现 run_mod 方法。其实,run_mod 与 run 方法是一样的,只有一些细微的差别。你可以看一下具体的代码实现。

HiDict* Interpreter::run_mod(Handle<CodeObject*> codes, Handle<HiString*> mod_name) {
    FrameObject* frame = new FrameObject();
    enter_frame(frame);
    frame->initialize(codes);
    frame->set_entry_frame(true);
    frame->locals()->put(ST(name), mod_name);

    eval_frame();
    HiDict* result = frame->locals();
    destroy_frame();
    return result;
}

run_mod 方法把这个 frame 设置成 enter_frame(第 5 行),以便模块执行结束以后返回到这里继续执行。这个方法的核心是调用 eval_frame 真正执行模块代码。最后把 frame 中的局部变量表返回出去,并且把这一个 frame 销毁掉。

完成了这些基础工作,我们就可以实现 IMPORT_NAME 这条字节码了。

void Interpreter::eval_frame() {
    ...
    while (_frame->has_more_codes()) {
        unsigned char op_code = _frame->get_op_code();
        ...
        switch (op_code) {
        ...
            case ByteCode::IMPORT_NAME:
                v = _frame->names()->get(op_arg);
                w = _modules->get(v);
                if (w != Universe::HiNone) {
                    PUSH(w);
                    break;
                }
                w = ModuleObject::import_module(v);
                _modules->put(v, w);
                PUSH(w);
                break;
        ...
        }
    }
}

IMPORT_NAME 的实现就是简单地调用了 import_module(第 15 行),通过模块的名字加载模块。加载成功以后,就把它放到 _modules 存储起来,下一次再遇到 import 同一个模块的时候,就从缓存中查找,如果缓存中已经有了,就可以直接得到,这就避免了重复加载模块。

做完了这些工作,重新编译虚拟机的代码就可以正确执行 test_import 例子了。

实现 from 子句

使用 import 语句加载一个模块,使用它们命名空间中的变量时,要加上模块名字,例如:

import test_func
print(test_func.fact(5))

每次要使用 fact 函数的时候,都必须通过 test_func 符号来引用,不太方便。

这时候,我们也可以使用 from 子句来进行化简。

from test_func import fact
print(fact(5))

fact 这个符号就被加载到当前的局部变量表里了。这种做法的优点是使代码简洁,性能也会稍好一些,因为少了一次 LOAD_ATTR 的调用。但它也有一个问题,那就是增加了命名冲突的可能。比如本模块中也有一个 fact 的定义,那么这两个符号就冲突了,后面的定义会覆盖前边那一次的定义。

我们看一下 from 子句所对应的字节码。

  1           0 LOAD_CONST               0 (0)
              2 LOAD_CONST               1 (('fact',))
              4 IMPORT_NAME              0 (test_func)
              6 IMPORT_FROM              1 (fact)
              8 STORE_NAME               1 (fact)
             10 POP_TOP

  2          12 LOAD_NAME                2 (print)
             14 LOAD_NAME                1 (fact)
             16 LOAD_CONST               2 (5)
             18 CALL_FUNCTION            1
             20 CALL_FUNCTION            1
             22 POP_TOP
             24 LOAD_CONST               3 (None)
             26 RETURN_VALUE

在 IMPORT_NAME 字节码之后,是一个 IMPORT_FROM 和 STORE_NAME。这个 IMPORT_FROM 的作用,就是从刚刚加载的那个 Module 中查找 fact 符号。

ModuleObject 中提供了 get 方法,可以从一个模块对象中获得一个符号。所以 IMPORT_FROM 字节码的实现就依赖于 get 方法来获取符号。

void Interpreter::eval_frame() {
    ...
    while (_frame->has_more_codes()) {
        unsigned char op_code = _frame->get_op_code();
        ...
        switch (op_code) {
        ...
        case ByteCode::IMPORT_FROM:
                v = _frame->names()->get(op_arg);
                w = TOP();
                u = w->getattr(v);
                PUSH(u);
                break;
        ...
        }
    }
}

到这里,我们就完全实现了 IMPORT 语句的功能。

构建 builtin 模块

回顾第 18 课,实现 TypeObject 的时候,我们把 object、list、dict 这些符号都放在 builtin 中创建。其实 builtin 中有很多符号还没被创建,比如,Exception 类、range、map 等函数。

这些类和函数如果使用 C++ 来写就会非常麻烦,但如果使用 Python 来写就很简单,有了模块以后,我们就可以把这些函数丢到 Python 库里,在虚拟机启动的时候,通过调用 import_module 加载这个库,完成这些内建符号的初始化。

第一步,先把 builtin 由原来简版的 HiDict 封装成豪装版的 ModuleObject。

class Interpreter {
private:
    ...
    ModuleObject*         _builtins;
    HiDict*               _modules;
};

第二步,在 Interpreter::initialize 方法中加载 builtin.pyc 模块,并和原来的那个内建的 _builtins 模块合并。

void Interpreter::initialize() {
    _search_path = HiList::new_instance();
    _search_path->append(ST(lib));

    _modules  = HiDict::new_instance();

    _builtins = new ModuleObject(HiDict::new_instance());
    _builtins->put(HiString::new_instance("True"),     Universe::HiTrue);
    // ....
    _builtins->put(ST(build_class), new FunctionObject(build_type_object, ST(build_class)));

    _builtins->extend(ModuleObject::import_module(HiString::new_instance("builtins")));

    _modules->put(HiString::new_instance("__builtins__"), _builtins);
}

在 Interpreter 的初始化方法中,先创建 _builtins 模块(第 7 行),并在其中创建 True、False、None 等符号。这段代码中还包括一些其他符号的初始化操作,比如类型对象 int、list 等,还有内建函数 len、isinstance 等。这些机制之前都讲过,这里就不重复了(第 9 行以省略号简化)。

然后通过调用 import_module 将 builtin.py 中的符号加载成模块,并且通过 extend 方法将两个模块合并,这才得到了完整的 builtin 模块(第 12 行)。最后,将这个模块以 __builtins__ 为名称缓存在 _modules 对象中即可(第 14 行)。

修改完解释器的初始化方法以后,再来创建 builtin.py 文件。在 vm 的同级目录下,新建一个名为 lib 的目录,所有的内建模块,比如未来可能添加的 asyncio、math等,都放到 lib 目录下。在 lib 目录中新建 builtin.py 文件。

def sum(iterable, i):
    temp = i
    for e in iterable:
        temp = temp + e

    return temp

最后,在 lib 下新建 CMakeLists.txt 文件用来构建 builtin.pyc。由于 CMake 的用法和我们课程的主题无关,这里就不再详细解释了。你可以通过代码仓自己查看。

做完了所有的工作以后,我们可以通过例子来测试builtin 模块里的符号是否可以正常访问。

lst = list()
i = 0
while i < 10:
    lst.append(i)
    i += 1
print(sum(lst, 0))

我们看到这些方法都能被正常地调用了。到这里,我们就实现了 Python 中的模块的加载功能。

总结

这节课我们实现了模块的基本功能,首先分析了 import 语句翻译成什么样的字节码,然后实现了 ModuleObject。

import_module 方法是这节课最核心的内容。它的主要工作流程是:

  1. 在搜索目录中找到要加载的模块所对应的文件;
  2. 加载 pyc 文件并使用 BinaryFileParser,对它进行解析从而得到 CodeObject;
  3. 调用 run_mod 方法执行代码,并且使用执行所得到的局部变量表创建 ModuleObject。

在这节课之前构建的 builtins 模块并不完整,还有一些核心功能需要使用 Python 来实现。这些功能被安放在 lib 目录下的 builtin.py 文件里。这里就可以通过 import_moudle 方法进行加载,这样得到的 ModuleObject 再与原来的 builtins 模块进行合并,才是完整的 builtins 模块。

除了使用 pyc 来组织 Python 的模块之外,还有一些依赖于具体平台的功能,需要通过动态链接库实现,比如数学库、图形界面等。下节课我们就进一步研究虚拟机如何通过本地库来调用相应平台的服务。

思考题

在导入模块的时候,目录名也可以作为模块名。这个时候,Python 虚拟机会自动地执行目录下的 __init__.py 文件。请你思考一下,这个功能如何实现?如果可以的话,你自己实现一下这个功能。欢迎你把你实现的结果分享到评论区,我们一起交流讨论,同时也欢迎你把这节课的内容分享给其他朋友,我们下节课再见!

精选留言(2)
  • 冯某 👍(0) 💬(0)

    记录一下

    2024-12-14

  • ifelse 👍(0) 💬(0)

    学习打卡

    2024-11-09