天天看點

《Fluent Python》學習筆記:第 8 章 對象引用、可變性和垃圾回收

本文主要是 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 執行個體不能作為弱引用的目标,甚至它們的子類也不行。

巨人的肩膀

  1. 《Fluent Python》
  2. 《流暢的 Python》

後記:

我從本碩藥學零基礎轉行計算機,自學路上,走過很多彎路,也慶幸自己喜歡記筆記,把知識點進行總結,幫助自己成功實作轉行。

2020下半年進入職場,深感自己的不足,是以2021年給自己定了個計劃,每日學一技,日積月累,厚積薄發。

如果你想和我一起交流學習,歡迎大家關注我的微信公衆号

每日學一技

,掃描下方二維碼或者搜尋

每日學一技

關注。

這個公衆号主要是分享和記錄自己每日的技術學習,不定期整理子類分享,主要涉及 C – > Python – > Java,計算機基礎知識,機器學習,職場技能等,簡單說就是一句話,成長的見證!

《Fluent Python》學習筆記:第 8 章 對象引用、可變性和垃圾回收

繼續閱讀