首頁 > 軟體

Python物件的生命週期原始碼學習

2022-05-17 19:00:31

思考:

當我們輸入這個語句的時候,Python內部是如何去建立這個物件的?

a = 1.0

物件使用完畢,銷燬的時機又是怎麼確定的呢?

下面,我們以一個基本型別float為例,來分析物件從建立到銷燬這整個生命週期中的行為。

1 C API

Python是用C寫的,對外提供了API,讓使用者可以從C環境中與其互動,並且Python內部也大量使用了這些API。C API分為兩類:泛型API以及特型API。

泛型API:與型別無關,屬於抽象物件層,這類API的引數是PyObject *,即可以處理任意型別的物件。以PyObject_Print為例:

// 列印浮點物件
PyObject *fo = PyFloat_FromDouble(3.14);
PyObject_Print(fo, stdout, 0);
// 列印整數物件
PyObject *lo = PyLong_FromLong(100);
PyObject_Print(lo, stdout, 0);

特型API:與型別相關,屬於具體物件層,這類API只能作用於某種型別的物件

2 物件的建立

2.1 兩種建立物件的方式

Python內部一般通過兩種方法建立物件:

通過C API,多用於內建型別

以浮點型別為例,Python內部提供PyFloat_FromDouble,這是一個特型C API,在這個介面內部為PyFloatObject結構體變數分配記憶體,並初始化相關欄位:

PyObject *
PyFloat_FromDouble(double fval)
{
    PyFloatObject *op = free_list;
    if (op != NULL) {
        free_list = (PyFloatObject *) Py_TYPE(op);
        numfree--;
    } else {
        op = (PyFloatObject*) PyObject_MALLOC(sizeof(PyFloatObject));
        if (!op)
            return PyErr_NoMemory();
    }
    /* Inline PyObject_New */
    (void)PyObject_INIT(op, &PyFloat_Type);
    op->ob_fval = fval;
    return (PyObject *) op;
}

通過型別物件,多用於自定義型別

對於自定義型別,Python就無法事先提供C API了,這種情況下就只能通過型別物件中包含的後設資料(分配多少記憶體,如何初始化等等)來建立範例物件。

由型別物件建立範例物件是一個更通用的流程,對於內建型別,除了通過C API來建立物件意外,同樣也可以通過型別物件來建立。以浮點型別為例,我們通過型別物件float,建立了一個範例物件f:

f: float = float('3.123')

2.2 由型別物件建立範例物件

思考:既然我們可以通過型別物件來建立範例物件,那麼型別物件中應該存在相應的介面。

在PyType_Type中找到了tp_call欄位:

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 */
    // ...
    (ternaryfunc)type_call,                     /* tp_call */
    // ...
};

因此,float(‘3.123’)在C層面就等價於:

PyFloat_Type.ob_type.tp_call(&PyFloat_Type, args. kwargs)

這裡大家可以思考下為什麼是PyFloat_Type.ob_type——因為我們在float(‘3.14’)中是通過float這個型別物件去建立一個浮點物件,而物件的通用方法是由它對應的型別管理的,自然float的型別就是type,所以我們要找的就是type的tp_call欄位。

type_call函數的C原始碼:(只列出部分)

static PyObject *
type_call(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
    PyObject *obj;
    // ...
    obj = type->tp_new(type, args, kwds);
    obj = _Py_CheckFunctionResult((PyObject*)type, obj, NULL);
    if (obj == NULL)
        return NULL;
    // ...
    type = Py_TYPE(obj);
    if (type->tp_init != NULL) {
        int res = type->tp_init(obj, args, kwds);
        if (res < 0) {
            assert(PyErr_Occurred());
            Py_DECREF(obj);
            obj = NULL;
        }
        else {
            assert(!PyErr_Occurred());
        }
    }
    return obj;
}

其中有兩個關鍵的步驟:(這兩個步驟大家應該是很熟悉的)

  • 呼叫型別物件的tp_new函數指標,用於申請記憶體;
  • 如果型別物件的tp_init函數指標不為空,則會對物件進行初始化。

總結:(以float為例)

  • 呼叫float,Python最終執行的是其型別物件type的tp_call指標指向的type_call函數。
  • type_call函數呼叫float的tp_new函數為範例物件分配記憶體空間。
  • type_call函數必要時進一步呼叫tp_init函數對範例物件進行初始化。

圖示如下:

3 物件的多型性

通過型別物件建立範例物件,最後會落實到呼叫type_call函數,其中儲存具體物件時,使用的是PyObject *obj,並沒有通過一個具體的物件(例如PyFloatObject)來儲存。這樣做的好處是:可以實現更抽象的上層邏輯,而不用關心物件的實際型別和實現細節。(記得當初從C語言的程式導向向Java中的物件導向過度的時候,應該就是從結構體)

以物件雜湊值計算為例,有這樣一個函數介面:

Py_hash_t
PyObject_Hash(PyObject *v)
{
    // ...
}

對於浮點數物件和整數物件:

PyObject *fo = PyFloatObject_FromDouble(3.14);
PyObject_Hash(fo);
PyObject *lo = PyLongObject_FromLong(100);
PyObject_Hash(lo);

可以看到,對於浮點數物件和整數物件,我們計算物件的雜湊值時,呼叫的都是PyObject_Hash()這個函數,但是物件型別不同,其行為是有區別的,雜湊值計算也是如此。

那麼在PyObject_Hash函數內部是如何區分的呢?

PyObject_Hash()函數具體邏輯:

Py_hash_t
PyObject_Hash(PyObject *v)
{
    PyTypeObject *tp = Py_TYPE(v);
    if (tp->tp_hash != NULL)
        return (*tp->tp_hash)(v);
    /* To keep to the general practice that inheriting
     * solely from object in C code should work without
     * an explicit call to PyType_Ready, we implicitly call
     * PyType_Ready here and then check the tp_hash slot again
     */
    if (tp->tp_dict == NULL) {
        if (PyType_Ready(tp) < 0)
            return -1;
        if (tp->tp_hash != NULL)
            return (*tp->tp_hash)(v);
    }
    /* Otherwise, the object can't be hashed */
    return PyObject_HashNotImplemented(v);
}

函數會首先通過Py_TYPE找到物件的型別,然後通過型別物件的tp_hash函數指標來呼叫對應的雜湊計算函數。

即:PyObject_Hash()函數根據物件的型別,呼叫不同的函數版本,這就是多型。

4 物件的行為

除了tp_hash欄位,PyTypeObject結構體還定義了很多函數指標,這些指標最終都會指向某個函數,或者為空。我們可以把這些函數指標看作是型別物件中定義的操作,這些操作決定了對應的範例物件在執行時的行為。

雖然不同的型別物件中儲存了對應範例物件共有的行為,但是不同型別的物件也會存在一些共性。例如:整數物件和浮點數物件都支援加減乘除等擦歐總,元組物件和列表物件都支援下標操作。因此,我們以行為為分類標準,對物件進行分類:

Python以此為依據,為每個類別都定義了一個標準操作集:

  • PyNumberMethods結構體定義了數值型操作
  • PySequenceMethods結構體定義了序列型操作
  • PyMappingMethods結構體定義了關聯型操作

如果型別物件提供了相關的操作集,則對應的範例物件就具備對應的行為:

typedef struct _typeobject {
    PyObject_VAR_HEAD
    const char *tp_name; /* For printing, in format "<module>.<name>" */
    Py_ssize_t tp_basicsize, tp_itemsize; /* For allocation */
   // ...
    PyNumberMethods *tp_as_number;
    PySequenceMethods *tp_as_sequence;
    PyMappingMethods *tp_as_mapping;
    // ...
} PyTypeObject;

以float為例,型別物件PyFloat_Type的這三個欄位是這樣初始化的:

PyTypeObject PyFloat_Type = {
    PyVarObject_HEAD_INIT(&PyType_Type, 0)
    "float",
    sizeof(PyFloatObject),
    // ...
    &float_as_number,                           /* tp_as_number */
    0,                                          /* tp_as_sequence */
    0,                                          /* tp_as_mapping */
    // ...
};

可以看到,只有tp_as_number非空,即float物件支援數值型操作,不支援序列型操作和關聯型操作。

5 參照計數

在Python中,很多場景都涉及參照計數的調整:

  • 變數賦值
  • 函數引數傳遞
  • 屬性操作
  • 容器操作

參照計數是Python生命週期中很關鍵的一個知識點,後續我會用一個單獨的章節來介紹,這裡咱們先按下不表,更多關於Python物件生命週期的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com