本文主要是 Fluent Python 第 8 章的學習筆記。這部分主要是介紹了變量、引用、對象、深拷貝、淺拷貝、垃圾回收等。本章雖然枯燥,但是非常有用。
《Fluent Python》學習筆記:第 8 章 對象引用、可變性和垃圾回收
-
- 8.1 變量不是盒子
- 8.2 辨別、相等性和别名
- 8.3 預設淺拷貝
- 8.5 del 和垃圾回收
- 8.6 弱引用
- 巨人的肩膀
8.1 變量不是盒子
在 Python 中變量是标簽(label),不是盒子(box)。Python 中的變量是引用式變量,類似于 Java 中的引用式變量,是以把 Python 中的變量了解為附加在對象上的标簽。
通過一個例子感受一下:
a = [1, 2, 3] # a 标簽指向了 [1, 2, 3] 這個清單
b = a # 把 b 标簽也指向了 [1, 2, 3] 清單
a.append(4)
print(b)
[1, 2, 3, 4]
指派語句的右邊先執行,對象在指派之前就建立了。是以,對于引用式變量,說把變量配置設定給對象更合理。看下面這個例子:
class Gizmo(object):
def __init__(self):
print('Gizmo id: %d' % id(self))
x = Gizmo()
y = Gizmo() * 10 # 可以看得到 Gizmo() 會建立一個新的 Gizmo 執行個體,求積失敗,是以 y 變量不會建立
Gizmo id: 2236148132296
Gizmo id: 2236148132168
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-6-91f1348d782b> in <module>
6
7 x = Gizmo()
----> 8 y = Gizmo() * 10 # 可以看得到 Gizmo() 會建立一個新的 Gizmo 執行個體,求積失敗,是以 y 變量不會建立
TypeError: unsupported operand type(s) for *: 'Gizmo' and 'int'
是以,為了了解 Python 中的指派語句,應該始終先讀右邊。對象在右邊建立或擷取,在此之後左邊的變量才會綁定到對象上,這就像為對象貼上标簽。
8.2 辨別、相等性和别名
每個對象(object)都有辨別(identity)、類型(type)和值(value)。
- 辨別(identity):對象一旦建立,辨別就不會改變。對象的辨別具有唯一性,并且在對象的生命周期中不會改變。可以把辨別了解為對象在記憶體中的位址。CPython 中,可以用 id() 傳回對象的記憶體位址,用 is 比較兩個對象的辨別,判斷它們是否是同一個對象。
- 類型:可以用 type() 檢視對象的類型。
- 值。
别名(alias):一個對象可以有多個标簽,每個标簽就是一個别名。
is
和
==
比較:
is
:比較對象的辨別。
==
:比較對象的值。通常我們比較關注值,而不是辨別,是以在 Python 代碼中
==
出現的頻率比
is
高。
is 運算符比
==
速度快,因為它不能重載,是以 Python 不用尋找并調用特殊方法,而是直接比較兩個整數 ID。而
a == b
是文法糖,等同于
a.__eq__(b)
,相等性測試可能涉及大量處理工作。
注意:在變量和單例值之間比較時,應該使用 is。目前,最常使用 is 檢查變量綁定的值是不是 None。下面是推薦的寫法:
x is None
# 否定的正确寫法
x is not None
# 比較 is 和 ==
a = [1, 2, 3]
b = a # a, b 都是 [1, 2, 3] 對象的别名
c = [1, 2, 3] # 注意,這裡新建立了一個 [1, 2, 3]清單對象
print(f'id(a)={id(a)}, id(b)={id(b)}, id(c)={id(c)}')
print(f'a is b: {a is b}; a == b: {a == b}')
print(f'a is c: {a is c}; a == c: {a == c}')
id(a)=2236148131656, id(b)=2236148131656, id(c)=2236156692488
a is b: True; a == b: True
a is c: False; a == c: True
重新了解元組的不可變:
元組和 Python 大多數集合類型(清單、字典、集合等)一樣,都是儲存的對象的引用(reference)。如果引用的對象是可變的,即便元組本身不可變,引用的對象依然可變。換句話說就是,元組的不可變性實際上是指元組資料結構的實體内容(即儲存的引用)不可變,與引用的對象無關。
看下面的例子:
t1 = (1, 2, [30, 40])
t2 = (1, 2, [30, 40])
print(f'id(t1)={id(t1)}; id(t2)={id(t2)}, t1 is t2: {t1 is t2}; t1 == t2: {t1 == t2}')
print(id(t1[-1]))
t1[-1].append(99)
print(t1)
print(id(t1[-1]))
print(t1 == t2) # t1 和 t2 值不同了
id(t1)=2236146528568; id(t2)=2236148887864, t1 is t2: False; t1 == t2: True
2236150622728
(1, 2, [30, 40, 99])
2236150622728
False
上述例子說明,元組的值會随着引用的可變對象的變化而變。元組中不可變的是元素的辨別。是以有些元組是不可散列的。
8.3 預設淺拷貝
Python 中使用構造方法和切片預設做淺拷貝(shallow copy),即複制了最外層容器,副本中的元素是源容器中元素的引用。如果所有元素都是不可變的,這麼做沒有問題,還能節省記憶體。但是如果有可變元素,可能就會導緻意想不到的問題。
深拷貝(deep copy):即副本不共享内部對象的引用。
copy 子產品中的 deepcopy 和 copy 函數能夠為任意對象做深拷貝或者淺拷貝。
關于深淺拷貝,看個例子:
# 校車乘客在途中上車和下車
import copy
class Bus(object):
def __init__(self, passengers=None):
if passengers is None:
self.passengers = []
else:
self.passengers = list(passengers)
def pick(self, name):
self.passengers.append(name)
def drop(self, name):
self.passengers.remove(name)
bus1 = Bus(['Alice', 'Bill', 'Claire', 'David'])
bus2 = copy.copy(bus1) # 淺拷貝
bus3 = copy.deepcopy(bus1) # 深拷貝
print(f' id(bus1)={id(bus1)} \n id(bus2)={id(bus2)} \n id(bus3)={id(bus3)}') # 深淺拷貝都和原對象的 ID 不同
bus1.drop('Bill') # bus2 也會受影響
print(f' bus1.passengers={bus1.passengers} \n bus2.passengers={bus2.passengers} \n bus3.passengers={bus3.passengers}')
# bus1 和 bus2 共用同一個passengers 清單對象
print(f''' id(bus1.passengers)={id(bus1.passengers)} \n id(bus2.passengers)={id(bus2.passengers)} \n id(bus3.passengers)={id(bus3.passengers)}''')
id(bus1)=2236156482888
id(bus2)=2236144713544
id(bus3)=2236156999176
bus1.passengers=['Alice', 'Claire', 'David']
bus2.passengers=['Alice', 'Claire', 'David']
bus3.passengers=['Alice', 'Bill', 'Claire', 'David']
id(bus1.passengers)=2236144711688
id(bus2.passengers)=2236144711688
id(bus3.passengers)=2236156482632
注意:一般來說,深拷貝不是件簡單的事。如果有對象有循環引用(cyclic reference),那麼這個樸素的算法會進入無限循環。 deepcopy 函數會記住已經複制的對象,是以能夠優雅的處理循環引用。
如下面這個例子:
# 循環引用:b 引用 a,然後追加到 a 中;deepcopy 會想辦法複制 a
from copy import deepcopy
a = [10, 20]
b = [a, 30]
a.append(b)
print(a)
c = deepcopy(a)
print(c)
[10, 20, [[...], 30]]
[10, 20, [[...], 30]]
深拷貝有時可能太深,對象可能會引用不該指派的外部資源或單例值。我們可以實作
__copy__()
和
__deepcopy__()
特殊方法,控制 copy 和 deepcopy 的行為。具體參考 copy 子產品文檔。
Python 唯一支援的參數傳遞模式是共享傳參(call by sharing)。Java 中引用類型是傳引用,基本類型是按值傳參。
共享傳參值函數的各個形參獲得實參中各個引用的副本。也就是說,函數内部的形參是實參的别名。
這個方案的結果是:函數可能會修改作為參數傳入的可變對象,但是無法修改這些對象的辨別(即不能把一個對象替換成另一個對象)。下面這個例子展示了把數字、清單、元組傳入函數,實際傳入的實參會以不同的方式受到影響:
# 函數可能會修改接收到的人和可變對象
def f(a, b):
a += b
return a
x, y = 1, 2
print(f(x, y))
print(x, y) # 數字 x 不變
a, b = [1, 2], [3, 4]
print(f(a, b))
print(a, b) # 清單 a 變了
t, u = (10, 20), (30, 40)
print(f(t, u))
print(t, u) # 元組 t 不變
3
1 2
[1, 2, 3, 4]
[1, 2, 3, 4] [3, 4]
(10, 20, 30, 40)
(10, 20) (30, 40)
不要使用可變類型作為參數的預設值。
以下例子說明可變預設值的危險:
# 一個簡單的類,說明可變預設值的危險
class HauntedBus(object):
"""備受幽靈乘客折磨的校車"""
def __init__(self, passengers=[]): # 沒有傳入passengers參數,使用預設綁定的清單對象,一開始是空清單
self.passengers = passengers
def pick(self, name):
self.passengers.append(name)
def drop(self, name):
self.passengers.remove(name)
bus1 = HauntedBus(['Alice', 'Bill'])
print(bus1.passengers)
bus1.pick('Charlie')
bus1.drop('Alice')
print(bus1.passengers) # 到這裡都一切正常
bus2 = HauntedBus() # 一開始bus2是空的,是以會把預設的空清單指派給self.passengers
bus2.pick('Carrie')
print(bus2.passengers)
bus3 = HauntedBus() # bus3一開始也是空的,是以還是指派預設的清單
print(bus3.passengers) # 但是預設清單不為空!
bus3.pick('Dave')
print(bus2.passengers) # 登上bus3 的Dave也出現在了bus2
print(bus2.passengers is bus3.passengers) # bus2.passengers 和 bus3.passengers 指向同一個清單
print(bus1.passengers) # 但是bus1.passengers是不同的清單
['Alice', 'Bill']
['Bill', 'Charlie']
['Carrie']
['Carrie']
['Carrie', 'Dave']
True
['Bill', 'Charlie']
這裡的問題在于,沒有指定初始乘客的 HauntedBus 執行個體會共享同一個乘客清單。這種問題很難發現。出現這個問題的根源是,預設值在定義函數時計算(通常在加載子產品時),是以預設值變成了函數對象的屬性。是以,如果預設值是可變對象,而且修改了它的值,那麼後續的函數調用都會受到影響。
我們可以審查
HauntedBus.__init__
對象,看看它的
__defaults__
屬性有哪些幽靈學生。
print(dir(HauntedBus.__init__))
print(HauntedBus.__init__.__defaults__)
['__annotations__', '__call__', '__class__', '__closure__', '__code__', '__defaults__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__get__', '__getattribute__', '__globals__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__kwdefaults__', '__le__', '__lt__', '__module__', '__name__', '__ne__', '__new__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']
(['Carrie', 'Dave'],)
最後,可以驗證 bus2.passengers 是一個别名,它綁定到
HauntedBus.HauntedBus.__init__.__defaults__
屬性的第一個元素上。
True
是以通常使用 None 作為接收可變值參數的預設值。是以我們需要在
__init__
方法做檢查,如果 passengers 是 None,就把一個新的空清單指派給
self.passengers
,如果不是,正确的實作是
把passengers
的副本指派給
self.passengers
。
下面這個 TwilightBus 執行個體與客戶共享乘客清單,這會産生意外的結果,如下:
# 一個簡單的類,說明接收可變參數的風險
class TwilightBus(object):
"""讓乘客銷聲匿迹的校車"""
def __init__(self, passengers=None):
if passengers is None:
self.passengers = [] # 這裡謹慎處理,當passengers為None時,建立一個新的空清單
else:
self.passengers = passengers # self.passengers 變成了passengers的别名,即實參的别名
def pick(self, name):
self.passengers.append(name) # 會修改傳入的 passengers 清單
def drop(self, name):
self.passengers.remove(name) # 會修改傳入的 passengers 清單
basketball_team = ['Sue', 'Tina', 'Maya', 'Diana', 'Pat']
bus = TwilightBus(basketball_team)
bus.drop('Tina')
bus.drop('Pat')
print(basketball_team) # basketball_team 被改變了
['Sue', 'Maya', 'Diana']
這裡的 TwilightBus 違反了設計接口的最佳實踐,即“最少驚訝原則”。學生下車後,她的名字就從籃球隊的名單消失了,這不是我們想要的。
這裡的問題是,校車為傳給構造方法的清單建立了别名。
正确做法是,校車自己維護乘客清單。是以我們隻需要把參數值的副本指派給 self.passengers 就可以了。
def __init__(self, passengers=None):
if passengers is None:
self.passengers = []
else:
self.passengers = list(passengers) # 建立passengers清單的副本,如果不是清單,就把它轉換成清單
這種處理方式更加靈活,passengers 可以是任何可疊代對象。
建議:除非這個方法确實想修改通過參數傳入的對象,否則在類中直接把參數指派給執行個體變量之前一定要三思,因為這樣會為參數對象建立别名。如果不确定,那就建立副本。減少客戶的麻煩。
8.5 del 和垃圾回收
del 語句時删除名稱,而不是對象。del 指令可能會導緻對象被當做垃圾回收,但是僅當删除的變量儲存的是對象最後一個引用,或者無法得到對象時。
重新綁定也可能會導緻對象的引用數量歸零,導緻對象被銷毀。
在 CPython 中垃圾回收使用的主要算法是引用計數。每個對象都會統計有多少個引用指向自己。當引用計數歸 0 時,對象立即被銷毀:CPython 會在對象上調用
__del__
方法(如果定義了),然後釋放配置設定給對象的記憶體。CPython 2.0 增加了分代垃圾回收算法,用于檢測引用循環中涉及的對象組——如果一組對象之間全是互相引用,即使再出色的引用方式也會導緻組中的對象不可擷取。
8.6 弱引用
弱引用(weak references):弱引用不會增加對象的引用數量。弱引用的目标對象稱為所指對象(referent)。是以弱引用不會妨礙所指對象被當做垃圾回收。
弱引用在緩存應用中非常有用。因為我們不想僅因為被緩存引用着而始終儲存緩存對象。
弱引用是可調用的對象,傳回的是被引用的對象;如果所指對象不存在了,傳回 None。
弱引用的局限:不是每個 Python 對象都能作為弱引用的目标。基本的 list 和 dict 執行個體不能作為所指對象,但它們的子類可以輕松解決這個問題。
set 執行個體和使用者定義的類型可以作為弱引用的目标。但是 int 和 tuple 執行個體不能作為弱引用的目标,甚至它們的子類也不行。
巨人的肩膀
- 《Fluent Python》
- 《流暢的 Python》
後記:
我從本碩藥學零基礎轉行計算機,自學路上,走過很多彎路,也慶幸自己喜歡記筆記,把知識點進行總結,幫助自己成功實作轉行。
2020下半年進入職場,深感自己的不足,是以2021年給自己定了個計劃,每日學一技,日積月累,厚積薄發。
如果你想和我一起交流學習,歡迎大家關注我的微信公衆号
每日學一技
,掃描下方二維碼或者搜尋
每日學一技
關注。
這個公衆号主要是分享和記錄自己每日的技術學習,不定期整理子類分享,主要涉及 C – > Python – > Java,計算機基礎知識,機器學習,職場技能等,簡單說就是一句話,成長的見證!
