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

《流暢的Python》筆記

本篇是「面向對象慣用方法」的第一篇,一共六篇。本篇主要是一些概念性的討論,內容有:Python中的變量,對象標識,值,別名,元組的某些特性,深淺複製,引用,函數參數,垃圾回收,del命令,弱引用等,比較枯燥,但卻能解決程序中不易察覺的bug。html

1. 變量、標識、相等性和別名

先用一個形象的比喻來講明Python中的變量:變量是標註而不是盒子。也就是說,Python中的變量更像C++中的引用,最能說明這一點的就是多個變量指向同一個列表,但也有例外,在遇到某些內置類型,好比字符串str時,變量則變成了「盒子」:python

# 代碼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==快。app

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]]

在作深複製時,若是對象之間有循環引用,樸素的深複製算法(換句話說就是你本身寫的深複製算法)極可能會陷入無限循環,而後報錯。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變成了用戶傳入的參數的別名),則用戶傳入的參數在運行過程當中可能會被修改,而這並不必定是用戶想要的,這便違反了"最少驚訝原則"(竟然還真有這麼個原則

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 ~

相關文章
相關標籤/搜索