流暢的Python:對象引用、可變性和垃圾回收

對象引用、可變性和垃圾回收

1. 變量不是盒子

人們經常使用“變量是盒子”這樣的比喻解釋變量,但是這有礙於理解面嚮對象語言中的引用式變量。Python 變量類似於 Java 中的引用式變量,因此最好把它們理解爲附加在對象上的標註。

class Gi():
    def __init__(self):
        print(f'Gi id:{id(self)}')
        
gi = Gi()
# Gi id:2323558943432
gi2 = Gi()
# Gi id:2323559105872

變量只不過是標註,所以無法阻止爲對象貼上多個標註。貼的多個標註,就是別名。


2. 標識、相等性和別名

tom = {'name':'TOM', 'age':10, 'sex':'m'}
tomy = tom

tomy is tom
# True
id(tomy), id(tom)
# (2323559005352, 2323559005352)
tomy == tom
# True
  
tam = {'name':'TOM', 'age':10, 'sex':'m'}

tom is tam
# False
id(tom), id(tam)
# (2323559005352, 2323558896264)
tom == tam
# True

tomy和tom 是別名,即兩個變量綁定同一個對象{'name':'TOM', 'age':10, 'sex':'m'}。而 tam 不是 tom的別名,因爲二者綁定的是不同的對象。tam 和 tom 綁定的對象具有相同的值(== 比較的就是值),但是它們的標識不同。

每個變量都有標識、類型和值。

  • 對象一旦創建,它的標識絕不會變;
  • 可以把標識理解爲對象在內存中的地址。
  • is 運算符比較兩個對象的標識;
  • id() 函數返回對象標識的整數表示。

編程中很少使用 id() 函數。標識最常使用 is 運算符檢查,而不是直接比較 ID。


3. 在**==**和is之間選擇

== 運算符比較兩個對象的值(對象中保存的數據),而 is 比較對象的標識。

通常,我們關注的是值,而不是標識,因此 Python 代碼中 == 出現的頻率比 is 高。

在變量和單例值之間比較時,應該使用 is。目前,最常使用 is 檢查變量綁定的值是不是 None。推薦的寫法:x is None,否定的正確寫法是:x is not None

is 運算符比 == 速度快,因爲它不能重載,所以 Python 不用尋找並調用特殊方法,而是直接比較兩個整數 ID。而 a == b 是語法糖,等同於a.__eq__(b)。繼承自 object 的__eq__方法比較兩個對象的 ID,結果與 is 一樣。但是多數內置類型使用更有意義的方式覆蓋了__eq__ 方法,會考慮對象屬性的值。相等性測試可能涉及大量處理工作,例如,比較大型集合或嵌套層級深的結構時。


4. 元組的相對不可變性

元組與多數 Python 集合(列表、字典、集,等等)一樣,保存的是對象的引用。如果引用的元素是可變的,即便元組本身不可變,元素依然可變。也就是說,元組的不可變性其實是指 tuple 數據結構的物理內容(即保存的引用)不可變,與引用的對象無關。

t1 = (1,2,[3,4,5])
t2 = (1,2,[3,4,5])
t1 == t2
# True
id(t1[-1])  
# 2323559065032
t1[-1].append(0)   # t1 不可變,但是 t1[-1] 可變
id(t1[-1])
# 2323559065032   t1[-1] 的標識沒變
t1 == t2  
# False   t1[-1]只是值變了, 導致t1不等於t2。

可以說,元組具有相對不可變性,這也是有些元組(元素中有可變的類型)不可散列的原因。


5. 默認做淺複製

複製列表(或多數內置的可變集合)最簡單的方式是使用內置的類型構造方法。

l1 = [1, [2, 3, 4], (5, 6, 7)]
l2 = list(l1)

l1 is l2
# False
l1 == l2
# True
id(l1), id(l2)
# (2323559065672, 2323561081096)

對列表和其他可變序列來說,還能使用簡潔的 l2 = l1[:] 語句創建副本。然而,構造方法或 [:] 做的是淺複製(即複製了最外層容器,副本中的元素是源容器中元素的引用)。如果所有元素都是不可變的,那麼這樣沒有問題,還能節省內存。但是,如果有可變的元素,可能就會導致意想不到的問題。

