《深度剖析CPython解釋器》8. 解密Python中列表的底層實現,通過源碼分析列表支持的相關操作


楔子

Python中的列表可以說使用的非常廣泛了,在初學列表的時候,老師會告訴你列表就是一個大倉庫,什么都可以存放。不過在最開始的幾個章節中,我們花了很大的筆墨介紹了Python中的對象,並明白了Python中變量的本質,我們知道列表中存放的元素其實都是泛型指針PyObject *,所以到現在列表已經沒有什么好神秘的了。

並且根據我們使用列表的經驗,我們可以得出以下兩個結論:

  • 每個列表中的元素個數可以不一樣:所以這是一個變長對象
  • 可以對列表中的元素進行添加、刪除、修改等操作,所以這是一個可變對象

在分析列表對應的底層結構之前,我們先來回顧一下列表的使用。

# 創建一個列表,這里是通過Python/C API創建的
>>> lst = [1, 2, 3, 4]
>>> lst
[1, 2, 3, 4]

# 往列表尾部追加一個元素,此時是在本地操作的,返回值為None
# 但是列表被改變了
>>> lst.append(5)
>>> lst
[1, 2, 3, 4, 5]

# 從尾部彈出一個元素,會返回彈出的元素
>>> lst.pop()
5
# 此時列表也會被修改
>>> lst
[1, 2, 3, 4]
# 另外在pop的時候還可以指定索引,彈出指定的元素
>>> lst.pop(0)
1
>>> lst
[2, 3, 4]

# 也可以在指定位置插入一個元素
>>> lst.insert(0, 'x')
>>> lst
['x', 2, 3, 4]

# 通過extend在尾部追加多個元素
>>> lst.extend([7, 8, 9])
>>> lst
['x', 2, 3, 4, 7, 8, 9]

# 查找指定元素第一次出現的位置
>>> lst.index(3)
2

# 計算某個元素在列表中出現的次數
>>> lst.count(3)
2

# 翻轉列表
>>> lst.reverse()
>>> lst
[9, 8, 7, 4, 3, 2, 'x']

# 根據元素的值刪除第一個出現的元素
>>> lst.remove(4)
[9, 8, 7, 3, 2, 'x']

# 清空列表
>>> lst.clear()
>>> lst
[]
>>>

上面的一些操作是列表經常使用的,但是在分析它的實現之前,我們肯定要了解它們的時間復雜度如何。這些東西即使不看源碼,也是必須要知道的,尤其想要成為一名優秀的Python工程師。

  • append:會向尾部追加元素,所以時間復雜度為O(1)
  • pop:默認從尾部彈出元素,所以時間復雜度為O(1);如果不是尾部,而是從其它的位置彈出元素的話,那么該位置后面所有的元素都要向前移動,此時時間復雜度為O(n)
  • insert:向指定位置插入元素,該位置后面的所有元素都要向后移動,所以時間復雜度為O(n)

注意:由於列表里面的元素個數是可以自由變化的,所以列表有一個容量的概念,我們后面會說。當添加元素時,列表可能會擴容;同理當刪除元素時,列表可能會縮容。

下面我們就來看一下列表對應的底層結構。

列表的內部結構--PyListObject

list 對象(列表)Python 內部,由 PyListObject 結構體表示,定義於頭文件 Include/listobject.h 中:

typedef struct {
    PyObject_VAR_HEAD
    PyObject **ob_item;
    Py_ssize_t allocated;
} PyListObject;

我們看到里面有如下成員:

  • PyObject_VAR_HEAD: 變長對象的公共頭部信息
  • ob_item:一個二級指針,指向一個PyObject *類型的指針數組,這個指針數組保存的便是對象的指針,而操作底層數組都是通過ob_item來進行操作的。
  • allocated:容量, 我們知道列表底層是使用了C的數組, 而底層數組的長度就是列表的容量

列表之所以要有容量的概念,是因為列表可以動態添加元素,但是底層的數組在創建完畢之后,其長度卻是固定的。所以一旦添加新元素的時候,發現底層數組已經滿了,這個時候只能申請一個更長的數組,同時把原來數組中的元素依次拷貝到新的數組里面(這一過程就是列表的擴容),然后再將新元素添加進去。但是問題來了,總不可能每添加一個元素,就申請一次數組、將所有元素都拷貝一次吧。所以Python在列表擴容的時候,會將底層數組申請的長一些,可以在添加元素的時候不用每次都申請新的數組。

這便是列表的底層結構示意圖,圖中的object只是單純的代指對象,不是Python中的基類object。我們看到底層數組的長度為5,說明此時列表的容量為5,但是里面只有3個PyObject *指針,說明列表的ob_size是3,或者說列表里面此時有3個元素。注意:盡管底層數組的容量目前是5個,但是我們訪問的時候,最多只能訪問到第三個元素,也就是說索引最大只能是2,這是顯而易見的,因為列表里面只有3個元素。

如果這個時候我們往列表中append一個元素,那么會將這個新元素設置在底層數組中索引為ob_size的位置、或者說第四個位置。一旦設置完,ob_size會自動加1,因為ob_size要和列表的長度保持一致。

如果此時再往列表中append一個元素的話,那么還是將新元素設置在索引為ob_size的位置,此時也就是第5個位置。

列表的容量是5,但此時長度也達到了5,這說明當下一次append的時候已經沒有辦法再容納新的元素了。因為此時列表的長度、或者說元素個數已經達到了容量,當然最直觀的還是這里的底層數組,很明顯全都占滿了。那這個時候如果想再接收新的元素的話,要怎么辦呢?顯然只能擴容了。

原來的容量是5個,長度也是5個,當再來一個新元素的時候由於沒有位置了,所以要擴容。但是擴容的時候肯定會將容量申請的大一些、即底層數組申請的長一些(具體申請多長,Python內部有一個公式,我們后面會說),假設申請的新的底層數組長度是8,那么說明列表的容量就變成了8。然后將原來數組中的PyObject *按照順序依次拷貝到新的數組里面,再讓ob_item指向新的數組。最后將要添加的新元素設置在新的數組中索引為ob_size的位置、即第6個位置,然后將ob_size加1,此時ob_size就變成了6。

以上便是列表底層在擴容的時候所經歷的過程。

由於擴容會申請新的數組,然后將舊數組的元素拷貝到新數組中,所以這是一個時間復雜度為O(n)的操作。而append可能會導致列表擴容,因此append最壞情況下也是一個O(n)的操作,只不過擴容不會頻繁發生,所以append的平均時間復雜度還是O(1)。

另外我們還可以看到一個現象,那就是Python中的列表在底層是分開存儲的,因為PyListObject結構體實例並沒有存儲相應的指針數組,而是存儲了指向這個指針數組的二級指針。顯然我們添加、刪除、修改元素等操作,都是通過ob_item這個二級指針來間接操作這個指針數組。

所以底層對應的PyListObject實例的大小其實是不變的,因為指針數組沒有存在PyListObject里面。但是Python在計算內存大小的時候是會將這個指針數組也算進去的,所以Python中列表的大小是可變的。

而且我們知道,列表在append之后地址是不變的,至於原因上面的幾張圖已經解釋的很清楚了。如果長度沒有達到容量,那么append其實就是往底層數組中設置了一個新元素;如果達到容量了,那么會擴容,但是擴容只是申請一個新的指針數組,然后讓ob_item重新指向罷了。所以底層數組會變,但是PyListObject結構體實例本身是沒有變化的。因此列表無論是append、extend、pop、insert等等,只要是在本地操作,那么它的地址是不會變化的。

下面我們再來看看Python中的列表所占的內存大小是怎么算的:

  • PyObject_VAR_HEAD: 24字節
  • ob_item: 二級指針, 8字節
  • allocated: 8字節

但是不要忘記,在計算列表大小的時候,ob_item指向的指針數組也要算在內。所以:一個列表的大小 = 40 + 8 * 指針數組長度(或者列表容量)。注意是底層數組長度、或者列表容量,可不是列表長度,因為底層數組一旦申請了,不管你用沒用,大小就擺在那里了。就好比你租了間房子,就算不住,房租該交還是得交。

# 顯然一個空數組占40個字節
print([].__sizeof__())  # 40

# 40 + 3 * 8 = 64
print([1, 2, "x" * 1000].__sizeof__())  # 64
# 雖然里面有一個長度為1000的字符串,但我們說列表存放的都是指針, 所以大小都是8字節


# 注意: 我們通過l = [1, 2, 3]這種方式創建列表的話
# 不管內部元素有多少個, 其ob_size和allocated都是一樣的
# 那么列表什么時候會擴容呢? 答案是在添加元素的時候發現容量不夠了才會擴容
lst = list(range(10))
# 40 + 10 * 8 = 120
print(lst.__sizeof__())  # 120
# 這個時候append一個元素
lst.append(123)
print(lst.__sizeof__())  # 184
# 我們發現大小達到了184, (184 - 40) // 8 = 18, 說明擴容之后申請的底層數據的長度為18 

所以列表的大小我們就知道是怎么來的了,而且為什么列表在通過索引定位元素的時候,時間復雜度是O(1)。因為列表中存儲的都是對象的指針,不管對象有多大,其指針大小是固定的,都是8字節。通過索引可以瞬間計算出偏移量,從而找到對應元素的指針,而操作指針會自動操作指針所指向的內存。

print([1, 2, 3].__sizeof__())  # 64
print([[1, 2, 3]].__sizeof__())  # 48

相信上面這個結果,你肯定能分析出原因。因為第一個列表中有3個指針,所以是40 + 24 = 64;而第二個列表中有一個指針,所以是40 + 8 = 48。用一張圖來展示一下[1, 2, 3][[1, 2, 3]]的底層結構,看看它們之間的區別:

分析完PyListObject之后,我們來看看它支持的操作,顯然我們要通過類型對象PyList_Type來查看。

PyTypeObject PyList_Type = {
    PyVarObject_HEAD_INIT(&PyType_Type, 0)
    "list",
    sizeof(PyListObject),
    0,
    (destructor)list_dealloc,                   /* tp_dealloc */
    0,                                          /* tp_vectorcall_offset */
    0,                                          /* tp_getattr */
    0,                                          /* tp_setattr */
    0,                                          /* tp_as_async */
    (reprfunc)list_repr,                        /* tp_repr */
    0,                                          /* tp_as_number */
    &list_as_sequence,                          /* tp_as_sequence */
    &list_as_mapping,                           /* tp_as_mapping */
    //......
};

我們看到,列表支持序列型操作和映射型操作,下面我們就來分析一下。

列表支持的操作

我們看看平常使用的列表所支持的操作在底層是如何實現的。

自動擴容

我們先來說說列表的擴容,因為我們知道列表是會自動擴容的,那么什么時候會擴容呢?我們說列表擴容的時候,是在添加元素時發現底層數組已經滿了的情況下才會擴容。換句話說,一個列表在添加元素的時候會擴容,那么說明在添加元素之前,其內部的元素個數和容量是相等的。然后我們看看底層是怎么實現的,這些操作都位於Objects/listobject.c中。

static int
list_resize(PyListObject *self, Py_ssize_t newsize)
{   //參數self就是列表啦,newsize指的元素在添加之后的ob_size
    //比如列表的ob_size是5,那么在append的時候發現容量不夠,所以會擴容,那么這里的newsize就是6
    //如果是extend添加3個元素,那么這里的newsize就是8
    //當然list_resize這個函數不僅可以擴容,也可以縮容,假設列表原來有1000個元素,這個時候將列表清空了
    //那么容量肯定縮小,不然會浪費內存,如果清空了列表,那么這里的newsize顯然就是0
    
    //items是一個二級指針,顯然是用來指向指針數組的
    PyObject **items;
    //新的容量,以及對應的內存大小
    size_t new_allocated, num_allocated_bytes;
    //獲取原來的容量
    Py_ssize_t allocated = self->allocated;
	
    //如果newsize達到了容量的一半,但還沒有超過容量, 那么意味着newsize、或者新的ob_size和容量是匹配的,所以不會變化
    if (allocated >= newsize && newsize >= (allocated >> 1)) {
        assert(self->ob_item != NULL || newsize == 0);
        //只需要將列表的ob_size設置為newsize即可
        Py_SIZE(self) = newsize;
        return 0;
    }

    //走到這里說明容量和ob_size不匹配了,所以要進行擴容或者縮容。
    //因此要申請新的底層數組,申請多少個?這里給出了公式,一會兒我們可以通過Python進行測試
    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;
    }
	
    //如果newsize為0,那么容量也會變成0,假設將列表全部清空了,容量就會變成0
    if (newsize == 0)
        new_allocated = 0;
    
    //我們說數組中存放的都是PyObject *, 所以要計算內存
    num_allocated_bytes = new_allocated * sizeof(PyObject *);
    //申請相應大小的內存,將其指針交給items
    items = (PyObject **)PyMem_Realloc(self->ob_item, num_allocated_bytes);
    if (items == NULL) {
        //如果items是NULL, 代表申請失敗
        PyErr_NoMemory();
        return -1;
    }
    //然后讓ob_item = items, 也就是指向新的數組, 此時列表就發生了擴容或縮容
    self->ob_item = items;
    //將ob_size設置為newsize, 因為它維護列表內部元素的個數
    Py_SIZE(self) = newsize;
    //將原來的容量大小設置為新的容量大小
    self->allocated = new_allocated;
    return 0;
}

我們看到還是很簡單的,沒有什么黑科技,下面我們就來分析一下列表擴容的時候,容量和元素個數之間的規律。其實在list_resize函數中是有注釋的,其種一行寫着:The growth pattern is: 0, 4, 8, 16, 25, 35, 46, 58, 72, 88, ...

說明我們往一個空列表中不斷append元素的時候,容量會按照上面的規律進行變化,我們來試一下。

# 還記得底層是怎么改變容量的嗎?
# 我們說有一個公式: new_allocated = (size_t)newsize + (newsize >> 3) + (newsize < 9 ? 3 : 6);
# 我們來看一下

lst = []
allocated = 0
print("此時容量是: 0")

for item in range(100):
    lst.append(item)  # 添加元素
    
    # 計算ob_size
    ob_size = len(lst)

    # 判斷ob_size和當前的容量
    if ob_size > allocated:
        # lst的大小減去空列表的大小, 再除以8顯然就是容量的大小, 因為不管你有沒有用, 容量已經分配了
        allocated = (lst.__sizeof__() - [].__sizeof__()) // 8
        print(f"列表擴容啦, 新的容量是: {allocated}")
"""
此時容量是: 0
列表擴容啦, 新的容量是: 4
列表擴容啦, 新的容量是: 8
列表擴容啦, 新的容量是: 16
列表擴容啦, 新的容量是: 25
列表擴容啦, 新的容量是: 35
列表擴容啦, 新的容量是: 46
列表擴容啦, 新的容量是: 58
列表擴容啦, 新的容量是: 72
列表擴容啦, 新的容量是: 88
列表擴容啦, 新的容量是: 106

Process finished with exit code 0
"""

