深入理解 Python 的物件複製和記憶體佈局

一無是處的研究僧發表於2022-12-16

深入理解 Python 的物件複製和記憶體佈局

前言

在本篇文章當中主要給大家介紹 python 當中的複製問題,話不多說我們直接看程式碼,你知道下面一些程式片段的輸出結果嗎?

a = [1, 2, 3, 4]
b = a
print(f"{a = } \t|\t {b = }")
a[0] = 100
print(f"{a = } \t|\t {b = }")
a = [1, 2, 3, 4]
b = a.copy()
print(f"{a = } \t|\t {b = }")
a[0] = 100
print(f"{a = } \t|\t {b = }")
a = [[1, 2, 3], 2, 3, 4]
b = a.copy()
print(f"{a = } \t|\t {b = }")
a[0][0] = 100
print(f"{a = } \t|\t {b = }")
a = [[1, 2, 3], 2, 3, 4]
b = copy.copy(a)
print(f"{a = } \t|\t {b = }")
a[0][0] = 100
print(f"{a = } \t|\t {b = }")
a = [[1, 2, 3], 2, 3, 4]
b = copy.deepcopy(a)
print(f"{a = } \t|\t {b = }")
a[0][0] = 100
print(f"{a = } \t|\t {b = }")

在本篇文章當中我們將對上面的程式進行詳細的分析。

Python 物件的記憶體佈局

首先我們介紹一下一個比較好用的關於資料在記憶體上的邏輯分佈的網站,https://pythontutor.com/visualize.html#mode=display

我們在這個網站上執行第一份程式碼:

從上面的輸出結果來看 a 和 b 指向的是同一個記憶體當中的資料物件。因此第一份程式碼的輸出結果是相同的。我們應該如何確定一個物件的記憶體地址呢?在 Python 當中給我們提供了一個內嵌函式 id() 用於得到一個物件的記憶體地址:

a = [1, 2, 3, 4]
b = a
print(f"{a = } \t|\t {b = }")
a[0] = 100
print(f"{a = } \t|\t {b = }")
print(f"{id(a) = } \t|\t {id(b) = }")
# 輸出結果
# a = [1, 2, 3, 4] 	|	 b = [1, 2, 3, 4]
# a = [100, 2, 3, 4] 	|	 b = [100, 2, 3, 4]
# id(a) = 4393578112 	|	 id(b) = 4393578112

事實上上面的物件記憶體佈局是有一點問題的,或者說是不夠準確的,但是也是能夠表示出各個物件之間的關係的,我們現在來深入瞭解一下。在 Cpython 裡你可以認為每一個變數都可以認為是一個指標,指向被表示的那個資料,這個指標儲存的就是這個 Python 物件的記憶體地址。

在 Python 當中,實際上列表儲存的指向各個 Python 物件的指標,而不是實際的資料,因此上面的一小段程式碼,可以用如下的圖表示物件在記憶體當中的佈局:

變數 a 指向記憶體當中的列表 [1, 2, 3, 4],列表當中有 4 個資料,這四個資料都是指標,而這四個指標指向記憶體當中 1,2,3,4 這四個資料。可能你會有疑問,這不是有問題嗎?都是整型資料為什麼不直接在列表當中存放整型資料,為啥還要加一個指標,再指向這個資料呢?

事實上在 Python 當中,列表當中能夠存放任何 Python 物件,比如下面的程式是合法的:

data = [1, {1:2, 3:4}, {'a', 1, 2, 25.0}, (1, 2, 3), "hello world"]

在上面的列表當中第一個到最後一個資料的資料型別為:整型資料,字典,集合,元祖,字串,現在來看為了實現 Python 的這個特性,指標的特性是不是符合要求呢?每個指標所佔用的記憶體是一樣的,因此可以使用一個陣列去儲存 Python 物件的指標,然後再將這個指標指向真正的 Python 物件!

牛刀小試

在經過上面的分析之後,我們來看一下下面的程式碼,他的記憶體佈局是什麼情況:

data = [[1, 2, 3], 4, 5, 6]
data_assign = data
data_copy = data.copy()

  • data_assign = data,關於這個賦值語句的記憶體佈局我們在之前已經談到過了,不過我們也在複習一下,這個賦值語句的含義就是 data_assign 和 data 指向的資料是同一個資料,也就是同一個列表。
  • data_copy = data.copy(),這條賦值語句的含義是將 data 指向的資料進行淺複製,然後讓 data_copy 指向複製之後的資料,這裡的淺複製的意思就是,對列表當中的每一個指標進行複製,而不對列表當中指標指向的資料進行複製。從上面的物件的記憶體佈局圖我們可以看到 data_copy 指向一個新的列表,但是列表當中的指標指向的資料和 data 列表當中的指標指向的資料是一樣的,其中 data_copy 使用綠色的箭頭進行表示,data 使用黑色的箭頭進行表示。

