首頁 > 軟體

Python內建型別list原始碼學習

2022-05-17 19:00:50

問題:

“深入認識Python內建型別”這部分的內容會從原始碼角度為大家介紹Python中各種常用的內建型別。

list是日常開發中最常用的內建型別之一,掌握好它的底層知識,無論是對資料結構基礎知識的理解,還是對開發效率的提升,應該都是有一定幫助的

  • list物件支援哪些操作?時間複雜度、空間複雜度分別是多少?
  • 試分析append和insert這兩個典型方法的時間複雜度。
  • 頭部新增元素時效能較差,如何解決?

1 常用方法

大家對於list應該是比較熟悉的,我們先列舉一些常用的方法:

append:向尾部追加元素

>>> l = [1, 2, 3]
>>> l.append(4)
>>> l
[1, 2, 3, 4]

pop:彈出元素(預設從尾部彈出元素,也可以通過index引數從指定位置彈出)

>>> l = [1, 2, 3, 4]
>>> l.pop()
4
>>> l
[1, 2, 3]
>>> l = [1, 2, 3, 4]
>>> l.pop(0)  # 指定index
1
>>> l
[2, 3, 4]

insert:在指定位置插入元素

>>> l = [1, 2, 3]
>>> l.insert(0, 4)
>>> l
[4, 1, 2, 3]

index:查詢指定元素第一次出現位置的下標

>>> l = [1, 2, 3, 4]
>>> l.index(1)
0

extend:用一個可迭代物件擴充套件列表——元素逐一追加到尾部

>>> l = [1, 2, 3]
>>> l.extend({1: 2, 3: 4})
>>> l
[1, 2, 3, 1, 3]

count:計算元素出現的次數

>>> l = [1, 2, 3, 1]
>>> l.count(1)
2
>>> l.count(2)
1

reverse:將列表反轉(注意這裡是直接在原列表上進行操作,可以和切片區分下)

>>> l = [1, 2, 3]
>>> l.reverse()
>>> l
[3, 2, 1]

clear:將列表清空

>>> l = [1, 2, 3]
>>> l.clear()
>>> l
[]

小結:

list的操作總體比較簡單,但是要注意的是:由於list底層是由陣列實現的,對應的各類插入和刪除操作就會由於陣列的特型而在複雜度上有所差別,例如:通過insert()在頭部插入元素時,需要挪動整個列表,此時時間複雜度為O(n),而append()直接在尾部插入元素時,時間複雜度為O(1)。在使用時要注意時空複雜度問題(後續我會結合原始碼詳細介紹)。

題外話:

list的操作有很多,後續我會對典型操作進行原始碼分析,還有很多操作可能就需要大家自行去學習瞭解了,但是本質上都是大同小異的,掌握好陣列以及Python對此在原始碼處理上的一些技巧就能熟練掌握了。這裡我結合自己的學習、工作、面試經歷,總結了一點問題和心得:

  • list為什麼可以使用負數索引——從原始碼角度看是因為判斷了索引輸入為負數的情況,會做一個相應的處理:索引值+列表長度。例如:索引為-1,列表長度為3,最終的索引就是2,也就是最後一個元素的索引。對比一下Java中的陣列,如果使用負數索引,會報錯索引越界,在網上查能否有相關方法實現負數索引:有人說用一個類來包裝,也有的說用一個字典去對映負數和正確的索引,也有的直接給出了無法做到的答案。相關的做法應該是有很多的,但同時也不要忽視了安全性等相關問題。這裡提出這個點也是我自己覺得比較有趣的一個地方吧,“索引值+列表長度”其實是一個很簡單的做法,但總感覺又有那麼一點一般人想不出來的巧妙,hh
  • 以pop()和insert()為例,這兩個方法有一個共同的引數:索引。對於pop(),如果索引值大於等於列表長度,則會報錯;而對於insert(),如果索引值大於等於列表長度則統一將元素插入到列表最後。從原始碼角度看其實就是insert()對於索引引數做了一個判斷處理,當它大於列表長度時,會將索引值更改為列表長度,例如:index = 5,而list = [1, 2, 3],原始碼處理時,會將index置為3。所以如果開發者樂意的話,應該可以對pop()也採用相同的操作,我個人認為這裡應該是有其他的考慮的,例如安全性等問題,當然也有可能只是寫成了這樣而已,hh
  • reverse(),reversed(),切片的區別。個人認為本質上這三者其實沒啥關係,大家如果容易弄混可能還是因為不夠熟練。重點問題可能在切片以及深拷貝、淺拷貝的問題上,後續我會詳細介紹相關內容。
  • append()和extend()的區別。面試題好像會問這個,也是很基礎的知識了。如果換個角度問可能會更有趣些:l.append(3)和l.extend(3)的效果是不是一樣的?答:不一樣,後者會直接報錯,hh

不知不覺就寫了這麼多,相比上次寫str相關的內容還是要順暢多了(捂臉)。相關的小知識應該是還有很多的,大家可以在學習的過程中多找一些問題和資料來融會貫通,當然我個人認為如果能把底層的邏輯搞清楚,其他的應該都不成問題~

2 list的內部結構:PyListObject

原始碼如下:

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;

原始碼分析:

  • ob_item:二維指標,指向動態陣列的指標,陣列儲存元素物件指標
  • allocated:動態陣列總長度,即列表當前的容量
  • ob_size:當前元素個數,即列表當前的長度

這裡出現了一些新的概念:動態陣列,容量。後續我會對此進行介紹,這裡大家對照示意圖應該就能有比較初步的認識了:

3 尾部操作和頭部操作

認識了list的底層結構之後,這裡在強調一下尾部操作和頭部操作的一些問題,鞏固一下陣列相關的資料結構知識,也順便引出一些動態陣列的相關內容。

3.1 尾部操作

假設列表物件 l 內部陣列長度為5,當前儲存了2個元素,分別是1,2。當我們呼叫append()方法向尾部追加元素時,由於內部陣列還未用滿,只需將新元素儲存於下一個可用位置,並更新ob_size欄位。因此,絕大多數情況下,append()方法的效能都足夠好,時間複雜度為O(1)。

若list物件內部陣列已滿,則再新增元素時就需要進行擴容。append()等方法在操作時都會對內部陣列進行檢查,如需擴容,則會重新分配一個長度更大的陣列並替換舊陣列。因此,當使用append()方法遇到擴容時,會將列表元素從舊陣列拷貝到新陣列,此時時間複雜度為O(n)。

個人想法:這裡引出這個問題是因為我個人當時在學習到這個知識點時,意識到了一個問題:怎麼擴容。如果沒記錯的話Java面試題中也會經常聞到動態擴容。這裡拋開面試不談,我的想法主要是在怎麼去避免頻繁擴容,畢竟擴容一次就需要拷貝所有的元素。這在日常開發中應該也是一個需要大家關注的問題:像這種類似的消耗突增的操作,我們需要去避免,降低它的觸發頻率,但同時也不能忽視時空上的消耗。

3.2 頭部操作

與尾部相比,由於在列表頭部增刪元素需要挪動其他元素,效能差別就很大了(陣列的基礎知識)

這裡既然提到了頭部操作,我們來看一下Python中的佇列:

通過list實現棧是很好的,但是用來實現佇列是很不合理的:(LeetCode上的用棧實現佇列除外,hh)

q = []
q.append(1)
q.append(2)
q.append(3)
# deque
q.pop(0)

這樣的佇列實現,在出隊操作時會移動所有佇列元素(除pop掉的元素以外),效能很差

如果需要頻繁進行頭部操作,可以使用標準庫中的deque,這是一種雙端佇列結構:

from collections import deque
q = deque()
# enqueue
q.append(job)
# deque
q.popleft()

4 淺拷貝和深拷貝

淺拷貝和深拷貝應該是容器相關的一個比較重要的知識點了,無論是Python還是Java中都會涉及,面試中應該也比較常見。(其實可以單獨寫一篇部落格來介紹這部分內容,這裡就先放在了list中來介紹,後續會考慮重新總結歸納出一篇部落格)

4.1 淺拷貝

呼叫list物件的copy方法,可以將列表拷貝一份,生成一個新的列表,但是不會拷貝列表中的元素。結合原始碼來說就是:會拷貝一下底層陣列中指向具體元素物件的指標,但是元素物件不會被拷貝。

下面通過對淺拷貝後的列表進行修改來實際體會一下:

範例1:

