【Python】詳解 可變/不可變對象 與 深/淺拷貝

目錄

一、緒論

二、說明

2.1 賦值 (Assignment)

2.1.1 變量與對象 (Variables and Objects)

2.1.2 不可變對象 (Immutable Objects)

2.1.3 可變對象 (Mutable Objects)

2.1.4 直接賦值 (Direct Assignment)

2.2 copy.copy() —— 淺拷貝 (Shallow Copy)

2.3 copy.deepcopy() —— 深拷貝 (Deep Copy)

2.4 其他 (Others)


一、緒論

copy 模塊定義了對象拷貝相關方法。有別於使用等號 “=” 賦值的操作,copy 模塊能夠實現對數據對象的深、淺拷貝。具體而言,copy 模塊的方法爲:

名稱 功能
copy() 返回數據對象的淺拷貝
deepcopy() 返回數據對象的深拷貝

以下將結合 Python 等號賦值對比說明深、淺拷貝的作用和意義。但此前,須先理清可變對象與不可變對象的含義與聯繫。

二、說明

2.1 賦值 (Assignment)

2.1.1 變量與對象 (Variables and Objects)

對象 指的是內存中存儲數據的實體,具有明確的類型,在 Python 中一切都是對象,包括函數。

變量 作爲指向對象的 指針,實質是(保存着)對對象的 引用

Python 是一門 動態的 (dynamic) 強類型 (strong) 語言。動態類型語言即在運行期間纔去確定數據類型的語言,靜態類型則相反。例如,VBScript 和 Python 是動態類型的,因爲它們是 在賦值時確定變量的類型。靜態類型語言則是一種在編譯期間就確定數據類型的語言,這類語言大都通過要求 在使用任一變量前聲明其數據類型 來確保如此,例如 Java 和 C。

>>> x = 666    # 666 是一個對象, 而 x 是指向對象 666 的一個變量, 類型相應爲 int 型
>>> x
666
## 變量 x 可以指向任意對象, 而沒有類型的前提限制, 因爲動態語言變量類型可隨着賦值而動態改變
>>> x = '666'  # 變量 x 指向新的對象 '666', 類型隨之變爲 string 型
>>> x
'666'

總之,在 Python 中,類型屬於對象,變量本無類型,僅僅是一個對象的引用 (一個指針)。而 變量指向對象的數據類型若發生變化,則變量的類型亦隨之改變。而賦值語句改變的是變量所執的對對象的引用,故一個變量可指向各種數據類型的對象。

此外,在 Python 中,從數據類型的角度看,對象可分爲“可變對象”和“不可變對象”,常見的內建類型有:


2.1.2 不可變對象 (Immutable Objects)

不可變對象:對象相應內存中的值不可改變,常見的有 int、float、string、tuple 等類型的對象。因爲 Python 中的變量存放的是對象引用,所以對不可變對象而言,儘管對象本身不可改變,但變量的對象引用仍是可變的。具體而言,指向原對象的變量被改變爲指向新對象時,Python 會開闢一塊新的內存區域,並令變量指向這個新內存 (存放新對象引用)。例如:

i = 73   # 變量 i 指向原對象 73 (變量 i 存放原對象 73 的引用)
i += 2   # 變量 i 指向新對象 75 (變量 i 存放原對象 75 的引用)

綜上可知,不可變的對象並未改變,而是創建了新對象,改變了變量的對象引用。具體而言,原對象 —— 不可變對象 73 內存中的值不變,Python 創建了新對象 75,令變量 i 重新指向新對象 75 / 保存對新對象 75 的引用,並通過“垃圾回收機制”回收原對象 73 的內存。

  • 垃圾回收 (garbage collection) 機制指的是:對處理完畢後不再需要的堆內存空間的數據對象進行清理,釋放它們所使用的內存空間的過程,而將不需要的數據比喻爲“垃圾”。例如,C 使用 free() 函數;C++ 使用 delete 運算符;而在 C++ 基礎上開發的 C# 和 Java 等,其程序運行環境會自動進行垃圾回收,以避免用戶疏忽而忘記內存釋放處理,造成內存泄露 (memory leaky) 問題。
  • Python 通過 引用計數 (Reference Counting) 和一個 能夠檢測和打破循環引用的循環垃圾回收器 來執行垃圾回收。可用 gc 模塊 控制垃圾回收器。具體而言,對每個對象維護一個 ob_refcnt 字段 (對象引用計數器),用於記錄該對象當前被引用的次數。每當有新引用指向該對象時,該對象的引用計數 ob_refcnt +1;每當該對象的引用失效時,該對象的引用計數 ob_refcnt -1;一旦對象的引用計數 ob_refcnt = 0,該對象立即被回收,對象佔用的內存空間將被自動放入 自由內存空間池,以待後用。
  • 這種引用計數垃圾回收機制的優點在於,能夠自動清理不用的內存空間,甚至能夠隨意新建對象引用 (不建議) 而無需考慮手動釋放內存空間的問題,故相比於 C 或 C++ 這類靜態語言更“省心”。
  • 這種引用計數垃圾回收機制的次要缺點是需要額外空間資源維護引用計數,主要缺點則是無法解決對象的“循環引用”問題。因此,也有很多語言如 Java 並未採用該機制。