l1 = [3, [66, 55, 44], (7, 8, 9)]
l2 = list(l1)
l1.append(100)
l1[1].remove(55)  # l2[1] 綁定的列表與 l1[1] 是同一個
print('l1:', l1) 
print('l2:', l2)
# l1: [3, [66, 44], (7, 8, 9), 100]
# l2: [3, [66, 44], (7, 8, 9)]
l2[1] += [33, 22]   # += 運算符就地修改列表
l2[2] += (10, 11)   # += 運算符創建一個新元組,然後重新綁定給變量 l2[2]
print('l1:', l1) 
print('l2:', l2)
# l1: [3, [66, 44, 33, 22], (7, 8, 9), 100]
# l2: [3, [66, 44, 33, 22], (7, 8, 9, 10, 11)]

6.爲任意對象做深複製和淺複製

演示 copy() 和 deepcopy() 的用法:

import copy
class Bus(object):
    def __init__(self, passengers = None):
        if passengers is None:
            self.passagers = []
        else:
            self.passagers = list(passengers)
    def up(self, someone):
        if someone in self.passagers:
            assert 0, f'{someone} is in bus already'
        self.passagers.append(someone)
    def down(self, someone):
        if someone not in self.passagers:
            assert 0, f'{someone} is not in bus now'
        self.passagers.remove(someone)
        
bus1 = Bus(['a','b','c','d'])
bus2 = copy.copy(bus1)   # 淺複製副本(bus2)
bus3 = copy.deepcopy(bus1)  # 是深複製副本(bus3)
id(bus1), id(bus2), id(bus3)
# (2057602206352, 2057602063440, 2057602206296)

id(bus1.passagers), id(bus2.passagers), id(bus3.passagers)
# (2057599601928, 2057599601928, 2057599601736)

bus1.down('d')
print(bus1.passagers, bus2.passagers, bus3.passagers)
# ['a', 'b', 'c'] ['a', 'b', 'c'] ['a', 'b', 'c', 'd']

觀察 passengers 屬性後發現,bus1 和 bus2 共享同一個列表對象,因爲 bus2 是 bus1 的淺複製副本。bus3 是 bus1 的深複製副本,因此它的 passengers 屬性指代另一個列表。

一般來說,深複製不是件簡單的事。如果對象有循環引用,那麼這個樸素的算法會進入無限循環。deepcopy 函數會記住已經複製的對象,因此能優雅地處理循環引用。

a = [1,2]
b = [a, 0]
a.append(b)
a
# [1, 2, [[...], 0]]

7. 函數的參數作爲引用時

Python 唯一支持的參數傳遞模式是 共享傳參(call by sharing)。多數面嚮對象語言都採用這一模式,包括 Ruby、Smalltalk 和 Java(Java 的引用類型是這樣,基本類型按值傳參)。

共享傳參指函數的各個形式參數獲得實參中各個引用的副本。也就是說,函數內部的形參是實參的別名

這種方案的結果是,函數可能會修改作爲參數傳入的可變對象,但是無法修改那些對象的標識(即不能把一個對象替換成另一個對象)。

def test(a, b):
    a += b
    return a
  
x, y = 1, 2
test(x, y)
# 3
x, y
# (1, 2)

lx, ly = [1,2], [3,4]
test(lx, ly)
# [1, 2, 3, 4]
lx, ly
# ([1, 2, 3, 4], [3, 4])

t1, t2 = (1,2), (3,4)
test(t1, t2)
# (1, 2, 3, 4)
t1, t2
# ((1, 2), (3, 4))
不要使用可變類型作爲參數的默認值

可選參數可以有默認值,這是 Python 函數定義的一個很棒的特性,這樣我們的 API 在進化的同時能保證向後兼容。然而,我們應該避免使用可變的對象作爲參數的默認值。

class Bus(object):
    def __init__(self, passengers = []):
        self.passagers = passengers
    def up(self, someone):
        self.passagers.append(someone)
    def down(self, someone):
        self.passagers.remove(someone)
        
bus1 = Bus(['A', 'B'])
bus1.passagers
Out[3]: ['A', 'B']
bus1.up('C')
bus1.down('A')
bus1.passagers
Out[6]: ['B', 'C']
  
