Python Context Manager (上下文管理器) 與 with 語法

在 Python 中,管理資源(如開啟檔案、網路連線、資料庫連線或執行緒鎖)時,我們必須確保在使用完畢後正確地釋放這些資源。如果忘記釋放,可能會導致記憶體洩漏或檔案鎖死等問題。

with 語法(上下文管理器 Context Manager)就是為了解決這個問題而生的。它能確保進入程式碼區塊前做準備工作,離開區塊後做清理工作,即使發生例外錯誤 (Exception) 也能確保執行清理。

基礎語法:with open()

最常見的例子就是開啟檔案。

傳統寫法 (不推薦)

如果不使用 with,你需要手動呼叫 close(),而且如果中間發生錯誤,close() 可能永遠不會被執行:

f = open("hello.txt", "w")
try:
    f.write("Hello World")
finally:
    # 確保發生錯誤時也能關閉檔案
    f.close()

使用 with (推薦)

使用 with 語法,Python 會自動幫你呼叫 close(),程式碼更簡潔也更安全:

# 離開縮排區塊後,檔案會已自動關閉
with open("hello.txt", "w") as f:
    f.write("Hello World")

運作原理:Context Management Protocol

with 語法的背後是透過 Context Management Protocol 運作的。任何實作了以下兩個魔法方法的物件,都可以作為 Context Manager:

  1. __enter__(self):進入 with 區塊時執行。回傳的值會被賦予給 as 後面的變數。
  2. __exit__(self, exc_type, exc_value, traceback):離開 with 區塊時執行(無論是否發生例外)。

自定義一個 Context Manager (Class 寫法)

讓我們寫一個自定義的 Timer,用來計算程式執行時間:

import time

class Timer:
    def __enter__(self):
        self.start = time.time()
        return self  # 這個值會傳給 as 後面的變數

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.end = time.time()
        self.interval = self.end - self.start
        print(f"程式執行時間: {self.interval} 秒")
        # 回傳 False (預設) 表示若有例外發生,不攔截它,讓它繼續往外丟

# 使用我們自定義的 Timer
with Timer() as t:
    for i in range(1000000):
        pass

# 輸出:程式執行時間: 0.05 秒

__exit__ 的例外處理

__exit__ 接收的三個參數與例外有關。如果區塊內發生錯誤:

  • 如果 __exit__ 回傳 True:表示例外已被處理,不會向外丟出。
  • 如果 __exit__ 回傳 False (或 None):表示例外繼續向外丟出 (Bubble up)。

這常用於製作各種「忽略錯誤」的管理器。

使用 @contextmanager 裝飾器 (Generator 寫法)

寫一個 Class 有點麻煩?Python 的 contextlib 模組提供了一個裝飾器,讓你用 Generator 函數就能快速建立 Context Manager。

這通常是更現代、更簡潔的寫法。

from contextlib import contextmanager

@contextmanager
def my_file_opener(filename, mode):
    # __enter__ 部分
    print("準備開啟檔案...")
    f = open(filename, mode)

    try:
        yield f  # yield 出去的值會給 as 變數
    finally:
        # __exit__ 部分
        print("正在關閉檔案...")
        f.close()

# 使用方式完全一樣
with my_file_opener("test.txt", "w") as f:
    f.write("Hello via generator")

重點:

  1. yield 之前的程式碼是「進入準備」。
  2. yield 之後的程式碼是「離開清理」。
  3. 必須用 try...finally 包果 yield,確保清理程式碼一定會執行。

常見應用場景

除了檔案操作,Context Manager 還常用於:

  1. 鎖 (Locks):在多執行緒中自動上鎖與解鎖。

    import threading
    lock = threading.Lock()
    with lock:
        # 自動 acquire()
        # 執行緒安全的操作
        pass # 自動 release()
    
  2. 資料庫交易 (Database Transaction):確保交易正確 Commit 或 Rollback。

    # 虛擬碼範例
    # with db.transaction():
    #     user = User.create(...)
    #     Profile.create(...)
    # 成功則 commit,失敗則 rollback
    
  3. 暫時更改環境設定:例如暫時將浮點數精度提高,做完計算後恢復。

巢狀 with (Nested with)

如果你需要同時開啟多個資源,可以寫在一起:

with open("input.txt") as f_in, open("output.txt", "w") as f_out:
    data = f_in.read()
    f_out.write(data)

進階:使用 ExitStack 動態管理資源

如果你要同時管理「數量未知」的多個資源,或是想根據條件動態決定要不要啟動某個 Context Manager,contextlib.ExitStack 是一個非常強大的工具。

動態開啟多個檔案:enter_context()

假設你有一個檔案名稱列表,且數量不固定,傳統的 with 語法很難處理。這時可以使用 ExitStack.enter_context()

from contextlib import ExitStack

filenames = ["file1.txt", "file2.txt", "file3.txt"]
files = []

with ExitStack() as stack:
    for fname in filenames:
        # 動態將開啟的檔案加入 stack 中
        f = stack.enter_context(open(fname, "w"))
        files.append(f)
    
    # 這裡可以一次操作所有被開啟的檔案
    for f in files:
        f.write("Hello\n")

# 離開 with 區塊後,stack 會自動把所有檔案關閉

ExitStack 就像一個堆疊 (Stack),它會記住所有透過 enter_context() 進入的 Context Manager,並在離開 with 區塊時,以後進先出 (LIFO) 的順序依次呼叫它們的清理動作(例如 close())。

註冊清理動作:callback()

除了管理 Context Manager,你也可以用 callback() 隨時註冊一個普通的函式,讓它在離開 with 區塊時自動執行:

from contextlib import ExitStack

def my_cleanup(name):
    print(f"執行清理工作: {name}")

with ExitStack() as stack:
    # 註冊一個普通函式作為清理動作
    stack.callback(my_cleanup, "清空快取")
    stack.callback(my_cleanup, "斷開連線")
    
    print("正在執行主要任務...")

# 輸出:
# 正在執行主要任務...
# 執行清理工作: 斷開連線
# 執行清理工作: 清空快取

可以看到,callback() 也是以後進先出的順序執行的。

取消清理動作:pop_all()

如果你在設定資源的過程中發生錯誤,你希望立刻關閉已經開啟的資源;但如果一切順利,你想把這些資源打包回傳給外部使用(不要立刻關閉)。這時可以使用 pop_all()

from contextlib import ExitStack

def setup_multiple_resources():
    with ExitStack() as stack:
        f1 = stack.enter_context(open("data1.txt", "w"))
        f2 = stack.enter_context(open("data2.txt", "w"))
        
        # 假設如果一切正常,我們不想在這裡關閉檔案,
        # 而是要把 stack 轉交給呼叫者去處理。
        # pop_all() 會回傳一個包含了所有目前清理動作的新 ExitStack,
        # 並且清空當前 stack 中的紀錄。
        return stack.pop_all(), f1, f2
        
    # 如果在中途發生錯誤,不會執行到 pop_all(),
    # stack 還是會負責把已經開啟的檔案關閉。

# 外部接手管理
stack, f1, f2 = setup_multiple_resources()
with stack: 
    f1.write("data 1")
    f2.write("data 2")
# 外部離開 with 時才會關閉 f1 和 f2

ExitStack 是 Python 處理複雜資源管理時的進階武器,特別適合用在撰寫框架或底層函式庫。