Python深拷貝和淺拷貝詳解

2020-07-16 10:05:04
對於淺拷貝(shallow copy)和深度拷貝(deep copy),本節並不打算一上來丟擲它們的概念,而是先從它們的操作方法說起,通過程式碼來理解兩者的不同。

Python淺拷貝

常見的淺拷貝的方法,是使用資料型別本身的構造器,比如下面兩個例子:
list1 = [1, 2, 3]
list2 = list(list1)
print(list2)
print("list1==list2 ?",list1==list2)
print("list1 is list2 ?",list1 is list2)

set1= set([1, 2, 3])
set2 = set(set1)
print(set2)
print("set1==set2 ?",set1==set2)
print("set1 is set2 ?",set1 is set2)
執行結果為:

[1, 2, 3]
list1==list2 ? True
list1 is list2 ? False
{1, 2, 3}
set1==set2 ? True
set1 is set2 ? False

在上面程式中,list2 就是 list1 的淺拷貝,同理 set2 是 set1 的淺拷貝。

當然,對於可變的序列,還可以通過切片操作符“:”來完成淺拷貝,例如:
list1 = [1, 2, 3]
list2 = list1[:]
print(list2)
print("list1 == list2 ?",list1 == list2)
print("list1 is list2 ?",list1 is list2)
執行結果為:

[1, 2, 3]
list1 == list2 ? True
list1 is list2 ? False


除此之外,Python 還提供了對應的函數 copy.copy() 函數,適用於任何資料型別。其用法如下:
import copy
list1 = [1, 2, 3]
list2 = copy.copy(list1)
print(list2)
print("list1 == list2 ?",list1 == list2)
print("list1 is list2 ?",list1 is list2)
執行結果為:

[1, 2, 3]
list1 == list2 ? True
list1 is list2 ? False


不過需要注意的是,對於元組,使用 tuple() 或者切片操作符 ':' 不會建立一份淺拷貝,相反它會返回一個指向相同元組的參照:
tuple1 = (1, 2, 3)
tuple2 = tuple(tuple1)
print(tuple2)
print("tuple1 == tuple2 ?",tuple1 == tuple2)
print("tuple1 is tuple2 ?",tuple1 is tuple2)
執行結果為:

(1, 2, 3)
tuple1 == tuple2 ? True
tuple1 is tuple2 ? True

此程式中,元組 (1, 2, 3) 只被建立一次,t1 和 t2 同時指向這個元組。

看到這裡,也許你可能對淺拷貝有了初步的認識。淺拷貝,指的是重新分配一塊記憶體,建立一個新的物件,但裡面的元素是原物件中各個子物件的參照。

對資料採用淺拷貝的方式時,如果原物件中的元素不可變,那倒無所謂;但如果元素可變,淺拷貝通常會出現一些問題,例如:
list1 = [[1, 2], (30, 40)]
list2 = list(list1)

list1.append(100)
print("list1:",list1)
print("list2:",list2)

list1[0].append(3)
print("list1:",list1)
print("list2:",list2)

list1[1] += (50, 60)
print("list1:",list1)
print("list2:",list2)
執行結果為:

list1: [[1, 2], (30, 40), 100]
list2: [[1, 2], (30, 40)]
list1: [[1, 2, 3], (30, 40), 100]
list2: [[1, 2, 3], (30, 40)]
list1: [[1, 2, 3], (30, 40, 50, 60), 100]
list2: [[1, 2, 3], (30, 40)]

此程式中,首先初始化了 list1 列表,包含一個列表和一個元組;然後對 list1 執行淺拷貝,賦予 list2。因為淺拷貝里的元素是對原物件元素的參照,因此 list2 中的元素和 list1 指向同一個列表和元組物件。

接著往下看,list1.append(100) 表示對 list1 的列表新增元素 100。這個操作不會對 list2 產生任何影響,因為 list2 和 list1 作為整體是兩個不同的物件,並不共用記憶體地址。操作過後 list2 不變,list1 會發生改變。

再來看,list1[0].append(3) 表示對 list1 中的第一個列表新增元素 3。因為 list2 是 list1 的淺拷貝,list2 中的第一個元素和 list1 中的第一個元素,共同指向同一個列表,因此 list2 中的第一個列表也會相對應的新增元素 3。

最後是 list1[1] += (50, 60),因為元組是不可變的,這裡表示對 list1 中的第二個元組拼接,然後重新建立了一個新元組作為 list1 中的第二個元素,而 list2 中沒有參照新元組,因此 list2 並不受影響。

通過這個例子,你可以很清楚地看到使用淺拷貝可能帶來的副作用。如果想避免這種副作用,完整地拷貝一個物件,就需要使用深拷貝。所謂深拷貝,是指重新分配一塊記憶體,建立一個新的物件,並且將原物件中的元素,以遞迴的方式,通過建立新的子物件拷貝到新物件中。因此,新物件和原物件沒有任何關聯。

Python 中以 copy.deepcopy() 來實現物件的深度拷貝。比如上述例子寫成下面的形式,就是深度拷貝:
import copy
list1 = [[1, 2], (30, 40)]
list2 = copy.deepcopy(list1)

list1.append(100)
print("list1:",list1)
print("list2:",list2)

list1[0].append(3)
print("list1:",list1)
print("list2:",list2)

list1[1] += (50, 60)
print("list1:",list1)
print("list2:",list2)
執行結果為:

list1: [[1, 2], (30, 40), 100]
list2: [[1, 2], (30, 40)]
list1: [[1, 2, 3], (30, 40), 100]
list2: [[1, 2], (30, 40)]
list1: [[1, 2, 3], (30, 40, 50, 60), 100]
list2: [[1, 2], (30, 40)]

可以看到,無論 list1 如何變化,list2 都不變。因為此時的 list1 和 list2 完全獨立,沒有任何聯絡。

不過,深度拷貝也不是完美的,往往也會帶來一系列問題。如果被拷貝物件中存在指向自身的參照,那麼程式很容易陷入無限迴圈,例如:
import copy
list1 = [1]
list1.append(list1)
print(list1)

list2 = copy.deepcopy(list1)
print(list2)
執行結果為:

[1, [...]]
[1, [...]]

此例子中,列表 x 中有指向自身的參照,因此 x 是一個無限巢狀的列表。但是當深度拷貝 x 到 y 後,程式並沒有出現棧溢位的現象。這是為什麼呢?

其實,這是因為深度拷貝函數 deepcopy 中會維護一個字典,記錄已經拷貝的物件與其 ID。拷貝過程中,如果字典裡已經儲存了將要拷貝的物件,則會從字典直接返回。通過檢視 deepcopy 函數實現的原始碼就會明白:
def deepcopy(x, memo=None, _nil=[]):
    """Deep copy operation on arbitrary Python objects.
       
    See the module's __doc__ string for more info.
    """
   
    if memo is None:
        memo = {}
    d = id(x) # 查詢被拷貝物件 x 的 id
    y = memo.get(d, _nil) # 查詢字典裡是否已經儲存了該物件
    if y is not _nil:
        return y # 如果字典裡已經儲存了將要拷貝的物件,則直接返回
        ...