檢視物件的記憶體地址

在前面的文章當中我們主要分析了一下物件的記憶體佈局,在本小節我們使用 python 給我們提供一個非常有效的工具去驗證這一點。在 python 當中我們可以使用 id() 去檢視物件的記憶體地址,id(a) 就是檢視物件 a 所指向的物件的記憶體地址。

  • 看下面的程式的輸出結果:
a = [1, 2, 3]
b = a
print(f"{id(a) = } {id(b) = }")
for i in range(len(a)):
    print(f"{i = } {id(a[i]) = } {id(b[i]) = }")

根據我們之前的分析,a 和 b 指向的同一塊記憶體,也就說兩個變數指向的是同一個 Python 物件,因此上面的多有輸出的 id 結果 a 和 b 都是相同的,上面的輸出結果如下:

id(a) = 4392953984 id(b) = 4392953984
i = 0 id(a[i]) = 4312613104 id(b[i]) = 4312613104
i = 1 id(a[i]) = 4312613136 id(b[i]) = 4312613136
i = 2 id(a[i]) = 4312613168 id(b[i]) = 4312613168
  • 看一下淺複製的記憶體地址:
a = [[1, 2, 3], 4, 5]
b = a.copy()
print(f"{id(a) = } {id(b) = }")
for i in range(len(a)):
    print(f"{i = } {id(a[i]) = } {id(b[i]) = }")

根據我們在前面的分析,呼叫列表本身的 copy 方法是對列表進行淺複製,只複製列表的指標資料,並不複製列表當中指標指向的真正的資料,因此如果我們對列表當中的資料進行遍歷得到指向的物件的地址的話,列表 a 和列表 b 返回的結果是一樣的,但是和上一個例子不同的是 a 和 b 指向的列表的本身的地址是不一樣的(因為進行了資料複製,可以參照下面淺複製的結果進行理解)。

可以結合下面的輸出結果和上面的文字進行理解:

id(a) = 4392953984 id(b) = 4393050112 # 兩個物件的輸出結果不相等
i = 0 id(a[i]) = 4393045632 id(b[i]) = 4393045632 # 指向的是同一個記憶體物件因此記憶體地址相等 下同
i = 1 id(a[i]) = 4312613200 id(b[i]) = 4312613200
i = 2 id(a[i]) = 4312613232 id(b[i]) = 4312613232

copy模組

在 python 裡面有一個自帶的包 copy ,主要是用於物件的複製,在這個模組當中主要有兩個方法 copy.copy(x) 和 copy.deepcopy()。

  • copy.copy(x) 方法主要是用於淺複製,這個方法的含義對於列表來說和列表本身的 x.copy() 方法的意義是一樣的,都是進行淺複製。這個方法會構造一個新的 python 物件並且會將物件 x 當中所有的資料引用(指標)複製一份。

  • copy.deepcopy(x) 這個方法主要是對物件 x 進行深複製,這裡的深複製的含義是會構造一個新的物件,會遞迴的檢視物件 x 當中的每一個物件,如果遞迴檢視的物件是一個不可變物件將不會進行複製,如果檢視到的物件是可變物件的話,將重新開闢一塊記憶體空間,將原來的在物件 x 當中的資料複製的新的記憶體當中。(關於可變和不可變物件我們將在下一個小節仔細分析)

  • 根據上面的分析我們可以知道深複製的花費是比淺複製多的,尤其是當一個物件當中有很多子物件的時候,會花費很多時間和記憶體空間。

  • 對於 python 物件來說進行深複製和淺複製的區別主要在於複合物件(物件當中有子物件,比如說列表,元祖、類的例項等等)。這一點主要是和下一小節的可變和不可變物件有關係。

可變和不可變物件與物件複製

在 python 當中主要有兩大類物件,可變物件和不可變物件,所謂可變物件就是物件的內容可以發生改變,不可變物件就是物件的內容不能夠發生改變。

  • 可變物件:比如說列表(list),字典(dict),集合(set),位元組陣列(bytearray),類的例項物件。
  • 不可變物件:整型(int),浮點型(float),複數(complex),字串,元祖(tuple),不可變集合(frozenset),位元組(bytes)。

看到這裡你可能會有疑問了,整數和字串不是可以修改嗎?

a = 10
a = 100
a = "hello"
a = "world"

