帶你搞懂 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 alist() 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=}')

上述程式碼執行結果如下,可以看到 ab 的 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

    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

    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] 也受到影響。

畫成圖表示的話:

shallow-copy.png

所以,淺層複製的行為是:

  1. 建立 1 個新的 compound object 這也再次呼應前述範例 a , b 的 id 不同,淺層複製會建立新的 compound object

  2. 盡可能地把新的 compound object 裡的元素引用指向它原本的 object 此處前述範例呼應 id(self) 的結果都相同,而且會互相影響的情況

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 就不難了。

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

畫成圖的話,妥妥的風馬牛不相干:

deepcopy.png

這就是 deep copy 。

不過 deep copy 也存在若干缺點:

  1. 因為是完整複製,所以可能導致多份相同 copy 佔用記憶體空間
  2. 執行速度慢
  3. 如果是遞迴物件(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

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!