查看原文
其他

Python 类对象诞生记

Python猫 2022-04-12

The following article is from 小菜学编程 Author fasionchan

 △点击上方“Python猫”关注 ,回复“1”领取电子书

作者:fasionchan
来源:小菜学编程

在本文中,我们着手研究 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 类是一种自定义类对象,因此它底层应该是一个 PyTypeObjectPyTypeObject 是每个类型对象在虚拟机底层的表现形式,它中源码中位于 Objects/typeobject.c 文件。这个结构体前面章节已有所涉猎,多少还有些印象吧?

进入 Objects 目录,我们的目光还被 Objects/classobject.c 这个文件吸引住了,这不就是 类对象 吗?历史上,Python 自定义类和内建类型是分开实现的,也因此造成鸿沟。后来 Python 完成了类型统一,不管自定义的还是内建的,具有 PyTypeObject 实现,Objects/classobject.c 中的大部分功能也因之废弃。

由于篇幅的关系,这里不打算展开太多源码细节,同样以最通俗易懂的结构图进行介绍:

结构图省略了很多字段,但不会影响学习理解,重点注意这几个字段:

  • tp_nameDog 类对象名字,即类名;
  • tp_callDog 类被调用时执行的函数指针,用于创建 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

掌握这些原理之后,我们后续可以干很多不可思议的事情呢!敬请期待。

Python猫技术交流群开放啦!群里既有国内一二线大厂在职员工,也有国内外高校在读学生,既有十多年码龄的编程老鸟,也有中小学刚刚入门的新人,学习氛围良好!想入群的同学,请在公号内回复『交流群』,获取猫哥的微信(谢绝广告党,非诚勿扰!)~

近期热门文章推荐:

解密 Python 中的对象模型
GitHub 上适合新手的 Python 开源项目
Python 之父为什么嫌弃 lambda 匿名函数?
Python 为什么使用缩进来划分代码块?

感谢创作者的好文

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存