深入理解 Python 虛擬機器:列表(list)的實現原理及原始碼剖析

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

深入理解 Python 虛擬機器:列表(list)的實現原理及原始碼剖析

在本篇文章當中主要給大家介紹 cpython 虛擬機器當中針對列表的實現,在 Python 中,List 是一種非常常用的資料型別,可以儲存任何型別的資料,並且支援各種操作,如新增、刪除、查詢、切片等,在本篇文章當中將深入去分析這一點是如何實現的。

列表的結構

在 cpython 實現的 python 虛擬機器當中,下面就是 cpython 內部列表實現的原始碼:

typedef struct {
    PyObject_VAR_HEAD
    /* Vector of pointers to list elements.  list[0] is ob_item[0], etc. */
    PyObject **ob_item;

    /* ob_item contains space for 'allocated' elements.  The number
     * currently in use is ob_size.
     * Invariants:
     *     0 <= ob_size <= allocated
     *     len(list) == ob_size
     *     ob_item == NULL implies ob_size == allocated == 0
     * list.sort() temporarily sets allocated to -1 to detect mutations.
     *
     * Items must normally not be NULL, except during construction when
     * the list is not yet visible outside the function that builds it.
     */
    Py_ssize_t allocated;
} PyListObject;

#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;

將上面的結構體展開之後,PyListObject 的結構大致如下所示:

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

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

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

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

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

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

  • allocated,這個表示在進行記憶體分配的時候,一共分配了多少個 (PyObject *) ,真實分配的記憶體空間為 allocated * sizeof(PyObject *)

列表操作函式原始碼分析

建立列表

首先需要了解的是在 python 虛擬機器內部為列表建立了一個陣列,所有的建立的被釋放的記憶體空間,並不會直接進行釋放而是會將這些記憶體空間的首地址儲存到這個陣列當中,好讓下一次申請建立新的列表的時候不需要再申請記憶體空間,而是直接將之前需要釋放的記憶體直接進行復用即可。

/* Empty list reuse scheme to save calls to malloc and free */
#ifndef PyList_MAXFREELIST
#define PyList_MAXFREELIST 80
#endif
static PyListObject *free_list[PyList_MAXFREELIST];
static int numfree = 0;
  • free_list,儲存被釋放的記憶體空間的首地址。
  • numfree,目前 free_list 當中有多少個地址是可以被使用的,事實上是 free_list 前 numfree 個首地址是可以被使用的。

建立連結串列的程式碼如下所示(為了精簡刪除了一些程式碼只保留核心部分):

PyObject *
PyList_New(Py_ssize_t size)
{
    PyListObject *op;
    size_t nbytes;

    /* Check for overflow without an actual overflow,
     *  which can cause compiler to optimise out */
    if ((size_t)size > PY_SIZE_MAX / sizeof(PyObject *))
        return PyErr_NoMemory();
    nbytes = size * sizeof(PyObject *);
  // 如果 numfree 不等於 0 那麼說明現在 free_list 有之前使用被釋放的記憶體空間直接使用這部分即可
    if (numfree) {
        numfree--;
        op = free_list[numfree]; // 將對應的首地址返回
        _Py_NewReference((PyObject *)op); // 這條語句的含義是將 op 這個物件的 reference count 設定成 1
    } else {
      // 如果沒有空閒的記憶體空間 那麼就需要申請記憶體空間 這個函式也會對物件的 reference count 進行初始化 設定成 1
        op = PyObject_GC_New(PyListObject, &PyList_Type);
        if (op == NULL)
            return NULL;
    }
  /* 下面是申請列表物件當中的 ob_item 申請記憶體空間,上面只是給列表本身申請記憶體空間,但是列表當中有許多元素
  	儲存這些元素也是需要記憶體空間的 下面便是給這些物件申請記憶體空間
  */
    if (size <= 0)
        op->ob_item = NULL;
    else {
        op->ob_item = (PyObject **) PyMem_MALLOC(nbytes);
      // 如果申請記憶體空間失敗 則報錯
        if (op->ob_item == NULL) {
            Py_DECREF(op);
            return PyErr_NoMemory();
        }
      // 對元素進行初始化操作 全部賦值成 0
        memset(op->ob_item, 0, nbytes);
    }
  // Py_SIZE 是一個宏
    Py_SIZE(op) = size; // 這條語句會被展開成 (PyVarObject*)(ob))->ob_size = size
  // 分配陣列的元素個數是 size
    op->allocated = size;
  // 下面這條語句對於垃圾回收比較重要 主要作用就是將這個列表物件加入到垃圾回收的連結串列當中
  // 後面如果這個物件的 reference count 變成 0 或者其他情況 就可以進行垃圾回收了
    _PyObject_GC_TRACK(op);
    return (PyObject *) op;
}

在 cpython 當中,建立連結串列的位元組碼為 BUILD_LIST,我們可以在檔案 ceval.c 當中找到對應的位元組碼對應的執行步驟:

TARGET(BUILD_LIST) {
    PyObject *list =  PyList_New(oparg);
    if (list == NULL)
        goto error;
    while (--oparg >= 0) {
        PyObject *item = POP();
        PyList_SET_ITEM(list, oparg, item);
    }
    PUSH(list);
    DISPATCH();
}

從上面 BUILD_LIST 位元組碼對應的解釋步驟可以知道,在解釋執行位元組碼 BUILD_LIST 的時候確實呼叫了函式 PyList_New 建立一個新的列表。

列表 append 函式

static PyObject *
// 這個函式的傳入引數是列表本身 self 需要 append 的元素為 v
  // 也就是將物件 v 加入到列表 self 當中
listappend(PyListObject *self, PyObject *v)
{
    if (app1(self, v) == 0)
        Py_RETURN_NONE;
    return NULL;
}

static int
app1(PyListObject *self, PyObject *v)
{
  // PyList_GET_SIZE(self) 展開之後為 ((PyVarObject*)(self))->ob_size
    Py_ssize_t n = PyList_GET_SIZE(self);

    assert (v != NULL);
  // 如果元素的個數已經等於允許的最大的元素個數 就報錯
    if (n == PY_SSIZE_T_MAX) {
        PyErr_SetString(PyExc_OverflowError,
            "cannot add more objects to list");
        return -1;
    }
	// 下面的函式 list_resize 會儲存 ob_item 指向的位置能夠容納最少 n+1 個元素(PyObject *)
  // 如果容量不夠就會進行擴容操作
    if (list_resize(self, n+1) == -1)
        return -1;
		
  // 將物件 v 的 reference count 加一 因為列表當中使用了一次這個物件 所以物件的引用計數需要進行加一操作
    Py_INCREF(v);
    PyList_SET_ITEM(self, n, v); // 宏展開之後 ((PyListObject *)(op))->ob_item[i] = v
    return 0;
}

列表的擴容機制

static int
list_resize(PyListObject *self, Py_ssize_t newsize)
{
    PyObject **items;
    size_t new_allocated;
    Py_ssize_t allocated = self->allocated;

    /* Bypass realloc() when a previous overallocation is large enough
       to accommodate the newsize.  If the newsize falls lower than half
       the allocated size, then proceed with the realloc() to shrink the list.
    */
  // 如果列表已經分配的元素個數大於需求個數 newsize 的就直接返回不需要進行擴容
    if (allocated >= newsize && newsize >= (allocated >> 1)) {
        assert(self->ob_item != NULL || newsize == 0);
        Py_SIZE(self) = newsize;
        return 0;
    }

    /* This over-allocates proportional to the list size, making room
     * for additional growth.  The over-allocation is mild, but is
     * enough to give linear-time amortized behavior over a long
     * sequence of appends() in the presence of a poorly-performing
     * system realloc().
     * The growth pattern is:  0, 4, 8, 16, 25, 35, 46, 58, 72, 88, ...
     */
  // 這是核心的陣列大小擴容機制 new_allocated 表示新增的陣列大小
    new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6);

    /* check for integer overflow */
    if (new_allocated > PY_SIZE_MAX - newsize) {
        PyErr_NoMemory();
        return -1;
    } else {
        new_allocated += newsize;
    }

    if (newsize == 0)
        new_allocated = 0;
    items = self->ob_item;
    if (new_allocated <= (PY_SIZE_MAX / sizeof(PyObject *)))
      	// PyMem_RESIZE 這是一個宏定義 會申請 new_allocated 個數元素並且將原來陣列的元素複製到新的陣列當中
        PyMem_RESIZE(items, PyObject *, new_allocated);
    else
        items = NULL;
  // 如果沒有申請到記憶體 那麼報錯
    if (items == NULL) {
        PyErr_NoMemory();
        return -1;
    }
  // 更新列表當中的元素資料
    self->ob_item = items;
    Py_SIZE(self) = newsize;
    self->allocated = new_allocated;
    return 0;
}

在上面的擴容機制下,陣列的大小變化大致如下所示:

\[newsize \approx size \cdot (size + 1)^{\frac{1}{8}} \]

列表的插入函式 insert

在列表當中插入一個資料比較簡單,只需要將插入位置和其後面的元素往後移動一個位置即可,整個過程如下所示:

在 cpython 當中列表的插入函式的實現如下所示:

  • 引數 op 表示往哪個連結串列當中插入元素。
  • 引數 where 表示在連結串列的哪個位置插入元素。
  • 引數 newitem 表示新插入的元素。
int
PyList_Insert(PyObject *op, Py_ssize_t where, PyObject *newitem)
{
  // 檢查是否是列表型別
    if (!PyList_Check(op)) {
        PyErr_BadInternalCall();
        return -1;
    }
  // 如果是列表型別則進行插入操作
    return ins1((PyListObject *)op, where, newitem);
}

static int
ins1(PyListObject *self, Py_ssize_t where, PyObject *v)
{
    Py_ssize_t i, n = Py_SIZE(self);
    PyObject **items;
    if (v == NULL) {
        PyErr_BadInternalCall();
        return -1;
    }
  // 如果列表的元素個數超過限制 則進行報錯
    if (n == PY_SSIZE_T_MAX) {
        PyErr_SetString(PyExc_OverflowError,
            "cannot add more objects to list");
        return -1;
    }
  // 確保列表能夠容納 n + 1 個元素
    if (list_resize(self, n+1) == -1)
        return -1;
  // 這裡是 python 的一個小 trick 就是下標能夠有負數的原理
    if (where < 0) {
        where += n;
        if (where < 0)
            where = 0;
    }
    if (where > n)
        where = n;
    items = self->ob_item;
  // 從後往前進行元素的複製操作,也就是將插入位置及其之後的元素往後移動一個位置
    for (i = n; --i >= where; )
        items[i+1] = items[i];
  // 因為連結串列應用的物件,因此物件的 reference count 需要進行加一操作
    Py_INCREF(v);
  // 在列表當中儲存物件 v 
    items[where] = v;
    return 0;
}

列表的刪除函式 remove

對於陣列 ob_item 來說,刪除一個元素就需要將這個元素後面的元素往前移動,因此整個過程如下所示:

static PyObject *
listremove(PyListObject *self, PyObject *v)
{
    Py_ssize_t i;
  	// 編譯陣列 ob_item 查詢和物件 v 相等的元素並且將其刪除
    for (i = 0; i < Py_SIZE(self); i++) {
        int cmp = PyObject_RichCompareBool(self->ob_item[i], v, Py_EQ);
        if (cmp > 0) {
            if (list_ass_slice(self, i, i+1,
                               (PyObject *)NULL) == 0)
                Py_RETURN_NONE;
            return NULL;
        }
        else if (cmp < 0)
            return NULL;
    }
  	// 如果沒有找到這個元素就進行報錯處理 在下面有一個例子重新編譯 python 直譯器 將這個錯誤內容修改的例子
    PyErr_SetString(PyExc_ValueError, "list.remove(x): x not in list");
    return NULL;
}

執行的 python 程式內容為:

data = []
data.remove(1)

下面是整個修改內容和報錯結果:

從上面的結果我們可以看到的是,我們修改的錯誤資訊正確列印了出來。

列表的統計函式 count

這個函式的主要作用就是統計列表 self 當中有多少個元素和 v 相等。

static PyObject *
listcount(PyListObject *self, PyObject *v)
{
    Py_ssize_t count = 0;
    Py_ssize_t i;

    for (i = 0; i < Py_SIZE(self); i++) {
        int cmp = PyObject_RichCompareBool(self->ob_item[i], v, Py_EQ);
      // 如果相等則將 count 進行加一操作
        if (cmp > 0)
            count++;
      // 如果出現錯誤就返回 NULL
        else if (cmp < 0)
            return NULL;
    }
  // 將一個 Py_ssize_t 的變數變成 python 當中的物件
    return PyLong_FromSsize_t(count);
}

列表的複製函式 copy

這是列表的淺複製函式,它只複製了真實 python 物件的指標,並沒有複製真實的 python 物件 ,從下面的程式碼可以知道列表的複製是淺複製,當 b 對列表當中的元素進行修改時,列表 a 當中的元素也改變了。如果需要進行深複製可以使用 copy 模組當中的 deepcopy 函式。

>>> a = [1, 2, [3, 4]]
>>> b = a.copy()
>>> b[2][1] = 5
>>> b
[1, 2, [3, 5]]

copy 函式對應的原始碼(listcopy)如下所示:

static PyObject *
listcopy(PyListObject *self)
{
    return list_slice(self, 0, Py_SIZE(self));
}

static PyObject *
list_slice(PyListObject *a, Py_ssize_t ilow, Py_ssize_t ihigh)
{
  // Py_SIZE(a) 返回列表 a 當中元素的個數(注意不是陣列的長度 allocated)
    PyListObject *np;
    PyObject **src, **dest;
    Py_ssize_t i, len;
    if (ilow < 0)
        ilow = 0;
    else if (ilow > Py_SIZE(a))
        ilow = Py_SIZE(a);
    if (ihigh < ilow)
        ihigh = ilow;
    else if (ihigh > Py_SIZE(a))
        ihigh = Py_SIZE(a);
    len = ihigh - ilow;
    np = (PyListObject *) PyList_New(len);
    if (np == NULL)
        return NULL;

    src = a->ob_item + ilow;
    dest = np->ob_item;
  // 可以看到這裡迴圈複製的是指向真實 python 物件的指標 並不是真實的物件
    for (i = 0; i < len; i++) {
        PyObject *v = src[i];
      // 同樣的因為並沒有建立新的物件,但是這個物件被新的列表使用到啦 因此他的 reference count 需要進行加一操作 Py_INCREF(v) 的作用:將物件 v 的 reference count 加一
        Py_INCREF(v);
        dest[i] = v;
    }
    return (PyObject *)np;
}

下圖就是使用 a.copy() 淺複製的時候,記憶體的佈局的示意圖,可以看到列表指向的物件陣列發生了變化,但是陣列中元素指向的 python 物件並沒有發生變化。

下面是對列表物件進行深複製的時候記憶體的大致示意圖,可以看到陣列指向的 python 物件也是不一樣的。

列表的清空函式 clear

當我們在使用 list.clear() 的時候會呼叫下面這個函式。清空列表需要注意的就是將表示列表當中元素個數的 ob_size 欄位設定成 0 ,同時將列表當中所有的物件的 reference count 設定進行 -1 操作,這個操作是透過宏 Py_XDECREF 實現的,這個宏還會做另外一件事就是如果這個物件的引用計數變成 0 了,那麼就會直接釋放他的記憶體。

static PyObject *
listclear(PyListObject *self)
{
    list_clear(self);
    Py_RETURN_NONE;
}

static int
list_clear(PyListObject *a)
{
    Py_ssize_t i;
    PyObject **item = a->ob_item;
    if (item != NULL) {
        /* Because XDECREF can recursively invoke operations on
           this list, we make it empty first. */
        i = Py_SIZE(a);
        Py_SIZE(a) = 0;
        a->ob_item = NULL;
        a->allocated = 0;
        while (--i >= 0) {
            Py_XDECREF(item[i]);
        }
        PyMem_FREE(item);
    }
    /* Never fails; the return value can be ignored.
       Note that there is no guarantee that the list is actually empty
       at this point, because XDECREF may have populated it again! */
    return 0;
}

列表反轉函式 reverse

在 python 當中如果我們想要反轉類表當中的內容的話,就會使用這個函式 reverse 。

>>> a = [i for i in range(10)]
>>> a.reverse()
>>> a
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

其對應的源程式如下所示:

static PyObject *
listreverse(PyListObject *self)
{
    if (Py_SIZE(self) > 1)
        reverse_slice(self->ob_item, self->ob_item + Py_SIZE(self));
    Py_RETURN_NONE;
}

static void
reverse_slice(PyObject **lo, PyObject **hi)
{
    assert(lo && hi);

    --hi;
    while (lo < hi) {
        PyObject *t = *lo;
        *lo = *hi;
        *hi = t;
        ++lo;
        --hi;
    }
}

上面的源程式還是比較容易理解的,給 reverse_slice 傳遞的引數就是儲存資料的陣列的首尾地址,然後不斷的將首尾資料進行交換(其實是交換指標指向的地址)。

總結

本文介紹了 Python 中列表物件的實現細節,介紹了一些常用函式的實現,包括列表的擴容機制,插入、刪除、統計、複製、清空和反轉等操作的實現方式。

  • 列表的擴容機制採用了一種線性時間攤銷的方式,使得列表的插入操作具有較好的時間複雜度。
  • 列表的插入、刪除和統計操作都是透過操作ob_item 陣列實現的,其中插入和刪除操作需要移動陣列中的元素。
  • 列表的複製操作是淺複製,需要注意的是進行深複製需要使用 copy 模組當中的 deepcopy 函式。
  • 列表清空會將 ob_size 欄位設定成 0,同時需要將列表當中的所有物件的 reference count 進行 -1 操作,從而避免記憶體洩漏。
  • 列表的反轉操作可以透過交換 ob_item 陣列中前後元素的位置實現。

總之,瞭解 Python 列表物件的實現細節有助於我們更好地理解 Python 的內部機制,從而編寫更高效、更可靠的 Python 程式碼。


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

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

相關文章