分享

万丈高楼平地起,一切从 PyObject 开始

 古明地觉O_o 2024-05-13 发布于北京

楔子


在前面的文章中我们说到,面向对象理论中的对象这两个概念在 Python 内部都是通过对象实现的。类是一种对象,称为类型对象,类实例化得到的也是对象,称为实例对象

但是对象在 Python 的底层是如何实现的呢?Python 解释器是基于 C 语言实现的 ,但 C 并不是一个面向对象的语言,那么它是如何实现 Python 的面向对象的呢?

首先对于人的思维来说,对象是一个比较形象的概念,但对于计算机来说,对象却是一个抽象的概念。它并不能理解这是一个整数,那是一个字符串,计算机所知道的一切都是字节。

通常的说法是:对象是数据以及基于这些数据所能进行的操作的集合。在计算机中,一个对象实际上就是一片被分配的内存空间,这些内存可能是连续的,也可能是离散的。

而 Python 的任何对象在 C 中都对应一个结构体实例,在 Python 中创建一个对象,等价于在 C 中创建一个结构体实例。所以 Python 的对象,其本质就是 C 的 malloc 函数为结构体实例在堆区申请的一块内存。

下面我们就来分析一下对象在 C 中是如何实现的。


对象的地基:PyObject


Python 一切皆对象,而所有的对象都拥有一些共同的信息(也叫头部信息),这些信息位于 PyObject 中,它是 Python 对象机制的核心,下面来看看它的定义。

注:我们整个系列的源码都是 3.12 版本的。

// Include/pytypedefs.h
typedef struct _object PyObject;

我们看到具体定义位于 struct _object 中,PyObject 只是它的别名。

// Include/object.h
struct _object {
    _PyObject_HEAD_EXTRA
    union {
       Py_ssize_t ob_refcnt;
       PY_UINT32_T ob_refcnt_split[2];
    };
    PyTypeObject *ob_type;
};

注:源码中定义的 struct _object 看起来会更复杂一些,因为里面还包含了一些宏判断,用于适配不同的操作系统和编译器。

这些宏判断我们不需要关注,对于当前的 64 位机器来说,等价于如下

// Include/object.h
struct _object {
    _PyObject_HEAD_EXTRA
    union {
       Py_ssize_t ob_refcnt;
       PY_UINT32_T ob_refcnt_split[2];
    };
    PyTypeObject *ob_type;
};

然后是 _PyObject_HEAD_EXTRA,它也是一个宏,定义如下。

// Include/object.h

// 如果定义了宏 Py_TRACE_REFS
#ifdef Py_TRACE_REFS
// 那么 _PyObject_HEAD_EXTRA 会展开成如下两个字段
// 显然程序中创建的对象会组成一个双向链表
#define _PyObject_HEAD_EXTRA      \
    PyObject *_ob_next;           \
    PyObject *_ob_prev;

// 用于将 _ob_next 和 _ob_prev 初始化为空
#define _PyObject_EXTRA_INIT _Py_NULL, _Py_NULL,

// 否则说明没有定义宏 Py_TRACE_REFS
// 那么 _PyObject_HEAD_EXTRA 和 _PyObject_EXTRA_INIT 不会有任何作用
#else
#  define _PyObject_HEAD_EXTRA
#  define _PyObject_EXTRA_INIT
#endif

关于 PyObject 的定义,再画一张图总结一下。

Py_TRACE_REFS 一般只在编译调试的时候会开启,我们从官网下载的都是 Release 版本,不包含这个宏,因此这里我们也不考虑它。

所以 PyObject 最终就等价于下面这个样子:

// Include/object.h
struct _object {
    union {
       Py_ssize_t ob_refcnt;
       PY_UINT32_T ob_refcnt_split[2];
    };
    PyTypeObject *ob_type;
};

// Include/pytypedefs.h
typedef struct _object PyObject;

当然这两者也可以写在一起,即定义结构体的同时起一个别名。

typedef struct _object {
    union {
       Py_ssize_t ob_refcnt;
       PY_UINT32_T ob_refcnt_split[2];
    };
    PyTypeObject *ob_type;
} PyObject;

方式是等价的,只不过 Python 将两者分开了,并写在了不同的文件中。

了解了 PyObject 的结构之后,我们再来看一下它内部的字段。

ob_refcnt:引用计数

ob_refcnt 表示对象的引用计数,当对象被引用时,ob_refcnt 会自增 1;引用解除时,ob_refcnt 会自减 1。而当对象的引用计数为 0 时,则会被回收。

那么在哪些情况下,引用计数会加 1 呢?哪些情况下,引用计数会减 1 呢?

导致引用计数加 1 的情况:

  • 对象被创建:比如 name = "古明地觉",此时对象就是 "古明地觉" 这个字符串, 创建成功时它的引用计数为 1;

  • 变量传递使得对象被新的变量引用:比如 name2 = name;

  • 引用该对象的某个变量作为参数传到一个函数或者类中:比如 func(name);

  • 引用该对象的某个变量作为元组、列表、集合等容器的元素:比如 lst = [name];

导致引用计数减 1 的情况:

  • 引用该对象的变量被显式地销毁:del name;

  • 引用该对象的变量指向了别的对象:name = "";

  • 引用该对象的变量离开了它的作用域,比如函数的局部变量在函数执行完毕的时候会被删除;

  • 引用该对象的变量所在的容器被销毁,或者变量从容器里面被删除;

因为变量只是一个和对象绑定的符号,接地气一点的说法就是变量是个便利贴,贴在指定的对象上面。所以 del 变量 并不是删除变量指向的对象,而是删除变量本身,可以理解为将对象身上的便利贴给撕掉了,其结果就是对象的引用计数减一。

至于对象是否被删除(回收)则是解释器判断引用计数是否为 0 决定的,为 0 就删,不为 0 就不删,就这么简单。

然后需要强调的是,在 3.12 之前的 Python 源码中,PyObject 是这么定义的,以 3.8 为例。

在 3.12 之前,引用计数通过一个 ob_refcnt 字段来维护,字段类型为 Py_ssize_t,它是 ssize_t 的别名,在 64 位机器上等价于 int64。因此一个对象的引用计数不能超过 int64 所表示的最大范围。但很明显,如果不费九牛二虎之力去写恶意代码,是不可能超过这个范围的。

还是很好理解的,但从 3.12 开始,却搞了个共同体(union)出来,这是为啥呢?因为 Python 从 3.12 开始引入了一个概念叫永恒对象。顾名思义,永恒对象就是那些永远不会被回收的对象。

// Include/object.h
#define _Py_IMMORTAL_REFCNT UINT_MAX

永恒对象的引用计数为 uint32 类型的最大值,即 2 的 32 次方减 1,像 None、-5 到 256 之间的小整数,都属于永恒对象。

共同体中的 ob_refcnt 字段的作用还和之前一样,依旧是负责维护对象的引用计数。

 ob_refcnt_split 也会维护一份引用计数,它是 uint32 类型的数组,长度为 2,但只会用数组的一个元素来维护。如果发现对象的引用计数达到了 uint32 的最大值,那么会将对象判定为永恒对象,而永恒对象永远不会被回收。

所以 ob_refcnt_split 是针对永恒对象引入的,它是一个长度为 2 的 uint32 类型的数组,大小是 8 字节。而 ob_refcnt 是 Py_ssize_t 类型,等价于 int64,大小也是 8 字节。由于这两者组成的是共同体,所以整体大小依旧是 8 字节,因此 PyObject 结构体实的大小和之前一样。

当然啦,虽然引用计数是由共同体来维护,但你把它当成普通的 Py_ssize_t 类型的字段来理解也是可以的。因为 3.12 之前只有一个 ob_refcnt,而 ob_refcnt_split 是针对永恒对象专门引入的。

ob_type:类型指针

对象是有类型的,类型对象描述实例对象的行为,而 ob_type 存储的便是对应类型对象的指针,所以类型对象在底层是一个 PyTypeObject 结构体实例。

从这里可以看出,所有的类型对象在底层都是由同一个结构体实例化得到的,因为 PyObject 是所有对象共有的,它们的 ob_type 指向的都是 PyTypeObject。

所以不同的实例对象对应不同的结构体,但是类型对象对应的都是同一个结构体。

以上就是 PyObject,它的定义非常简单,就一个引用计数和一个类型对象的指针。这两个字段的大小都是 8 字节,所以一个 PyObject 结构体实例的大小是 16 字节。

另外,由于 PyObject 是所有对象都具有的,换句话说就是所有对象对应的结构体内部都内嵌了 PyObject,因此你在 Python 里面看到的任何一个对象都有引用计数和类型这两个属性。

>>> num = 666  
>>> sys.getrefcount(num)
2
>>> num.__class__
<class 'int'>

>>> sys.getrefcount(sys)
72
>>> sys.__class__
<class 'module'>

>>> sys.getrefcount(sys.path)
2
>>> sys.path.__class__
<class 'list'>

>>> def foo():
  pass
... 
>>> sys.getrefcount(foo)
2
>>> foo.__class__
<class 'function'>

