《流暢的Python》筆記
本篇是“物件導向慣用方法”的第一篇,一共六篇。本篇主要是一些概念性的討論,內容有:Python中的變數,物件標識,值,別名,元組的某些特性,深淺複製,引用,函式引數,垃圾回收,del命令,弱引用等,比較枯燥,但卻能解決程式中不易察覺的bug。
1. 變數、標識、相等性和別名
先用一個形象的比喻來說明Python中的變數:變數是標註而不是盒子。也就是說,Python中的變數更像C++中的引用,最能說明這一點的就是多個變數指向同一個列表,但也有例外,在遇到某些內建型別,比如字串str
時,變數則變成了“盒子”:
# 程式碼1
>>> a = [1, 2]
>>> b = a # 標註,引用
>>> a.append(3)
>>> b
[1, 2, 3]
>>> c = "c"
>>> d = c # “盒子”
>>> c = "cc"
>>> d
'c'
複製程式碼
補充:說到了賦值方式,Python和C++一樣,也是等號右邊先執行。
1.1 相等性( == )與標識( is )
用一個更學術的詞來替換“標註”,那就是“別名”。在C++中,引用就是變數的別名,Python中也是,比如程式碼1
中的變數b
就是變數a
的別名,但如果是以下形式,變數b
則不是a
的別名:
# 程式碼2
>>> a = [1, 2]
>>> b = [1, 2]
>>> a == b # a和b的值相等
True
>>> a is b # a和b分別繫結了不同的物件,雖然物件的值相等
False
複製程式碼
==
檢測物件的值是否相等,is
運算子檢測物件的標識(ID)是否相等,id()
返回物件標識的整數表示。一般判斷兩物件的標識是否相等並不直接使用id()
,更多的是使用is
運算子。
物件ID在不同的實現中有所不同:在CPython中,id()
返回物件的記憶體地址,但在其他Python直譯器中可能是別的值。但不管怎麼,物件的ID一定唯一,且在生命週期中保持不變。
通常我們關心的是值,而不是標識,所以==
出現的頻率比is
高。但在變數和單例值之間比較時,應該使用is
。目前,最常使用is
檢測變數繫結的值是不是None
,推薦的寫法是:
# 程式碼3
x is None # 並非 x == None
x is not None # 並非 x != None
複製程式碼
is
運算子比==
速度快,因為它不能過載,所以Python不用尋找並呼叫特殊方法,而是直接比較兩個物件的ID。a == b
其實是語法糖,實際呼叫a.__eq__(b)
。雖然繼承自object
的__eq__
方法也是比較物件的ID,結果和is
一樣,但大多數內建型別覆蓋了該方法,處理過程更復雜,這就是為什麼is
比==
快。
1.2 元組的相對不可變性
元組和大多數Python集合一樣,儲存的是物件的引用。元組的不可變性其實是指tuple
資料結構的物理內容(即儲存的引用)不可變,與引用的物件無關。如果引用的物件可變,即便元組本身不可變,元素依然可變,不變的是元素的標識:
# 程式碼4
>>> t1 = (1, 2, [30, 40])
>>> t2 = (1, 2, [30, 40])
>>> t1 == t2
True
>>> id(t1[-1])
2019589413704
>>> t1[-1].append(99)
>>> t1
(1, 2, [30, 40, 99])
>>> id(t1[-1]) # 內容變了,標識沒有變
2019589413704
>>> t1 == t2
False
複製程式碼
這同時也說明,並不是每個元組都是可雜湊的!
2.深淺複製
複製物件時,相等性和標識之間的區別有更深入的影響。副本與源物件相等,但ID不同。而如果物件內部還有其他物件,這就涉及到了深淺複製的問題:到底是複製內部物件呢還是共享內部物件?
2.1 預設做淺複製
對列表和其他可變序列來說,我們可以使用構造方法或[:]
來建立副本。然而,這兩種方法做的都是淺複製,它們只複製了最外層的容器,副本中的元素是源容器中元素的引用。如果所有元素都是不可變的,那這樣做沒問題,還能節省記憶體;但如果其中有可變元素,這麼做就可能出問題:
# 程式碼5
l1 = [3, [11, 22], (7, 8)]
l2 = list(l1) # <1>
l1.append(100)
l1[1].remove(22)
print("l1:", l1, "\nl2:", l2)
l2[1] += [33, 44] # <2>
l2[2] += (10, 11) # <3>
print("l1:", l1, "\nl2:", l2)
# 結果
l1: [3, [11], (7, 8), 100] # 追加元素隻影響了l1
l2: [3, [11], (7, 8)] # 但刪除l1[1]中的元素影響了兩個列表
l1: [3, [11, 33, 44], (7, 8), 100] # +=對可變物件是就地操作,影響了兩個列表
l2: [3, [11, 33, 44], (7, 8, 10, 11)] # +=對不可變物件會建立新物件,隻影響了l2
複製程式碼
以上程式碼有3點需要解釋:
- <1>:
l1[1]
和l2[1]
指向同一列表,l1[2]
和l2[2]
指向同一元組。因為是淺複製,只是複製引用; - <2>:
+=
運算對可變物件來說是就地運算,不會建立新物件,所以對兩個列表都有影響; - <3>:
+=
運算對元組這樣的不可變物件來說,等同於l2[2] = l2[2] + (10, 11)
,此操作隱式地建立了新物件,l2[2]
重新繫結到了新物件,所以只有列表l2[2]
發生了改變,而l1[2]
沒有改變。
2.2 為任意物件做深複製和淺複製
淺複製並非是一種錯誤,只是一種選擇。而有時我們需要的是深複製,即副本不共享內部物件的引用。copy模組提供的deepcopy
和copy
函式能為任意物件做深複製和淺複製。
# 程式碼6
import copy
l1 = [3, [11, 22]]
l2 = copy.copy(l1) # 淺複製
l3 = copy.deepcopy(l1) # 深複製
l1[1].append(33) # 影響了l2,但沒有影響l3
print("l1:", l1, "\nl2:", l2, "\nl3:", l3)
# 結果
l1: [3, [11, 22, 33]]
l2: [3, [11, 22, 33]]
l3: [3, [11, 22]]
複製程式碼
在做深複製時,如果物件之間有迴圈引用,樸素的深複製演算法(換句話說就是你自己寫的深複製演算法:laughing:)很可能會陷入無限迴圈,然後報錯。deepcopy
會記住已經複製的物件,而不會進入無限迴圈:
# 程式碼7
>>> a = [10, 20]
>>> b = [a, 30] # 包含a的引用
>>> b
[[10, 20], 30]
>>> a.append(b) # 相互引用
>>> a
[10, 20, [[...], 30]]
>>> a[2][0]
[10, 20, [[...], 30]]
>>> a[2][0][2][0]
[10, 20, [[...], 30]]
>>> from copy import deepcopy
>>> c = deepcopy(a) # 不會報錯,能正確處理相互引用的問題
>>> c
[10, 20, [[...], 30]]
複製程式碼
此外,深複製有時可能太深了。例如,物件可能會引用不該複製的外部資源或單例值,這時,深複製就不應該複製這些值。如果要控制copy
和deepcopy
的行為,我們可以在物件中重寫特殊方法__copy__
和__deepcopy__
,具體內容這裡就不展開了,大家可以參考copy模組的官方文件。
3. 函式引數
通過別名共享物件還能解釋Python中傳遞引數的方式,以及使用可變型別作為引數預設值引起的問題。
3.1 函式的引數作為引用時
Python唯一支援的引數傳遞模式是共享傳參(call by sharing),它指函式的形參獲得實參中各個引用的副本,即形參是實參的別名。這種方案的結果就是,函式可能會修改作為引數傳入的可變物件,但無法修改這些物件的標識(不能把一個物件替換成另一個物件):
# 程式碼8
def f(a, b):
a += b
return a
x, y = 1, 2
print(f(x, y), x, y)
a, b = [1, 2], [3, 4]
print(f(a, b), a, b)
t, u = (10, 20), (30, 40)
print(f(t, u), t, u)
# 結果
3 1 2 # x, y是不可變物件,沒有影響到x, y
[1, 2, 3, 4] [1, 2, 3, 4] [3, 4] # x是可變物件,影響到了x
(10, 20, 30, 40) (10, 20) (30, 40) # x沒有指向新的元組,但形參a指向了新的元組
複製程式碼
3.2 引數預設值
不要使用可變型別作為引數的預設值!其實這個問題在之前的文章“Python學習之路7-函式”的2.3小節中有所提及。現在我們來看下面這個例子:
首先定義一個類:
# 程式碼9
class Bus:
def __init__(self, passengers=[]): # 預設值是個可變物件
self.passengers = passengers
def pick(self, name):
self.passengers.append(name)
def drop(self, name):
self.passengers.remove(name)
複製程式碼
下面是這個類的行為:
# 程式碼10
>>> bus1 = Bus(["Alice", "Bill"]) # 直到第8行Bus的表現都是正常的
>>> bus1.passengers
['Alice', 'Bill']
>>> bus1.pick("Charlie")
>>> bus1.drop("Alice")
>>> bus1.passengers
['Bill', 'Charlie']
>>> bus2 = Bus() # 使用預設值
>>> bus2.pick("Carrie")
>>> bus2.passengers
['Carrie'] # 到目前為止也是正常的
>>> bus3 = Bus() # 也是用預設值
>>> bus3.passengers
['Carrie'] # 不正常了!
>>> bus3.pick("Dave")
>>> bus2.passengers
['Carrie', 'Dave'] # bus2的值也被改變了
>>> bus2.passengers is bus3.passengers # 這倆是同一物件的別名
True
>>> bus1.passengers # bus1依然正常
['Bill', 'Charlie']
複製程式碼
上述行為的原因在於,引數的預設值在匯入模組時計算,方法或函式的形參指向這個預設值。而在上面這個例子中,類的屬性self.passengers
實際上是形參passengers
所指向的物件(所指物件,referent)的別名。而bus1
行為正常是因為從一開始它的passengers
就沒有指向預設值。
這裡有點像單例模式:引數的預設值是唯一的,只要採用預設值,不管建立多少個Bus
的例項,它們的self.passengers
都是同一個空列表[]
物件的別名,不會為每一個例項單獨建立一個專屬的[]
。
執行上述程式碼之後,可以檢視Bus.__init__
物件的__defaults__
屬性,它儲存了引數的預設值:
# 程式碼11
>>> Bus.__init__.__defaults__
(['Carrie', 'Dave'],)
>>> Bus.__init__.__defaults__[0] is bus2.passengers # self.passengers就是一個別名!
True
複製程式碼
這也說明了為什麼要用None
作為接收可變值的引數的預設值:
# 程式碼12
class Bus:
def __init__(self, passengers=None): # 預設值是個可變物件
if passengers is None: # 並不推薦 if passengers == None 這種寫法
self.passengers = []
else:
self.passengers = list(passengers) # 注意這裡!
-- snip --
複製程式碼
程式碼12
中的第7行並不是直接把形參passengers
賦值給self.passengers
,而是形參的副本(這裡是淺複製)。如果直接賦值,即self.passengers = passengers
(self.passengers
變成了使用者傳入的引數的別名),則使用者傳入的引數在執行過程中可能會被修改,而這並不一定是使用者想要的,這便違反了**"最少驚訝原則"**(居然還真有這麼個原則:joy_cat:)
4. del和垃圾回收
物件絕不會自行銷燬;然而,無法得到物件時,可能會被當做垃圾回收。——Python語言參考手冊
del
語句刪除變數(即"引用"),而不是物件。del
命令可能導致物件被當做垃圾回收,但這僅發生在當刪除的變數儲存的是物件的最後一個引用,或者無法得到物件時(如果兩個物件相互引用,如程式碼7
,當它們的引用只存在二者之間時,垃圾回收程式會判定它們都無法獲取,進而把它們都銷燬)。重新繫結也可能會導致物件的引用數量歸零,進而物件被銷燬。
在CPython中,垃圾回收使用的主要演算法是引用計數。實際上,每個物件都會統計有多少個引用指向自己。當引用計數歸零時,物件立即被銷燬。但在其他Python直譯器中則不一定是引用計數演算法。
補充:有個__del__
特殊方法,它不是用來銷燬例項的,而是在例項被銷燬前用來執行一些最後的操作,比如釋放外部資源等。我們不應該在程式碼中呼叫它,Python直譯器會在銷燬例項時先呼叫它(如果定義了),然後再釋放記憶體。它相當於C++中的解構函式。
我們可以使用weakref.finalize
來演示物件被銷燬時的情況:
# 程式碼13
>>> import weakref
>>> s1 = {1, 2, 3}
>>> s2 = s1
>>> def bye(): # 它充當一個回撥函式
... print("Gone with the wind...")
# 一定不要傳入待銷燬物件的繫結方法,否則會有一個指向物件的引用
>>> ender = weakref.finalize(s1, bye) # 在s1引用的物件上註冊bye回撥
>>> ender.alive
True
>>> del s1
>>> ender.alive
True # 說明 del s1並沒有刪除物件
>>> s2 = "spam"
Gone with the wind... # 引用計數為零,物件被刪除
>>> ender.alive
False
複製程式碼
5. 弱引用
不知道大家看到上述程式碼第15行時會不會產生如下疑惑:第8行程式碼明明把s1
引用傳給了finalize
函式(為了監控物件和呼叫回撥,必須要有引用),那麼物件{1, 2, 3}
則應該至少有三個引用,可為什麼最後它還是被銷燬了呢?這就牽扯到了弱引用這個概念。
5.1 weakref.ref
弱引用不會妨礙所指物件被當做垃圾回收,即弱引用不會增加物件的引用計數。(弱引用常被用於快取,但具體用在快取的哪些地方目前筆者還不清楚.....)
弱引用還是可呼叫物件,下面的程式碼展示瞭如何使用weakref.ref
例項獲取所指物件。
補充在程式碼之前:Python控制檯會自動把結果不為None
的表示式的結果繫結到變數_
(下劃線)上。這也說明了一個問題:微觀管理記憶體時,隱式賦值會為物件建立新引用,而這有可能會導致一些意外結果。
# 程式碼14
>>> import weakref
>>> a_set = {1, 2} # 物件{1, 2}的引用數+1
>>> wref = weakref.ref(a_set) # 並沒有增加所指物件的引用數
>>> wref
<weakref at 0x0000013D739E2D18; to 'set' at 0x0000013D739BE588>
>>> wref() # 弱引用是個可呼叫物件
{1, 2} # 發生了隱式賦值,變數 _ 指向了物件{1, 2},引用數+1
>>> a_set = {2, 3} # 引用數 -1
>>> wref() # 所指物件依然存在,還沒有被銷燬
{1, 2}
>>> wref() is None # 此時所指物件依然存在
False # 變數 _ 指向了物件False,物件{1, 2}引用數歸零,銷燬
>>> wref() is None # 驗證所指物件已被銷燬
True
複製程式碼
5.2 weakref集合
weakref.ref
類其實是底層介面,供高階用途使用,一般程式最好使用werakref
集合和finalize
函式,即最好使用WeakKeyDictionary
、WeakValueDictionary
、WeakSet
和finalize
(它們在內部使用弱引用),不推薦自己動手建立並處理weakref.ref
例項,除非你的工作就是專門和這些東西打交道的。
WeakValueDictionary
類實現的是一種可變對映,裡面的值("鍵值對"中的"值",而不是字典中的"值")是物件的弱引用。被引用的物件在程式中的其他地方被當做垃圾回收後,對應的鍵會自動從WeakValueDictionary
中刪除。因此,它經常用於快取。(檢視快取中變數是否依然存在?給框架用?)
# 程式碼15
>>> import weakref
>>> class Cheese:
... def __init__(self, kind):
... self.kind = kind
...
>>> stock = weakref.WeakValueDictionary()
>>> catalog = [Cheese("Red Leicester"), Cheese("Parmesan")]
>>> for cheese in catalog:
... stock[cheese.kind] = cheese
...
>>> sorted(stock.keys())
['Red Leicester', 'Parmesan'] # 表現正常
>>> del catalog
>>> sorted(stock.keys())
['Parmesan'] # 這是怎麼回事?
>>> del cheese # 這是問題所在
>>> sorted(stock.keys())
[]
複製程式碼
臨時變數引用了物件,這可能會導致該變數的存在時間比預期長。通常,這對區域性變數來說不是問題,因為它們在函式返回時會被銷燬。但上述程式碼中,for
迴圈中的變數cheese
是全域性變數,除非顯示刪除,否則不會消失。
與WeakValueDictionary
對應的是WeakKeyDictionary
,後者的鍵是弱引用,它的一些可能用途如下:
它的例項可以為應用中其他部分擁有的物件附加資料,這樣就無需為物件新增屬性。這對屬性訪問受限的物件尤其有用。
WeakSet
類的用途則很簡單:*"儲存元素弱引用的集合。當某元素沒有強引用時,集合會把它刪除。"*如果一個類需要知道它的所有例項,一種好的方案是建立一個WeakSet
型別的類屬性,儲存例項的弱引用。
5.3 弱引用的侷限
weakref
集合以及一般的弱引用,能處理的物件型別有限:
-
基本的
list
和dict
例項不能作為弱引用的所指物件,但它們的子類則可以;class MyList(list): """MyList的例項可作為弱引用的所指物件""" 複製程式碼
-
set
的例項可作為所指物件; -
自定義類的例項可以;
-
int
和tuple
的例項不能作為弱引用的所指物件,它們的子類也不行。
但這些侷限基本上是CPython的實現細節,其他Python直譯器的情況可能不同。
6. CPython對不可變型別走的捷徑
本節內容是Python實現的細節,可以跳過。
這些細節是CPython核心開發者走的捷徑和優化措施,利用這些細節寫的程式碼在其他Python直譯器中可能沒用,在CPython未來的版本中也可能沒用。下面是具體內容:
-
對元組
t
來說,t[:]
和tuple(t)
不建立副本,而是返回同一個物件的引用; -
str
、bytes
和frozenset
例項也是如此,並且frozenset
的copy
方法返回的也不是副本(注意,frozenset
的例項fs
不能用fs[:]
,因為fs
不是序列); -
str
的例項還有共享字串字面量的行為:>>> s1 = "ABC" >>> s2 = "ABC" >>> s1 is s2 True 複製程式碼
這叫做"駐留"(interning),這是一種優化措施。CPython還會在小的整數上使用這種優化,防止重複建立常用數字,如0,-1。但CPython不會駐留所有字串和數字,駐留的條件是實現細節,而且沒有文件說明。所以千萬不要依賴這個特性!(比較字串或數字請用
==
,而不是is
!)
7. 總結
每個Python物件都有標識、型別和值,只有物件的值可能變化。
變數儲存的是引用,這對Python程式設計有很多實際的影響:
- 簡單的賦值不會建立副本;
- 對
+=
或*=
等運算子來說,如果左邊的變數繫結了不可變物件,則會建立新物件,然後重新繫結;如果是可變物件,則就地修改; - 對現有的變數賦予新值不會修改之前繫結的物件。這叫重新繫結:現有變數繫結了其它物件。如果變數是之前那個物件的最後一個引用,該物件會被回收;
- 函式的引數以別名的形式傳遞,這意味著,函式可能會修改通過引數傳入的可變物件。這一行為無法避免,除非在函式內部建立副本,或者使用不可變物件;
- 不要使用可變型別作為函式的預設值!
==
用於比較值,is
用於比較引用。
某些情況下,可能需要儲存物件的引用,但不留存物件本身,比如記錄某個類的所有例項,這可以用弱引用解決。
迎大家關注我的微信公眾號"程式碼港" & 個人網站 www.vpointer.net ~