跳转至

19 自定义类型:面向对象编程的基础设施

你好,我是海纳。

在 Python 中,包括函数、方法、类型在内的一切皆是对象,我们前面已经很深刻地认识到了这一点。这些机制为面向对象编程提供了基础。上一节课,我们为虚拟机添加了类型系统。这节课,我们会在上节课的基础上,继续研究如何实现用户自定义类型。

自定义类的实现可能是 Python 3 相对于 Python 2 变化最大的一个特性了。它新增了很多机制,使类的功能变得更加强大、灵活了。但同时,这些新的特性也给开发者设置了不小的门槛,尤其是元类(metaclass),很多人即使可以合理地使用它来进行开发,但仍然很难说出它背后的原理。

这一节课,我们就一步步地来实现自定义类的这些功能。

支持用户自定义类型

Python 是一种支持对象的编程语言,而面向对象的编程语言中,最重要的一个特性就是自定义类。我们经过了长途跋涉,终于走到自定义类的门口了。和之前一样,我们先来研究测试用例以及它的字节码。

class A(object):
    value = 1 

a = A() 
print(a.value)

对应的字节码:

  1           0 LOAD_BUILD_CLASS
              2 LOAD_CONST               0 (<code object A>)
              4 LOAD_CONST               1 ('A')
              6 MAKE_FUNCTION            0
              8 LOAD_CONST               1 ('A')
             10 LOAD_NAME                0 (object)
             12 CALL_FUNCTION            3
             14 STORE_NAME               1 (A)

  4          16 LOAD_NAME                1 (A)
             18 CALL_FUNCTION            0
             20 STORE_NAME               2 (a)

  5          22 LOAD_NAME                3 (print)
             24 LOAD_NAME                2 (a)
             26 LOAD_ATTR                4 (value)
             28 CALL_FUNCTION            1
             30 POP_TOP
             32 LOAD_CONST               2 (None)
             34 RETURN_VALUE

这段字节码有点复杂,我来给你逐行讲解。第 1 条指令是 LOAD_BUILD_CLASS,这是一个 Python 3 版本新加的指令,用来向栈顶加载一个内建函数 __build_class__,这个内建函数非常复杂,我们使用 help 来查看这个函数的格式。

>>> help(__build_class__)
Help on built-in function __build_class__ in module builtins:

__build_class__(...)
    __build_class__(func, name, *bases, [metaclass], **kwds) -> class

    Internal helper function used by the class statement.

可以看到,这个函数的作用是创建一个类型对象,它接受的参数有 5 个。

  1. func 代表一个函数对象,这个对象用来构建类型。
  2. name,一个字符串,代表类型的名称。
  3. bases,一个列表,这是一个扩展位置参数,代表了类型的父类。
  4. metaclass,可选参数,翻译为元类。其实它的作用是类型的工厂,后面我们会专门讲解。
  5. kwds,一个字典,可以在创建类型的时候为类型添加额外的属性。

第二组指令包括第 2、3、4 三条,它们的作用是创建一个函数对象,这个函数对象就是用来构建类型的,也就是 __build_class__ 的第一个参数。第 2 条指令将定义 A 的 CodeObject 加载到栈顶,目前我们知道,函数、方法和lambda 表达式会被翻译成 CodeObject。这里类定义中的代码也会被翻译成一个 CodeObject。

第三组指令是第 5 条,它的作用是加载字符串 A 到栈顶。这个字符串正是类型的名称。

第四组指令是第 6 条,它把 object 类型加载到栈顶,代表了 A 类型的父类。如果A 类型还有更多的父类,这里就会有多条指令把父类的类型对象都加载到栈顶。然后通过扩展位置参数传递给 __build_class__

最后一组就是第 7 条指令,CALL_FUNCTION 的作用是真正地执行 __build_class__ 来创建类型对象。类型对象在我们的虚拟机中,其实就是上节课所实现的 HiTypeObject。

接下来的字节码就比较简单了,我就不再解释了。注意到第 4 行的 MAKE_FUNCTION 的参数都是 0,这说明定义类的代码既没有默认值,也不接受任何参数。接下来,我们再研究一下第 2 行加载的那个 CodeObject 到底做了什么事情。

用于构建类型的CodeObject

在 show_file 的结果里,我们也可以找到用于创建类型 A 的 CodeObject 的字节码。

  1           0 LOAD_NAME                0 (__name__)
              2 STORE_NAME               1 (__module__)
              4 LOAD_CONST               0 ('A')
              6 STORE_NAME               2 (__qualname__)

  2           8 LOAD_CONST               1 (1)
             10 STORE_NAME               3 (value)
             12 LOAD_CONST               2 (None)
             14 RETURN_VALUE

第 1 行和第 2 行的指令是进行一次关于 module 的赋值操作。

module 是 Python 中的核心概念之一,通常一个 Python 文件就是一个 module。module 提供了一个命名空间,在某个 module 中定义的方法,类型不会和其他 module 里的名称冲突。关于 module 你暂时先了解这么多就可以了,后面我们还会深入地研究 module 的实现。

至于 __name__ 这个名称,它是 Python 虚拟机的一个规定,作为程序入口的那个模块,在当前阶段,就认为是被执行的那个 py 文件,它的局部变量表里会设置 __name__ 的值为 __main__。我们可以通过一个例子来验证一下。

if __name__ == "__main__":
    print("hello")

为了满足这一项规定,我们只需要在第一个 FrameObject 的局部变量表里增加 __name__ 的初始化就可以了。

FrameObject::FrameObject(CodeObject* codes) {
    ...
    _locals  = new HiDict();
    _globals = _locals;
    _locals->put(new HiString("__name__"), new HiString("__main__"));
    ...
}

注意,我们只在程序开始的时候,也就是创建第一个虚拟栈帧的时候才加入这个初始化动作。添加了这一行代码以后,上面那个小的测试用例就可以正确执行了。

再回到 code A 的字节码,接下来的两个功能比较简单,定义了类型的全限定名(__qualname__)为字符串 A(第3、4行),还定义了一个名为 value 的局部变量(第 5、6行)。

接下来就结束了。如果你对 Python 2.7 有一些了解的话,就会发现这里少了一条非常关键的字节码:LOAD_LOCALS,它会把 A CodeObject 执行过程中的局部变量表拉出来,作为参数发送给类型 A 所对应的 HiTypeObject。

在 Python 3 里,并没有任何 return 语句可以把这个 CodeObject 的局部变量传递出来。

实际上,__build_class__ 函数是通过 exec 函数来完成这个功能的。也就是说,__build_class__ 的第一个参数 func,它所对应的 CodeObject 就是类型 A 的 CodeObject。在 __build_class__ 内部,又会进一步调用 exec 来执行这个 func 对象,exec 会把 func 对象执行过程中产生的局部变量表取出来。

exec 函数也可以在 Python 中直接使用,你可以看一下它的定义。

>>> help(exec)

exec(source, globals=None, locals=None, /)
    Execute the given source in the context of globals and locals.

    The source may be a string representing one or more Python statements
    or a code object as returned by compile().
    The globals must be a dictionary and locals can be any mapping,
    defaulting to the current globals and locals.
    If only globals is given, locals defaults to it.

也就是说,exec 除了可以接受可执行对象以外,还额外接受两个参数:globals 和 locals,这两个参数都是字典类型,分别代表了全局变量表和局部变量表。下面我用两个例子来说明变量表的作用。

>>> locals = {"a" : 1, "b" : 2}
>>> exec("print(a + b)", globals, locals)
3

>>> locals = {}
>>> exec("a = 3\nb = 4", globals, locals)
>>> locals
{'a': 3, 'b': 4}

第一个例子说明在执行语句的时候,exec 会从 locals 所指向的字典里查找变量。第二个例子说明exec 在更新局部变量表的时候,会把值写入 locals 所指向的字典。
最后,我们把完整的创建类型的过程串起来。

  1. 加载 __build_class__ 函数。这个函数的主要作用是创建类型对象,也就是一个 HiTypeObject,其中包含了类的各种属性。
  2. 类的属性由第一个参数 func 来构造,func 所对应的 CodeObject 正是类型 A 的 CodeObject,在这段代码里,初始化了全量名、模块名、所有的方法和类属性。
  3. 调用 __build_class__ 来创建类型对象,这个过程中会使用 exec 方法把 func 的局部变量取出来,用于构建类型对象。

到这里,我们就把创建 A 的类型对象的过程梳理完了。可见,创建类型对象的核心逻辑位于 __build_class__ 和 exec 两个函数中。接下来,我们就来实现它们。

创建类型对象

我们采用自顶向下的思路来分析问题,有利于使问题逐渐细化。但实现功能的时候,就需要采用自底向上逐步添加的办法。所以这里我们先来实现 exec 方法。

HiObject* internal_exec(FunctionObject* callable, HiDict* globals, HiDict* locals) {
    if (globals) {
        callable->set_globals(globals);
    }

    if (locals) {
        callable->set_locals(locals);
    }

    return Interpreter::get_instance()->call_virtual(callable, nullptr);
}

这段代码的逻辑比较简单,就是使用参数 globals 代替函数对象的全局变量表,使用 locals 代表局部变量表,然后再执行函数对象。

在实现 List 遍历功能的时候,虚拟机中就已经引入了 call_virtual 方法。当时只是用它来调用虚拟机内建函数 next,所以并没有深入解释这个方法的意义。

实际上,call_virtual 的意义在于,从虚拟机内部开始调用执行一段 Python 字节码。虚拟机是由 C++ 实现的,而目标代码是由 Python 实现的,所以这是一种跨语言的调用。

在 Java 虚拟机 hotspot 中,虚拟机调用 Java 代码进行跨语言调用的时候,使用的方法的名字就是 call_virtual。所以我们也在自己的虚拟机里增加一个同名的函数用于跨语言调用。你可以看一下它的代码。

HiObject* Interpreter::call_virtual(HiObject* func, HiList* args) {
    if (func->klass() == NativeFunctionKlass::get_instance()) {
        // ...
    }
    else if (MethodObject::is_method(func)) {
        // return value is ignored here, because they are handled
        // by other pathes.
        if (!args) {
            args = new HiList();
        }
        args->insert(0, method->owner());
        return call_virtual(method->func(), args);
    }
    else if (MethodObject::is_function(func)) {
        FrameObject* frame = new FrameObject((FunctionObject*) func, args, nullptr);

        enter_frame(frame);
        _frame->set_entry_frame(true);
        eval_frame();
        destroy_frame();

        return _ret_value;
    }

    return Universe::HiNone;
}

这段代码会先判断可执行对象的类型。如果可执行对象是内建函数(第 2 至 4 行),或者是与具体对象绑定的方法(第 5 至 13 行),处理方式与 build_frame 一样。

如果可执行对象是 FunctionObject(第 14 至 23 行),就要创建一个新的虚拟机栈帧 FrameObject,并在这个栈帧里进行运算(第 15 行)。

其中,enter_frame 负责切换解释器的 _frame 变量,使其指向新创建的帧。然后调用 eval_frame 进入解释器执行 Python 代码。从虚拟栈帧返回以后,就调用 destroy_frame 销毁新创建的栈帧,并把 _ret_value 作为返回值返回给调用者。

经过这种重构,Interpreter 的逻辑变得更加清晰易读。这些修改涉及到的代码量并不大,这里我就不再列出所有函数的代码了,你可以通过代码仓自己查看。

以前,在执行 RETURN_VALUE 的时候,虚拟机并不会立即从 eval_frame 中返回,而是回到上一个虚拟栈帧,也就是调用者的 FrameObject 继续执行。现在虚拟机调用 Python 代码的时候却希望 RETURN_VALUE 可以直接结束 eval_frame 方法的执行,回到虚拟机的 C++ 代码中来。

图片

如图所示,我们希望 RETURN_VALUE 能正确地区分是否需要结束 eval_frame,所以就引入了 _entry_frame 这个变量来作为标记。所有 C++ 代码调用 Python 代码产生的第一个 frame,我们叫做 entry frame。set_entry_frame 方法就是为了设置这个标志位(第 18 行)。

相应的,RETURN_VALUE 的实现也会发生变化,你可以看一下变化后的代码。

// [runtime/interpreter.cpp]
void Interpreter::eval_frame() {
    while (_frame->has_more_codes()) {
        unsigned char op_code = _frame->get_op_code();
        ...
        switch (op_code) {
        ...
            case ByteCode::RETURN_VALUE:
                _ret_value = POP();
                if (_frame->is_first_frame() ||
                        _frame->is_entry_frame())
                    return;
                leave_frame();
                break;
        ...
        }
    }
}

如果解释器判断当前栈帧是 first frame,或者是 entry frame,就直接结束 eval_frame 的执行。否则就只是从当前的虚拟栈帧退回到上一个虚拟栈帧。

这样,exec 方法就完成了,接下来我们实现最重要的创建类型对象的方法,你可以看一下实现代码。

HiObject* build_type_object(HiList* args) {
    int length = args->length();
    assert(length >= 2);
    FunctionObject* cls_def = args->get(0)->as<FunctionObject>();
    HiString* name = args->get(1)->as<HiString>();
    HiList* super_list = new HiList();

    for (int i = 2; i < length; i++) {
        super_list->append(args->get(i));
    }

    HiDict* locals = new HiDict();
    internal_exec(cls_def, nullptr, locals);
    return Klass::create_klass(locals, super_list, name);
}

