Python對象的比較、拷貝

Python對象的比較、拷貝

這篇文章我們介紹下python的copy機制,其實已經接觸到了很多 Python對象比較和複製的例子,比如下面這個,判斷a和b是否相等的if語句:

if a == b:
...

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

l1 = [1, 2, 3]
l2 = list(l1)

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

  • l2是l1的淺拷貝(shallow copy)還是深度拷貝(deep copy)呢?
  • a == b是比較兩個對象的值相等,還是兩個對象完全相等呢?

關於這些的種種知識,讓你有個全面的瞭解。

'==' VS 'is'

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

a == b

而'is'操作符比較的是對象的身份標識是否相等,即它們是否是同一個對象,是否指向同一個內存地址。

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

a = 10
b = 10
a == b
True
id(a)
4427562448
id(b)
4427562448
a is b
True

這裡,首先Python會為10這個值開闢一塊內存,然後變量a和b同時指向這塊內存區域,即a和b都是指向10這個變量,因此a和b的值相等,id也相等,a == b和a is b都返回True。

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

a = 257
b = 257
a == b
True
id(a)
4473417552
id(b)

4473417584
a is b
False

這裡我們把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:

if a is None:
...
if a is not None:
...

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

但是''操作符卻不同,執行a == b相當於是去執行a.eq(b),而Python大部分的數據類型都會去重載eq這個函數,其內部的處理通常會複雜一些。比如,對於列表,eq函數會去遍歷列表中的元素,比較它們的順序和值是否相等。

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

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

t1 = (1, 2, [3, 4])
t2 = (1, 2, [3, 4])
t1 == t2
True
t1[-1].append(5)
t1 == t2
False

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

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

淺拷貝和深度拷貝

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

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

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

l1 = [1, 2, 3]
l2 = list(l1)
l2
[1, 2, 3]
l1 == l2
True
l1 is l2
False
s1 = set([1, 2, 3])
s2 = set(s1)
s2
{1, 2, 3}
s1 == s2
True
s1 is s2
False

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

l1 = [1, 2, 3]
l2 = l1[:]
l1 == l2
True
l1 is l2
False

當然,Python中也提供了相對應的函數copy.copy(),適用於任何數據類型:

import copy
l1 = [1, 2, 3]
l2 = copy.copy(l1)

不過,需要注意的是,對於元組,使用tuple()或者切片操作符':'不會創建一份淺拷貝,相反,它會返回一個指向相同元組的引用:

t1 = (1, 2, 3)
t2 = tuple(t1)
t1 == t2
True
t1 is t2
True

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

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

l1 = [[1, 2], (30, 40)]
l2 = list(l1)
l1.append(100)
l1[0].append(3)
l1
[[1, 2, 3], (30, 40), 100]
l2
[[1, 2, 3], (30, 40)]
l1[1] += (50, 60)
l1
[[1, 2, 3], (30, 40, 50, 60), 100]
l2
[[1, 2, 3], (30, 40)]

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

接著往下看。l1.append(100),表示對l1的列表新增元素100。這個操作不會對l2產生任何影響,因為l2和l1作為整體是兩個不同的對象,並不共享內存地址。操作過後l2不變,l1會發生改變:

[[1, 2, 3], (30, 40), 100]

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

l1: [[1, 2, 3], (30, 40), 100] 

l2: [[1, 2, 3], (30, 40)]

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

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

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

所謂深度拷貝,是指重新分配一塊內存,創建一個新的對象,並且將原對象中的元素,以遞歸的方式,通過創建新的子對象拷貝到新對象中。因此,新對象和原對象沒有任何關聯。

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

import copy
l1 = [[1, 2], (30, 40)]
l2 = copy.deepcopy(l1)
l1.append(100)
l1[0].append(3)
l1
[[1, 2, 3], (30, 40), 100]
l2
[[1, 2], (30, 40)]

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

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

import copy
x = [1]
x.append(x)
x
[1, [...]]
y = copy.deepcopy(x)
y
[1, [...]]

上面這個例子,列表x中有指向自身的引用,因此x是一個無限嵌套的列表。但是我們發現深度拷貝x到y後,程序並沒有出現stack overflow的現象。這是為什麼呢?

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

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 # 如果字典裡已經存儲了將要拷貝的對象,則直接返回
...

總結

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

  • 比較操作符'=='表示比較對象間的值是否相等,而'is'表示比較對象的標識是否相等,即它們是否指向同一個內存地址。
  • 比較操作符'is'效率優於'==',因為'is'操作符無法被重載,執行'is'操作只是簡單的獲取對象的ID,並進行比較;而'=='操作符則會遞歸地遍歷對象的所有值,並逐一比較。
  • 淺拷貝中的元素,是原對象中子對象的引用,因此,如果原對象中的元素是可變的,改變其也會影響拷貝後的對象,存在一定的副作用。
  • 深度拷貝則會遞歸地拷貝原對象中的每一個子對象,因此拷貝後的對象和原對象互不相關。另外,深度拷貝中會維護一個字典,記錄已經拷貝的對象及其ID,來提高效率並防止無限遞歸的發生。

最後,大家思考下下面這道題。我曾用深度拷貝,拷貝過一個無限嵌套的列表。那麼。當我們用等於操作符'=='進行比較時,輸出會是什麼呢?是True或者False還是其他?為什麼呢?

import copy
x = [1]
x.append(x)
y = copy.deepcopy(x)
以下命令的輸出是?
x == y

歡迎大家留言!


分享到:


相關文章: