深入理解 Python 虛擬機器:元組(tuple)的實現原理及原始碼剖析

一無是處的研究僧發表於2023-03-11

深入理解 Python 虛擬機器:元組(tuple)的實現原理及原始碼剖析

在本篇文章當中主要給大家介紹 cpython 虛擬機器當中針對列表的實現,在 Python 中,tuple 是一種非常常用的資料型別,在本篇文章當中將深入去分析這一點是如何實現的。

元組的結構

在這一小節當中主要介紹在 python 當中元組的資料結構:

typedef struct {
    PyObject_VAR_HEAD
    PyObject *ob_item[1];

    /* ob_item contains space for 'ob_size' elements.
     * Items must normally not be NULL, except during construction when
     * the tuple is not yet visible outside the function that builds it.
     */
} PyTupleObject;

#define PyObject_VAR_HEAD      PyVarObject ob_base;
typedef struct {
    PyObject ob_base;
    Py_ssize_t ob_size; /* Number of items in variable part */
} PyVarObject;

typedef struct _object {
    _PyObject_HEAD_EXTRA
    Py_ssize_t ob_refcnt;
    struct _typeobject *ob_type;
} PyObject;

從上面的資料結構來看和 list 的資料結構基本上差不多,最終的使用方法也差不多。將上面的結構體展開之後,PyTupleObject 的結構大致如下所示:

現在來解釋一下上面的各個欄位的含義:

  • Py_ssize_t,一個整型資料型別。

  • ob_refcnt,表示物件的引用記數的個數,這個對於垃圾回收很有用處,後面我們分析虛擬機器中垃圾回收部分在深入分析。

  • ob_type,表示這個物件的資料型別是什麼,在 python 當中有時候需要對資料的資料型別進行判斷比如 isinstance, type 這兩個關鍵字就會使用到這個欄位。

  • ob_size,這個欄位表示這個元組當中有多少個元素。

  • ob_item,這是一個指標,指向真正儲存 python 物件資料的地址,大致的記憶體他們之間大致的記憶體佈局如下所示:

需要注意的是元組的陣列大小是不能夠進行更改的,這一點和 list 不一樣,我們可以注意到在 list 的資料結構當中還有一個 allocated 欄位,但是在元組當中是沒有的,這主要是因為元組的陣列大小是固定的,而列表的陣列大小是可以更改的。

元組操作函式原始碼剖析

建立元組

首先我們需要了解一下在 cpython 內部關於元組記憶體分配的問題,首先和 list 一樣,在 cpython 當中對於分配的好的元組進行釋放的時候,並不會直接進行釋放,而是會先儲存下來,當下次又有元組申請記憶體的時候,直接將這塊記憶體進行返回即可。

在 cpython 內部會進行快取的元組大小為 20,如果元組的長度為 0 - 19 那麼在申請分配記憶體之後釋放並不會直接釋放,而是將其先儲存下來,下次有需求的時候直接分配,而不需要申請。在 cpython 內部,相關的定義如下所示:

static PyTupleObject *free_list[PyTuple_MAXSAVESIZE];
static int numfree[PyTuple_MAXSAVESIZE];
  • free_list,儲存指標——指向被釋放的元組。
  • numfree,對應的下標表示元組當中元素的個數,numfree[i] 表示有 i 個元素的元組的個數。

下面是新建 tuple 物件的源程式:

PyObject *
PyTuple_New(Py_ssize_t size)
{
    PyTupleObject *op;
    Py_ssize_t i;
    if (size < 0) {
        PyErr_BadInternalCall();
        return NULL;
    }
#if PyTuple_MAXSAVESIZE > 0
    // 如果申請一個空的元組物件 當前的 free_list 當中是否存在空元組物件 如果存在則直接返回
    if (size == 0 && free_list[0]) k
        op = free_list[0];
        Py_INCREF(op);
        return (PyObject *) op;
    }
    // 如果元組的物件元素個數小於 20 而且對應的 free_list 當中還有餘下的元組物件 則不需要進行記憶體申請直接返回
    if (size < PyTuple_MAXSAVESIZE && (op = free_list[size]) != NULL) {
        free_list[size] = (PyTupleObject *) op->ob_item[0];
        numfree[size]--;
        /* Inline PyObject_InitVar */
        _Py_NewReference((PyObject *)op); // _Py_NewReference 這個宏是將物件 op 的引用計數設定成 1
    }
    else
#endif
    {
        /* Check for overflow */
        // 如果元組的元素個數大或者等於 20 或者 當前 free_list 當中沒有沒有剩餘的物件則需要進行記憶體申請
        if ((size_t)size > ((size_t)PY_SSIZE_T_MAX - sizeof(PyTupleObject) -
                    sizeof(PyObject *)) / sizeof(PyObject *)) {
          	// 如果元組長度大於某個值直接報記憶體錯誤
            return PyErr_NoMemory();
        }
        // 申請元組大小的記憶體空間
        op = PyObject_GC_NewVar(PyTupleObject, &PyTuple_Type, size);
        if (op == NULL)
            return NULL;
    }
		// 初始化記憶體空間
    for (i=0; i < size; i++)
        op->ob_item[i] = NULL;
#if PyTuple_MAXSAVESIZE > 0
    // 因為 size == 0 的元組不會進行修改操作 因此可以直接將這個申請到的物件放到 free_list 當中以備後續使用
    if (size == 0) {
        free_list[0] = op;
        ++numfree[0];
        Py_INCREF(op);          /* extra INCREF so that this is never freed */
    }
