详解 PyTypeObject,Python 类型对象的载体

2024年 5月 16日 76.4k 0

楔子

通过 PyObject 和 PyVarObject,我们看到了所有对象的公共信息以及变长对象的公共信息。任何一个对象,不管它是什么类型,内部必有引用计数(ob_refcnt)和类型指针(ob_type)。任何一个变长对象,不管它是什么类型,内部除了引用计数和类型指针之外,还有一个表示元素个数的 ob_size。

显然目前没有什么问题,一切都是符合预期的,但是当我们顺着时间轴回溯的话,就会发现端倪。比如:

  • 当在内存中创建对象、分配空间的时候,解释器要给对象分配多大的空间?显然不能随便分配,那么对象的内存信息在什么地方?
  • 对象是可以执行相关操作的,解释器怎么知道某个对象支持哪些操作呢?再比如一个整数可以和一个整数相乘,一个列表也可以和一个整数相乘,即使是相同的操作,但不同类型的对象执行也会有不同的效果,那么此时解释器又是如何进行区分的?

想都不用想,这些信息肯定都在对象的类型对象中。因为占用的空间大小实际上是对象的一个元信息,这样的元信息和其所属类型是密切相关的,因此它一定会出现在与之对应的类型对象当中。

至于支持的操作就更不用说了,我们平时自定义类的时候,功能函数都写在什么地方,显然都是写在类里面,因此一个对象支持的操作也定义在类型对象当中。

而将对象和它的类型对象关联起来的,毫无疑问正是该对象内部的 PyObject 的 ob_type 字段,也就是类型指针。我们通过对象的 ob_type 字段即可获取类型对象的指针,然后通过指针获取存储在类型对象中的某些元信息。

下面我们来看看类型对象在底层是怎么定义的。

解密 PyTypeObject

PyObject 的 ob_type 字段的类型是 PyTypeObject *,所以类型对象由 PyTypeObject 结构体负责实现,看一看它长什么样子。

// Include/pytypedefs.h
typedef struct _typeobject PyTypeObject;

// Include/cpython/object.h
struct _typeobject {
    PyObject_VAR_HEAD
    const char *tp_name; 
    Py_ssize_t tp_basicsize, tp_itemsize; 

    destructor tp_dealloc;
    Py_ssize_t tp_vectorcall_offset;
    getattrfunc tp_getattr;
    setattrfunc tp_setattr;
    PyAsyncMethods *tp_as_async; 
                                    
    reprfunc tp_repr;

    PyNumberMethods *tp_as_number;
    PySequenceMethods *tp_as_sequence;
    PyMappingMethods *tp_as_mapping;

    hashfunc tp_hash;
    ternaryfunc tp_call;
    reprfunc tp_str;
    getattrofunc tp_getattro;
    setattrofunc tp_setattro;

    PyBufferProcs *tp_as_buffer;

    unsigned long tp_flags;
    const char *tp_doc; 
    traverseproc tp_traverse;
    inquiry tp_clear;
    richcmpfunc tp_richcompare;

    Py_ssize_t tp_weaklistoffset;

    getiterfunc tp_iter;
    iternextfunc tp_iternext;

    PyMethodDef *tp_methods;
    PyMemberDef *tp_members;
    PyGetSetDef *tp_getset;

    PyTypeObject *tp_base;
    PyObject *tp_dict;
    descrgetfunc tp_descr_get;
    descrsetfunc tp_descr_set;
    Py_ssize_t tp_dictoffset;
    initproc tp_init;
    allocfunc tp_alloc;
    newfunc tp_new;
    freefunc tp_free; 
    inquiry tp_is_gc; 
    PyObject *tp_bases;
    PyObject *tp_mro; 
    PyObject *tp_cache; 
    void *tp_subclasses;  
    PyObject *tp_weaklist; 
    destructor tp_del;

    unsigned int tp_version_tag;

    destructor tp_finalize;
    vectorcallfunc tp_vectorcall;

    unsigned char tp_watched;
};

类型对象在底层对应的是 struct _typeobject,或者说 PyTypeObject,它保存了实例对象的元信息。

所以不难发现,Python 中实例对象在底层会对应不同的结构体实例,但类型对象则是对应同一个结构体实例。换句话说无论是 int、str、dict 等内置类型,还是我们使用 class 关键字自定义的类型,它们在 C 的层面都是由 PyTypeObject 这个结构体实例化得到的,只不过内部字段的值不同,PyTypeObject 这个结构体在实例化之后得到的类型对象也不同。

