這篇文章我們介紹下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
歡迎大家留言!