从本节开始,我们着手研究 Python 面向对象中自定义类的运行机制,并借此实现一些有趣的应用。
类对象行为
我们从最简单的类入手,研究类对象和实例对象的创建过程以及典型行为。还记得我们在 对象模型 部分中,作为例子讲解的 Dog 类吗?这类麻雀虽小,却五脏俱全,非常适合作为我们研究的起点:
|
|
根据 对象模型 部分学到的知识,我们可以得到这样的关系图:
|
|
如果你对 类型 对象、 实例 对象、 继承 、 实例化 等面向对象概念印象不深,请复习 对象模型 部分章节。
我们接着观察 Dog 类的行为,发现它支持属性设置。例如,我们设置 legs 属性,表示狗都有 4 条腿:
|
|
由此,我们可以大胆推测,每个自定义类对象都有一个 属性空间 ,背后同样是由 dict 对象实现的。
我们还注意到类对象中的属性,可以被实例对象访问到。在 继承与属性查找 一节,我们将深入研究这个现象:
|
|
那么,类方法是不是也保存在类属性空间中呢?我们知道,通过 dict 即可获取一个对象的属性空间:
|
|
除了几个内置属性,我们找到了 legs 这是我们刚刚设置的属性;还找到了 yelp ,它应该就是类的方法了:
|
|
这是否意味着,可以将新方法作为属性设置到类对象身上,让它具有某些新行为呢?事不宜迟,我们来试试:
|
|
哇,我们在运行时让 Dog 类拥有 yelp2 这个新能力,Python 果然很动态!这个例子帮我们理解 Python 的运行行为,但这种写法不是一个好习惯,在实际项目中尽量少用吧。
至此,我们得到了 Dog 这个类对象的大致轮廓,它有一个属性空间,由 dict 对象实现, yelp 方法便位于其中。
底层表现形式
Dog 类是一种自定义类对象,因此它底层应该是一个 PyTypeObject 。PyTypeObject 是每个类型对象在虚拟机底层的表现形式,它中源码中位于 Objects/typeobject.c 文件。这个结构体前面章节已有所涉猎,多少还有些印象吧?
进入 Objects 目录,我们的目光还被 Objects/classobject.c 这个文件吸引住了,这不就是 类对象 吗?历史上,Python 自定义类和内建类型是分开实现的,也因此造成鸿沟。后来 Python 完成了类型统一,不管自定义的还是内建的,具有 PyTypeObject 实现,Objects/classobject.c 中的大部分功能也因之废弃。
由于篇幅的关系,这里不打算展开太多源码细节,同样以最通俗易懂的结构图进行介绍:
结构图省略了很多字段,但不会影响学习理解,重点注意这几个字段:
- tp_name ,Dog 类对象名字,即类名;
- tp_call ,Dog 类被调用时执行的函数指针,用于创建 Dog 实例对象 ,如
dog = Dog()
; - tp_dict ,该字段指向一个 dict 对象,dict 存储 Dog 类对象的属性空间;
对源码比较感兴趣的同学,可以深入 Objects/typeobject.c 以及 Objects/classobject.c ,研究体会。
创建步骤
那么,Python 类代码时如何一步步转换成类对象的呢?同样,我们从字节码入手,一起窥探这其中的秘密。
首先,我们将 Dog 类的代码作为字符串保存起来,并调用 compile 函数进行编译,得到一个代码对象:
|
|
这个代码对象里头应该就保持着创建类对象的密码!迫不及待想要揭晓答案,那就字节码反编译一下吧:
|
|
里头大部分字节码我们都认识,但是连起来看却一脸懵逼……这不打紧,至少我们已经发现了某些蛛丝马迹。看到字节码 LOAD_BUILD_CLASS 没?顾名思义,它应该就是构建类对象的关键所在。
开始窥探 LOAD_BUILD_CLASS 字节码之前,先继续考察代表 Dog 类的代码对象。由于我们将 exec 参数传递给 compile 函数,将代码代码作为模块进行编译,因此代码对象 code 对应着模块级别代码块。
我们发现,code 里面还藏有子代码对象,子代码对象作为常量存在于 code 常量表中。而子代码对象中藏在另一个代码对象,同样以常量的形式存在:
|
|
从这三个代码对象的字节码来看,code 对应着模块代码,也就是最外层代码块;code.co_consts[0] 则对应着 Dog 类代码块;而 code.co_consts[0].co_consts[1] 则对应着 yelp 函数代码块。三者关系如下图:
代码对象作为源码编译的结果,与源码在层级上一一对应,堪称完美。那么,这些毫无生命力的代码对象,又是如何一步步变身为活生生的类对象的呢?我们得深入字节码中寻找答案。
接下来,我们一起来推演,虚拟机逐条执行模块字节码之后,发生了什么神奇的事情!当模块代码执行时,虚拟机内部有一个 栈帧 对象,维护着代码对象运行时时的上下文信息,全局和局部名字空间均指向模块属性空间。
注意到,前 3 条字节码,负责往运行栈加载数据。LOAD_BUILD_CLASS 这个字节码很特殊,我们第一次遇到。从 Python/ceval.c 源码,我们发现他的作用很简单,只是将 build_class 这个内建函数加载进栈顶。该函数为 builtins 模块中的一员,它的名字告诉我们,这是一个负责创建类的工具函数。
接着,第 2 条字节码将下标为 0 的常量加载到栈顶,这是代表 Dog 类代码块的代码对象,如红色箭头所示。第 3 条字节码将常量 Dog 加载到栈顶。第 4 条字码时我们在函数机制中学过的 MAKE_FUNCTION 指令,用于创建函数对象。指令执行完毕后,名为 Dog 的函数对象就被保存在栈顶了,如图粉红色部分:
这就很诡异,为啥这里需要创建一个函数对象呢?我们接着推演。第 5 条字节码将常量 Dog 加载到栈顶;第 6 条字节码调用 build_class 函数。请特别注意,这里调用的不是刚刚创建的 Dog 函数,它只是作为参数传给了 build_class 函数。从 build_class 函数的帮助文档得知,该函数第一个参数为一个函数,第二个参数为一个名字,跟我们的分析是吻合的。
|
|
那么,Dog 函数是在 build_class 中被调用的吗?它的作用又是什么呢?我们接着扒开 build_class 的源码看一看,它位于 builtins 模块中,源码路径为 Python/bltinmodule.c 。
我们发现,build_class 并没有直接调用新创建的 Dog 函数。它先创建一个 dict 对象,作为新生类的属性空间;然后,从 Dog 函数中取出全局名字空间和代码对象;最后新建一个栈帧对象并开始执行 Dog 类代码对象。
注意到,新栈帧对象从从 Dog 函数对象中取得全局名字空间,这也是模块的属性空间;又从函数对象取得代码对象,这个代码对象正好对应着 Dog 类的代码块;而局部名字空间则是新生类的属性空间。因此,Dog 这个函数对象只是作为打包参数的包袱,将代码对象、全局名字空间等参数作为一个整体进行传递,多么巧妙!
Dog 类代码对象的字节码我们已经非常熟悉了,接着推演一番。代码先从全局名字空间取出模块名 name ,在局部名字空间中保存为 module ,原来类对象的 module 属性就是这么来的!然后将类名 Dog 保存到局部名字空间中的 qualname 。最后取出 yelp 函数的代码对象,完成 yelp 函数对象的创建工作。至此,新生类 Dog 的属性空间完成初始化:
类属性空间准备完毕,build_class 接着调用 type 元类型对象完成 Dog 类的创建。type 需要的参数有 3个,分别是: 类名 、 基类列表 (类继承)以及代表 类属性空间 的 dict 对象。
|
|
至此,Dog 类对象横空出世!
模块代码对象最后的字节码将 Dog 类对象保存于模块属性空间,我们已经很熟悉就不再赘述了:
最后,我们以 Python 的语言来回顾类对象创建过程中的关键步骤,以此加深理解。
还记得吗?模块代码对象中包含类代码对象,而类代码对象中又包含着类函数的代码对象:
|
|
接着,新建一个 dict 对象,作为类的属性空间:
|
|
以类属性空间为局部名字空间,执行类代码对象,以此完成新类属性空间初始化:
|
|
最后,调用 type 函数完成类对象创建,由于 Dog 没有显式继承关系,基类列表为空:
|
|
哇!我们以一种全新的方式得到一个全新的类!
|
|
掌握这些原理之后,我们后续可以干很多不可思议的事情呢!敬请期待。
洞悉 Python 虚拟机运行机制,探索高效程序设计之道!
到底如何才能提升我的 Python 开发水平,向更高一级的岗位迈进呢? 如果你有这些问题或者疑惑,请订阅我们的专栏 Python源码深度剖析 ,阅读更多章节:
【Python源码剖析】系列文章首发于公众号【小菜学编程】,敬请关注: