帶你搞懂 Python 的淺層複製(shallow copy)與深層複製(deep copy)
Posted on Jun 25, 2024 in Python 程式設計 - 中階 by Amo Chen ‐ 5 min read
Python 的淺層複製與深層複製是相當重要的存在,幾乎中、高階的 Python 應用開發者都有在日常工作中遇到有關淺層複製與深層複製的問題。
本文將從官方文件出發,並用實際範例搞懂淺層複製(shallow copy)與深層複製(deep copy)!
本文環境
- Python 3
從官方文件出發談 Binding
了解何謂淺層複製(shallow copy)與深層複製(deep copy)之前,我們有必要了解一些藏在文件深層的概念。
以下摘自 Python copy — Shallow and deep copy operations 文件的第 1 句:
Assignment statements in Python do not copy objects, they create bindings between a target and an object.
這句話的意思告訴我們,當我們使用 =
進行賦值(assignment)時, Python 不會複製物件(object),而是在目標與物件之間建立綁定(binding)的關係。
也就是說,以下 Python 程式碼:
a = list()
對 Python 來說是 target a
與 list()
object 綁定的意思(也可以理解成 a 指向 1 個 list object):
target -> object
知道 =
賦值(assignment)是建立 binding 之後,就會出現 1 個有趣的問題——請問以下程式碼的 a, b 的值在最後會相同嗎?
a = [1, 2, 3]
b = a
b.append(4)
print(f'{a=} {b=}')
上述程式碼執行結果如下:
a=[1, 2, 3, 4] b=[1, 2, 3, 4]
答案是相同!
p.s. 如果你知道答案相同,那代表你多少已經懂得 binding 的運作。
之所以答案相同,是因為前文所述的 binding 的緣故,因為 b = a
並不會創造 1 個新的 list object, 而是簡單建立 binding 而已,所以 a
, b
所綁定的 list object 都是同 1 個 list [1, 2, 3]
, 最後當 b
對 list [1, 2, 3]
增加數字 4 時, a
也會受到影響。
如果想知道 2 個 object 是否相同,可以使用 Python 的內建函式 id(), id(object)
函式會回傳 1 個整數代表 object 的獨特編號,如果有 2 個 objects 的 id 編號相同,就代表該 2 個 objects 實際是同 1 個。
同樣的範例,這次加上 id()
試試看:
a = [1, 2, 3]
print(f'id(a)={id(a)}')
b = a
print(f'id(b)={id(b)}')
b.append(4)
print(f'{a=} {b=}')
上述程式碼執行結果如下,可以看到 a
與 b
的 id 相同,代表 2 個變數使用的是相同的 object, 又再次說明為何 b.append(4)
執行之後, a
也會受到影響。
id(a)=134795087686208
id(b)=134795087686208
a=[1, 2, 3, 4] b=[1, 2, 3, 4]
相同的情況也可以用 dictionary 或者 object 重現:
- dictionary
foo = {'a': 1, 'b': 2}
bar = foo
bar['a'] += 1
print(f'{foo=} {bar=}') # foo={'a': 2, 'b': 2} bar={'a': 2, 'b': 2}
- object
class X():
def __repr__(self):
return f'{id(self)=} {self.x=}'
a = X()
b = a
b.x = 1
print(f'{a=}')
print(f'{b=}')
如果你不了解 binding 的話,實際情況是很有可能會因為賦值(assignment)的特性導致 bug 產生(個人就曾經犯過幾次 😢 )。
問題來了,為什麼需要 copy 模組?
雖然 binding 不會建立新 object 的做法在記憶體的使用上較有效率。不過,我們仍有需求處理必須建立新 object 的情況,這就是 Python copy 模組想提供的功能,讓開發者可以選擇用複製的方式,避免 binding 方式會互相影響的情況。
For collections that are mutable or contain mutable items, a copy is sometimes needed so one can change one copy without changing the other.
而複製可分為 2 種:
淺層複製 / Shallow Copy
A shallow copy constructs a new compound object and then (to the extent possible) inserts references into it to the objects found in the original.
深層複製 / Deep Copy
A deep copy constructs a new compound object and then, recursively, inserts copies into it of the objects found in the original.
這 2 者的差異是 compound objects 的複製行為。
什麼是 compound objects ?
compound objects (objects that contain other objects, like lists or class instances)
Compound objects 來說就是 1 個物件裡又包其他物件的情況,例如 list 裡面包很多個 class instances 的情況就屬於 compound object 。
例如下列範例程式碼的 x
指向的 object 就屬於 compound object:
class A: pass
a1 = A()
a2 = A()
x = [a1, a2]
Shallow Copy 做了什麼事?
談 shallow copy 做了什麼之前,先做個淺層複製試試。
Shallow copies of dictionaries can be made using dict.copy(), and of lists by assigning a slice of the entire list, for example,
copied_list = original_list[:]
.
Python 官方文件提到 dictionary 的淺層複製可以呼叫 dict.copy() 方法,如果是 list 的淺層複製則是 copied_list = original_list[:]
。
此外,也可以呼叫 copy.copy() 進行淺層複製。
以下範例程式碼是 1 個 compound object 的淺層複製,該 compound object 是 list 包 Num
實例(instance):
class Num(object):
def __init__(self, x) -> None:
self.x = x
def __repr__(self):
return f'{id(self)=} {self.x=}'
a = [Num(1), ]
print(f'id(a)={id(a)}')
b = a[:]
print(f'id(b)={id(b)}')
b[0].x += 1
print(f'{a[0]}')
print(f'{b[0]}')
上述程式碼執行結果如下,從結果可以看到 a
, b
還是互相影響了:
id(a)=134698100323520
id(b)=134698100324864
id(self)=134698101692192 self.x=2
id(self)=134698101692192 self.x=2
重點說明一下。
雖然 a
, b
的 id 經過淺層複製後已經不同了,但是只有外層的 list object 不同,而 list 內的元素還是使用相同 id, 所以我們才可以看到 id(self)
的結果都相同,這也導致 b[0].x += 1
執行之後, a[0]
也受到影響。
畫成圖表示的話:
所以,淺層複製的行為是:
建立 1 個新的 compound object 這也再次呼應前述範例
a
,b
的 id 不同,淺層複製會建立新的 compound object盡可能地把新的 compound object 裡的元素引用指向它原本的 object 此處前述範例呼應
id(self)
的結果都相同,而且會互相影響的情況
A shallow copy constructs a new compound object and then (to the extent possible) inserts references into it to the objects found in the original.
讀到此處,大家可能會心想「 WTF, 淺層複製還會互相影響,那幹嘛淺層複製?」
回答這個問題之前,再看以下範例:
class LargeFile(object):
def __repr__(self):
return f'{id(self)=}'
a = [LargeFile(), ]
b = a[:]
b.append(LargeFile())
print(f'{a=}')
print(f'{b=}')
上述範例程式碼執行結果如下:
a=[id(self)=134698100687248]
b=[id(self)=134698100687248, id(self)=134698100692048]
從結果可以看到 b
利用淺層複製使 b
新增元素不會影響到 a
,同時 b
也還保有 a
的資料,如果 LargeFile
是 1 個會耗用 1 GB 記憶體的檔案,那麽上述的淺層複製只耗用 2 GB 的記憶體,若使用完整複製,就會是 3 GB 的記憶體喔!
所以,總結來說,淺層複製雖然不是完整複製,但是記憶體利用較佳、執行速度也比較快(畢竟需要複製的東西少)。
Deep Copy 做了什麼事?
理解淺層複製之後, deep copy 就不難了。
A deep copy constructs a new compound object and then, recursively, inserts copies into it of the objects found in the original.
簡單來講, deep copy 就是符合我們直覺的複製(copy),而且不管 compound object 有多深,都會用遞迴的方式建立新的 compound object 。
以下是深層複製的範例程式,深層複製只需要呼叫 copy.deepcopy() 即可:
import copy
class Num(object):
def __init__(self, x) -> None:
self.x = x
def __repr__(self):
return f'{id(self)=} {self.x=}'
a = [Num(1), ]
print(f'id(a)={id(a)}')
b = copy.deepcopy(a)
print(f'id(b)={id(b)}')
b[0].x += 1
print(f'{a[0]}')
print(f'{b[0]}')
上述程式碼執行結果如下,可以看到 a
, b
不僅 id 不同,而且改了 b[0].x
也不會影響 a[0].x
:
id(a)=134795085943616
id(b)=134795085963264
id(self)=134795086214480 self.x=1
id(self)=134795086211120 self.x=2
畫成圖的話,妥妥的風馬牛不相干:
這就是 deep copy 。
不過 deep copy 也存在若干缺點:
- 因為是完整複製,所以可能導致多份相同 copy 佔用記憶體空間
- 執行速度慢
- 如果是遞迴物件(recursive object)的話,有造成遞迴迴圈(recursive loop)的可能性
Recursive objects (compound objects that, directly or indirectly, contain a reference to themselves) may cause a recursive loop.
哪些情況下要注意淺層複製與深層複製?
答案又回到一開頭。
For collections that are mutable or contain mutable items, a copy is sometimes needed so one can change one copy without changing the other.
當我們在賦值(assignment)操作 Python mutable 資料型態時,例如 list, dictionary, set 等等,就要注意淺層複製與深層複製的差別,以及它們可能帶來的優點或 bug 。
總結
正確理解 Python 的淺層複製與深層複製是相當重要的一件事,因爲不僅影響記憶體的利用率之外,也可能影響執行速度,更重要的是可以避免賦值(assignment)的 binding 特性所帶來的預料外的問題!
以上!
Enjoy!
References
copy — Shallow and deep copy operations