天天看點

15 | Python對象的比較、拷貝

15 | Python對象的比較、拷貝

在前面的學習中,我們其實已經接觸到了很多  Python 對象比較和複制的例子,比如下面這個,判斷 a 和 b 是否相等的 if 語句:

再比如第二個例子,這裡 l2 就是 l1 的拷貝。

但你可能并不清楚,這些語句的背後發生了什麼。比如,

l2 是 l1 的淺拷貝(shallow copy)還是深度拷貝(deep copy)呢?

a == b是比較兩個對象的值相等,還是兩個對象完全相等呢?關于這些的種種知識,我希望通過這節課的學習,讓你有個全面的了解。

關于這些的種種知識,我希望通過這節課的學習,讓你有個全面的了解。

等于(==)和 is 是 Python 中對象比較常用的兩種方式。簡單來說,'=='操作符比較對象之間的值是否相等,比如下面的例子,表示比較變量 a 和 b 所指向的值是否相等。

而 'is' 操作符比較的是對象的身份辨別是否相等,即它們是否是同一個對象,是否指向同一個記憶體位址。

在 Python 中,每個對象的身份辨別,都能通過函數 id(object) 獲得。是以,'is' 操作符,相當于比較對象之間的 ID 是否相等,我們來看下面的例子:

這裡,首先 Python 會為 10 這個值開辟一塊記憶體,然後變量 a 和 b 同時指向這塊記憶體區域,即 a 和 b 都是指向 10 這個變量,是以 a 和 b 的值相等,id 也相等,a == b 和 a is b 都傳回 True。

不過,需要注意,對于整型數字來說,以上a is b為 True 的結論,隻适用于 -5 到 256 範圍内的數字。比如下面這個例子:

這裡我們把 257 同時指派給了 a 和 b,可以看到a == b仍然傳回 True,因為 a 和 b 指向的值相等。但奇怪的是,a is b 傳回了 false,并且我們發現,a 和 b 的 ID 不一樣了,這是為什麼呢?

事實上,出于對性能優化的考慮,Python 内部會對 -5 到 256 的整型維持一個數組,起到一個緩存的作用。這樣,每次你試圖建立一個 -5 到 256 範圍内的整型數字時,Python 都會從這個數組中傳回相對應的引用,而不是重新開辟一塊新的記憶體空間。

但是,如果整型數字超過了這個範圍,比如上述例子中的 257,Python 則會為兩個 257 開辟兩塊記憶體區域,是以 a 和 b 的 ID 不一樣,a is b就會傳回 False 了。

通常來說,在實際工作中,當我們比較變量時,使用'=='的次數會比'is'多得多,因為我們一般更關心兩個變量的值,而不是它們内部的存儲位址。但是,當我們比較一個變量與一個單例(singleton)時,通常會使用'is'。一個典型的例子,就是檢查一個變量是否為 None:

這裡注意,比較操作符'is'的速度效率,通常要優于'=='。因為'is'操作符不能被重載,這樣,Python 就不需要去尋找,程式中是否有其他地方重載了比較操作符,并去調用。執行比較操作符'is',就僅僅是比較兩個變量的 ID 而已。

但是'=='操作符卻不同,執行a == b相當于是去執行a.__eq__(b),而 Python 大部分的資料類型都會去重載__eq__這個函數,其内部的處理通常會複雜一些。比如,對于清單,__eq__函數會去周遊清單中的元素,比較它們的順序和值是否相等。

不過,對于不可變(immutable)的變量,如果我們之前用'=='或者'is'比較過,結果是不是就一直不變了呢?

答案自然是否定的。我們來看下面一個例子:

我們知道元組是不可變的,但元組可以嵌套,它裡面的元素可以是清單類型,清單是可變的,是以如果我們修改了元組中的某個可變元素,那麼元組本身也就改變了,之前用'is'或者'=='操作符取得的結果,可能就不适用了。

這一點,你在日常寫程式時一定要注意,在必要的地方請不要省略條件檢查。

淺拷貝和深度拷貝

接下來,我們一起來看看 Python 中的淺拷貝(shallow copy)和深度拷貝(deep copy)。

對于這兩個熟悉的操作,我并不想一上來先抛概念讓你死記硬背來區分,我們不妨先從它們的操作方法說起,通過代碼來了解兩者的不同。

先來看淺拷貝。常見的淺拷貝的方法,是使用資料類型本身的構造器,比如下面兩個例子:

這裡,l2 就是 l1 的淺拷貝,s2 是 s1 的淺拷貝。當然,對于可變的序列,我們還可以通過切片操作符':'完成淺拷貝,比如下面這個清單的例子:

當然,Python 中也提供了相對應的函數 copy.copy(),适用于任何資料類型:

這裡,元組 (1, 2, 3) 隻被建立一次,t1 和 t2 同時指向這個元組。

到這裡,對于淺拷貝你應該很清楚了。淺拷貝,是指重新配置設定一塊記憶體,建立一個新的對象,裡面的元素是原對象中子對象的引用。是以,如果原對象中的元素不可變,那倒無所謂;但如果元素可變,淺拷貝通常會帶來一些副作用,尤其需要注意。我們來看下面的例子:

這個例子中,我們首先初始化了一個清單 l1,裡面的元素是一個清單和一個元組;然後對 l1 執行淺拷貝,賦予 l2。因為淺拷貝裡的元素是對原對象元素的引用,是以 l2 中的元素和 l1 指向同一個清單和元組對象。

接着往下看。l1.append(100),表示對 l1 的清單新增元素 100。這個操作不會對 l2 産生任何影響,因為 l2 和 l1 作為整體是兩個不同的對象,并不共享記憶體位址。操作過後 l2 不變,l1 會發生改變:

再來看,l1[0].append(3),這裡表示對 l1 中的第一個清單新增元素 3。因為 l2 是 l1 的淺拷貝,l2 中的第一個元素和 l1 中的第一個元素,共同指向同一個清單,是以 l2 中的第一個清單也會相對應的新增元素 3。操作後 l1 和 l2 都會改變:

最後是l1[1] += (50, 60),因為元組是不可變的,這裡表示對 l1 中的第二個元組拼接,然後重新建立了一個新元組作為 l1 中的第二個元素,而 l2 中沒有引用新元組,是以 l2 并不受影響。操作後 l2 不變,l1 發生改變:

通過這個例子,你可以很清楚地看到使用淺拷貝可能帶來的副作用。是以,如果我們想避免這種副作用,完整地拷貝一個對象,你就得使用深度拷貝。

所謂深度拷貝,是指重新配置設定一塊記憶體,建立一個新的對象,并且将原對象中的元素,以遞歸的方式,通過建立新的子對象拷貝到新對象中。是以,新對象和原對象沒有任何關聯。

Python 中以 copy.deepcopy() 來實作對象的深度拷貝。比如上述例子寫成下面的形式,就是深度拷貝:

我們可以看到,無論 l1 如何變化,l2 都不變。因為此時的 l1 和 l2 完全獨立,沒有任何聯系。

不過,深度拷貝也不是完美的,往往也會帶來一系列問題。如果被拷貝對象中存在指向自身的引用,那麼程式很容易陷入無限循環:

上面這個例子,清單 x 中有指向自身的引用,是以 x 是一個無限嵌套的清單。但是我們發現深度拷貝 x 到 y 後,程式并沒有出現 stack overflow 的現象。

這是為什麼呢?其實,這是因為深度拷貝函數 deepcopy 中會維護一個字典,記錄已經拷貝的對象與其 ID。拷貝過程中,如果字典裡已經存儲了将要拷貝的對象,則會從字典直接傳回,我們來看相對應的源碼就能明白:

總結

今天這節課,我們一起學習了 Python 中對象的比較和拷貝,主要有下面幾個重點内容。

比較操作符'=='表示比較對象間的值是否相等,而'is'表示比較對象的辨別是否相等,即它們是否指向同一個記憶體位址。

比較操作符'is'效率優于'==',因為'is'操作符無法被重載,執行'is'操作隻是簡單的擷取對象的 ID,并進行比較;而'=='操作符則會遞歸地周遊對象的所有值,并逐一比較。

淺拷貝中的元素,是原對象中子對象的引用,是以,如果原對象中的元素是可變的,改變其也會影響拷貝後的對象,存在一定的副作用。

深度拷貝則會遞歸地拷貝原對象中的每一個子對象,是以拷貝後的對象和原對象互不相關。另外,深度拷貝中會維護一個字典,記錄已經拷貝的對象及其 ID,來提高效率并防止無限遞歸的發生。

思考題

最後,我為你留下一道思考題。這節課我曾用深度拷貝,拷貝過一個無限嵌套的清單。那麼。當我們用等于操作符'=='進行比較時,輸出會是什麼呢?是 True 或者 False 還是其他?為什麼呢?建議你先自己動腦想一想,然後再實際跑一下代碼,來檢驗你的猜想。

歡迎在公衆号背景回複數字:“3” 加我好友并拉你進群讨論。寫下你的答案和學習感想,也歡迎你把這篇文章分享給你的同僚、朋友。我們一起交流,一起進步。