我們看到和官方給的結果是一樣的,顯然這是毫無疑問的,我們根據底層的公式也能算出來。

ob_size = 0
allocated = 0

print(allocated, end=" ")
for item in range(100):
    ob_size += 1
    if ob_size > allocated:
        allocated = ob_size + (ob_size >> 3) + (3 if ob_size < 9 else 6)
        print(allocated, end=" ")
# 0 4 8 16 25 35 46 58 72 88 106 

但還是那句話,擴容是指解釋器發現容量不夠的情況下才會擴容,如果我們直接通過lst = []這種形式創建列表的話,那么其長度和容量是一樣的。

lst = [0] * 1000
# 長度和容量一致
print(len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8)  # 1000 1000

# 但此時添加一個元素的話, 那么ob_size會變成1001, 大於容量1000
# 所以此時列表就要擴容了, 執行list_resize, 里面的new_size就是1001, 然后是怎么分配容量來着
# new_allocated = (size_t)newsize + (newsize >> 3) + (newsize < 9 ? 3 : 6);
print("新容量:", 1001 + (1001 >> 3) + (3 if 1001 < 9 else 6))  # 新容量: 1132

# append一個元素,列表擴容
lst.append(123)
# 計算容量
print((lst.__sizeof__() - [].__sizeof__()) // 8)  # 1132

# 結果是一樣的, 因為底層就是這么實現的, 所以結果必須一樣
# 只不過我們通過這種測試的方式證明了這一點, 也更加了解了底層的結構是什么樣子的。

介紹完擴容,那么介紹縮容,因為列表元素個數要是減少到和容量不匹配的話,也要進行縮容。

舉個生活中的例子,假設你租了10間屋子用於辦公,顯然你要付10間屋子的房租,不管你有沒有住,一旦租了肯定是要付錢的。同理底層數組也是一樣,只要你申請了,不管有沒有元素,內存已經占用了。但有一天你用不到10間屋子了,假設會用8間或者9間,那么會讓剩余的屋子閑下來。但由於退租比較麻煩,並且只閑下來一兩間屋子,所以多余的屋子就不退了,還是會付10間屋子的錢,這樣當沒准哪天又要用的時候就不用重新租了。對於列表也是如此,如果在刪除元素(相當於屋子不用了)的時候發現長度沒有超過容量但是又達到了容量的一半,所以也不會縮容。但是,如果屋子閑了8間,也就是只需要兩間屋子就足夠了,那么此時肯定要退租了,閑了8間,可能會退掉6間。

lst = [0] * 1000
print(len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8)  # 1000 1000

# 刪除500個元素, 此時長度或者說ob_size就為500
lst[500:] = []
# 但是ob_size還是達到了容量的一半, 所以不會縮容
print(len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8)  # 500 1000

# 如果再刪除一個元素的話, 那么不好意思, 顯然就要進行縮容了, 因為ob_size變成了499, 小於1000 // 2
# 縮容之后容量怎么算呢? 還是之前那個公式
print(499 + (499 >> 3) + (3 if 499 < 9 else 6))  # 567

# 測試一下, 刪除一個元素, 看看會不會按照我們期待的規則進行縮容
lst.pop()
print(len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8)  # 499 567

一切都和我們想的是一樣的,另外在代碼中我們還看到一個if語句,就是如果newsize是0,那么容量也是0,我們來測試一下。

lst = [0] * 1000
print(len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8)  # 1000 1000

lst[:] = []
print(len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8)  # 0 0

# 如果按照之前的容量變化公式的話, 會發現結果應該是3, 但是結果是0, 就是因為多了if判斷:如果newsize是0, 就把容量也設置為0
print(0 + (0 >> 3) + (3 if 0 < 9 else 6))  # 3
# 但為什么要這么做呢?因為Python認為, 列表長度為0的話,說明你不想用這個列表了, 所以多余的3個也沒有必要申請了

# 我們還以租房為栗, 如果你一間屋子都不用了, 說明可能你不用這里的屋子辦公了
# 因此多余3間屋子也沒有必要再租了, 所以直接全部退掉

以上就是列表在改變容量時所采用的策略,我們從頭到尾全部分析了一遍。

append追加元素

append方法用於像尾部追加一個元素,我們看看底層實現。

static PyObject *
list_append(PyListObject *self, PyObject *object)
{	
    //顯然調用的app1是核心, 它里面實現了添加元素的邏輯
    //Py_RETURN_NONE是一個宏,表示返回Python中的None, 因為list.append返回的就是None
    if (app1(self, object) == 0)
        Py_RETURN_NONE;
    return NULL;
}

static int
app1(PyListObject *self, PyObject *v)
{	//self是列表,v是要添加的對象
    
    //獲取列表的長度
    Py_ssize_t n = PyList_GET_SIZE(self);
    assert (v != NULL);
    //如果長度已經達到了限制,那么無法再添加了, 會拋出OverflowError
    if (n == PY_SSIZE_T_MAX) {
        PyErr_SetString(PyExc_OverflowError,
            "cannot add more objects to list");
        return -1;
    }
	
    //還記得這個list_resize嗎? self就是列表, n + 1就是newsize,或者說新的ob_size
    //會自動判斷是否要進行擴容, 當然里面還有重要的一步,就是將列表的ob_size設置成newsize、也就是這里的n + 1
    //因為append之后列表長度大小會變化,而ob_size則要時刻維護這個大小
    if (list_resize(self, n+1) < 0)
        return -1;
	
    //因為v作為了列表的一個元素,所以其指向的對象的引用計數要加1
    Py_INCREF(v);
    //然后調用PyList_SET_ITEM,這是一個宏,它的作用就是設置元素的,我們下面會看這個宏長什么樣
    //原來的列表長度為n, 里面的元素的最大索引是n - 1,那么追加的話就等於將元素設置在索引為n的地方
    PyList_SET_ITEM(self, n, v);
    return 0;
}

//我們說PyList_SET_ITEM是用來設置元素的,設置在什么地方呢?顯然是設置在底層數組中
//PyList_SET_ITEM一個宏,除了這個宏之外,還有很多其它的宏,它們位於Inlcude/listobject.h中
#define PyList_GET_ITEM(op, i) (((PyListObject *)(op))->ob_item[i])
#define PyList_SET_ITEM(op, i, v) (((PyListObject *)(op))->ob_item[i] = (v))
#define PyList_GET_SIZE(op)    (assert(PyList_Check(op)),Py_SIZE(op))
//這些宏的作用是啥,一目了然

獲取元素

我們在使用列表的時候,可以通過val = lst[1]這種方式獲取元素,那么底層是如何實現的呢?

static PyObject *
list_subscript(PyListObject* self, PyObject* item)
{	
    //先看item是不是一個整型,顯然這個item除了整型之外,也可以是切片
    if (PyIndex_Check(item)) {
        Py_ssize_t i;
        //這里檢測i是否合法,因為Python的整型是沒有限制的
        //但是列表的長度和容量都是由一個有具體類型的變量維護的,所以其個數肯定是有范圍的
        //所以你輸入一個lst[2 ** 100000]顯然是不行的, 在Python中會報錯IndexError: cannot fit 'int' into an index-sized integer
        i = PyNumber_AsSsize_t(item, PyExc_IndexError);
        
        //設置異常
        if (i == -1 && PyErr_Occurred())
            return NULL;
        
        //如果i小於0, 那么加上列表的長度, 變成正數索引
        if (i < 0)
            i += PyList_GET_SIZE(self);
        //然后調用list_item
        return list_item(self, i);
    }
    else if (PySlice_Check(item)) {
    //......
    }    
    else {
        //......
    }
}


static PyObject *
list_item(PyListObject *a, Py_ssize_t i)
{	
    //檢測索引i的合法性,如果i > 列表的長度, 那么會報出索引越界的錯誤。
    if (!valid_index(i, Py_SIZE(a))) {
        //如果索引為負數也會報出索引越界錯誤,因為上面已經對負數索引做了處理了,但如果負數索引加上長度之后還是個負數, 那么同樣報錯。
        //假設列表長度是5, 你的索引是-100, 加上長度之后是-95,結果還是個負數, 所以也會報錯
        if (indexerr == NULL) {
            indexerr = PyUnicode_FromString(
                "list index out of range");
            if (indexerr == NULL)
                return NULL;
        }
        PyErr_SetObject(PyExc_IndexError, indexerr);
        return NULL;
    }
    //通過ob_item獲取第i個元素
    Py_INCREF(a->ob_item[i]);
    //返回
    return a->ob_item[i];
}

顯然獲取元素的時候不光可以通過索引,還可以通過切片的方式。

static PyObject *
list_subscript(PyListObject* self, PyObject* item)
{
    if (PyIndex_Check(item)) {
        //.......
    }
    else if (PySlice_Check(item)) {
        /*
        start: 切片的起始位置
        end: 切片的結束位置
        step: 切片的步長
        slicelength: 獲取元素個數,比如[1:5:2],顯然slicelength就是2, 因為只能獲取索引為1和3的元素
        cur: 底層數組中元素的索引
        i: 循環變量, 因為切片的話只能循環獲取每一個元素, 比如[1:5:2], 需要循環兩次。第一次循環, 上面的cur就是1, 第二次循環cur就是3
        */
        Py_ssize_t start, stop, step, slicelength, cur, i;
        //返回的結果
        PyObject* result;
        
        //下面代碼中會有所體現
        PyObject* it;
        PyObject **src, **dest;
		
        //對切片item進行解包進行解包, 得到起始位置、結束位置、步長
        if (PySlice_Unpack(item, &start, &stop, &step) < 0) {
            return NULL;
        }
        //計算出slicelength, 因為即便我們指定的切片是[1:3:5], 但如果列表只有3個元素, 所以slicelength也只能是1
        slicelength = PySlice_AdjustIndices(Py_SIZE(self), &start, &stop,
                                            step);
		
        //如果slicelength為0, 那么不好意思, 表示沒有元素可以獲取, 因此直接返回一個空列表即可
        if (slicelength <= 0) {
            //PyList_New表示創建一個PyListObject, 里面的參數表示底層數組的長度
            //另外對於創建列表,Python底層只提供了PyList_New這一種Python/C API
            //當我們執行lst = [1, 2, 3]的時候就會執行PyList_New(3)
            return PyList_New(0);
        }
        //如果步長為1, 那么會調用list_slice,這個函數內部的邏輯很簡單,首先接收一個PyListObject *和兩個整型(ilow, ihigh)
        //然后在內部會創建一個PyListObject *np, 申請相應的底層數組,設置allocated
        //然后將參數列表中索引為ilow的元素到索引為ihigh的元素依次拷貝到np -> ob_item里面, 然后這是ob_size並返回
        else if (step == 1) {
            return list_slice(self, start, stop);
        }
        else {
            //走到這里說明步長不為1, 我們說result是一個PyListObject *, 底層數組沒有存儲在PyListObject中,而是通過ob_item發生關聯
            //所以這一步是申請底層數組、設置容量的,容量就是這里的slicelength, 上面的list_slice中也調用了這一步
            result = list_new_prealloc(slicelength);
            if (!result) return NULL;
			
            //src是一個二級指針, 也就是self -> ob_item
            src = self->ob_item;
            //同理dest是result -> ob_item
            dest = ((PyListObject *)result)->ob_item;
            //進行循環, cur從start開始遍歷, 每次加上step步長
            for (cur = start, i = 0; i < slicelength;
                 cur += (size_t)step, i++) {
                //it就是self -> ob_item中的元素
                it = src[cur];
                //增加指向的對象的引用計數
                Py_INCREF(it);
                //將其設置到dest中
                dest[i] = it;
            }
            //將大小設置為slicelength,說明通過切片創建新列表, 其長度和容量也是一致的
            Py_SIZE(result) = slicelength;
            //返回結果
            return result;
        }
    }
    else {
        //此時說明item不合法
        PyErr_Format(PyExc_TypeError,
                     "list indices must be integers or slices, not %.200s",
                     item->ob_type->tp_name);
        return NULL;
    }
}

我們發現這個和字符串類似啊,因為通過字符串也支持切片的方式獲取。

隨着源碼的分析,我們也漸漸明朗Python的操作在底層是如何實現的了,真的一點不神秘,實現的邏輯非常簡單。

設置元素

獲取元素知道了,設置元素也不難了。

static int
list_ass_subscript(PyListObject* self, PyObject* item, PyObject* value)
{	//在list_subscript的基礎上多了一個value參數
    
    if (PyIndex_Check(item)) {
        //依舊是進行檢測i是否合法
        Py_ssize_t i = PyNumber_AsSsize_t(item, PyExc_IndexError);
        if (i == -1 && PyErr_Occurred())
            return -1;
        //索引小於0,則加上列表的長度
        if (i < 0)
            i += PyList_GET_SIZE(self);
        //調用list_ass_item進行設置,我們之前見到了list_item,是用來基於索引獲取的
        //這里的list_ass_item是基於索引進行元素設置的
        return list_ass_item(self, i, value);
    }
    else if (PySlice_Check(item)) {
    	//......
    }
}


static int
list_ass_item(PyListObject *a, Py_ssize_t i, PyObject *v)
{	
    //判斷索引是否越界
    if (!valid_index(i, Py_SIZE(a))) {
        PyErr_SetString(PyExc_IndexError,
                        "list assignment index out of range");
        return -1;
    }
    //這里的list_ass_slice后面會說
    if (v == NULL)
        return list_ass_slice(a, i, i+1, v);
    //增加v指向對象的引用計數,因為指向它的指針被傳到了列表中
    Py_INCREF(v);
    //將第i個元素設置成v
    Py_SETREF(a->ob_item[i], v);
    return 0;
}

通過索引設置元素,邏輯很容易,關鍵是通過切片設置元素會比較復雜。而復雜的原因就在於步長,我們通過Python來演示一下。

lst = [1, 2, 3, 4, 5, 6, 7, 8]

# 首先通過切片進行設置的話, 右值一定要是一個可迭代對象
lst[0: 3] = [11, 22, 33]
# 會將lst[0]設置為11, lst[1]設置為22, lst[2]設置為33
print(lst)  # [11, 22, 33, 4, 5, 6, 7, 8]


# 而且它們的長度是可以不相等的
# 這里表示將[0: 3]的元素設置為[1, 2], lst[0]設置成1, lst[1]設置成2
# 問題來了, lst[2]咋辦? 由於右值中已經沒有元素與之匹配了, 那么lst[2]就會被刪掉
lst[0: 3] = [1, 2]
print(lst)  # [1, 2, 4, 5, 6, 7, 8]

# 所以我們如果想刪除[0: 3]的元素, 那么只需要執行lst[0: 3] = []即可
# 因為[]里面沒有元素能與之匹配, 所以lst中[0: 3]的元素由於匹配不到, 所以直接就沒了
# 當然由於Python的動態特性, lst[0: 3] = []、lst[0: 3] = ()、lst[0: 3] = ""等等都是可以的
lst[0: 3] = ""
print(lst)  # [5, 6, 7, 8]
# 實際上我們del lst[0]的時候, 實際上就是執行了lst[0: 1] = []


# 當然如果右值元素多的話也是可以的
lst[0: 1] = [1, 2, 3, 4]
print(lst)  # [1, 2, 3, 4, 6, 7, 8]
# lst[0]匹配1很好理解, 但是此時左邊已經結束了, 所以剩余的元素會依次插在后面


# 然后重點來了, 如果切片有步長的話, 那么兩邊一定要匹配
# 由於此時lst中有8個元素, lst[:: 2]會得到4個元素, 那么右邊的可迭代對象的長度也是4
lst[:: 2] = ['a', 'b', 'c', 'd']
print(lst)  # ['a', 2, 'b', 4, 'c', 7, 'd']

# 但是,如果長度不一致
try:
    lst[:: 2] = ['a', 'b', 'c']
except Exception as e:
    # 顯然會報錯
    print(e)  # attempt to assign sequence of size 3 to extended slice of size 4

至於通過切片來設置元素,源碼很長,這里就不分析了,總之核心如下:

  • 如果步長為1: 那么會調用list_ass_slice。我們說: list_item是基於索引獲取元素、list_slice是基於切片獲取元素、list_ass_item是基於索引設置元素、list_ass_slice是基於切片設置元素。而list_ass_slice內部的代碼邏輯也很長,但是核心並不難, 我們通過lst[a: b] = [v1, v2, v3, ...]這種方式就會走這里的list_ass_slice。
  • 如果步長不為1,那么就是采用循環的方式逐個設置。

主要是考慮的情況比較多,但是核心邏輯並不復雜,有興趣可以自己去深入了解一下。

insert插入元素

insert用來在指定的位置插入元素,我們知道它是一個時間復雜度為O(n)的一個操作,因為插入位置后面的所有元素都要向后移動。

int
PyList_Insert(PyObject *op, Py_ssize_t where, PyObject *newitem)
{	
    //類型檢查
    if (!PyList_Check(op)) {
        PyErr_BadInternalCall();
        return -1;
    }
    //底層又調用ins1
    return ins1((PyListObject *)op, where, newitem);
}


static int
ins1(PyListObject *self, Py_ssize_t where, PyObject *v)
{	
    /*參數self:PyListObject *
    參數where:索引
    參數v:插入的值,這是一個PyObject *指針,因為list里面存的都是指針
    */
    
    //i:后面for循環遍歷用的,n則是當前列表的元素個數
    Py_ssize_t i, n = Py_SIZE(self);
    //指向指針數組的二級指針
    PyObject **items;
    //如果v是NULL,錯誤的內部調用
    if (v == NULL) {
        PyErr_BadInternalCall();
        return -1;
    }
    //列表的元素個數不可能無限增大,一般當你還沒創建到PY_SSIZE_T_MAX個對象時
    //你內存就已經玩完了,但是python仍然做了檢測,當達到這個PY_SSIZE_T_MAX時,會報出內存溢出錯誤
    if (n == PY_SSIZE_T_MAX) {
        PyErr_SetString(PyExc_OverflowError,
            "cannot add more objects to list");
        return -1;
    }
	
    //調整列表容量,既然要inert,那么就勢必要多出一個元素
    //這個元素還沒有設置進去,但是先把這個坑給留出來
    //當然如果容量夠的話,是不會擴容的,只有當容量不夠的時候才會擴容
    if (list_resize(self, n+1) < 0)
        return -1;
	
    //確定插入點
    if (where < 0) {
        //這里可以看到如果where小於0,那么我們就加上n,也就是當前列表的元素個數
        //比如有6個元素,那么我們where=-1,加上6,就是5,顯然就是insert在最后一個索引的位置上 
        where += n;
        //如果吃撐了,寫個-100,加上元素的個數還是小於0
        if (where < 0)
            //那么where=0,就在開頭插入
            where = 0;
    }
    //如果where > n,那么就索引為n的位置插入,
    //可元素個數為n,最大索引是n-1啊,對,所以此時就相當於append
    if (where > n)
        where = n;
    //拿到原來的二級指針,指向一個指針數組
    items = self->ob_item;
    //然后不斷遍歷,把索引為i的值賦值給索引為i+1
    //既然是在where處插入那么where之前的就不需要動了,到where處就停止了
    for (i = n; --i >= where; )
        items[i+1] = items[i];
    //增加v指向的對象的引用計數,因為列表中的元素也引用了該對象
    Py_INCREF(v);
    //將索引為where的值設置成v
    items[where] = v;
    return 0;
}

所以可以看到,Python插入數據是非常靈活的。不管你在什么位置插入,都是合法的。因為它會自己調整位置,在確定位置之后,會將當前位置以及之后的所有元素向后挪動一個位置,空出來的地方設置為插入的值。

pop彈出元素

pop默認是從尾部彈出元素的,因為如果不指定索引的話,默認是-1。當然我們也可以指定索引,彈出指定索引對應的元素。

static PyObject *
list_pop_impl(PyListObject *self, Py_ssize_t index)
{	
    //彈出的對象的指針,因為彈出一個元素實際上是先用某個變量保存起來,然后再從列表中刪掉
    PyObject *v;
    
    //下面代碼中體現
    int status;
	
    //如果列表長度為0,顯然沒有元素可以彈, 因此會報錯
    if (Py_SIZE(self) == 0) {
        PyErr_SetString(PyExc_IndexError, "pop from empty list");
        return NULL;
    }
    //索引小於0,那么加上列表的長度得到正數索引
    if (index < 0)
        index += Py_SIZE(self);
    //依舊是調用valid_index,判斷是否越界。顯然pop沒有insert那么智能
    //insert的話,索引在加上列表長度之和還小於0,那么默認是在索引為0的地方插入
    //但是pop就不行了,pop的話會報出索引越界錯誤,同理索引大於等於列表長度,insert會等價於append,而pop同樣報出索引越界錯誤
    if (!valid_index(index, Py_SIZE(self))) {
        PyErr_SetString(PyExc_IndexError, "pop index out of range");
        return NULL;
    }
    //根據索引獲取指定位置的元素
    v = self->ob_item[index];
    
    //這里同樣是一個快分支,如果index是最后一個元素
    if (index == Py_SIZE(self) - 1) {
        //那么直接調用list_resize即可,我們說只要涉及元素的添加、刪除都要執行list_resize
        //至於容量是否變化,就看是否滿足newsize和allocated之間的關系,如果allocated//2 <= newsize <= allocated,那么容量就不變
        //list_resize中會將ob_size設置成newsize,也就是原來的ob_size減去1, 因為是在尾部刪除的,所以只需要將ob_size設置為ob_size-1即可
        status = list_resize(self, Py_SIZE(self) - 1);
        //list_resize執行成功會返回0
        if (status >= 0)
            //直接將對象的指針返回
            return v; 
        else
            return NULL;
    }
    //否則說明不是快分支
    Py_INCREF(v);
    //這里調用了list_ass_slice, 這一步等價於self[index: index + 1] = []
    status = list_ass_slice(self, index, index+1, (PyObject *)NULL);
    if (status < 0) {
        //設置失敗,減少引用計數
        Py_DECREF(v);
        return NULL;
    }
    //返回指針
    return v;
}

所以pop本質上也是調用了list_ass_slice。

index查詢元素的索引

index可以接收一個元素,返回該元素首次出現的索引。當然還可以額外指定一個start和end,表示查詢的范圍

static PyObject *
list_index_impl(PyListObject *self, PyObject *value, Py_ssize_t start,
                Py_ssize_t stop)
{
    Py_ssize_t i;
	
    //如果start小於0,加上長度。
    //還小於0,那么等於0
    if (start < 0) {
        start += Py_SIZE(self);
        if (start < 0)
            start = 0;
    }
    if (stop < 0) {
        //如果stop小於0,加上長度
        //還小於0,那么等於0
        stop += Py_SIZE(self);
        if (stop < 0)
            stop = 0;
    }
    //從start開始循環
    for (i = start; i < stop && i < Py_SIZE(self); i++) {
        //獲取相應元素
        PyObject *obj = self->ob_item[i];
        //增加引用計數,因為有指針指向它
        Py_INCREF(obj);
        //進行比較PyObject_RichCompareBool是一個富比較,接收三個參數:元素1、元素2、操作(這里顯然是Py_EQ)
        //相等返回1,不相等返回0
        int cmp = PyObject_RichCompareBool(obj, value, Py_EQ);
        //比較完之后,減少引用計數
        Py_DECREF(obj);
        if (cmp > 0)
            //如果相等,返回其索引
            return PyLong_FromSsize_t(i);
        else if (cmp < 0)
            return NULL;
    }
    //循環走完一圈,發現都沒有相等的,那么報錯,提示元素不再列表中
    PyErr_Format(PyExc_ValueError, "%R is not in list", value);
    return NULL;
}

所以lst.index是一個時間復雜度為O(n)的操作,因為它在底層要循環整個列表,如果運氣好,可以第一個元素就是,運氣不好可能就好循環整個列表了。同理后面要說的if value in lst這種方式也是一樣的,因為都要循環整個列表,只不過后者返回的是一個布爾值。

count查詢元素出現的次數

static PyObject *
list_count(PyListObject *self, PyObject *value)
{	
    //初始為0
    Py_ssize_t count = 0;
    Py_ssize_t i;
	
    //遍歷每一個元素
    for (i = 0; i < Py_SIZE(self); i++) {
        //獲取元素
        PyObject *obj = self->ob_item[i];
        //如果相等,那么count自增1,繼續下一次循環
        //注意這里的相等,判斷的是什么呢?顯然是對象的地址,如果地址一樣,那么肯定指向同一個對象,所以一定相等。
        if (obj == value) {
           count++;
           continue;
        }
        Py_INCREF(obj);
        //走到這里說明地址不一樣,但是地址不一樣只能說明a is b不成立,但並不代表a == b不成立,所以調用PyObject_RichCompareBool進行判斷
        int cmp = PyObject_RichCompareBool(obj, value, Py_EQ);
        Py_DECREF(obj);
        //大於0,說明相等,count++
        if (cmp > 0)
            count++;
        else if (cmp < 0)
            return NULL;
    }
    //返回count
    return PyLong_FromSsize_t(count);
}

count毫無疑問,無論在什么情況下,它都是一個時間復雜度為O(n)的操作,因為列表必須要從頭遍歷到尾。

remove根據元素的值刪除元素

除了根據索引刪除元素之外,也可以元素指向的對象維護的值刪除元素,刪除第一個出現元素。

static PyObject *
list_remove(PyListObject *self, PyObject *value)
{
    Py_ssize_t i;

    for (i = 0; i < Py_SIZE(self); i++) {
        //從頭開始遍歷,獲取元素
        PyObject *obj = self->ob_item[i];
        Py_INCREF(obj);
        //比較是否相等
        int cmp = PyObject_RichCompareBool(obj, value, Py_EQ);
        Py_DECREF(obj);
        //如果相等
        if (cmp > 0) {
            //調用list_ass_slice刪除元素
            if (list_ass_slice(self, i, i+1,
                               (PyObject *)NULL) == 0)
                //返回None
                Py_RETURN_NONE;
            return NULL;
        }
        else if (cmp < 0)
            return NULL;
    }
    //否則說明元素不在列表中
    PyErr_SetString(PyExc_ValueError, "list.remove(x): x not in list");
    return NULL;
}

reverse翻轉列表

static PyObject *
list_reverse_impl(PyListObject *self)
{	
    //如果列表長度不大於1的話, 那么直接返回其本身即可
    if (Py_SIZE(self) > 1)
        //大於1的話,執行reverse_slice, 傳遞了兩個參數
        //第一個參數self -> ob_item顯然是底層數組首元素的地址
        //而第二個參數self->ob_item + Py_SIZE(self)則是底層數組中索引為ob_size的元素的地址
        //但是很明顯能訪問的最大索引應該是ob_size - 1才對, 別急我們繼續往下看, 看一下reverse_slice函數的實現
        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,將hi移動到了ob_size - 1位置,也就是說此時二級指針hi保存的還是索引為ob_size - 1的元素的值
    //所以個人覺得有點納悶, 直接reverse_slice(self->ob_item, self->ob_item + Py_SIZE(self) - 1);不行嗎
    --hi;
    //當lo小於hi的時候
    while (lo < hi) {
        PyObject *t = *lo;
        *lo = *hi;
        *hi = t;
        //上面三步就等價於 *lo, *hi = *hi, *lo, 但是C不支持這么寫
        //所以我們看到就是將索引為0的元素和索引為ob_size-1的元素進行了交換,前后兩個指針繼續靠近,指向的元素繼續交換,知道兩個指針相遇
        ++lo;
        --hi;
    }
}

所以到現在,你還認為Python中的列表神秘嗎?雖然我們不可能寫出一個Python解釋器,但是底層的一些思想其實並沒有那么難,作為一名程序猿很容易想的到。

兩個列表相加

static PyObject *
list_concat(PyListObject *a, PyObject *bb)
{
    Py_ssize_t size;  //相加之后的列表長度
    Py_ssize_t i; //循環變量
    //兩個二級指針,指向ob_item
    PyObject **src, **dest;
    //新的列表
    PyListObject *np;
    //類型檢測
    if (!PyList_Check(bb)) {
        PyErr_Format(PyExc_TypeError,
                  "can only concatenate list (not \"%.200s\") to list",
                  bb->ob_type->tp_name);
        return NULL;
    }
#define b ((PyListObject *)bb)
    //判斷長度是否溢出
    if (Py_SIZE(a) > PY_SSIZE_T_MAX - Py_SIZE(b))
        return PyErr_NoMemory();
    //計算新列表的長度
    size = Py_SIZE(a) + Py_SIZE(b);
    //設置np -> ob_item指向的底層數組
    np = (PyListObject *) list_new_prealloc(size);
    if (np == NULL) {
        return NULL;
    }
    //獲取a -> ob_item和np -> ob_item
    src = a->ob_item;
    dest = np->ob_item;
    //將元素依次拷貝過去, 增加引用計數
    for (i = 0; i < Py_SIZE(a); i++) {
        PyObject *v = src[i];
        Py_INCREF(v);
        dest[i] = v;
    }
    //獲取b->ob_item
    //獲取np->ob_item + Py_SIZE(a), 要從Py_SIZE(a)的位置開始設置, 否則就把之前的元素覆蓋掉了
    src = b->ob_item;
    dest = np->ob_item + Py_SIZE(a);
    //將元素依次拷貝過去, 增加引用計數
    for (i = 0; i < Py_SIZE(b); i++) {
        PyObject *v = src[i];
        Py_INCREF(v);
        dest[i] = v;
    }
    //設置ob_size
    Py_SIZE(np) = size;
    //返回np
    return (PyObject *)np;
#undef b
}

判斷元素是否在列表中

對於一個序列來說,可以使用in操作符,等價於調用其__contains__魔法方法。

static int
list_contains(PyListObject *a, PyObject *el)
{
    PyObject *item;
    Py_ssize_t i;
    int cmp;
	//挨個循環,比較是否相等。存在cmp會等於1,cmp == 0 && i < Py_SIZE(a)不滿足,直接返回
    //不相等則為0, 會一直比完列表中所有的元素
    for (i = 0, cmp = 0 ; cmp == 0 && i < Py_SIZE(a); ++i) {
        item = PyList_GET_ITEM(a, i);
        Py_INCREF(item);
        cmp = PyObject_RichCompareBool(el, item, Py_EQ);
        Py_DECREF(item);
    }
    return cmp;
}

真的非常簡單,沒有什么好說的。

列表的深淺拷貝

列表的深淺拷貝也是初學者容易犯的錯誤之一,我們看一個Python的例子。

lst = [[]]

# 默認是淺拷貝, 這個過程會創建一個新列表, 會將里面的指針拷貝一份
# 但是指針指向的內存並沒有拷貝
lst_cp = lst.copy()

# 兩個對象的地址是一樣的
print(id(lst[0]), id(lst_cp[0]))  # 2207105155392 2207105155392

# 操作lst[0], 會改變lst_cp[0]
lst[0].append(123)
print(lst, lst_cp)  # [[123]] [[123]]

# 操作lst_cp[0], 會改變lst[0]
lst_cp[0].append(456)
print(lst, lst_cp)  # [[123, 456]] [[123, 456]]

我們通過索引或者切片也是一樣的道理

lst = [[], 1, 2, 3]
val = lst[0]
lst_cp = lst[0: 1]

print(lst[0] is val is lst_cp[0])  # True
# 此外,lst[:]完全等價於lst.copy()

之所以會有這樣現象,是因為我們說過Python中變量、容器里面的元素都是一個泛型指針PyObject *,在傳遞的時候會傳遞指針, 但是在操作的時候會操作指針指向的內存。

所以lst.copy()就是創建了一個新列表,然后把元素拷貝了過去,並且這里的元素是指針。因為只是拷貝指針,沒有拷貝指針指向的對象(內存),所以它們的地址都是一樣的,因為指向的是同一個對象。

但如果我們就想在拷貝指針的同時也拷貝指針指向的對象呢?答案是使用一個叫copy的模塊。

import copy

lst = [[]]
# 此時拷貝的時候,就會把指針指向的對象也給拷貝一份
lst_cp1 = copy.deepcopy(lst)
lst_cp2 = lst[:]

lst_cp2[0].append(123)
print(lst)  # [[123]]
print(lst_cp1)  # [[]]

# lst[:]這種方式也是淺拷貝, 所以修改lst_cp2[0], 也會影響lst[0]
# 但是沒有影響lst_cp1[0], 證明它們是相互獨立的, 因為指向的是不同的對象

淺拷貝示意圖如下:

里面的兩個底層數組的元素是一樣的

深拷貝示意圖如下:

里面的兩個底層數組的元素是不一樣的

注意:copy.deepcopy雖然在拷貝指針的同時會將指針指向的對象也拷貝一份,但這僅僅是針對於可變對象,對於不可變對象是不會拷貝的。

import copy

lst = [[], "古明地覺"]
lst_cp = copy.deepcopy(lst)

print(lst[0] is lst_cp[0])  # False
print(lst[1] is lst_cp[1])  # True

為什么會這樣,其實原因很簡單。因為不可變對象是不支持本地修改的,你若想修改只能指向新的對象,但是對其它的變量則沒有影響,其它變量該指向誰就還指向誰。因為a = b只是將b的指針拷貝一份給a,然后a和b都指向了同一個對象,至於a和b本身則是沒有任何關系的。如果此時b指向了新的對象,是完全不會影響a的,a還是指向原來的對象。所以如果一個指針指向的對象不支持本地修改,那么深拷貝不會拷貝對象本身,因為指向的是不可變對象,所以不會有修改一個影響另一個的情況出現。

關於列表還有一些陷阱:

lst = [[]] * 5
lst[0].append(1)
print(lst)  # [[1], [1], [1], [1], [1]]
# 列表乘上一個n,等於把列表里面的元素重復n次
# 注意: 類似於lst = [1, 2, 3], 雖然我們寫的是整數,但是它存儲的並不是整數,而是其指針
# 所以會把指針重復5次, 因此列表里面5個指針都指向了同一個列表


# 這種方式創建的話,里面的元素都指向了不同的列表
lst = [[], [], [], [], []]
lst[0].append(1)
print(lst)  # [[1], [], [], [], []]


# 再比如字典,在后續系列中會說
d = dict.fromkeys([1, 2, 3, 4], [])
print(d)  # {1: [], 2: [], 3: [], 4: []}
d[1].append(123)
print(d)  # {1: [123], 2: [123], 3: [123], 4: [123]}
# 它們都指向了同一個列表,因此這種陷阱在工作中要注意, 因為一不小心就會出現大問題

創建PyListObject

我們說創建一個列表,Python底層只提供了唯一一個Python/C API,也就是PyList_New。這個函數接收一個size參數,從而允許我們在創建一個PyListObject對象時指定底層數組的長度。

PyObject *
PyList_New(Py_ssize_t size)
{	
    //聲明一個PyListObject *對象
    PyListObject *op;
#ifdef SHOW_ALLOC_COUNT
    static int initialized = 0;
    if (!initialized) {
        Py_AtExit(show_alloc);
        initialized = 1;
    }
#endif
	
    //如果size小於0,直接拋異常
    if (size < 0) {
        PyErr_BadInternalCall();
        return NULL;
    }
    //緩存池是否可用,如果可用
    if (numfree) {
        //將緩存池內對象個數減1
        numfree--;
        //從緩存池中獲取
        op = free_list[numfree];
        //設置引用計數
        _Py_NewReference((PyObject *)op);
#ifdef SHOW_ALLOC_COUNT
        count_reuse++;
#endif
    } else {
        //不可用的時候,申請內存
        op = PyObject_GC_New(PyListObject, &PyList_Type);
        if (op == NULL)
            return NULL;
#ifdef SHOW_ALLOC_COUNT
        count_alloc++;
#endif
    }
    //如果size小於等於0,ob_item設置為NULL
    if (size <= 0)
        op->ob_item = NULL;
    else {
        //否則的話,創建一個指定容量的指針數組,然后讓ob_item指向它
        //所以是先創建PyListObject對象, 然后創建底層數組, 最后通過ob_item建立聯系
        op->ob_item = (PyObject **) PyMem_Calloc(size, sizeof(PyObject *));
        if (op->ob_item == NULL) {
            Py_DECREF(op);
            return PyErr_NoMemory();
        }
    }
    //設置ob_size和allocated,然后返回op
    Py_SIZE(op) = size;
    op->allocated = size;
    _PyObject_GC_TRACK(op);
    return (PyObject *) op;
}

我們注意到源碼里面有一個緩沖池,是的,創建PyListObject對象時,會先檢測緩沖池free_lists里面是否有可用的對象,有的話直接拿來用,否則通過malloc在系統堆上申請。緩沖池中最多維護80個PyListObject對象。

/* 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 void
list_dealloc(PyListObject *op)
{
    Py_ssize_t i;
    PyObject_GC_UnTrack(op);
    Py_TRASHCAN_SAFE_BEGIN(op)
    if (op->ob_item != NULL) {
        i = Py_SIZE(op);
        //將底層數組中每個指針指向的對象的引用計數都減去1
        while (--i >= 0) {
            Py_XDECREF(op->ob_item[i]);
        }
      	//然后釋放底層數組所占的內存
        PyMem_FREE(op->ob_item);
    }
    //判斷緩沖池里面PyListObject對象的個數,如果沒滿,就添加到緩存池
    //注意:我們看到執行到這一步的時候, 底層數組已經被釋放掉了
    if (numfree < PyList_MAXFREELIST && PyList_CheckExact(op))
        free_list[numfree++] = op;
    else
        //否則的話再釋放掉PyListObject對象所占的內存
        Py_TYPE(op)->tp_free((PyObject *)op);
    Py_TRASHCAN_SAFE_END(op)
}

我們知道在創建一個新的PyListObject對象時,實際上是分為兩步的,先創建PyListObject對象,然后創建底層數組,最后讓PyListObject對象中的ob_item成員指向這個底層數組。同理,在銷毀一個PyListObject對象時,先銷毀ob_item維護的底層數組,然后再釋放PyListObject對象自身(如果緩存池已滿的情況下)

現在可以很清晰地明白了,原本空盪盪的緩存池其實是被已經死去的PyListObject對象填充了,在以后創建新的PyListObject對象時,Python會首先喚醒這些死去的PyListObject對象,給它們一個洗心革面、重新做人的機會。但需要注意,這里緩存的僅僅是PyListObject對象,對於底層數組,其ob_item已經不再指向了。從list_dealloc中我們看到,PyListObject對象在放進緩存池之前,ob_item指向的數組就已經被釋放掉了,同時數組中指針指向的對象的引用計數會減1。所以最終數組中這些指針指向的對象也大難臨頭各自飛了,或生存、或毀滅,總之此時和PyListObject之間已經沒有任何聯系了。但是為什么要這么做呢?為什么不連底層數組也一起維護呢?可以想一下,如果繼續維護的話,數組中指針指向的對象永遠不會被釋放,那么很可能會產生懸空指針的問題,所以這些指針指向的對象所占的空間必須交還給系統(前提是沒有其它指針指向了)

但是實際上,是可以將PyListObject對象維護的底層數組進行保留的,即只將數組中指針指向的對象的引用計數減1,然后將數組中的指針都設置為NULL,不再指向之前的對象了,但是並不釋放底層數組本身所占用的內存空間。因此這樣一來,釋放的內存不會交給系統堆,那么再次分配的時候,速度會快很多。但是這樣帶來一個問題,就是這些內存沒人用也會一直占着,並且只能供PyListObject對象的ob_item指向的底層數組使用,因此Python還是為避免消耗過多內存,采取將底層數組的內存交換給了系統堆這樣的做法,在時間和空間上選擇了空間。

元組的底層結構--PyTupleObject

因為元組比較簡單,和列表比較相似,所以就放在一起介紹了。我們知道元組,就相當於不支持元素添加、修改、刪除等操作的列表。

元組的實現機制非常簡單,可以看做是在列表的基礎上刪除了增刪改等操作。既然如此,那要元組有什么用呢?畢竟元組的功能只是列表的子集。元組存在的最大一個特點就是,它可以作為字典的key、以及可以作為集合的元素。因為字典和集合存儲數據的原理是哈希表,字典和集合我們后續章節會說。對於列表這樣的可變對象來說是可以動態改變的,而哈希值是一開始就計算好的,顯然如果支持動態修改的話,那么哈希值肯定會變,這是不允許的。所以如果我們希望字典的key是一個序列,顯然元組再適合不過了。

從tuple的特點也能看出:tuple的底層是一個變長對象,但同時也是一個不可變對象。

typedef struct {
    PyObject_VAR_HEAD
    PyObject *ob_item[1];
} PyTupleObject;

可以看到,對於不可變對象來說,它底層結構體定義也非常簡單。一個引用計數、一個類型、一個指針數組。這里的1可以想象成n,我們在PyLongObject中說過。

並且我們發現不像列表,元組沒有allocated,這是因為它是不可變的,不支持resize操作。至於維護的值,同樣是指針組成的數組,數組里面的每一個指針都指向了具體的值。

PyTupleObject的創建

正如列表一樣,Python創建PyTupleObject也提供了類似的初始化方法。

PyObject *
PyTuple_New(Py_ssize_t size)
{	
    //PyTupleObject指針
    PyTupleObject *op;
    Py_ssize_t i;
    if (size < 0) {
        PyErr_BadInternalCall();
        return NULL;
    }
#if PyTuple_MAXSAVESIZE > 0
    //元組同樣有緩存池
    if (size == 0 && free_list[0]) {
        op = free_list[0];
        Py_INCREF(op);
#ifdef COUNT_ALLOCS
        tuple_zero_allocs++;
#endif  
        //如果長度為0,那么直接返回
        return (PyObject *) op;
    }
    if (size < PyTuple_MAXSAVESIZE && (op = free_list[size]) != NULL) {
        //從緩存池中獲取
        free_list[size] = (PyTupleObject *) op->ob_item[0];
        numfree[size]--;
#ifdef COUNT_ALLOCS
        fast_tuple_allocs++;
#endif
        /* Inline PyObject_InitVar */
#ifdef Py_TRACE_REFS
        //設置ob_size,和ob_type
        Py_SIZE(op) = size;
        Py_TYPE(op) = &PyTuple_Type;
#endif
        //引用計數初始化為1
        _Py_NewReference((PyObject *)op);
    }
    else