比如下面的程式碼是正確的,並不會發生錯誤,但是事實上其實 a 指向的物件是發生了變化的,第一個物件指向整型或者字串的時候,如果重新賦一個新的不同的整數或者字串物件的話,python 會建立一個新的物件,我們可以使用下面的程式碼進行驗證:

a = 10
print(f"{id(a) = }")
a = 100
print(f"{id(a) = }")
a = "hello"
print(f"{id(a) = }")
a = "world"
print(f"{id(a) = }")

上面的程式的輸出結果如下所示:

id(a) = 4365566480
id(a) = 4365569360
id(a) = 4424109232
id(a) = 4616350128

可以看到的是當重新賦值之後變數指向的記憶體物件是發生了變化的(因為記憶體地址發生了變化),這就是不可變物件,雖然可以對變數重新賦值,但是得到的是一個新物件並不是在原來的物件上進行修改的!

我們現在來看一下可變物件列表發生修改之後記憶體地址是怎麼發生變化的:

data = []
print(f"{id(data) = }")
data.append(1)
print(f"{id(data) = }")
data.append(1)
print(f"{id(data) = }")
data.append(1)
print(f"{id(data) = }")
data.append(1)
print(f"{id(data) = }")

上面的程式碼輸出結果如下所示:

id(data) = 4614905664
id(data) = 4614905664
id(data) = 4614905664
id(data) = 4614905664
id(data) = 4614905664

從上面的輸出結果來看可以知道,當我們往列表當中加入新的資料之後(修改了列表),列表本身的地址並沒有發生變化,這就是可變物件。

我們在前面談到了深複製和淺複製,我們現在來分析一下下面的程式碼:

data = [1, 2, 3]
data_copy = copy.copy(data)
data_deep = copy.deepcopy(data)
print(f"{id(data ) = } | {id(data_copy) = } | {id(data_deep) = }")
print(f"{id(data[0]) = } | {id(data_copy[0]) = } | {id(data_deep[0]) = }")
print(f"{id(data[1]) = } | {id(data_copy[1]) = } | {id(data_deep[1]) = }")
print(f"{id(data[2]) = } | {id(data_copy[2]) = } | {id(data_deep[2]) = }")

上面的程式碼輸出結果如下所示:

id(data ) = 4620333952 | id(data_copy) = 4619860736 | id(data_deep) = 4621137024
id(data[0]) = 4365566192 | id(data_copy[0]) = 4365566192 | id(data_deep[0]) = 4365566192
id(data[1]) = 4365566224 | id(data_copy[1]) = 4365566224 | id(data_deep[1]) = 4365566224
id(data[2]) = 4365566256 | id(data_copy[2]) = 4365566256 | id(data_deep[2]) = 4365566256

看到這裡你肯定會非常疑惑,為什麼深複製和淺複製指向的記憶體物件是一樣的呢?前列我們可以理解,因為淺複製複製的是引用,因此他們指向的物件是同一個,但是為什麼深複製之後指向的記憶體物件和淺複製也是一樣的呢?這正是因為列表當中的資料是整型資料,他是一個不可變物件,如果對 data 或者 data_copy 指向的物件進行修改,那麼將會指向一個新的物件並不會直接修改原來的物件,因此對於不可變物件其實是不用開闢一塊新的記憶體空間在重新賦值的,因為這塊記憶體中的物件是不會發生改變的。

我們在來看一個可複製的物件:

data = [[1], [2], [3]]
data_copy = copy.copy(data)
data_deep = copy.deepcopy(data)
print(f"{id(data ) = } | {id(data_copy) = } | {id(data_deep) = }")
print(f"{id(data[0]) = } | {id(data_copy[0]) = } | {id(data_deep[0]) = }")
print(f"{id(data[1]) = } | {id(data_copy[1]) = } | {id(data_deep[1]) = }")
print(f"{id(data[2]) = } | {id(data_copy[2]) = } | {id(data_deep[2]) = }")

上面的程式碼輸出結果如下所示:

id(data ) = 4619403712 | id(data_copy) = 4617239424 | id(data_deep) = 4620032640
id(data[0]) = 4620112640 | id(data_copy[0]) = 4620112640 | id(data_deep[0]) = 4620333952
id(data[1]) = 4619848128 | id(data_copy[1]) = 4619848128 | id(data_deep[1]) = 4621272448
id(data[2]) = 4620473280 | id(data_copy[2]) = 4620473280 | id(data_deep[2]) = 4621275840

從上面程式的輸出結果我們可以看到,當列表當中儲存的是一個可變物件的時候,如果我們進行深複製將建立一個全新的物件(深複製的物件記憶體地址和淺複製的不一樣)。

程式碼片段分析

經過上面的學習對於在本篇文章開頭提出的問題對於你來說應該是很簡單的,我們現在來分析一下這幾個程式碼片段:

a = [1, 2, 3, 4]
b = a
print(f"{a = } \t|\t {b = }")
a[0] = 100
print(f"{a = } \t|\t {b = }")

這個很簡單啦,a 和 b 不同的變數指向同一個列表,a 中間的資料發生變化,那麼 b 的資料也會發生變化,輸出結果如下所示:

a = [1, 2, 3, 4] 	|	 b = [1, 2, 3, 4]
a = [100, 2, 3, 4] 	|	 b = [100, 2, 3, 4]
id(a) = 4614458816 	|	 id(b) = 4614458816

我們再來看一下第二個程式碼片段

a = [1, 2, 3, 4]
b = a.copy()
print(f"{a = } \t|\t {b = }")
a[0] = 100
print(f"{a = } \t|\t {b = }")

因為 b 是 a 的一個淺複製,所以 a 和 b 指向的是不同的列表,但是列表當中資料的指向是相同的,但是由於整型資料是不可變資料,當a[0] 發生變化的時候,並不會修改原來的資料,而是會在記憶體當中建立一個新的整型資料,因此列表 b 的內容並不會發生變化。因此上面的程式碼輸出結果如下所示:

a = [1, 2, 3, 4] 	|	 b = [1, 2, 3, 4]
a = [100, 2, 3, 4] 	|	 b = [1, 2, 3, 4]

再來看一下第三個片段:

a = [[1, 2, 3], 2, 3, 4]
b = a.copy()
print(f"{a = } \t|\t {b = }")
a[0][0] = 100
print(f"{a = } \t|\t {b = }")

這個和第二個片段的分析是相似的,但是 a[0] 是一個可變物件,因此進行資料修改的時候,a[0] 的指向沒有發生變化,因此 a 修改的內容會影響 b。

a = [[1, 2, 3], 2, 3, 4] 	|	 b = [[1, 2, 3], 2, 3, 4]
a = [[100, 2, 3], 2, 3, 4] 	|	 b = [[100, 2, 3], 2, 3, 4]

最後一個片段:

a = [[1, 2, 3], 2, 3, 4]
b = copy.deepcopy(a)
print(f"{a = } \t|\t {b = }")
a[0][0] = 100
print(f"{a = } \t|\t {b = }")

深複製會在記憶體當中重新建立一個和a[0]相同的物件,並且讓 b[0] 指向這個物件,因此修改 a[0],並不會影響 b[0],因此輸出結果如下所示:

a = [[1, 2, 3], 2, 3, 4] 	|	 b = [[1, 2, 3], 2, 3, 4]
a = [[100, 2, 3], 2, 3, 4] 	|	 b = [[1, 2, 3], 2, 3, 4]

撕開 Python 物件的神秘面紗

我們現在簡要看一下 Cpython 是如何實現 list 資料結構的,在 list 當中到底定義了一些什麼東西:

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;

在上面定義的結構體當中 :

  • allocated 表示分配的記憶體空間的數量,也就是能夠儲存指標的數量,當所有的空間用完之後需要再次申請記憶體空間。
  • ob_item 指向記憶體當中真正儲存指向 python 物件指標的陣列,比如說我們想得到列表當中第一個物件的指標的話就是 list->ob_item[0],如果要得到真正的資料的話就是 *(list->ob_item[0])。
  • PyObject_VAR_HEAD 是一個宏,會在結構體當中定一個子結構體,這個子結構題的定義如下:
typedef struct {
    PyObject ob_base;
    Py_ssize_t ob_size; /* Number of items in variable part */
} PyVarObject;
  • 這裡我們不去談物件 PyObject 了,主要說一下 ob_size,他表示列表當中儲存了多少個資料,這個和 allocated 不一樣,allocated 表示 ob_item 指向的陣列一共有多少個空間,ob_size 表示這個陣列儲存了多少個資料 ob_size <= allocated。

在瞭解列表的結構體之後我們現在應該能夠理解之前的記憶體佈局了,所有的列表並不儲存真正的資料而是儲存指向這些資料的指標。

總結

在本篇文章當中主要給大家介紹了 python 當中物件的複製和記憶體佈局,以及對物件記憶體地址的驗證,最後稍微介紹了一下 cpython 內部實現列表的結構體,幫助大家深入理解列表物件的記憶體佈局。


以上就是本篇文章的所有內容了,我是LeHung,我們下期再見!!!更多精彩內容合集可訪問專案:https://github.com/Chang-LeHung/CSCore

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

相關文章