跳转至

Python对象系统的工作原理

原文链接

https://tenthousandmeters.com/blog/python-behind-the-scenes-6-how-python-object-system-works/

正如我们从本系列前面的部分中所知,Python程序的执行包括两个主要步骤:

  1. CPython编译器将Python代码转换为字节码。
  2. CPython VM执行字节码。

我们关注第二步已经有一段时间了。在第4部分中,我们研究了求值循环,Python字节码在其中执行。在第5部分中,我们研究了VM如何执行用于实现变量的指令。 我们还没有讨论的是VM实际上是如何计算的。我们推迟了这个问题,因为要回答这个问题,我们首先需要了解语言最基本的部分是如何工作的。今天,我们将研究Python对象系统。
注:在本文中,我指的是CPython 3.9。随着CPython的发展,一些实现细节肯定会发生变化。我将努力跟踪重要的更改并添加更新注释。

动机

考虑一段极其简单的Python代码:

def f(x):
    return x + 7
要计算函数f,CPython必须计算表达式x+7。我想问的问题是:CPython是如何做到这一点的?您可能会想到__add__()和__radd__()等特殊方法。 当我们在类上定义这些方法时,可以使用+运算符添加该类的实例。所以,你可能会认为CPython做了这样的事情:

  1. 它调用x.add(7)或type(x)add(x,7)
  2. 如果x没有__add__(),或者如果此方法失败,则调用(7)__radd_(x)或int__radd_(7, x)

然而,现实情况有点复杂。真正发生的事情取决于x是什么。例如,如果x是用户定义类的实例,上面描述的算法类似于事实。然而,如果x是内置类型的实例, 比如int或float,CPython根本不会调用任何特殊方法。 要了解一些Python代码是如何执行的,我们可以执行以下操作:

  1. 将代码分解为字节码。
  2. 研究VM如何执行反汇编的字节码指令。

让我们将此算法应用于函数f。编译器将此函数的主体转换为以下字节码:

$ python -m dis f.py
...
  2           0 LOAD_FAST                0 (x)
              2 LOAD_CONST               1 (7)
              4 BINARY_ADD
              6 RETURN_VALUE
下面是这些字节码指令的作用: 1. LOAD_FAST将参数x的值加载到堆栈中。 2. LOAD_CONST将常量7加载到堆栈中。 3. BINARY_ADD从堆栈中弹出两个值,将它们相加并将结果推回到堆栈中。 4. RETURN_VALUE从堆栈中弹出值并返回。

VM如何添加两个值?要回答这个问题,我们需要了解这些值是什么。对我们来说,7是int的一个实例,x是任何东西。不过,对于VM来说,一切都是Python对象。 VM推送到堆栈上并从堆栈中弹出的所有值都是指向PyObject结构的指针(因此,短语“Python中的一切都是对象”)。VM不需要知道如何添加整数或字符串,也就是说, 如何进行算术或串联序列。它只需要知道每个Python对象都有一个类型。一个类型反过来知道它的对象的一切。例如,int类型知道如何添加整数,float类型知道如何增加浮点。因此,VM请求类型执行操作。 这种简化的解释抓住了解决方案的本质,但也省略了许多重要的细节。为了获得更真实的画面,我们需要了解Python对象和类型的真正含义以及它们的工作方式。

Python对象和类型

我们在第3部分中讨论了Python对象。这个讨论值得在这里重复。我们从PyObject结构的定义开始:

typedef struct _object {
    _PyObject_HEAD_EXTRA // macro, for debugging purposes only
    Py_ssize_t ob_refcnt;
    PyTypeObject *ob_type;
} PyObject;
它有两个成员:

  • CPython用于垃圾收集的引用计数ob_refcnt;和
  • 指向对象类型ob_type的指针。

我们说过VM将任何Python对象视为PyObject。这怎么可能?C编程语言没有类和继承的概念。然而,在C语言中实现一些可以称为单个继承的东西是可能的。C标准规定, 指向任何结构的指针都可以转换为指向其第一个成员的指针,反之亦然。因此,我们可以通过定义第一个成员为PyObject的新结构来“扩展”PyObject。 例如,以下是如何定义float对象的:

typedef struct {
    PyObject ob_base; // expansion of PyObject_HEAD macro
    double ob_fval;
} PyFloatObject;
浮点对象存储PyObject存储的所有内容,加上浮点值ob_fval。C标准简单地表示,我们可以将指向PyFloatObject的指针转换为指向PyObject的指针,反之亦然:
PyFloatObject float_object;
// ...
PyObject *obj_ptr = (PyObject *)&float_object;
PyFloatObject *float_obj_ptr = (PyFloatObject *)obj_ptr;
VM之所以将每个Python对象视为PyObject,是因为它只需要访问对象的类型。类型也是Python对象,是PyTypeObject结构的实例:
// PyTypeObject is a typedef for "struct _typeobject"

struct _typeobject {
    PyVarObject ob_base; // expansion of PyObject_VAR_HEAD macro
    const char *tp_name; /* For printing, in format "<module>.<name>" */
    Py_ssize_t tp_basicsize, tp_itemsize; /* For allocation */

