1. 程式人生 > >python按引用賦值和深、淺拷貝

python按引用賦值和深、淺拷貝

按引用賦值而不是拷貝副本

在python中,無論是直接的變數賦值,還是引數傳遞,都是按照引用進行賦值的

在計算機語言中,有兩種賦值方式:按引用賦值、按值賦值。其中按引用賦值也常稱為按指標傳值(當然,它們還是有點區別的),後者常稱為拷貝副本傳值。它們的區別,詳細內容參見:按值傳遞 vs. 按指標傳遞

下面僅解釋python中按引用賦值的相關內容,先分析下按引用賦值的特別之處,然後分析按引用賦值是什麼樣的過程。

按引用賦值的特性

例如:

a = 10000
b = a

>>> a,b
(10000, 10000)

這樣賦值後,b和a不僅在值上相等,而且是同一個物件,也就是說在堆記憶體中只有一個數據物件10000,這兩個變數都指向這一個資料物件。從資料物件的角度上看,這個資料物件有兩個引用,只有這兩個引用都沒了的時候,堆記憶體中的資料物件10000才會等待垃圾回收器回收。

它和下面的賦值過程是不等價的:

a = 10000
b = 10000

雖然a和b的值相等,但他們不是同一個物件,這時候在堆記憶體中有兩個資料物件,只不過這兩個資料物件的值相等。

對於不可變物件,修改變數的值意味著在記憶體中要新建立一個數據物件。例如:

a = 10000
b = a
a = 20000

>>> a,b
(20000, 10000)

在a重新賦值之前,b和a都指向堆記憶體中的同一個資料物件,但a重新賦值後,因為數值型別10000是不可變物件,不能在原始記憶體塊中直接修改資料,所以會新建立一個數據物件儲存20000,最後a將指向這個20000物件。這時候b仍然指向10000,而a則指向20000。

結論是:對於不可變物件,變數之間不會相互影響

。正如上面重新賦值了a=20000,但變數b卻沒有任何影響,仍然指向原始資料10000。

對於可變物件,比如列表,它是在"原處修改"資料物件的(注意加了雙引號)。比如修改列表中的某個元素,列表的地址不會變,還是原來的那個記憶體物件,所以稱之為"原處修改"。例如:

L1 = [111,222,333]
L2 = L1
L1[1] = 2222

>>> L1,L2
([111, 2222, 333], [111, 2222, 333])

L1[1]賦值的前後,資料物件[111,222,333]的地址一直都沒有改變,但是這個列表的第二個元素的值已經改變了。因為L1和L2都指向這個列表,所以L1修改第二個元素後,L2的值也相應地到影響

。也就是說,L1和L2仍然是同一個列表物件[111,2222,333]

結論是:對於可變物件,變數之間是相互影響的

按引用賦值的過程分析

當將段資料賦值給一個變數時,首先在堆記憶體中構建這個資料物件,然後將這個資料物件在記憶體中的地址儲存到棧空間的變數中,這樣變數就指向了堆記憶體中的這個資料物件。

例如,a = 10賦值後的圖示:

如果將變數a再賦值給變數b,即b = a,那麼賦值後的圖示:

因為a和b都指向對記憶體中的同一個資料物件,所以它們是完全等價的。這裡的等價不僅僅是值的比較相等,而是更深層次的表示同一個物件。就像a=20000和c=20000,雖然值相等,但卻是兩個資料物件。這些內容具體的下一節解釋。

在python中有可變資料物件和不可變資料物件的區分。可變的意思是可以在堆記憶體原始資料結構內修改資料,不可變的意思是,要修改資料,必須在堆記憶體中建立另一個數據物件(因為原始的資料物件不允許修改),並將這個新資料物件的地址儲存到變數中。例如,數值、字串、元組是不可變物件,列表是可變物件。

可變物件和不可變物件的賦值形式雖然一樣,但是修改資料時的過程不一樣。

對於不可變物件,修改資料是直接在堆記憶體中新建立一個數據物件。如圖:

對於可變物件,修改這個可變物件中的元素時,這個可變物件的地址不會改變,所以是"原處修改"的。但需要注意的是,這個被修改的元素是建立一個新資料物件,並作為可變物件的一部分,原始被修改的那個資料物件被丟棄。

>>> L=[333,444,555]
>>> id(L),id(L[1])
(56583832, 55771984)
>>> L[1]=4444
>>> id(L),id(L[1])
(56583832, 55771952)

如圖所示:

這個過程涉及到哪幾個步驟?以下為我猜測,從效能的角度上考慮,python中的列表是一個單(或雙)連結串列,修改某個元素意味著新建一個數據物件,並將它前面的元素所指向的下一個元素改為新物件的地址,然後那個原始的元素物件就等待被垃圾回收器回收。