>>> l1 = [1, [1, 2, 3]]
>>> l1
[1, [1, 2, 3]]
# 淺拷貝
>>> l2 = l1.copy()
>>> l2
[1, [1, 2, 3]]
# 修改新列表中的可變元素
>>> l2[1][0] = 6
>>> l2
[1, [6, 2, 3]]
>>> l1
[1, [6, 2, 3]]

範例2:

>>> l1 = [1, [1, 2, 3]]
>>> l1
[1, [1, 2, 3]]
# 淺拷貝
>>> l2 = l1.copy()
>>> l2
[1, [1, 2, 3]]
# 修改新列表中的不可變元素
>>> l2[0] = 2
>>> l2
[2, [1, 2, 3]]
>>> l1
[1, [1, 2, 3]]

範例3:

>>> l1 = [1, [1, 2, 3]]
>>> l1
[1, [1, 2, 3]]
# 淺拷貝
>>> l2 = l1.copy()
>>> l2
[1, [1, 2, 3]]
# 這裡要注意並沒有修改可變元素[1, 2, 3],而是將l2[1]處的指標修改了,指向了一個新物件[3, 4]
>>> l2[1] = [3, 4]
>>> l2
[2, [3, 4]]
>>> l1
[1, [1, 2, 3]]

list物件的底層陣列儲存的是元素物件的指標,copy()方法複製底層陣列時,拷貝的也是元素物件的指標,而不會拷貝具體的元素物件。因此,新舊列表物件的陣列中的指標指向的還是相同的物件。圖示如下:

4.2 深拷貝

通過copy模組中的deepcopy()函數可以進行深拷貝,deepcopy()函數會遞迴複製所有容器物件,確保新舊列表不會包含同一個容器物件(這裡要注意元組是比較特殊的,稍微會說明)

下面通過對深拷貝後的列表進行修改來實際體會一下:

>>> from copy import deepcopy
>>> l1 = [1, [1, 2, 3]]
>>> l1
[1, [1, 2, 3]]
# 深拷貝
>>> l2 = deepcopy(l1)
>>> l2
[1, [1, 2, 3]]
>>> l2[1][0] = 5
>>> l2
[1, [5, 2, 3]]
>>> l1
[1, [1, 2, 3]]
>>> l2[0] = 2
>>> l2
[2, [5, 2, 3]]
>>> l1
[1, [1, 2, 3]]

圖示如下:

4.3 直接賦值

除了深拷貝、淺拷貝外,最常見的操作就是直接賦值,即物件的參照(別名),這裡就不涉及到複製操作了。

4.4 小結

直接賦值:b = a

淺拷貝:b = a.copy()

深拷貝:b = copy.deepcopy(a)

個人總結:

弄清楚list底層陣列中儲存的是指向對應元素的指標,以及深拷貝時的遞迴,我覺得就能對相關的知識點有一個比較清晰的認識了。但是對於Python中的元組需要特殊考慮,它是一個不可變的容器,當元組中含有可變資料型別的容器和不含時,深拷貝的情況是有區別的。

本質上元組是不會去建立新物件的,因為它不可變(這裡我沒有找到百分百的證據,主要是根據經驗和查到的資料,大家可以去看一下原始碼),但是當元組中還有可變資料型別的容器,就又會由於深拷貝遞回去拷貝相應的物件而導致重新建立一個新的元組物件。

這裡可能比較繞,大家可以自行去列印看一下結果。但是我個人覺得核心就是“弄清楚list底層陣列中儲存的是指向對應元素的指標,以及深拷貝時的遞迴”。

TODO:

後續我會重新寫一篇部落格來專門將深淺拷貝的原始碼列出來,來看看為什麼對於元組就會特殊處理。

要是我們的列表元素是自定義的資料型別又是如何處理的。深拷貝遞迴複製時判斷的條件到底是如何寫的。

5 動態陣列

這一部分的內容也是這篇部落格最主要的重點:分析list底層陣列的特性。前面我們已經介紹了list的常用操作、底層結構,以及以list為例簡單介紹了深淺拷貝。這一小節會結合原始碼詳細介紹list底層動態陣列的特性,我個人覺得這一設計也傳達了我們在業務開發上的一個比較常用和關鍵的思想。

5.1 容量調整

前面已經提到,當我們呼叫append()、pop()、insert()等方法時,列表長度會發生變化。當列表長度超過底層陣列容量時,便需要對底層陣列進行擴容;當列表長度低於底層陣列容量並且達到某個值時,便需要對底層陣列進行縮容。