    /* Methods to implement standard operations */

    destructor tp_dealloc;
    Py_ssize_t tp_vectorcall_offset;
    getattrfunc tp_getattr;
    setattrfunc tp_setattr;
    PyAsyncMethods *tp_as_async; /* formerly known as tp_compare (Python 2)
                                    or tp_reserved (Python 3) */
    reprfunc tp_repr;

    /* Method suites for standard classes */

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

    /* More standard operations (here for binary compatibility) */

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

    /* Functions to access object as input/output buffer */
    PyBufferProcs *tp_as_buffer;

    /* Flags to define presence of optional/expanded features */
    unsigned long tp_flags;

    const char *tp_doc; /* Documentation string */

    /* Assigned meaning in release 2.0 */
    /* call function for all accessible objects */
    traverseproc tp_traverse;

    /* delete references to contained objects */
    inquiry tp_clear;

    /* Assigned meaning in release 2.1 */
    /* rich comparisons */
    richcmpfunc tp_richcompare;

    /* weak reference enabler */
    Py_ssize_t tp_weaklistoffset;

    /* Iterators */
    getiterfunc tp_iter;
    iternextfunc tp_iternext;

    /* Attribute descriptor and subclassing stuff */
    struct PyMethodDef *tp_methods;
    struct PyMemberDef *tp_members;
    struct PyGetSetDef *tp_getset;
    struct _typeobject *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; /* Low-level free-memory routine */
    inquiry tp_is_gc; /* For PyObject_IS_GC */
    PyObject *tp_bases;
    PyObject *tp_mro; /* method resolution order */
    PyObject *tp_cache;
    PyObject *tp_subclasses;
    PyObject *tp_weaklist;
    destructor tp_del;

    /* Type attribute cache version tag. Added in version 2.6 */
    unsigned int tp_version_tag;

    destructor tp_finalize;
    vectorcallfunc tp_vectorcall;
};
顺便注意,类型的第一个成员不是PyObject,而是PyVarObject,其定义如下:
typedef struct {
    PyObject ob_base;
    Py_ssize_t ob_size; /* Number of items in variable part */
} PyVarObject;
然而,由于PyVarObject的第一个成员是PyObject,因此指向类型的指针仍然可以转换为指向PyObject的指针。那么,什么是类型,为什么它有这么多成员? 类型决定了该类型的对象的行为方式。称为slot的类型的每个成员都负责对象行为的特定方面。例如:

  • tp_new是指向创建该类型新对象的函数的指针。
  • tp_str是指向一个函数的指针,该函数为该类型的对象实现str()。
  • tp_hash是指向一个函数的指针,该函数为该类型的对象实现hash()。

一些插槽(称为子插槽)在套件中组合在一起。套件只是包含相关槽的结构。例如,PySequenceMethods结构是一套实现序列协议的子槽:

typedef struct {
    lenfunc sq_length;
    binaryfunc sq_concat;
    ssizeargfunc sq_repeat;
    ssizeargfunc sq_item;
    void *was_sq_slice;
    ssizeobjargproc sq_ass_item;
    void *was_sq_ass_slice;
    objobjproc sq_contains;

    binaryfunc sq_inplace_concat;
    ssizeargfunc sq_inplace_repeat;
} PySequenceMethods;
如果你算一下所有的槽和子槽,你会得到一个可怕的数字。幸运的是,Python/C API参考手册中对每个插槽都有很好的记录 (我强烈建议您将此链接标记为书签)。今天我们将只介绍几个插槽。然而,它将给我们一个如何使用插槽的总体概念。 由于我们对CPython如何添加对象感兴趣,让我们来查找负责添加的插槽。必须至少有一个这样的插槽。仔细检查PyTypeObject结构后, 我们发现它具有“number”套件PyNumberMethods,该套件的第一个槽是一个名为nd_add的二进制函数:
typedef struct {
    binaryfunc nb_add; // typedef PyObject * (*binaryfunc)(PyObject *, PyObject *)
    binaryfunc nb_subtract;
    binaryfunc nb_multiply;
    binaryfunc nb_remainder;
    binaryfunc nb_divmod;
    // ... more sub-slots
} PyNumberMethods;
看来nb_add插槽就是我们要找的。关于此插槽,自然会出现两个问题:

  • 设置为什么?
  • 它是如何使用的?

我认为最好从第二个开始。我们应该期望VM调用nb_add来执行BINARY_add操作码。因此,让我们暂时暂停对类型的讨论,看看BINARY_ADD操作码是如何实现的。

BINARY_ADD

与任何其他操作码一样,BINARY_ADD在Python/ceval.c中的求值循环中实现:

case TARGET(BINARY_ADD): {
    PyObject *right = POP();
    PyObject *left = TOP();
    PyObject *sum;
    /* NOTE(haypo): Please don't try to micro-optimize int+int on
        CPython using bytecode, it is simply worthless.
        See http://bugs.python.org/issue21955 and
        http://bugs.python.org/issue10044 for the discussion. In short,
        no patch shown any impact on a realistic benchmark, only a minor
        speedup on microbenchmarks. */
    if (PyUnicode_CheckExact(left) &&
                PyUnicode_CheckExact(right)) {
        sum = unicode_concatenate(tstate, left, right, f, next_instr);
        /* unicode_concatenate consumed the ref to left */
    }
    else {
        sum = PyNumber_Add(left, right);
        Py_DECREF(left);
    }
    Py_DECREF(right);
    SET_TOP(sum);
    if (sum == NULL)
        goto error;
    DISPATCH();
}
此代码需要一些注释。我们可以看到,它调用PyNumber_Add()来添加两个对象,但如果对象是字符串,则调用unicode_concateate()。为什么?这是一种优化。 Python字符串看起来是不可变的,但有时CPython会对字符串进行变异,从而避免创建新字符串。考虑将一个字符串附加到另一个字符串:
output += some_string
如果输出变量指向一个没有其他引用的字符串,那么对该字符串进行变异是安全的。这正是unicode_concateate()实现的逻辑。 在求值循环中处理其他特殊情况并优化 (例如,整数和浮点)可能很有吸引力。注释明确警告不要这样做。问题是,新的特殊情况会附带一个额外的检查,而该检查只有在成功时才有用。否则,它可能会对性能产生负面影响。 在这个小题外话之后,让我们看看PyNumber_Add():
PyObject *
PyNumber_Add(PyObject *v, PyObject *w)
{
    // NB_SLOT(nb_add) expands to "offsetof(PyNumberMethods, nb_add)"
    PyObject *result = binary_op1(v, w, NB_SLOT(nb_add));
    if (result == Py_NotImplemented) {
        PySequenceMethods *m = Py_TYPE(v)->tp_as_sequence;
        Py_DECREF(result);
        if (m && m->sq_concat) {
            return (*m->sq_concat)(v, w);
        }
        result = binop_type_error(v, w, "+");
    }
    return result;
}
我建议立即进入binary_op1(),然后找出PyNumber_Add()的其余部分稍后会做什么:
static PyObject *
binary_op1(PyObject *v, PyObject *w, const int op_slot)
{
    PyObject *x;
    binaryfunc slotv = NULL;
    binaryfunc slotw = NULL;

    if (Py_TYPE(v)->tp_as_number != NULL)
        slotv = NB_BINOP(Py_TYPE(v)->tp_as_number, op_slot);
    if (!Py_IS_TYPE(w, Py_TYPE(v)) &&
        Py_TYPE(w)->tp_as_number != NULL) {
        slotw = NB_BINOP(Py_TYPE(w)->tp_as_number, op_slot);
        if (slotw == slotv)
            slotw = NULL;
    }
    if (slotv) {
        if (slotw && PyType_IsSubtype(Py_TYPE(w), Py_TYPE(v))) {
            x = slotw(v, w);
            if (x != Py_NotImplemented)
                return x;
            Py_DECREF(x); /* can't do it */
            slotw = NULL;
        }
        x = slotv(v, w);
        if (x != Py_NotImplemented)
            return x;
        Py_DECREF(x); /* can't do it */
    }
    if (slotw) {
        x = slotw(v, w);
        if (x != Py_NotImplemented)
            return x;
        Py_DECREF(x); /* can't do it */
    }
    Py_RETURN_NOTIMPLEMENTED;
}
binary_op1()函数接受三个参数:左操作数、右操作数和标识插槽的偏移量。两个操作数的类型都可以实现槽。因此,binary_op1()查找这两个实现。为了计算结果, 它根据以下逻辑调用一个或另一个实现:

  1. 如果一个操作数的类型是另一操作数的子类型,请调用该子类型的槽。
  2. 如果左操作数没有槽,则调用右操作数的槽。
  3. 否则,调用左操作数的槽。

为子类型的槽设置优先级的原因是允许子类型覆盖其祖先的行为:

$ python -q
>>> class HungryInt(int):
...     def __add__(self, o):
...             return self
...
>>> x = HungryInt(5)
>>> x + 2
5
>>> 2 + x
7
>>> HungryInt.__radd__ = lambda self, o: self
>>> 2 + x
5
让我们回到PyNumber_Add()。如果binary_op1()成功,则PyNumber_Add()只返回binary_opl()的结果。但是,如果binary_op1()返回NotImplemented常量, 这意味着无法对给定的类型组合执行操作,则PyNumber_Add()调用第一个操作数的sq_concat“sequence”槽并返回此调用的结果:
PySequenceMethods *m = Py_TYPE(v)->tp_as_sequence;
if (m && m->sq_concat) {
    return (*m->sq_concat)(v, w);
}
类型可以通过实现nb_add或sq_concat来支持+运算符。这些插槽具有不同的含义:

  • nbadd表示具有财产的代数加法,如a+b=b+a。
  • sq_concat表示序列的串联。

内置类型如int和float实现nb_add,以及内置类型如str和list实现sq_concat。从技术上讲,没有太大区别。选择一个槽而不是另一个槽的主要原因是为了表明适当的含义。 事实上,sq_concat槽是如此不必要,以至于对于所有用户定义的类型(即类),它都设置为NULL。 我们看到了nb_add槽的用法:它由binary_op1()函数调用。下一步是查看它的设置。