早就存在的小整數

數值物件是不可變物件,理論上每個數值都會建立新物件。

但實際上並不總是如此,對於[-5,256]這個區間內的小整數,因為python內部引用過多,這些整數在python執行的時候就事先建立好並編譯好物件了。所以,a=2, b=2, c=2根本不會在記憶體中新建立資料物件2,而是引用早已建立好的初始化數值2。

所以:

>>> a=2
>>> b=2
>>> a is b
True

其實可以通過sys.getrefcount()函式檢視資料物件的引用計數。例如:

>>> sys.getrefcount(2)
78
>>> a=2
>>> sys.getrefcount(2)
79

對於小整數範圍內的數的引用計數都至少是幾十次的,而超出小整數範圍的數都是2或者3(不同執行方式得到的計數值不一樣,比如互動式、檔案執行)。

對於超出小整數範圍的數值,每一次使用數值物件都建立一個新資料物件。例如:

>>> a=20000
>>> b=20000
>>> a is b
False

因為這裡的20000是兩個物件,這很合理論。但是看下面的:

>>> a=20000;b=20000
>>> a is b
True
>>> a,b=20000,20000
>>> a is b
True

為什麼它們會返回True?原因是python解析程式碼的方式是按行解釋的,讀一行解釋一行,建立了第一個20000時發現本行後面還要使用一個20000,於是b也會使用這個20000,所以它返回True。而前面的換行賦值的方式,在解釋完一行後就會立即忘記之前已經建立過20000的資料物件,於是會為b建立另一個20000,所以它返回False。

如果是在python檔案中執行,則在同意作用域內的a is b一直都會是True,而不管它們的賦值方式如何。這和程式碼塊作用域有關:整個py檔案是一個模組作用域。此處只給測試結果,不展開解釋,否則篇幅太大了,如不理解下面的結果,可看我的另一篇Python作用域詳述

a = 25700
b = 25700
print(a is b)      # True

def f():
    c = 25700
    d = 25700
    print(c is d)  # True
    print(a is c)  # False

f()

深拷貝和淺拷貝

對於下面的賦值過程:

L1 = [1,2,3]
L2 = L1

前面分析過修改L1或L2的元素時都會影響另一個的原因:按引用賦值。實際上,按引用是指直接將L1中儲存的列表記憶體地址拷貝給L2。

再看一個巢狀的資料結構:

L1 = [1,[2,22,222],3]
L2 = L1

這裡從L1拷貝給L2的也是外層列表的地址,所以L2可以找到這個外層列表包括其內元素。但L2

首先是深、淺拷貝的概念:

  • 淺拷貝:shallow copy,只拷貝第一層的資料。python中賦值操作或copy模組的copy()就是淺拷貝
  • 深拷貝:deep copy,遞迴拷貝所有層次的資料,python中copy模組的deepcopy()是深拷貝

所謂第一層次,指的是出現巢狀的複雜資料結構時,那些引用指向的資料物件屬於深一層次的資料。例如:

L = [2,22,222]
L1 = [1,2,3]
L2 = [1,L,3]

L1和L2都只有一層深度,L3有兩層深度。淺拷貝時只拷貝第一層的資料作為副本,深拷貝遞迴拷貝所有層次的資料作為副本。

例如:

>>> L=[2,22,222]
>>> L1=[1,L,3]
>>> L11 = copy.copy(L1)

>>> L11,L1
([1, [2, 22, 222], 3], [1, [2, 22, 222], 3])

>>> L11 is L1
False
>>> id(L1),id(L11)         # 不想等
(17788040, 17786760)
>>> id(L1[1]),id(L11[1])   # 相等
(17787880, 17787880)

注意上面的L1和L11是不同的列表物件,但它們中的第二個元素是同一個物件,因為copy.copy是淺拷貝,只拷貝了這個內嵌列表的地址。

而深拷貝則完全建立新的副本物件:

>>> L111 = copy.deepcopy(L1)

>>> L1[1],L111[1]
([2, 22, 222], [2, 22, 222])

>>> id(L1[1]),id(L111[1])
(17787880, 17787800)

因為是淺拷貝,對於內嵌了可變物件的資料時,修改內嵌的可變資料,會影響其它變數。因為它們都指向同一個資料物件,這和按引用賦值是同一個道理。例如:

>>> s = [1,2,[3,33,333,3333]]
>>> s1 = copy.copy(s)

>>> s1[2][3] = 333333333

>>> s[2], s1[2]
([3, 33, 333, 333333333], [3, 33, 333, 333333333])

一般來說,淺拷貝或按引用賦值就是我們所期待的操作。只有少數時候(比如資料序列化、要傳輸、要持久化等),才需要深拷貝操作,但這些操作一般都內建在對應的函式中,無需我們手動去深拷貝。