跳轉到內容

Python 程式設計/上下文管理器

來自華夏公益教科書,開放書籍,開放世界


程式設計中的一個基本問題是資源管理資源是指任何有限供應的東西,特別是檔案控制代碼、網路套接字、鎖等,一個關鍵問題是確保這些資源在獲取後被釋放。如果它們沒有被釋放,你將遇到資源洩漏,系統可能會變慢或崩潰。更一般地說,除了釋放資源之外,你可能希望始終執行清理操作。

Python 在with 語句中提供了用於此目的的特殊語法,該語句會自動管理封裝在上下文管理器型別中的資源,或更一般地執行圍繞程式碼塊的啟動和清理操作。你應該始終使用 with 語句進行資源管理。許多內建的上下文管理器型別,包括 File 的基本示例,並且編寫自己的上下文管理器型別很容易。程式碼並不難,但概念有點微妙,很容易出錯。

基本資源管理

[編輯 | 編輯原始碼]

基本資源管理使用顯式的 open()...close() 函式對,就像在基本的檔案開啟和關閉中一樣。不要這樣做,因為我們會解釋原因

f = open(filename)
# ...
f.close()

這段簡單程式碼的關鍵問題是,如果存在早期返回,無論是因為 return 語句還是異常(可能由被呼叫程式碼引發),程式碼都會失敗。為了解決這個問題,確保在退出程式碼塊時呼叫清理程式碼,可以使用 try...finally 子句

f = open(filename)
try:
    # ...
finally:
    f.close()

但是,這仍然需要手動釋放資源,這可能會被遺忘,並且釋放程式碼與獲取程式碼相隔較遠。可以透過使用 with 自動釋放資源,它之所以有效是因為 File 是一種上下文管理器型別

with open(filename) as f:
    # ...

這會將 open(filename) 的值賦值給 f(這一點很微妙,並且在不同的上下文管理器之間有所不同),然後在退出程式碼塊時自動釋放資源,在本例中呼叫 f.close()

技術細節

[編輯 | 編輯原始碼]

較新的物件是上下文管理器(正式的上下文管理器型別:子型別,因為它們實現了上下文管理器介面,該介面包含 __enter__()__exit__()),因此可以輕鬆地在 with 語句中使用(請參閱With Statement Context Managers)。

對於具有 close 方法但沒有 __exit__() 的較舊檔案式物件,可以使用 @contextlib.closing 裝飾器。如果你需要自己編寫,這非常容易,特別是使用 @contextlib.contextmanager 裝飾器。[1]

上下文管理器透過在進入 with 上下文時呼叫 __enter__() 來工作,並將返回值繫結到 as 的目標,並在退出上下文時呼叫 __exit__()。在退出期間處理異常有一些微妙之處,但對於簡單使用,你可以忽略它。

更微妙的是,__init__() 在建立物件時被呼叫,而 __enter__() 在進入 with 上下文時被呼叫。

__init__()/__enter__() 的區別對於區分一次性、可重用和可重入的上下文管理器非常重要。對於在 with 子句中例項化物件的常見用例,它不是一個有意義的區別,如下所示

with A() as a:
    ...

...在這種情況下,任何一次性上下文管理器都可以。

但是,總的來說,這是一個區別,特別是在區分可重用的上下文管理器和它所管理的資源時,如這裡

a_cm = A()
with a_cm as a:
   ...

將資源獲取放在 __enter__() 而不是 __init__() 中將提供一個可重用的上下文管理器

值得注意的是,File() 物件在 __init__() 中執行初始化,然後在進入上下文時只返回自身,如 def __enter__(): return self。如果你希望 as 的目標繫結到一個物件(並允許你使用像 open 這樣的工廠作為 with 子句的來源),這樣做是可以的,但如果你希望它繫結到其他東西,特別是控制代碼(檔名或檔案控制代碼/檔案描述符),你希望將實際物件包裝在一個單獨的上下文管理器中。例如

@contextmanager
def FileName(*args, **kwargs):
   with File(*args, **kwargs) as f:
       yield f.name

對於簡單使用,你不需要做任何 __init__() 程式碼,只需要將 __enter__()/__exit__() 配對即可。對於更復雜的使用,你可以擁有可重入的上下文管理器,但這對於簡單使用來說是不必要的。