nb_add可以是什么

由于加法对于不同的类型是不同的操作,因此类型的nb_add槽必须是以下两项之一:

  • 它要么是添加该类型对象的特定类型函数;或
  • 它是一个类型不可知的函数,它调用一些特定于类型的函数,例如类型的__add__()特殊方法。

它确实是这两种中的一种,哪一种取决于类型。例如,内置类型(如int和float)有自己的nb_add实现。相反,所有类共享相同的实现。从根本上讲,内置类型和类是同一回事——PyTypeObject的实例。 它们之间的重要区别在于它们是如何创建的。这种差异会影响插槽的设置方式,因此我们应该对此进行讨论。

创建类型的方法

创建类型对象有两种方法:

  • 通过静态定义它;或
  • 通过动态分配它。

静态定义的类型

静态定义类型的一个示例是任何内置类型。例如,以下是CPython如何定义float类型:

PyTypeObject PyFloat_Type = {
    PyVarObject_HEAD_INIT(&PyType_Type, 0)
    "float",
    sizeof(PyFloatObject),
    0,
    (destructor)float_dealloc,                  /* tp_dealloc */
    0,                                          /* tp_vectorcall_offset */
    0,                                          /* tp_getattr */
    0,                                          /* tp_setattr */
    0,                                          /* tp_as_async */
    (reprfunc)float_repr,                       /* tp_repr */
    &float_as_number,                           /* tp_as_number */
    0,                                          /* tp_as_sequence */
    0,                                          /* tp_as_mapping */
    (hashfunc)float_hash,                       /* tp_hash */
    0,                                          /* tp_call */
    0,                                          /* tp_str */
    PyObject_GenericGetAttr,                    /* tp_getattro */
    0,                                          /* tp_setattro */
    0,                                          /* tp_as_buffer */
    Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE,   /* tp_flags */
    float_new__doc__,                           /* tp_doc */
    0,                                          /* tp_traverse */
    0,                                          /* tp_clear */
    float_richcompare,                          /* tp_richcompare */
    0,                                          /* tp_weaklistoffset */
    0,                                          /* tp_iter */
    0,                                          /* tp_iternext */
    float_methods,                              /* tp_methods */
    0,                                          /* tp_members */
    float_getset,                               /* tp_getset */
    0,                                          /* tp_base */
    0,                                          /* tp_dict */
    0,                                          /* tp_descr_get */
    0,                                          /* tp_descr_set */
    0,                                          /* tp_dictoffset */
    0,                                          /* tp_init */
    0,                                          /* tp_alloc */
    float_new,                                  /* tp_new */
};
静态定义类型的插槽是显式指定的。通过查看“number”套件,我们可以很容易地看到float类型是如何实现nb_add的:
static PyNumberMethods float_as_number = {
    float_add,          /* nb_add */
    float_sub,          /* nb_subtract */
    float_mul,          /* nb_multiply */
    // ... more number slots
};
在这里我们可以找到float_add()函数,它是nb_add的一个简单实现:
static PyObject *
float_add(PyObject *v, PyObject *w)
{
    double a,b;
    CONVERT_TO_DOUBLE(v, a);
    CONVERT_TO_DOUBLE(w, b);
    a = a + b;
    return PyFloat_FromDouble(a);
}
浮点运算对我们的讨论来说并不是那么重要。此示例演示如何指定静态定义类型的行为。结果很简单:只需编写槽的实现,并将每个槽指向相应的实现。 如果您想学习如何静态定义自己的类型,请查看Python针对C/C++程序员的教程。

动态分配的类型

动态分配的类型是我们使用class语句定义的类型。正如我们已经说过的,它们是PyTypeObject的实例,就像静态定义的类型一样。传统上,我们称它们为类,但也可以称它们为用户定义的类型。 从程序员的角度来看,在Python中定义类比在C中定义类型更容易。这是因为CPython在创建类时会在幕后做很多事情。让我们看看这个过程中涉及到什么。 如果我们不知道从哪里开始,我们可以采用熟悉的方法:

  1. 定义一个简单类:
    class A:
        pass
    
  2. 运行拆装器:
    $ python -m dis class_A.py
    
  3. 研究VM如何执行生成的字节码指令。

如果你有时间的话,可以随意做,或者阅读Eli Bendersky的课程文章。我们走捷径。 对象是通过调用类型创建的,例如list()或MyClass()。类是通过调用元类型创建的。元类型只是其实例为类型的类型。Python有一个名为PyType_Type的内置元类型, 我们简单地称之为类型。以下是它的定义:

PyTypeObject PyType_Type = {
    PyVarObject_HEAD_INIT(&PyType_Type, 0)
    "type",                                     /* tp_name */
    sizeof(PyHeapTypeObject),                   /* tp_basicsize */
    sizeof(PyMemberDef),                        /* tp_itemsize */
    (destructor)type_dealloc,                   /* tp_dealloc */
    offsetof(PyTypeObject, tp_vectorcall),      /* tp_vectorcall_offset */
    0,                                          /* tp_getattr */
    0,                                          /* tp_setattr */
    0,                                          /* tp_as_async */
    (reprfunc)type_repr,                        /* tp_repr */
    0,                                          /* tp_as_number */
    0,                                          /* tp_as_sequence */
    0,                                          /* tp_as_mapping */
    0,                                          /* tp_hash */
    (ternaryfunc)type_call,                     /* tp_call */
    0,                                          /* tp_str */
    (getattrofunc)type_getattro,                /* tp_getattro */
    (setattrofunc)type_setattro,                /* tp_setattro */
    0,                                          /* tp_as_buffer */
    Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC |
    Py_TPFLAGS_BASETYPE | Py_TPFLAGS_TYPE_SUBCLASS |
    Py_TPFLAGS_HAVE_VECTORCALL,                 /* tp_flags */
    type_doc,                                   /* tp_doc */
    (traverseproc)type_traverse,                /* tp_traverse */
    (inquiry)type_clear,                        /* tp_clear */
    0,                                          /* tp_richcompare */
    offsetof(PyTypeObject, tp_weaklist),        /* tp_weaklistoffset */
    0,                                          /* tp_iter */
    0,                                          /* tp_iternext */
    type_methods,                               /* tp_methods */
    type_members,                               /* tp_members */
    type_getsets,                               /* tp_getset */
    0,                                          /* tp_base */
    0,                                          /* tp_dict */
    0,                                          /* tp_descr_get */
    0,                                          /* tp_descr_set */
    offsetof(PyTypeObject, tp_dict),            /* tp_dictoffset */
    type_init,                                  /* tp_init */
    0,                                          /* tp_alloc */
    type_new,                                   /* tp_new */
    PyObject_GC_Del,                            /* tp_free */
    (inquiry)type_is_gc,                        /* tp_is_gc */
};
所有内置类型的类型都是类型,所有类的类型默认为类型。因此,类型决定了类型的行为方式。例如,当我们调用一个类型(如list()或MyClass())时,会发生什么由类型的tp_call槽指定。 类型的tp_call槽的实现是type_call()函数。它的任务是创建新对象。它调用另外两个插槽来实现这一点:

  1. 它调用类型的tp_new来创建对象。
  2. 它调用类型的tp_init来初始化创建的对象。

类型的类型是类型本身。因此,当我们调用type()时,会调用type_call()函数。当我们向type()传递一个参数时,它会检查特殊情况。在这种情况下,type_call()只返回传递对象的类型:

$ python -q
>>> type(3)
<class 'int'>
>>> type(int)
<class 'type'>
>>> type(type)
<class 'type'>
但是,当我们向type()传递三个参数时,type_call()通过调用上述类型的tp_new和tp_init来创建一个新类型。以下示例演示如何使用type()创建类:
$ python -q
>>> MyClass = type('MyClass', (), {'__str__': lambda self: 'Hey!'})
>>> instance_of_my_class = MyClass()
>>> str(instance_of_my_class)
Hey!
我们传递给type()的参数是:

  1. 类的名称
  2. 其基元组;和
  3. 命名空间。

其他元类型也采用这种形式的参数。 我们看到我们可以通过调用type()来创建类,但这不是我们通常所做的。通常,我们使用class语句来定义类。事实证明,在这种情况下,VM最终也会调用某个元类型,并且通常会调用type()。 为了执行class语句,VM从内置模块调用__build_class__()函数。此函数的作用可概括如下:

  1. 决定要调用哪个元类型来创建类。
  2. 准备命名空间。命名空间将用作类的字典。
  3. 在命名空间中执行类的主体,从而填充命名空间。
  4. 调用元类型。

我们可以使用元类关键字指示__build_class__()它应该调用哪个元类型。如果未指定元类,build_class()默认调用type()。它还考虑了碱基的元类型。 文档中很好地描述了选择元类型的确切逻辑。假设我们定义了一个新类,但没有指定元类。类实际上是在哪里创建的?在这种情况下,build_class()调用type()。 这将调用type_call()函数,该函数依次调用类型为的tp_new和tp_init插槽。类型的tp_new槽指向type_new()函数。这是创建类的函数。类型的tp_init槽指向什么都不做的函数, 因此所有的工作都由type_new()完成。 type_new()函数将近500行长,可能需要单独发布。然而,其本质可以概括如下:

  1. 分配新类型对象。
  2. 设置分配的类型对象。

要完成第一步,type_new()必须分配PyTypeObject的实例以及套件。套件必须与PyTypeObject分开分配,因为PyTypeObject只包含指向套件的指针,而不包含套件本身。 为了处理这种不便,type_new()分配了PyHeapTypeObject结构的一个实例,该结构扩展了PyTypeObject并包含以下套件:

/* The *real* layout of a type object when allocated on the heap */
typedef struct _heaptypeobject {
    PyTypeObject ht_type;
    PyAsyncMethods as_async;
    PyNumberMethods as_number;
    PyMappingMethods as_mapping;
    PySequenceMethods as_sequence;
    PyBufferProcs as_buffer;
    PyObject *ht_name, *ht_slots, *ht_qualname;
    struct _dictkeysobject *ht_cached_keys;
    PyObject *ht_module;
    /* here are optional user slots, followed by the members. */
} PyHeapTypeObject;
设置类型对象意味着设置其插槽。这是type_new()在大多数情况下所做的。