bus2 = Bus()
bus2.passagers
Out[8]: []
bus2.up('D')
bus2.passagers
Out[10]: ['D']
  
bus3 = Bus()
bus3.passagers
Out[12]: ['D']
bus3.up('E')
bus2.passagers
Out[14]: ['D', 'E']   # 登上bus3的E在bus2中
  
bus2.passagers is bus3.passagers  # bus2.passagers 和bus3.passagers 指代同一個列表
Out[15]: True
bus1.passagers
Out[16]: ['B', 'C']  # bus1.passagers 是不同的列表

問題在於,沒有指定初始乘客的 Bus 實例會共享同一個乘客列表。

實例化 Bus 時,如果傳入乘客,會按預期運作。但是不爲 Bus 指定乘客的話,奇怪的事就發生了,這是因爲 self.passengers 變成了 passengers 參數默認值的別名。出現這個問題的根源是,默認值在定義函數時計算(通常在加載模塊時),因此默認值變成了函數對象的屬性。因此,如果默認值是可變對象,而且修改了它的值,那麼後續的函數調用都會受到影響。

審 查Bus.__init__對 象, 看 看 它 的__defaults__屬性中的元素:

Bus.__init__.__defaults__
Out[18]: (['D', 'E'],)

可以驗證 bus2.passengers 是一個別名,它綁定到Bus.__init__.__defaults__屬性的第一個元素上:

Bus.__init__.__defaults__[0] is bus2.passagers
Out[19]: True
Bus.__init__.__defaults__[0] is bus3.passagers
Out[20]: True

可變默認值導致的這個問題說明了爲什麼通常使用 None 作爲接收可變值的參數的默認值 (我的理解:之前的正確代碼是通過 if passengers is None判斷 passengers 是否爲空,如果爲空,通過self.passagers = [] 空列表賦值給self.passagers, 這樣就在函數體裏面執行,而不是在def定義時執行,空列表也就不會成爲函數對象的屬性了)。在之前正確的示例中,__init__方法檢查 passengers 參數的值是不是 None,如果是就把一個新的空列表賦值給 self.passengers。如果 passengers 不是 None,正確的實現會把 passengers 的副本賦值給 self.passengers。


8. 防禦可變參數

如果定義的函數接收可變參數,應謹慎考慮調用方是否期望修改傳入的參數。

例如,若函數接收一個字典,且在處理的過程中要修改它,那麼這個副作用要不要體現到函數外部?應該具體情況具體分析。

class Bus(object):
    def __init__(self, passengers = None):
        if passengers  is None:
            self.passengers  = []
        else:
            self.passengers  = passengers   # 這裏不是list(passengers)

    def up(self, someone):
        self.passengers .append(someone)

    def down(self, someone):
        self.passengers .remove(someone)
        
team = ['A', 'B', 'C', 'D']
bus = Bus(team)
bus.passengers 
# ['A', 'B', 'C', 'D']

bus.down('A')
bus.down('B')
bus.passengers 
# ['C', 'D']
team
# ['C', 'D']

A、B從bus下車後,team的成員名單竟然也變了!

Bus 類中__init__方法中把 self.passengers 變成 passengers 的別名,而實例化bus後把self.passengers是傳給__init__方法的實參的別名team。在 self.passengers 上調用 .remove() 和 .append() 方法其實會修改傳給構造方法的那個列表。

這裏的問題是: 校車Bus 爲傳給構造方法的列表創建了別名。正確的做法是,校車自己只維護乘客列表。修正的方法:在 __init__ 中,傳入 passengers 參數時,應該把參數值的副本賦值給 self.passengers, 比如使用list(passengers)。另外,此時傳給 passengers 參數的值可以是元組或任何其他可迭代對象,例如set 對象,甚至數據庫查詢結果,因爲 list 構造方法接受任何可迭代對象。自己創建並管理列表可以確保支持所需的 .remove() 和 .append() 操作,這樣 .pick() 和 .drop() 方法才能正常運作。

小結:除非當前方法確實想修改通過參數傳入的對象,否則在類中直接把參數賦值給實例變量之前,一定要三思,因爲這樣會爲參數對象創建 別名 。如果不確定,那就創建副本。


9. del和垃圾回收

對象絕不會自行銷燬;然而,無法得到對象時,可能會被當作垃圾回收。