#endif
    _PyObject_GC_TRACK(op); // _PyObject_GC_TRACK 這個宏是將物件 op 將入到垃圾回收佇列當中
    return (PyObject *) op;
}

新建元組物件的流程如下所示:

  • 檢視 free_list 當中是否已經存在空閒的元組,如果有則直接進行返回。
  • 如果沒有,則進行記憶體分配,然後將申請的記憶體空間進行初始化操作。
  • 如果 size == 0,則可以將新分配的元組物件放到 free_list 當中。

檢視元組的長度

這個功能比較簡單,直接只用 cpython 當中的宏 Py_SIZE 即可。他的宏定義為 #define Py_SIZE(ob) (((PyVarObject*)(ob))->ob_size)。

static Py_ssize_t
tuplelength(PyTupleObject *a)
{
    return Py_SIZE(a);
}

元組當中是否包含資料

這個其實和 list 一樣,就是遍歷元組當中的資料,然後進行比較即可。

static int
tuplecontains(PyTupleObject *a, PyObject *el)
{
    Py_ssize_t i;
    int cmp;

    for (i = 0, cmp = 0 ; cmp == 0 && i < Py_SIZE(a); ++i)
        cmp = PyObject_RichCompareBool(el, PyTuple_GET_ITEM(a, i),
                                           Py_EQ);
    return cmp;
}

獲取和設定元組中的資料

這兩個方法也比較簡單,首先檢查資料型別是不是元組型別,然後判斷是否越界,之後就返回資料,或者設定對應的資料。

這裡在設定資料資料的時候需要注意一點的是,當設定新的資料的時候,原來的 python 物件引用計數需要減去一,同理如果設定沒有成功的話傳入的新的資料的引用計數也需要減去一。

PyObject *
PyTuple_GetItem(PyObject *op, Py_ssize_t i)
{
    if (!PyTuple_Check(op)) {
        PyErr_BadInternalCall();
        return NULL;
    }
    if (i < 0 || i >= Py_SIZE(op)) {
        PyErr_SetString(PyExc_IndexError, "tuple index out of range");
        return NULL;
    }
    return ((PyTupleObject *)op) -> ob_item[i];
}

int
PyTuple_SetItem(PyObject *op, Py_ssize_t i, PyObject *newitem)
{
    PyObject *olditem;
    PyObject **p;
    if (!PyTuple_Check(op) || op->ob_refcnt != 1) {
        Py_XDECREF(newitem);
        PyErr_BadInternalCall();
        return -1;
    }
    if (i < 0 || i >= Py_SIZE(op)) {
        Py_XDECREF(newitem);
        PyErr_SetString(PyExc_IndexError,
                        "tuple assignment index out of range");
        return -1;
    }
    p = ((PyTupleObject *)op) -> ob_item + i;
    olditem = *p;
    *p = newitem;
    Py_XDECREF(olditem);
    return 0;
}

釋放元組記憶體空間

當我們在進行垃圾回收的時候,判定一個物件的引用計數等於 0 的時候就需要釋放這塊記憶體空間(相當於解構函式),下面就是釋放 tuple 記憶體空間的函式。

static void
tupledealloc(PyTupleObject *op)
{
    Py_ssize_t i;
    Py_ssize_t len =  Py_SIZE(op);
    PyObject_GC_UnTrack(op); // PyObject_GC_UnTrack 將物件從垃圾回收佇列當中移除
    Py_TRASHCAN_SAFE_BEGIN(op) 
    if (len > 0) {
        i = len;
        while (--i >= 0)
            // 將這個元組指向的物件的引用計數減去一
            Py_XDECREF(op->ob_item[i]);
#if PyTuple_MAXSAVESIZE > 0
        // 如果這個元組物件滿足加入 free_list  的條件,則將這個元組物件加入到 free_list 當中
        if (len < PyTuple_MAXSAVESIZE &&
            numfree[len] < PyTuple_MAXFREELIST &&
            Py_TYPE(op) == &PyTuple_Type)
        {
            op->ob_item[0] = (PyObject *) free_list[len];
            numfree[len]++;
            free_list[len] = op;
            goto done; /* return */
        }
#endif
    }
    Py_TYPE(op)->tp_free((PyObject *)op);
done:
    Py_TRASHCAN_SAFE_END(op)
}

將元組的記憶體空間回收的時候,主要有以下幾個步驟:

  • 將元組物件從垃圾回收連結串列當中移除。
  • 將元組指向的所有物件的引用計數減一。
  • 判斷元組是否滿足儲存到 free_list 當中的條件,如果滿足就將他加入到 free_list 當中去,否則回收這塊記憶體。加入到 free_list 當中整個元組當中 ob_item 指向變化如下所示:

  • 如果不能夠將釋放的元組物件加入到 free_list 當中,否則將記憶體釋放回收。

總結

在本篇文章當中主要介紹了在 cpython 當中是如何實現 tuple 的,以及相關的資料結構和一些基本的使用函式,最後簡單談了關於元組記憶體釋放的問題,這裡面還是涉及一些其他的知識點,不能夠在這篇文章進行分析,在本文記憶體分配主要是聚焦在元組身上,主要是分析記憶體分配和 tuple 的 free_list 是如何互動的。


本篇文章是深入理解 python 虛擬機器系列文章之一,文章地址:https://github.com/Chang-LeHung/dive-into-cpython

更多精彩內容合集可訪問專案:https://github.com/Chang-LeHung/CSCore

關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演算法與資料結構)知識。

相關文章