#endif
    {
        /* 元組的元素個數同樣有限制,但我們說這個限制一般達不到 */
        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
    if (size == 0) {
        free_list[0] = op;
        ++numfree[0];
        Py_INCREF(op);          /* extra INCREF so that this is never freed */
    }
#endif
#ifdef SHOW_TRACK_COUNT
    count_tracked++;
#endif
    _PyObject_GC_TRACK(op);
    return (PyObject *) op;
}

和PyListObject初始化類似,同樣需要做一些類型檢測,內存是否溢出等等。

當然有了列表的經驗,元組的一些底層操作我們就不分析了,它是列表的子集。

靜態資源緩存

列表和元組兩者在通過索引查找元素的時候是一致的,但是元組除了能作為字典的key之外,還有一個特點,就是分配的速度比較快。一方面是因為由於其不可變性,使得在編譯的時候就確定了,另一方面就是它還具有靜態資源緩存的作用。

對於一些靜態變量,比如元組,如果它不被使用並且占用空間不大時,Python 會暫時緩存這部分內存。這樣,下次我們再創建同樣大小的元組時,Python 就可以不用再向操作系統發出請求,去尋找內存,而是可以直接分配之前緩存的內存空間,這樣就能大大加快程序的運行速度。你可以理解為PyTupleObject對象在被析構時,不僅對象本身沒有被回收,連底層的指針數組也被緩存起來了。

from timeit import timeit


t1 = timeit(stmt="x1 = [1, 2, 3, 4, 5]", number=1000000)
t2 = timeit(stmt="x2 = (1, 2, 3, 4, 5)", number=1000000)

print(round(t1, 2))  # 0.05
print(round(t2, 2))  # 0.01

可以看到用時,元組只是列表的五分之一。這便是元組的另一個優勢,可以將資源緩存起來。

小結

這次我們介紹了Python中列表的底層實現,以及它的相關操作。當然內容都在正文里面,也沒啥好總結的了。話說寫到這里,我的typora明顯變卡了。。。。。。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM