Item 37: Finalize

如果我們要讓一個物件持有一些資源,然後希望這個物件被回收的時候釋放它持有的資源,我們該怎麼撰寫這個類別呢?舉例來說,TemporaryDirectory 類別會在建構實例的時候,使用 tempfile.mkdtemp() 產生一個暫時資料夾。當 TemporaryDirectory 的實例死亡後,會呼叫 shutil.rmtree() 刪除對應的暫時資料夾。我們該怎麼實作 TemporaryDirectory 呢?

簡單的想法

一個簡單的想法是定義類別的建構函式與解構函式。我們可以在 __init__() 方法(建構函式)中呼叫 tempfile.mkdtemp();另外在 __del__() 方法(解構函式)呼叫 shutil.rmtree()

#!/usr/bin/env python3
import shutil
import tempfile

class TemporaryDirectory(object):
    def __init__(self):
        self.name = tempfile.mkdtemp()
        print('__init__:', self.name)

    def __del__(self):
        print('__del__:', self.name)
        shutil.rmtree(self.name)

這段程式碼雖然可行,但從編寫風格的角度來評論,並不是一段好的程式碼。首先,__del__() 是一個有缺陷的語言構件,我們應該盡可能避免在類別中定義 __del__() 方法。其次,我們不能保證 __del__() 一定會被呼叫,若一個繼承 TemporaryDirectory 的子類別覆寫了 __del__() 方法,卻忘記呼叫 super().__del__(),則該子類別的實例會產生資源洩漏。

我們要如何改進這段程式碼呢?

使用 weakref.finalize 類別

Python 3.4 以後,weakref 模組多了一個 finalize 類別。一言以蔽之,finalize 物件會記錄一個「對應物件」、一個 callback 函式與若干個稍後會被傳給 callback 函式的參數。當「對應物件」被回收並被解構之後,finalize 物件會呼叫 callback 函式,讓我們得以釋放資源。

我們可以將先前的 TemporaryDirectory 改寫為:

#!/usr/bin/env python3
import shutil
import tempfile
import weakref

class TemporaryDirectory(object):
    def __init__(self):
        self.name = tempfile.mkdtemp()
        self.__finalizer = weakref.finalize(self, self._cleanup, self.name)
        print('__init__:', self.name)

    @staticmethod
    def _cleanup(name):
        print('_cleanup:', name)
        shutil.rmtree(name)

實際執行這段程式碼:

def main():
    t1 = TemporaryDirectory()

if __name__ == '__main__':
    main()

我們可以得到類似的結果:

__init__: /tmp/tmpy_13a7z8
_cleanup: /tmp/tmpy_13a7z8

和先前「簡單的想法」相比,finalize 的實作保證一定會在 TemporaryDirectory 真的死亡後,才會呼叫 _cleanup();另外,_cleanup() 是一個靜態方法,它一定不會碰到已經死亡的 TemporaryDirectory 物件。

值得注意的是:我們不應把 TemporaryDirectory 的實例當作 callback 的參數。如果 finalize 的「對應物件」同時是 callback 的參數,則「對應物件」永遠不會被回收。

提前釋放資源

如果我們想要提前釋放資源,則可以使用 finalize 物件的 detach() 方法。finalize 物件本身會記錄「對應物件」的狀態。若根據 finalize 物件的記錄,其對應物件是存活的,則 detach() 方法會將先內部記錄改為死亡(但不會修改對應物件本身),再回傳「對應物件、callback 函式、callback 函式的參數、callback 函式的關鍵字參數」。若根據 finalize 物件的記錄,其對應的物件是死亡的,則 detach() 會回傳 None

作為範例,讓我們為 TemporaryDirectory 類別加上 cleanup() 方法,讓使用者可以透過呼叫 cleanup() 直接刪除暫時資料夾:

#!/usr/bin/env python3
import shutil
import tempfile
import weakref

class TemporaryDirectory(object):
    def __init__(self):
        self.name = tempfile.mkdtemp()
        self.__finalizer = weakref.finalize(self, self._cleanup, self.name)
        print('__init__:', self.name)

    @staticmethod
    def _cleanup(name):
        print('_cleanup:', name)
        shutil.rmtree(name)

    def cleanup(self):
        print('cleanup:', self.name)
        if self.__finalizer.detach():
            shutil.rmtree(self.name)

實際執行這段程式碼:

def main():
    t1 = TemporaryDirectory()
    t1.cleanup()
    t2 = TemporaryDirectory()

if __name__ == '__main__':
    main()

我們會在標準輸出看到以下結果:

__init__: /tmp/tmp_kp3odef
cleanup: /tmp/tmp_kp3odef
__init__: /tmp/tmpp0nebtt0
_cleanup: /tmp/tmpp0nebtt0

如同我們預期的,若使用者有呼叫 cleanup() 方法,finalize 物件就不會呼叫 _cleanup() 函式。如果使用者沒有呼叫 cleanup() 方法,則 finalize 物件會在對應物件(t2 指向的物件)死亡後,執行 _cleanup() 函式。

結語

在這篇文章我們提到 weakref.finalize 類別,並簡單地介紹它的使用方法。若我們需要在物件死亡後釋放物件所持有的資源,就可以善用 weakref.finalize 類別。

然而因為篇幅有限,這篇文章遺漏了兩個重要事項:

  1. 除了 __del__ 方法可能會被子類別不正確地覆寫,本文並沒有解釋為什麼筆者認為 __del__() 方法是設計不良的語言構件。
  2. 在舊版的 Python 中,weakref 模組並沒有 finalize 類別,我們是否有替代品?

這些內容先保留到後續的文章。敬請期待。

參考資料