类型初始化

在可以使用任何类型之前,应该使用PyType_Ready()函数对其进行初始化。对于类,PyType_Ready()type_new()调用。对于静态定义的类型, 必须显式调用PyType_Ready()。当 CPython 启动时,它会为每个内置类型调用PyType_Ready()PyType_Ready() 函数做了很多事情。例如,它进行插槽继承。

插槽继承

当我们定义一个继承自其他类型的类时,我们希望该类继承该类型的某些行为。例如,当我们定义一个继承自 int 的类时,我们期望它支持加法:

$ python -q
>>> class MyInt(int):
...     pass
... 
>>> x = MyInt(2)
>>> y = MyInt(4)
>>> x + y
6
MyInt 是否继承了 int 的 nb_add 槽?是的,它确实。从单个祖先继承槽非常简单:只需复制该类没有的那些槽。当一个类有多个基类时,情况会稍微复杂一些。 由于基础可能反过来继承自其他类型,所有这些祖先类型组合起来形成一个层次结构。层次结构的问题在于它没有指定继承顺序。为了解决这个问题,PyType_Ready() 将这个层次结构转换成一个列表。 方法解析顺序 (MRO) 确定如何执行此转换。一旦计算出MRO,一般情况下实现继承就变得容易了。 PyType_Ready() 函数根据 MRO 迭代祖先。从每个祖先, 它复制那些以前没有在类型上设置的插槽。有些插槽支持继承,有些则不支持。您可以查看文档是否继承了特定插槽。 与类相反,静态定义的类型最多可以指定一个基类。这是通过实现 tp_base 插槽来完成的。 如果未指定基数,则 PyType_Ready() 假定对象类型是唯一的基数。每个类型都直接或间接地继承自对象。为什么?因为它实现了每种类型都应具有的插槽。例如, 它实现了 tp_alloc、tp_init 和 tp_repr 槽。

终极问题

到目前为止,我们已经看到了两种设置插槽的方法:

  • 它可以明确指定(如果类型是静态定义的类型)。
  • 它可以从祖先那里继承。

目前还不清楚一个类的槽是如何连接到它的特殊方法的。此外,对于内置类型,我们有一个逆向问题。他们如何实施特殊方法?他们当然会:

$ python -q
>>> (3).__add__(4)
7
我们来到了这篇文章的最终问题:特殊方法和槽之间有什么联系?

特殊方法和槽

答案在于 CPython 保持特殊方法和槽之间的映射。该映射由 slotdefs 数组表示。它看起来像这样:

#define TPSLOT(NAME, SLOT, FUNCTION, WRAPPER, DOC) \
    {NAME, offsetof(PyTypeObject, SLOT), (void *)(FUNCTION), WRAPPER, \
     PyDoc_STR(DOC)}

static slotdef slotdefs[] = {
    TPSLOT("__getattribute__", tp_getattr, NULL, NULL, ""),
    TPSLOT("__getattr__", tp_getattr, NULL, NULL, ""),
    TPSLOT("__setattr__", tp_setattr, NULL, NULL, ""),
    TPSLOT("__delattr__", tp_setattr, NULL, NULL, ""),
    TPSLOT("__repr__", tp_repr, slot_tp_repr, wrap_unaryfunc,
           "__repr__($self, /)\n--\n\nReturn repr(self)."),
    TPSLOT("__hash__", tp_hash, slot_tp_hash, wrap_hashfunc,
           "__hash__($self, /)\n--\n\nReturn hash(self)."),
    // ... more slotdefs
}
该数组的每个条目都是一个 slotdef 结构:
// typedef struct wrapperbase slotdef;

struct wrapperbase {
    const char *name;
    int offset;
    void *function;
    wrapperfunc wrapper;
    const char *doc;
    int flags;
    PyObject *name_strobj;
};
该结构的四个成员对我们的讨论很重要:

  • name 是一个特殊方法的名称。
  • offset 是 PyHeapTypeObject 结构中槽的偏移量。它指定与特殊方法对应的插槽。
  • function是插槽的实现。当定义了一个特殊的方法时,相应的槽被设置为函数。通常,函数调用特殊方法来完成工作。
  • wrapper 是围绕插槽的包装函数。定义插槽时,包装器会为相应的特殊方法提供实现。它调用插槽来完成工作。

例如,这是一个将 add() 特殊方法映射到 nb_add 插槽的条目:

  • 名称是“add”。
  • 偏移量是 offsetof(PyHeapTypeObject, as_number.nb_add)。
  • 函数是 slot_nb_add()。
  • 包装器是 wrap_binaryfunc_l()。

slotdefs 数组是一个多对多映射。例如,正如我们将看到的, add() 和 radd() 特殊方法都映射到同一个 nb_add 槽。相反, mp_subscript“映射”插槽和 sq_item“序列”插槽都映射到相同的 getitem() 特殊方法。

CPython 以两种方式使用 slotdefs 数组:

  • 根据特殊方法设置插槽;和
  • 设置基于插槽的特殊方法。

基于特殊方法的插槽

type_new() 函数调用 fixup_slot_dispatchers() 以根据特殊方法设置插槽。 fixup_slot_dispatchers() 函数为 slotdefs 数组中的每个槽调用 update_one_slot() , 并且 update_one_slot() 将槽设置为在类具有相应的特殊方法时起作用。 我们以 nb_add 插槽为例。 slotdefs 数组有两个对应于该插槽的条目:

static slotdef slotdefs[] = {
    // ...
    BINSLOT("__add__", nb_add, slot_nb_add, "+"),
    RBINSLOT("__radd__", nb_add, slot_nb_add,"+"),
    // ...
}
BINSLOT() 和 RBINSLOT() 是宏。让我们扩展它们:
static slotdef slotdefs[] = {
    // ...
    // {name, offset, function,
    //     wrapper, doc}
    // 
    {"__add__", offsetof(PyHeapTypeObject, as_number.nb_add), (void *)(slot_nb_add),
        wrap_binaryfunc_l, PyDoc_STR("__add__" "($self, value, /)\n--\n\nReturn self" "+" "value.")},

    {"__radd__", offsetof(PyHeapTypeObject, as_number.nb_add), (void *)(slot_nb_add),
        wrap_binaryfunc_r, PyDoc_STR("__radd__" "($self, value, /)\n--\n\nReturn value" "+" "self.")},
    // ...
}
update_one_slot() 所做的是查找 class.add() 和 class.radd()。如果定义了其中一个,它会将类的 nb_add 设置为 slot_nb_add()。请注意,两个条目都同意 slot_nb_add() 作为函数。否则,当两者都被定义时,我们会发生冲突。

现在,你问什么是 slot_nb_add()?此函数使用扩展如下的宏定义:

static PyObject *
slot_nb_add(PyObject *self, PyObject *other) {
    PyObject* stack[2];
    PyThreadState *tstate = _PyThreadState_GET();
    _Py_static_string(op_id, "__add__");
    _Py_static_string(rop_id, "__radd__");
    int do_other = !Py_IS_TYPE(self, Py_TYPE(other)) && \
        Py_TYPE(other)->tp_as_number != NULL && \
        Py_TYPE(other)->tp_as_number->nb_add == slot_nb_add;
    if (Py_TYPE(self)->tp_as_number != NULL && \
        Py_TYPE(self)->tp_as_number->nb_add == slot_nb_add) {
        PyObject *r;
        if (do_other && PyType_IsSubtype(Py_TYPE(other), Py_TYPE(self))) {
            int ok = method_is_overloaded(self, other, &rop_id);
            if (ok < 0) {
                return NULL;
            }
            if (ok) {
                stack[0] = other;
                stack[1] = self;
                r = vectorcall_maybe(tstate, &rop_id, stack, 2);
                if (r != Py_NotImplemented)
                    return r;
                Py_DECREF(r); do_other = 0;
            }
        }
        stack[0] = self;
        stack[1] = other;
        r = vectorcall_maybe(tstate, &op_id, stack, 2);
        if (r != Py_NotImplemented || Py_IS_TYPE(other, Py_TYPE(self)))
            return r;
        Py_DECREF(r);
    }
    if (do_other) {
        stack[0] = other;
        stack[1] = self;
        return vectorcall_maybe(tstate, &rop_id, stack, 2);
    }
    Py_RETURN_NOTIMPLEMENTED;
}
您无需仔细研究此代码。回想调用 nb_add 槽的 binary_op1() 函数。 slot_nb_add()函数基本上重复了binary_op1()的逻辑。主要区别在于 slot_nb_add() 最终会调用 add() 或 radd()。

在现有类上设置特殊方法

假设我们创建了一个没有 add() 和 radd() 特殊方法的类。在这种情况下,类的 nb_add 槽被设置为 NULL。正如预期的那样,我们无法添加该类的实例。然而,如果我们在创建类之后设置 add() 或 radd() ,那么添加就好像该方法是类定义的一部分一样。这就是我的意思:

$ python -q
>>> class A:
...     pass
... 
>>> x = A()
>>> x + 2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'A' and 'int'
>>> A.__add__ = lambda self, o: 5
>>> x + 2
5
>>> 
这是如何运作的?要在对象上设置属性,VM 会调用对象类型的 tp_setattro 槽。类型的 tp_setattro 插槽指向 type_setattro() 函数,因此当我们在类上设置属性时,会调用此函数。它将属性的值存储在类的字典中。然后它检查该属性是否是一个特殊方法,如果是,则通过调用 update_one_slot() 函数设置相应的插槽。

在我们了解 CPython 如何进行反向操作(即它如何向内置类型添加特殊方法)之前,我们需要了解什么是方法。

方法

方法是一种属性,但是是一种特殊的属性。当我们从实例调用方法时,该方法隐式接收实例作为其第一个参数,我们通常将其表示为 self:

$ python -q
>>> class A:
...     def method(self, x):
...             return self, x
...
>>> a = A()
>>> a.method(1)
(<__main__.A object at 0x10d10bfd0>, 1)
但是当我们从类中调用相同的方法时,我们必须显式传递所有参数:
>>> A.method(a, 1)
(<__main__.A object at 0x10d10bfd0>, 1)
在我们的示例中,该方法在一种情况下采用一个参数,在另一种情况下采用两个参数。根据我们访问它的方式,同一个属性怎么可能是不同的东西呢?

首先,要意识到我们在类上定义的方法只是一个函数。通过实例访问的函数不同于通过实例类型访问的相同函数,因为函数类型实现了描述符协议。如果您不熟悉描述符,我强烈建议您阅读 Raymond Hettinger 撰写的 Descriptor HowTo Guide。简而言之,描述符是一个对象,当用作属性时,它自己决定如何获取、设置和删除它。从技术上讲,描述符是一个实现了 get()、set() 或 delete() 特殊方法的对象。

函数类型实现了 get()。当我们查找某个方法时,我们得到的是调用 get() 的结果。传递给它的三个参数:

  • 一个属性,即一个函数
  • 一个实例
  • 实例的类型。

如果我们查找某个类型的方法,实例为 NULL,而 get() 只是返回该函数。如果我们在一个实例上查找一个方法, get() 返回一个方法对象:

>>> type(A.method)
<class 'function'>
>>> type(a.method)
<class 'method'>
方法对象存储函数和实例。调用时,它将实例添加到参数列表中并调用函数。

现在我们准备好解决最后一个问题。

基于槽的特殊方法

回想一下初始化类型和进行槽继承的 PyType_Ready() 函数。它还根据已实现的插槽向类型添加特殊方法。 PyType_Ready() 调用 add_operators() 来做到这一点。 add_operators() 函数迭代 slotdefs 数组中的条目。对于每个条目,它检查条目指定的特殊方法是否应该添加到类型的字典中。如果尚未定义特殊方法并且类型实现条目指定的槽,则添加特殊方法。例如,如果 add() 特殊方法未在类型上定义,但该类型实现了 nb_add 槽,则 add_operators() 将 add() 放入该类型的字典中。

add() 设置为什么?与任何其他方法一样,它必须设置为某个描述符才能表现得像一个方法。程序员定义的方法是函数,而 add_operators() 设置的方法是包装器描述符。包装器描述符是一个存储两件事的描述符:

  • 它存储一个包装槽。包装槽“完成”特殊方法的工作。例如,float 类型的 add() 特殊方法的包装器描述符将 float_add() 存储为包装槽。
  • 它存储一个包装函数。包装函数“知道”如何调用包装的插槽。它是 slotdef 条目的包装器。

当我们调用由 add_operators() 添加的特殊方法时,我们调用了包装器描述符。当我们调用包装器描述符时,它会调用包装器函数。包装器描述符将传递给包装器函数的参数与我们传递给特殊方法的相同参数加上包装槽。最后,包装函数调用包装槽。

让我们看看实现 nb_add 槽的内置类型如何获得它的 add() 和 radd() 特殊方法。回忆一下nb_add对应的slotdef条目:

static slotdef slotdefs[] = {
    // ...
    // {name, offset, function,
    //     wrapper, doc}
    // 
    {"__add__", offsetof(PyHeapTypeObject, as_number.nb_add), (void *)(slot_nb_add),
        wrap_binaryfunc_l, PyDoc_STR("__add__" "($self, value, /)\n--\n\nReturn self" "+" "value.")},

    {"__radd__", offsetof(PyHeapTypeObject, as_number.nb_add), (void *)(slot_nb_add),
        wrap_binaryfunc_r, PyDoc_STR("__radd__" "($self, value, /)\n--\n\nReturn value" "+" "self.")},
    // ...
}
如果一个类型实现了 nb_add 槽,则 add_operators() 将该类型的 add() 设置为包装描述符,其中 wrap_binaryfunc_l() 作为包装函数,nb_add 作为包装槽。它类似地设置类型的 radd(),但有一个例外:包装函数是 wrap_binaryfunc_r()。

wrap_binaryfunc_l() 和 wrap_binaryfunc_r() 都采用两个操作数和一个包装槽作为它们的参数。唯一的区别是他们如何称呼插槽:

  • wrap_binaryfunc_l(x, y, slot_func) 调用 slot_func(x, y)
  • wrap_binaryfunc_r(x, y, slot_func) 调用 slot_func(y, x)。

这个调用的结果就是我们调用特殊方法时得到的结果。

总结

今天,我们揭开了 Python 最神奇的一面的神秘面纱。我们已经了解到,Python 对象的行为是由对象类型的槽位决定的。可以显式指定静态定义类型的槽, 任何类型都可以从其祖先那里继承一些槽。真正的见解是类的槽是由 CPython 基于定义的特殊方法自动设置的。 CPython 也做相反的事情。如果类型实现了相应的槽, 它会向类型的字典中添加特殊方法。

我们学到了很多。然而,Python 对象系统是一个如此庞大的主题,至少有同样多的内容有待涵盖。例如,我们还没有真正讨论过属性是如何工作的。这就是我们下次要做的。

评论