del 語句刪除名稱,而不是對象。del 命令可能會導致對象被當作垃圾回收,但是僅當刪除的變量保存的是對象的最後一個引用,或者無法得到對象時。重新綁定也可能會導致對象的引用數量歸零,導致對象被銷燬。

有個__del__特殊方法,但是它不會銷燬實例,不應該在代碼中調用。即將銷燬實例時,Python 解釋器會調用 __del__方法,給實例最後的機會,釋放外部資源。

在 CPython 中,垃圾回收使用的主要算法是引用計數。實際上,每個對象都會統計有多少引用指向自己。當引用計數歸零時,對象立即就被銷燬:CPython 會在對象上調用 __del__方法(如果定義了),然後釋放分配給對象的內存。CPython 2.0 增加了分代垃圾回收算法,用於檢測引用循環中涉及的對象組——如果一組對象之間全是相互引用,即使再出色的引用方式也會導致組中的對象不可獲取。Python 的其他實現有更復雜的垃圾回收程序,而且不依賴引用計數,這意味着,對象的引用數量爲零時可能不會立即調用__del__方法。

示例:使用 weakref.finalize 註冊一個回調函數,在銷燬對象時調用,來演示對象生命結束的情形。

import weakref

s1 = {1,2,3}
s2 = s1    	# s1和s2是別名,指向同一個集合{1, 2, 3}
def bye():   # 這個函數一定不能是要銷燬的對象的綁定方法,否則會有一個指向對象的引用。
    print('bye~')  # 在 s1 引用的對象上註冊 bye 回調
    
ender = weakref.finalize(s1, bye)  

ender.alive
# True
del s1
ender.alive  # del 不刪除對象,而是刪除對象的引用。
# True
s2 = 'new s2'
# bye~
# 重新綁定最後一個引用 s2,讓 {1, 2, 3} 無法獲取。對象被銷燬了
# 調用了 bye 回調,ender.alive 的值變成了 False。

10. 弱引用

正是因爲有引用,對象纔會在內存中存在。當對象的引用數量歸零後,垃圾回收程序會把對象銷燬。但是,有時需要引用對象,而不讓對象存在的時間超過所需時間。這經常用在緩存中。

弱引用不會增加對象的引用數量。引用的目標對象稱爲所指對象(referent)。因此我們說,弱引用不會妨礙所指對象被當作垃圾回收。

弱引用在緩存應用中很有用,因爲我們不想僅因爲被緩存引用着而始終保存緩存對象。

WeakValueDictionary簡介:

WeakValueDictionary 類實現的是一種可變映射,裏面的值是對象的弱引用。被引用的對象在程序中的其他地方被當作垃圾回收後,對應的鍵會自動從 WeakValueDictionary 中刪除。因此,WeakValueDictionary 經常用於緩存。

import weakref

class Cheese():
    def __init__(self, kind):
        self.kind = kind

    def __repr__(self):
        return f'Chess-{self.kind}'

stock = weakref.WeakValueDictionary()  # stock 是 WeakValueDictionary 實例。
catalog = [Cheese('Leicester'), Cheese('Tilsit'), Cheese('Brie'), Cheese('Parmesan')]

for cheese in catalog:
    stock[cheese.kind] = cheese  # stock把奶酪的名稱映射到catalog中Cheese實例的弱引用上

del catalog
print(sorted(stock.keys()))   # ['Parmesan']

del cheese
print(sorted(stock.keys()))   # []

刪除 catalog 之後,stock 中的大多數奶酪都不見了,這是 WeakValueDictionary 的預期行爲。

for cheese in catalog,臨時變量cheese引用了對象,這可能會導致該變量的存在時間比預期長。通常,這對局部變量來說不是問題,因爲它們在函數返回時會被銷燬。但是在示例中,for 循環中的變量 cheese 是全局變量,除非顯式刪除,否則不會消失。


11. 弱引用的侷限

不是每個 Python 對象都可以作爲弱引用的目標(或稱所指對象)。基本的 list 和 dict 實例不能作爲所指對象,但是它們的子類可以輕鬆地解決這個問題:

li = range(10)
ref = weakref.ref(li)
Traceback (most recent call last):
  File "D:\Python3.6.0\lib\site-packages\IPython\core\interactiveshell.py", line 2961, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-15-740a7eb0b541>", line 1, in <module>
    ref = weakref.ref(li)
TypeError: cannot create weak reference to 'range' object
  
class Mylist(list):
    pass
myli = Mylist(range(10))
myref = weakref.ref(myli)

set 實例可以作爲所指對象,因此上面的那個示例才使用 set 實例。用戶定義的類型也沒問題,這就解釋了爲什麼使用那個簡單的 Cheese 類。但是,int 和 tuple 實例不能作爲弱引用的目標,甚至它們的子類也不行。


12. Python對不可變類型施加的把戲

通過前面的學習,我知道對於列表,如列表li,li[:]會創建一個副本,而list(li)返回一個對象的引用。

li = [1,2,3]

li2 = li[:]  # 創建副本
li is li2
# False

li3 = list(li)  # 創建副本
li3 is li
# False

li4 = li 
li4 is li
# True

對元組 t ,t[:]不創建副本,而是返回同一個對象的引用。此外,tuple(t) 獲得的也是同一個元組的引用

t = (1,2,3)

t2 = t[:]
t2 is t
# True

t3 = tuple(t)
t3 is t
# True

t4 = t 
t4 is t
# True

str、bytes 和 frozenset 實例也有這種行爲。注意,frozenset 實例不是序列,因此不能使用 fs[:](fs 是一個 frozenset 實例),但是fs.copy()具有相同的效果:它會欺騙你,返回同一個對象的引用,而不是創建一個副本。

字符串字面量可能會創建共享的對象:

t = (1,2,3)
t2 = (1,2,3)
t2 is t
# False

l = [1,2,3]
l2 = [1,2,3]
l2 is l
# False

s = 'abc'
s1 = 'abc'
s1 is s
# True

共享字符串字面量是一種優化措施,稱爲駐留(interning)。CPython 還會在小的整數上使用這個優化措施,防止重複創建“熱門”數字,如 0、—1 和 42。注意,CPython 不會駐留所有字符串和整數,駐留的條件是實現細節,而且沒有文檔說明。

**千萬不要依賴字符串或整數的駐留!比較字符串或整數是否相等時,應該使用 ==,而不是 is。**駐留是 Python 解釋器內部使用的一個特性。


13. 小結

  1. 每個 Python 對象都有標識類型。只有對象的值會不時變化。

  2. 如果兩個變量指代的 不可變對象 具有相同的值(a == b 爲 True),實際上它們指代的是副本還是同一個對象的別名基本沒什麼關係,因爲不可變對象的值不會變。但有一個例外:不可變的集合,如 元組frozenset :如果不可變集合保存的是可變元素的引用,那麼可變元素的值發生變化後,不可變集合也會隨之改變。實際上,這種情況不是很常見。不可變集合不變的是所含對象的標識。

  3. 變量保存的是引用,這一點對 Python 編程有很多實際的影響。

    • 簡單的賦值不創建副本。
    • 對 += 或 *= 所做的增量賦值來說,如果左邊的變量綁定的是不可變對象,會創建新對象;如果是可變對象,會就地修改。
    • 爲現有的變量賦予新值,不會修改之前綁定的變量。這叫重新綁定:現在變量綁定了其他對象。如果變量是之前那個對象的最後一個引用,對象會被當作垃圾回收。
    • 函數的參數以別名的形式傳遞,這意味着,函數可能會修改通過參數傳入的可變對象。這一行爲無法避免,除非在本地創建副本,或者使用不可變對象(例如,傳入元組,而不傳入列表)。
    • 使用可變類型作爲函數參數的默認值有危險,因爲如果就地修改了參數,默認值也就變了,這會影響以後使用默認值的調用。
  4. 在 CPython 中,對象的引用數量歸零後,對象會被立即銷燬。如果除了循環引用之外沒有其他引用,兩個對象都會被銷燬。某些情況下,可能需要保存對象的引用,但不留存對象本身。例如,有一個類想要記錄所有實例。這個需求可以使用弱引用實現,這是一種低層機制,是 weakref 模塊中 WeakValueDictionary、WeakKeyDictionary 和 WeakSet 等有用的集合類,以及 finalize 函數的底層支持。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章