然后我们来看看 PyTypeObject 里面的字段都代表啥含义,字段还是比较多的,我们逐一介绍。

PyObject_VAR_HEAD

宏,会被替换为 PyVarObject,所以类型对象是一个变长对象。因此类型对象也有引用计数和类型,这与我们前面分析的是一致的。

tp_name

对应 Python 中类型对象的 __name__ 属性,即类型对象的名称。

# 类型对象在底层对应的是 PyTypeObject 结构体实例
# 它的 tp_name 字段表示类型对象的名称
print(int.__name__)  # int
# 动态创建一个类
A = type("我是 A", (object,), {})
print(A.__name__)  # 我是 A

所以任何一个类型对象都有 __name__ 属性,也就是都有名称。

tp_basicsize,tp_itemsize

  • tp_basicsize:表示创建实例对象所需的基本内存大小;
  • tp_itemsize:如果对象是变长对象,并且元素保存在对应的结构体内部,比如元组,那么 tp_itemsize 表示内部每个元素的内存大小。如果是定长对象,或者虽然是变长对象,但结构体本身不保存数据,而是只保存了一个指针,那么 tp_itemsize 为 0;

tp_dealloc

析构函数,对应 Python 中类型对象的 __del__,会在实例对象被销毁时执行。

tp_vectorcall_offset

如果想调用一个对象,那么它的类型对象要定义 __call__ 函数。

class A:

    def __call__(self, *args, **kwargs):
        return "被调用了"

a = A()
# 如果调用 a,那么 type(a) 要定义 __call__ 函数
print(a())
"""
被调用了
"""
# 底层会转成如下逻辑
print(A.__call__(a))
"""
被调用了
"""


# 函数也是一个实例对象,它能被调用
# 说明 type(函数) 也一定实现了 __call__
def some_func(name, age):
    return f"name: {name}, age: {age}"
# 函数的类型是 function
print(type(some_func))
"""

"""
# 调用函数
print(some_func("古明地觉", 17))
"""
name: 古明地觉, age: 17
"""
# 也可以这么做
print(type(some_func).__call__(some_func, "古明地觉", 17))
"""
name: 古明地觉, age: 17
"""

以上就是对象最通用的调用逻辑,但通用也意味着平庸,这种调用方式的性能是不高的。自定义类的实例对象还好,因为需要支持调用的场景不多,而函数则不同,尽管它也是实例对象,但它生下来就是要被调用的。

如果函数调用也走通用逻辑的话,那么效率不高,因此 Python 从 3.8 开始引入了 vectorcall 协议,即矢量调用协议,用于优化和加速函数调用。至于它是怎么优化的,后续剖析函数的时候再细说。

总之当一个对象被调用时,如果它支持 vectorcall 协议,那么会通过 tp_vectorcall_offset 找到实现矢量调用的函数指针。

注意:vectorcall 函数指针定义在实例对象中,所以 tp_vectorcall_offset 字段维护了 vectorcall 函数指针在实例对象中的偏移量,该偏移量用于定位到一个特定的函数指针,这个函数指针符合 vectorcall 协议。

如果类型对象的 tp_vectorcall_offset 为 0,表示其实例对象不支持矢量调用,因此会退化为常规调用,即通过 __call__ 进行调用。

tp_getattr,tp_setattr

对应 Python 中类型对象的 __getattr__ 和 __setattr__,用于操作实例对象的属性。但这两个字段已经不推荐使用了,因为它要求在操作属性时,属性名必须为 C 字符串,以及不支持通过描述符协议处理属性。

所以这两个字段主要用于兼容旧版本,现在应该使用 tp_getattro 和 tp_setattro。

tp_as_number、tp_as_sequence、tp_as_mapping、tp_as_async

tp_as_number:实例对象为数值时,所支持的操作。这是一个结构体指针,指向的结构体中的每一个字段都是一个函数指针,指向的函数就是对象可以执行的操作,比如四则运算、左移、右移、取模等等。

tp_as_sequence:实例对象为序列时,所支持的操作,也是一个结构体指针。

tp_as_mapping:实例对象为映射时,所支持的操作,也是一个结构体指针。

tp_as_async:实例对象为协程时,所支持的操作,也是一个结构体指针。