注意,對於不可變對象,所有指向該對象的變量在內存中 共用同一個地址這種多個變量引用同一個對象的現象叫做 共享引用但不管有多少個引用指向它,都只有一個地址值,只有一個引用計數會記錄指向該地址的引用數目。

>>> x = 0
>>> y = 0
>>> print(id(x) == id(y))
True
>>> print(x is y)
True
>>> print(id(0), id(x), id(y))  # 結果不唯一, 但一定是相同的
2424416677616 2424416677616 2424416677616 

事實上,Python 對不可變對象有着許多性能/效率優化機制,若學有餘力或饒有興趣,不妨瞭解一下以加深對內存優化機制的理解,詳見文章《【Python】詳解 小整數池 & intern 機制 (不可變對象的內存優化原理) 》。 


2.1.3 可變對象 (Mutable Objects)

可變對象:變量所指向對象的內存地址處的值可改變,常見的有 list、set、dict 等類型的對象。因此指向可變對象的變量若發生改變,則該可變對象亦隨之改變,即發生 原地 (in-place) 修改。換言之, 當對象相應內存中的值變化時,變量的對對象引用是不變化,即變量仍指向原可變對象。例如:

>>> m = [5,9]  # 變量 m 指向可變對象(list)
>>> id(m)
1841032547080

>>> m += [6]   # 可變對象(list) 將隨變量 m 的改變而發生原地 (in-place) 修改
>>> id(m)
1841032547080

綜上可知,可變對象隨着變量的改變而改變,但變量的對對象引用保持不變,即變量仍指向原對象。具體而言,變量 m 先指向可變對象 [5, 9] ,然後隨着變量增加元素 6,可變對象 [5, 9] 也隨之在內存中增加 6,而變化前後變量 m 始終指向同一個可變對象 / 保存同一個可變對象的引用。 

但注意,我們也由此知道,對於“看起來相同”的可變對象,其內存地址是完全不同的,例如:

>>> n = [1, 2, 3]
>>> id(n)
1683653539464

>>> n = [1, 2, 3]
>>> id(n)
1683653609928

可見,對於兩個可變對象 [1, 2, 3],雖然看起來相同,但內存地址完全不同。不像不可變對象,所有指向不可變對象的變量在內存中共用同一個地址 (比如 2.1.2 中 666 的例子)。


2.1.4 直接賦值 (Direct Assignment)

Python 中的變量存在深、淺拷貝的區別。

對於不可變對象,無論深、淺拷貝,內存地址 (id) 都是一成不變的;

對於可變對象,則存在 3 種不同情況。以下以 list 爲例簡要說明:

情況一 —— 直接賦值:僅拷貝了對象的引用,故前後變量均未隔離,任一變量 / 對象發生改變,所有引用了同一對象的變量都作相同改變。例如:

>>> x = [555, 666, [555, 666]]
>>> y = x  # 直接賦值, 變量前後並未隔離
>>> y
[555, 666, [555, 666]]

# 修改變量 x, 變量 y 也隨之改變
>>> x.append(777)  
>>> x
[555, 666, [555, 666], 777]
>>> y
[555, 666, [555, 666], 777]

# 修改變量 y, 變量 x 也隨之改變
>>> y.pop()
777
>>> y
[555, 666, [555, 666]]
>>> x
[555, 666, [555, 666]]

在某些情況下,這是致命的,因此還需要深、淺拷貝來實現真實的拷貝目的。


2.2 copy.copy() —— 淺拷貝 (Shallow Copy)

情況二 —— 淺拷貝:使用 copy(x) 函數,拷貝可變對象如 list 的“最外圍”並實現隔離,但 list 內部的嵌套對象仍然是引用,未被隔離。例如:

>>> import copy
>>> x = [555, 666, [555, 666]]
>>> z = copy.copy(x)  # 淺拷貝
>>> zz = x[:]  # 也是淺拷貝, 等同於使用 copy() 函數的 z
>>> z
[555, 666, [555, 666]]
>>> zz
[555, 666, [555, 666]]