引用计数可以通过 sys.getrefcount 函数查看,类型可以通过 type(obj) 或者 obj.__class__ 查看。


可变对象的地基:PyVarObject


PyObject 是所有对象的核心,它包含了所有对象都共有的信息,但是还有那么一个属性虽然不是每个对象都有,但至少有一大半的对象会有,能猜到是什么吗?

前说过,对象根据所占的内存是否固定,可以分为定长对象和变长对象,而变长对象显然有一个长度的概念,比如字符串、列表、元组等等。即便是相同类型的实例对象,但是长度不同,所占的内存也是不同的。

比如字符串内部有多少个字符,元组、列表内部有多少个元素,显然这里的多少也是 Python 中很多对象的共有特征。虽然不像引用计数和类型那样是每个对象都必有的,但也是绝大部分对象所具有的。

所以针对变长对象,Python 底层也提供了一个结构体,因为 Python 里面很多都是变长对象。

// Include/object.h
typedef struct {
    PyObject ob_base;
    Py_ssize_t ob_size;
} PyVarObject;

我们看到 PyVarObject 实际上是 PyObject 的一个扩展,它在 PyObject 的基础上提供了一个 ob_size 字段,用于记录内部的元素个数。比如列表,列表的 ob_size 维护的就是列表的元素个数,插入一个元素,ob_size 会加 1,删除一个元素,ob_size 会减 1。

因此使用 len 函数获取列表的元素个数是一个时间复杂度为 O(1) 的操作,因为 ob_size 始终和内部的元素个数保持一致,所以会直接返回 ob_size。

所有的变长对象都拥有 PyVarObject,而所有的对象都拥有 PyObject,这就使得在 Python 中,对对象的引用变得非常统一。我们只需要一个 PyObject * 就可以引用任意一个对象,而不需要管这个对象实际是一个什么样的对象。

所以 Python 变量、以及容器内部的元素,本质上都是一个 PyObject *。而在操作变量的时候,也要先根据 ob_type 字段判断指向对象的类型,然后再寻找该对象具有的方法,这也是 Python 效率慢的原因之一。

由于 PyObject 和 PyVarObject 要经常被使用,所以底层提供了两个宏,方便定义。

// Include/object.h
#define PyObject_HEAD    PyObject ob_base;
#define PyObject_VAR_HEAD    PyVarObject ob_base;

比如定长对象浮点数,在底层对应的结构体为 PyFloatObject,它只需在 PyObject 的基础上再加一个 double 即可。

typedef struct {
    // 等价于 PyObject ob_base;
    PyObject_HEAD
    double ob_fval;
} PyFloatObject;

再比如变长对象列表,在底层对应的结构体是 PyListObject,所以它需要在 PyVarObject 的基础上再加一个指向指针数组首元素的二级指针和一个容量。

typedef struct {
    PyObject_VAR_HEAD
    PyObject **ob_item;
    Py_ssize_t allocated;
} PyListObject;

这上面的每一个字段都代表什么,我们之前提到过,当然这些内置的数据结构后续还会单独剖析。

里面的 ob_item 就是指向指针数组首元素的二级指针,而 allocated 表示已经分配的容量,一旦添加元素的时候发现 ob_size 自增 1 之后会大于 allocated,那么解释器就知道数组已经满了(容量不够了)。于是会申请一个长度更大的指针数组,然后将旧数组内部的元素按照顺序逐个拷贝到新数组里面去,并让 ob_item 指向新数组的首元素,这个过程就是列表的扩容,后续在剖析列表的时候还会细说。

所以我们看到列表在添加元素的时候,地址是不会改变的,即使容量不够了也没有关系,直接让 ob_item 指向新的数组就好了,至于 PyListObject 对象本身的地址是不会变化的。


小结


PyObject 是 Python 对象的核心,因为 Python 对象在 C 的层面就是一个结构体,并且所有的结构体都嵌套了 PyObject 结构体。而 PyObject 内部有引用计数和类型这两个字段,因此我们可以肯定的说 Python 的任何一个对象都有引用计数和类型这两个属性。

另外大部分对象都有长度的概念,所以 PyObject 再加上长度就诞生出了 PyVarObject,它在 PyObject 的基础上添加了一个 ob_size 字段,用于描述对象的长度。比如字符串内部的 ob_size 维护的是字符串的字符个数,元组、列表、字典等等,其内部的 ob_size 维护的是存储的元素个数,所以使用 len 函数获取对象长度是一个 O(1) 的操作。


本文参考自:

  • 陈儒《Python 源码剖析》

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多