Python 类对象诞生记
The following article is from 小菜学编程 Author fasionchan
△点击上方“Python猫”关注 ,回复“1”领取电子书
在本文中,我们着手研究 Python 面向对象中自定义类的运行机制,并借此实现一些有趣的应用。
类对象行为
我们从最简单的类入手,研究类对象和实例对象的创建过程以及典型行为。还记得我们在 对象模型 部分中,作为例子讲解的 Dog 类吗?这类麻雀虽小,却五脏俱全,非常适合作为我们研究的起点:
>>> class Dog:
... def yelp(self):
... print('woof')
...
>>> Dog
<class '__main__.Dog'>
>>> dog = Dog()
>>> dog.yelp()
woof
根据 对象模型 部分学到的知识,我们可以得到这样的关系图:
>>> type(dog) is Dog
True
>>> type(Dog) is type
True
>>> issubclass(Dog, object)
True
如果你对 类型 对象、 实例 对象、 继承 、 实例化 等面向对象概念印象不深,请复习《揭开Python对象的神秘面纱!》和《解密 Python 中的对象模型》。
我们接着观察 Dog 类的行为,发现它支持属性设置。例如,我们设置 legs 属性,表示狗都有 4 条腿:
>>> Dog.legs = 4
>>> Dog.legs
4
由此,我们可以大胆推测,每个自定义类对象都有一个 属性空间 ,背后同样是由 dict 对象实现的。
我们还注意到类对象中的属性,可以被实例对象访问到。在 继承与属性查找 一节,我们将深入研究这个现象:
>>> dog = Dog()
>>> dog.legs
4
那么,类方法是不是也保存在类属性空间中呢?我们知道,通过 __dict__ 即可获取一个对象的属性空间:
>>> Dog.__dict__.keys()
dict_keys(['__module__', 'yelp', '__dict__', '__weakref__', '__doc__', 'legs'])
除了几个内置属性,我们找到了 legs 这是我们刚刚设置的属性;还找到了 yelp ,它应该就是类的方法了:
>>> Dog.__dict__['yelp']
<function Dog.yelp at 0x10f8336a8>
>>> Dog.yelp
<function Dog.yelp at 0x10f8336a8>
这是否意味着,可以将新方法作为属性设置到类对象身上,让它具有某些新行为呢?事不宜迟,我们来试试:
>>> def yelp2(self):
... print('surprise')
...
>>> Dog.yelp2 = yelp2
>>> dog.yelp2()
surprise
哇,我们在运行时让 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 函数进行编译,得到一个代码对象:
>>> text = '''
... class Dog:
... def yelp(self):
... print('woof')
... '''
>>> code = compile(text, '', 'exec')
这个代码对象里头应该就保持着创建类对象的密码!迫不及待想要揭晓答案,那就字节码反编译一下吧:
>>> import dis
>>> dis.dis(code)
2 0 LOAD_BUILD_CLASS
2 LOAD_CONST 0 (<code object Dog at 0x10110f8a0, file "", line 2>)
4 LOAD_CONST 1 ('Dog')
6 MAKE_FUNCTION 0
8 LOAD_CONST 1 ('Dog')
10 CALL_FUNCTION 2
12 STORE_NAME 0 (Dog)
14 LOAD_CONST 2 (None)
16 RETURN_VALUE
Disassembly of <code object Dog at 0x10110f8a0, file "", line 2>:
2 0 LOAD_NAME 0 (__name__)
2 STORE_NAME 1 (__module__)
4 LOAD_CONST 0 ('Dog')
6 STORE_NAME 2 (__qualname__)
3 8 LOAD_CONST 1 (<code object yelp at 0x10110f390, file "", line 3>)
10 LOAD_CONST 2 ('Dog.yelp')
12 MAKE_FUNCTION 0
14 STORE_NAME 3 (yelp)
16 LOAD_CONST 3 (None)
18 RETURN_VALUE
Disassembly of <code object yelp at 0x10110f390, file "", line 3>:
4 0 LOAD_GLOBAL 0 (print)
2 LOAD_CONST 1 ('woof')
4 CALL_FUNCTION 1
6 POP_TOP
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
里头大部分字节码我们都认识,但是连起来看却一脸懵逼……这不打紧,至少我们已经发现了某些蛛丝马迹。看到字节码 LOAD_BUILD_CLASS 没?顾名思义,它应该就是构建类对象的关键所在。
开始窥探 LOAD_BUILD_CLASS 字节码之前,先继续考察代表 Dog 类的代码对象。由于我们将 exec 参数传递给 compile 函数,将代码代码作为模块进行编译,因此代码对象 code 对应着模块级别代码块。
我们发现,code 里面还藏有子代码对象,子代码对象作为常量存在于 code 常量表中。而子代码对象中藏在另一个代码对象,同样以常量的形式存在:
>>> code.co_consts
(<code object Dog at 0x10110f8a0, file "", line 2>, 'Dog', None)
>>> code.co_consts[0].co_consts
('Dog', <code object yelp at 0x10110f390, file "", line 3>, 'Dog.yelp', None)
从这三个代码对象的字节码来看,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__ 函数的帮助文档得知,该函数第一个参数为一个函数,第二个参数为一个名字,跟我们的分析是吻合的。
>>> help(__build_class__)
Help on built-in function __build_class__ in module builtins:
__build_class__(...)
__build_class__(func, name, *bases, metaclass=None, **kwds) -> class
Internal helper function used by the class statement.
那么,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 对象。
>>> help(type)
Help on class type in module builtins:
class type(object)
| type(object_or_name, bases, dict)
| type(object) -> the object's type
| type(name, bases, dict) -> a new type
至此,Dog 类对象横空出世!
模块代码对象最后的字节码将 Dog 类对象保存于模块属性空间,我们已经很熟悉就不再赘述了:
最后,我们以 Python 的语言来回顾类对象创建过程中的关键步骤,以此加深理解。
还记得吗?模块代码对象中包含类代码对象,而类代码对象中又包含着类函数的代码对象:
# 模块代码对象
>>> code
<code object <module> at 0x10b42e8a0, file "", line 2>
# Dog 类代码对象
>>> code.co_consts[0]
<code object Dog at 0x10b42ac00, file "", line 2>
# Dog 类 yelp 函数代码对象
>>> code.co_consts[0].co_consts[1]
<code object yelp at 0x10b5136f0, file "", line 3>
接着,新建一个 dict 对象,作为类的属性空间:
>>> attrs = {}
以类属性空间为局部名字空间,执行类代码对象,以此完成新类属性空间初始化:
>>> exec(code.co_consts[0], globals(), attrs)
>>> attrs
{'__module__': '__main__', '__qualname__': 'Dog', 'yelp': <function Dog.yelp at 0x10b732e18>}
最后,调用 type 函数完成类对象创建,由于 Dog 没有显式继承关系,基类列表为空:
>>> Dog = type('Dog', (), attrs)
>>> Dog
<class '__main__.Dog'>
哇!我们以一种全新的方式得到一个全新的类!
>>> dog = Dog()
>>> dog.yelp()
woof
掌握这些原理之后,我们后续可以干很多不可思议的事情呢!敬请期待。
近期热门文章推荐:
GitHub 上适合新手的 Python 开源项目
Python 之父为什么嫌弃 lambda 匿名函数?
Python 为什么使用缩进来划分代码块?