Python學習之路27-物件引用、可變性和垃圾回收

VPointer發表於2019-02-16

《流暢的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模組提供的deepcopycopy函式能為任意物件做深複製和淺複製。

# 程式碼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]]
複製程式碼

此外,深複製有時可能太深了。例如,物件可能會引用不該複製的外部資源或單例值,這時,深複製就不應該複製這些值。如果要控制copydeepcopy的行為,我們可以在物件中重寫特殊方法__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 = passengersself.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函式,即最好使用WeakKeyDictionaryWeakValueDictionaryWeakSetfinalize(它們在內部使用弱引用),不推薦自己動手建立並處理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集合以及一般的弱引用,能處理的物件型別有限:

  • 基本的listdict例項不能作為弱引用的所指物件,但它們的子類則可以;

    class MyList(list):
        """MyList的例項可作為弱引用的所指物件"""
    複製程式碼
  • set的例項可作為所指物件;

  • 自定義類的例項可以;

  • inttuple的例項不能作為弱引用的所指物件,它們的子類也不行。

但這些侷限基本上是CPython的實現細節,其他Python直譯器的情況可能不同。

6. CPython對不可變型別走的捷徑

本節內容是Python實現的細節,可以跳過

這些細節是CPython核心開發者走的捷徑和優化措施,利用這些細節寫的程式碼在其他Python直譯器中可能沒用,在CPython未來的版本中也可能沒用。下面是具體內容:

  • 對元組t來說,t[:]tuple(t)不建立副本,而是返回同一個物件的引用;

  • strbytesfrozenset例項也是如此,並且frozensetcopy方法返回的也不是副本(注意,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 ~

Python學習之路27-物件引用、可變性和垃圾回收

相關文章