cls_def 就是上一部分例子里 MAKE_FUNCTION 所得到的函数对象,虚拟机正是通过执行这一段函数得到了创建对象类型所需要的字典。name 是类型名字,super_list 是父类列表,如果参数的数量大于 2,就代表这个类有父类。在这节课的实际例子里,父类只有 object 一个,所以在创建 A 类型对象的时候,这里的参数只包含了 object 类的 TypeObject。

最后,通过调用 internal_exec,虚拟机就得到了创建 A 的类型对象所需要的字典了。当执行完以后,locals 字典里会包含类的全量名称、类属性 value 等。在调试的时候,你可以在这里增加一个打印函数,把 locals 的值打印出来,来观察执行结果是否符合预期。

最后一个步骤是实现 Klass 里的静态方法,create_klass。这个方法接受三个参数,然后创建一个新的 Klass,以及和它绑定的 TypeObject。

HiObject* Klass::create_klass(HiDict* klass_dict, HiList* supers_list, HiString* name) {
    Klass* new_klass   = new Klass();

    new_klass->set_klass_dict(klass_dict);
    new_klass->set_name(name);

    if (supers_list->length() > 0) {
        HiTypeObject* super = supers_list->get(0)->as<HiTypeObject>();
        new_klass->set_super(super->own_klass());
    }

    HiTypeObject* type_obj = new HiTypeObject();
    type_obj->set_own_klass(new_klass);
    
    return type_obj;
}

create_klass 方法会先创建一个空的 Klass 对象(第 2 行)。这个对象不同于以往的 ListKlass、DictKlass 等,它在创建的时候并不知道自己的类名。

接下来,第一个参数传进来的字典被设置成了这个新建的 klass 对象的 klass_dict(第 4 行)。并且,它的名字设成了第三个参数传入的字符串(第 5 行)。然后设置 klass 对象的父类。由于我们现在只支持单继承,所以就只取父类列表的第一个元素,将新建 klass 的父类设成父类列表的第一个元素(第 7 至 10 行)。

最后,创建与这个 klass 相对应的 TypeObject。请注意,create_klass 方法最后返回的是 TypeObject,而不是 Klass。当程序上下文要求出现的是一个对象的时候,就必须使用 TypeObject 代替 Klass。

build_type_object 方法被执行完以后,它的返回值就是新建类型的 TypeObject。接下来的指令是执行 STORE_NAME,把变量 A 和新创建的这个类型对象绑定在一起。最终变量 A 所代表的就是一个类对象。也就是说,Python 的 class 语句的作用是产生一个类对象,并与类名变量绑定。

执行 build_type_object 创建类型对象的时候,Python 源码中 class 定义里的那些代码都会被执行。TypeObject 的 Klass 是 TypeKlass,而 TypeKlass 里已经定义了 print 方法。所以我们可以通过调用 print 语句,来打印这个类型对象。

class A(object):
    value = 1

print(A)

执行上面这段代码,就会打印这个类的名字。这时候的运行结果与标准 Python 3 虚拟机有一点差异,这里没有打印模块名字,这是因为我们当前阶段还没有支持模块功能。

总结

这节课我们主要实现了创建自定义类型的功能。在 Python 中,开发者使用 class 关键字可以自行定义类型。上一节课我们使用了 TypeObject 和与它绑定的 Klass 对象来代表一个类型。所以,这节课的主要目标就是创建自定义类型的 TypeObject 对象

我们这节课只新增了一条新的字节码 LOAD_BUILD_CLASS,用来把内建函数 __build_class__ 加载到栈顶。所以在字节码层面并没有引入特别多的机制(这和 Python 2.7 非常不同),主要的功能都封闭在 __build_class__ 函数里了。

__build_class__ 通过使用 exec 调用类定义的 CodeObject 取出相关的属性字典,再用这个字典去构建一个新的 Klass,并把这个 Klass 的 TypeObject 作为返回值传递出来。

通过这种方式,我们就创建了一个自定义类型的类型对象,也就是它的对应的 TypeObject。下节课,我们就可以使用这个类型对象创建实例了。

思考题

对于以下代码:

class A(object):
    value = 1

print(A)
print(A.value)

我们要怎么修改才能正确运行?或者说,对于类型对象的 LOAD_ATTR 指令,应该如何实现呢?欢迎你把你的答案分享到评论区,也欢迎你把这节课的内容分享给需要的朋友,我们下节课再见!

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

    记录一下

    2024-12-08

  • ifelse 👍(0) 💬(0)

    学习打卡

    2024-11-03