append()等方法是依賴list_resize()函數來調整列表長度的。原始碼如下:

static int
list_resize(PyListObject *self, Py_ssize_t newsize)
{
    PyObject **items;
    size_t new_allocated, num_allocated_bytes;
    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.
    */
    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, ...
     * Note: new_allocated won't overflow because the largest possible value
     *       is PY_SSIZE_T_MAX * (9 / 8) + 6 which always fits in a size_t.
     */
    new_allocated = (size_t)newsize + (newsize >> 3) + (newsize < 9 ? 3 : 6);
    if (new_allocated > (size_t)PY_SSIZE_T_MAX / sizeof(PyObject *)) {
        PyErr_NoMemory();
        return -1;
    }

    if (newsize == 0)
        new_allocated = 0;
    num_allocated_bytes = new_allocated * sizeof(PyObject *);
    items = (PyObject **)PyMem_Realloc(self->ob_item, num_allocated_bytes);
    if (items == NULL) {
        PyErr_NoMemory();
        return -1;
    }
    self->ob_item = items;
    Py_SIZE(self) = newsize;
    self->allocated = new_allocated;
    return 0;
}

原始碼分析:

重要引數:

  • items指標:用於儲存新陣列
  • new_allocated:用於儲存新陣列容量
  • num_allocated_bytes:用於儲存新陣列記憶體大小,單位為位元組
  • allocated:用於儲存舊陣列容量

重要步驟:

第12行:檢查新長度與底層陣列容量的大小關係。如果新長度不超過現有陣列容量,並且大於等於現有容量的一半,則不會更新陣列容量,只修改ob_size即可。從這裡我們可以得知list物件的擴縮容條件:

  • 擴容條件:新長度大於底層陣列容量
  • 縮容條件:新長度小於底層陣列容量的一半

第27行:新容量=新長度+1/8*新長度+3或6(取決於新長度是否小於9)

第28~31行:如果新容量超過了允許的最大值,則返回錯誤

第33~34行:如果新長度為0,則新容量也為0,此時底層陣列為空

第36~40行:呼叫PyMem_Realloc()函數重新分配底層陣列

第41~44行:依次設定新底層陣列、新長度、新容量

注:

在擴容時先增加1/8,再增加3或6,是為了有效限制擴容頻率,避免list物件長度較小時會頻繁擴容(我個人在工作中沒有遇到需要考慮類似問題的需求,都是在寫業務程式碼(捂臉),但是我認為這種思想還是值得學習的)

PyMem_Realloc()函數是Python內部實現的記憶體管理函數之一,功能和C的庫函數realloc()類似。主要步驟如下:

PyAPI_FUNC(void *) PyMem_Realloc(void *ptr, size_t new_size);

新申請一塊大小為new_size的記憶體區域將資料從舊記憶體區域ptr拷貝到新記憶體區域釋放舊記憶體區域ptr返回新記憶體區域

圖示如下:

5.2 append()

append()方法在Python內部由C函數list_append()實現,而list_append()進一步呼叫app1()函數完成元素追加

app1()函數原始碼如下:

static int
app1(PyListObject *self, PyObject *v)
{
    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;
    }

    if (list_resize(self, n+1) < 0)
        return -1;

    Py_INCREF(v);
    PyList_SET_ITEM(self, n, v);
    return 0;
}

原始碼分析:

第4行:呼叫PyList_GET_SIZE()獲取列表當前長度,即ob_size

第7~11行:如果列表當前長度達到了最大值PY_SSIZE_T_MAX,則報錯

第13~14行:呼叫list_resize(),必要時進行擴容

第16行:增加元素物件參照計數(這裡是列表參照了該元素物件)

第17行:呼叫PyList_SET_ITEM()將元素物件指標儲存在列表的最後一個位置

5.3 insert()

insert()方法在Python內部由C函數list_insert_impl()實現,而list_insert_impl()進一步呼叫ins1()函數完成元素插入

ins1()函數原始碼如下:

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

    if (list_resize(self, n+1) < 0)
        return -1;

    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];
    Py_INCREF(v);
    items[where] = v;
    return 0;
}

原始碼分析:

第19~25行:檢查插入位置下標,如果下標為負數,則加上n將其轉化為非負數。如果轉化後的where值仍然小於0,則代表越界,但是這裡會直接將where設定為0(即插入到列表頭部),而沒有報錯。若下標為正數,也會判斷是否越界,越界時則插入到列表尾部,同樣不會報錯。

第26~28行:將插入位置以後的元素逐一往後移動一個位置。(這裡的for迴圈是從後往前迭代的)

5.4 pop()

pop()方法將指定下標的元素從列表中彈出,下標預設為-1,即預設彈出最後一個元素

pop()方法在Python內部由list_pop_impl()函數實現。原始碼如下:

static PyObject *
list_pop_impl(PyListObject *self, Py_ssize_t index)
/*[clinic end generated code: output=6bd69dcb3f17eca8 input=b83675976f329e6f]*/
{
    PyObject *v;
    int status;

    if (Py_SIZE(self) == 0) {
        /* Special-case most common failure cause */
        PyErr_SetString(PyExc_IndexError, "pop from empty list");
        return NULL;
    }
    if (index < 0)
        index += Py_SIZE(self);
    if (index < 0 || index >= Py_SIZE(self)) {
        PyErr_SetString(PyExc_IndexError, "pop index out of range");
        return NULL;
    }
    v = self->ob_item[index];
    if (index == Py_SIZE(self) - 1) {
        status = list_resize(self, Py_SIZE(self) - 1);
        if (status >= 0)
            return v; /* and v now owns the reference the list had */
        else
            return NULL;
    }
    Py_INCREF(v);
    status = list_ass_slice(self, index, index+1, (PyObject *)NULL);
    if (status < 0) {
        Py_DECREF(v);
        return NULL;
    }
    return v;
}

原始碼分析:

第12~13行:如果給定下標為負數,則加上列表長度,轉化為普通下標

第14~16行:檢查下標是否合法,如果越界則報錯

第19~25行:如果待彈出元素為列表的尾部元素,則呼叫list_resize()直接處理(比較巧妙,hh)

第26~31行:其他情況下呼叫list_ass_slice()刪除元素,呼叫前需要通過Py_INCREF增加元素的參照計數,因為在list_ass_slice()函數內部會釋放被刪除元素

5.5 remove()

remove()方法將給定元素從列表中刪除,與pop()不同,remove()直接刪除給定元素,而不是通過下標進行索引

remove()方法在Python內部由C函數list_remove()實現。原始碼如下:

static PyObject *
list_remove(PyListObject *self, PyObject *value)
/*[clinic end generated code: output=f087e1951a5e30d1 input=2dc2ba5bb2fb1f82]*/
{
    Py_ssize_t i;

    for (i = 0; i < Py_SIZE(self); i++) {
        int cmp = PyObject_RichCompareBool(self->ob_item[i], value, 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;
    }
    PyErr_SetString(PyExc_ValueError, "list.remove(x): x not in list");
    return NULL;
}

原始碼分析:

第7行:通過for迴圈查詢目標元素是否在列表中,若不在則會報錯第

10行:通過list_ass_slice()刪除指定元素

6 一些問題

擴容時引數選擇的依據?

答:兼顧記憶體利用率和擴容頻率。

淺拷貝:

static PyObject *
list_remove(PyListObject *self, PyObject *value)
/*[clinic end generated code: output=f087e1951a5e30d1 input=2dc2ba5bb2fb1f82]*/
{
    Py_ssize_t i;

    for (i = 0; i < Py_SIZE(self); i++) {
        int cmp = PyObject_RichCompareBool(self->ob_item[i], value, 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;
    }
    PyErr_SetString(PyExc_ValueError, "list.remove(x): x not in list");
    return NULL;
}

宣告一個空列表l = [],Python是如何為它分配空間的?

答:對於這類問題,有兩種策略:

預先分配一定大小的動態陣列,對於最開始的增加元素操作不需要立即擴容;

開始時不分配動態陣列,第一次新增元素時就會擴容。Python採用的是策略主要是為了將動態陣列的分配推遲到真正需要的時刻,提高記憶體的使用效率。程式設計中的懶載入,作業系統的fork程序後採用的copy on write策略,指導思想都是類似的。(日常開發中這個思想應該也是有一定用處的)

以上就是Python內建型別list原始碼學習的詳細內容,更多關於Python內建型別list的資料請關注it145.com其它相關文章!


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