注意事項

[編輯 | 編輯原始碼]

try...finally

[編輯 | 編輯原始碼]

請注意,try...finally 子句必要的,因為 @contextlib.contextmanager 不會捕獲 yield 之後引發的任何異常,但在 __exit__()不是必要的,因為即使引發異常,也會呼叫 __exit__()

上下文,而非作用域

[編輯 | 編輯原始碼]

上下文管理器的術語是經過精心選擇的,特別是在與“作用域”形成對比時。Python 中的區域性變數具有函式作用域,因此 with 語句的目標(如果有的話)在程式碼塊退出後仍然可見,儘管 __exit__() 已經對上下文管理器(with 語句的引數)呼叫,因此通常沒有用或無效。這是一個技術點,但值得區分 with 語句上下文與整個函式作用域。

生成器

[編輯 | 編輯原始碼]

持有或使用資源的生成器有點棘手。

請注意,在 with 語句中建立生成器並在程式碼塊之外使用它們不會起作用,因為生成器具有延遲計算,因此當它們被計算時,資源已經被釋放。這在使用檔案時最容易看到,例如以下生成器表示式用於將檔案轉換為行列表,並剝離行尾字元

with open(filename) as f:
    lines = (line.rstrip('\n') for line in f)

lines 隨後被使用時,可以強制計算 list(lines),這將以ValueError: I/O operation on closed file 失敗。這是因為檔案在 with 語句結束時被關閉,但行直到生成器被計算時才會被讀取。

最簡單的解決方案是避免生成器,而是使用列表,例如列表推導。在這種情況(讀取檔案)下,這通常是合適的,因為人們希望最大限度地減少系統呼叫,並且一次性讀取整個檔案(除非檔案非常大)

with open(filename) as f:
    lines = [line.rstrip('\n') for line in f]

如果希望在生成器中使用資源,則必須將資源儲存在生成器中,如以下生成器函式所示

def stripped_lines(filename):
    with open(filename) as f:
        for line in f:
            yield line.rstrip('\n')

巢狀清楚地表明,檔案在遍歷它時一直保持開啟狀態。

要釋放資源,必須使用 generator.close(), 顯式關閉生成器,就像處理其他持有資源的物件一樣(這是一種處置模式)。這可以透過將生成器變成上下文管理器來實現自動化,使用 @contextlib.closing, 如下所示

from contextlib import closing

with closing(stripped_lines(filename)) as lines:
    # ...

資源獲取即初始化是一種資源管理的替代形式,特別是在 C++ 中使用。在 RAII 中,資源是在物件構造期間獲取的,並在物件析構期間釋放。在 Python 中,類似的函式是 __init__()__del__()(終結器),但 RAII適用於 Python,並且在 __del__() 中釋放資源不起作用。這是因為不能保證 __del__() 會被呼叫:它只是為了記憶體管理器使用,而不是為了資源處理。

更詳細地講,Python 物件構造分兩階段:在 `__new__()` 中進行記憶體分配,在 `__init__()` 中進行屬性初始化。 Python 使用引用計數進行垃圾回收,物件透過 `__del__()` 完成最終化(而不是銷燬)。 然而,最終化是非確定性的(物件的生命週期是非確定性的),最終化器可能被呼叫得更晚,甚至根本不呼叫,尤其是在程式崩潰的情況下。 因此,使用 `__del__()` 進行資源管理通常會導致資源洩漏。

可以使用最終化器進行資源管理,但由此產生的程式碼依賴於實現(通常在 CPython 中有效,但在其他實現中無效,例如 PyPy),並且易受版本變更的影響。 即使進行了此操作,也需要非常小心地確保在所有情況下引用都降為零,包括:異常,如果捕獲異常或以互動方式執行,則異常會在跟蹤中包含引用;以及全域性變數中的引用,這些引用會在程式終止之前一直存在。 在 Python 3.4 之前,迴圈中物件的最終化器也是一個嚴重的問題,但現在不再是問題了;但是,迴圈中物件的最終化順序並非確定性的。

參考資料

[編輯 | 編輯原始碼]
[編輯 | 編輯原始碼]
華夏公益教科書