# 改變變量 x 的外圍元素, 不會改變淺拷貝變量
>>> x.append(777)
>>> x
[555, 666, [555, 666], 777]  # 只有自身改變, 增加了外圍元素 777
>>> z
[555, 666, [555, 666]]  # 未改變
>>> zz
[555, 666, [555, 666]]  # 未改變

# 改變變量 x 的內層元素, 則會改變淺拷貝變量
>>> x[2].append(888)
>>> x
[555, 666, [555, 666, 888], 777]  # 同時發生改變, 增加了內層元素 888
>>> z
[555, 666, [555, 666, 888]]  # 同時發生改變, 增加了內層元素 888
>>> zz
[555, 666, [555, 666, 888]]  # 同時發生改變, 增加了內層元素 888

# 淺拷貝變量的外圍元素改變不會相互影響
>>> z.pop(0)
555
>>> x
[555, 666, [555, 666, 888], 777]  # 未改變
>>> z
[666, [555, 666, 888]]  # 只有自身改變, 彈出了外圍元素 555
>>> zz
[555, 666, [555, 666, 888]]  # 未改變

# 淺拷貝變量的內層元素改變會相互影響
>>> z[1].pop()
888
>>> x
[555, 666, [555, 666], 777]  # 同時發生改變, 彈出了內層元素 888
>>> z
[666, [555, 666]]  # 同時發生改變, 彈出了內層元素 888
>>> zz
[555, 666, [555, 666]]  # 同時發生改變, 彈出了內層元素 888

注意,所謂改變應包含“增、刪、改”三種,以上僅展示了前兩種情況,第三種不言自明。


2.3 copy.deepcopy() —— 深拷貝 (Deep Copy)

情況三 —— 深拷貝:使用 deepcopy(x[,memo]) 函數,拷貝可變對象如 list 的“外圍+內層”而非引用,實現前後變量的完全隔離。例如:

>>> import copy
>>> x = [555, 666, [555, 666]]
>>> k = copy.deepcopy(x)  # 深拷貝
>>> k
[555, 666, [555, 666]]

# 改變變量 x 的外圍元素, 不會改變深拷貝變量
>>> x.append(777)
>>> x
[555, 666, [555, 666], 777]
>>> k
[555, 666, [555, 666]]  # 未改變

# 改變變量 x 的內層元素, 同樣不會改變深拷貝變量
>>> x[2].append(888)
>>> x
[555, 666, [555, 666, 888], 777]
>>> k
[555, 666, [555, 666]]  # 未改變

# 深拷貝變量的外圍元素改變不會相互影響
>>> k.pop(0)
555
>>> x
[555, 666, [555, 666, 888], 777]  # 未改變
>>> k
[666, [555, 666]]

# 深拷貝變量的內層元素改變同樣不會相互影響
>>> k[1].pop()
666
>>> x
[555, 666, [555, 666, 888], 777]  # 未改變
>>> k
[666, [555]]

2.4 其他 (Others)

上述內容即爲基本用法,對於普通使用足夠了。若想進一步深入,可選讀如下內容:

淺拷貝和深拷貝之間的區別僅在於複合對象 (即包含其他對象的對象,如 list 或類的實例) 相關:

  • 一個 淺拷貝 會構造一個新的複合對象,然後 (在可能的範圍內) 將原對象中找到的 引用  插入其中。

  • 一個 深拷貝 會構造一個新的複合對象,然後遞歸地將原始對象中所找到的對象的 副本 插入。

深拷貝操作通常存在兩個問題,而淺拷貝操作並不存在這些問題:

  • 遞歸對象 (直接或間接包含對自身引用的複合對象) 可能會導致遞歸循環

  • 由於深拷貝會複製所有內容 (外圍內層),故可能過多複製 (例如本應在副本間共享的數據) 。

深拷貝函數 deepcopy() 通過以下方式避免上述問題:

  • 保留在當前複製過程中已複製的對象的 “備忘錄” (memo) 字典;

  • 允許用戶定義的類重載複製操作或複製的組件集合。

copy 模塊不拷貝模塊、方法、棧追蹤(stack trace)、棧幀(stack frame)、文件、套接字、窗口、數組及任何類似的類型。它通過不改變地返回原始對象來(淺層或深層地)“複製” 函數和類;類似於 pickle 模塊處理這類問題的方式。


 

參考資料:

https://www.runoob.com/note/46684

https://docs.python.org/zh-cn/3.6/library/copy.html?highlight=copy#module-copy

https://www.cnblogs.com/ajianbeyourself/p/11151498.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章