tp_repr、tp_str

对应 Python 中类型对象的 __repr__ 和 __str__,用于控制实例对象的打印输出。

tp_hash

对应 Python 中类型对象的 __hash__,用于定义实例对象的哈希值。

tp_call

对应 Python 中类型对象的 __call__,用于控制实例对象的调用行为。当然这属于常规调用,而对象不仅可以支持常规调用,还可以支持上面提到的矢量调用(通过减少参数传递的开销,提升调用性能)。

但要注意的是,不管使用哪种调用协议,对象调用的行为必须都是相同的。因此一个对象如果支持矢量调用,那么它也必须支持常规调用,换句话说对象如果实现了 vectorcall,那么它的类型对象也必须实现 tp_call。

如果你在实现 vectorcall 之后发现它比 tp_call 还慢,那么你就不应该实现 vectorcall,因为实现 vectorcall 是有条件的,当条件不满足时性能反而会变差。

tp_getattro,tp_setattro

对应 Python 中类型对象的 __getattr__ 和 __setattr__。

tp_as_buffer

指向 PyBufferProcs 类型的结构体,用于共享内存。通过暴露出一个缓冲区,可以和其它对象共享同一份数据,因此当类型对象实现了 tp_as_buffer,我们也说其实例对象实现了缓冲区协议,举个例子。

import numpy as np

buf = bytearray(b"abc")
# 和 buf 共享内存
arr = np.frombuffer(buf, dtype="uint8")
print(arr)  # [97 98 99]
# 修改 buf
buf[0] = 255
# 会发现 arr 也改变了,因为它和 buf 共用一块内存
print(arr)  # [255  98  99]

所以 tp_as_buffer 主要用于那些自身包含大量数据,且需要允许其它对象直接访问的类型。通过实现缓冲区协议,其它对象可以直接共享数据,而无需事先拷贝,这在处理大型数据或进行高性能计算时非常有用。

关于缓冲区协议,后续还会详细介绍。

tp_flags

对应 Python 中类型对象的 __flags__,负责提供类型对象本身的附加信息,通过和指定的一系列标志位进行按位与运算,即可判断该类型是否具有某个特征。

那么标志位都有哪些呢?我们介绍几个常见的。

// Include/object.h

// 类型对象的内存是否是动态分配的
// 像内置的类型对象属于静态类,它们不是动态分配的
#define Py_TPFLAGS_HEAPTYPE (1UL  PyDict_Type
  • set -> PySet_Type
  • frozenset -> PyFrozenSet_Type
  • type -> PyType_Type
  • Python 底层的 C API 和对象的命名都遵循统一的标准,比如类型对象均以 Py***_Type 的形式命名,当然啦,它们都是 PyTypeObject 结构体实例。

    所以我们发现,Python 里的类在底层是以全局变量的形式静态定义好的。

    所以实例对象可以有很多个,但类型对象则是唯一的,在底层直接以全局变量的形式静态定义好了。

    比如列表的类型是 list,列表可以有很多个,但 list 类型对象则全局唯一。

    data1 = [1, 2, 3]
    data2 = [4, 5, 6]
    print(
        data1.__class__ is data2.__class__ is list
    )  # True

    如果站在 C 的角度来理解的话:

    data1 和 data2 变量均指向了列表,列表在底层对应 PyListObject 结构体实例。里面字段的含义之前说过,但需要注意的是,指针数组里面保存的是对象的指针,而不是对象。不过为了方便,图中就用对象代替了。

    然后列表的类型是 list,在底层对应 PyList_Type,它是 PyTypeObject 结构体实例,保存了列表的元信息(比如内存分配信息、支持的相关操作等)。

    而将这两者关联起来的便是 ob_type,它位于 PyObject 中,是所有对象都具有的。因为变量只是一个 PyObject * 指针,那么解释器要如何得知变量指向的对象的类型呢?答案便是通过 ob_type 字段。

    小结

    类型对象全局唯一,在底层以全局变量的形式存在,不管是什么类型对象,均由 PyTypeObject 结构体实例化得到,而不同的实例对象则对应不同的结构体。

    将实例对象和类型对象关联起来的,则是实例对象的 ob_type 字段,在 Python 里面可以通过调用 type 或者获取 __class__ 属性查看。

    关于类型对象的更多内容,后续会继续介绍。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论