快轉到主要內容
  1. 教學文章/

Python contextlib:掌握 Context Manager 的進階魔法

·7 分鐘· loading · loading · ·
Python Contextlib Context Manager With 資源管理
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 32: 本文

一、前言
#

嗨,大家好!我是拍拍君 ✨

你一定寫過這樣的程式碼:

f = open("data.txt", "r")
content = f.read()
f.close()

然後被前輩唸:「要用 with 啊,不然忘記 close() 會出事!」

with open("data.txt", "r") as f:
    content = f.read()
# 離開 with 區塊後,檔案自動關閉 ✅

with 語句背後的機制就是 Context Manager(上下文管理器)。它確保資源在使用完畢後一定會被正確釋放,不管程式是正常結束還是拋出例外。

但你知道嗎?Python 的 contextlib 模組提供了一整套工具,讓你可以輕鬆建立自己的 Context Manager,還有很多超實用的內建工具。

今天就跟拍拍君一起,把 contextlib 的魔法學起來!🪄

二、Context Manager 的基礎原理
#

在進入 contextlib 之前,先搞清楚 Context Manager 的運作原理。

一個 Context Manager 就是實作了 __enter__()__exit__() 兩個方法的物件:

class FileManager:
    def __init__(self, filename, mode):
        self.filename = filename
        self.mode = mode

    def __enter__(self):
        self.file = open(self.filename, self.mode)
        print(f"📂 開啟檔案:{self.filename}")
        return self.file

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.file.close()
        print(f"📁 關閉檔案:{self.filename}")
        return False  # 不攔截例外

# 使用方式
with FileManager("hello.txt", "w") as f:
    f.write("拍拍君到此一遊!")
# 輸出:
# 📂 開啟檔案:hello.txt
# 📁 關閉檔案:hello.txt

流程解析:

  1. with 語句呼叫 __enter__(),回傳值綁定到 as 後面的變數
  2. 執行 with 區塊內的程式碼
  3. 不管正常結束或發生例外,都會呼叫 __exit__()

這就是 Context Manager 的核心概念。但每次都要寫一個 class 好麻煩,有沒有更簡潔的方式?

有!這就是 contextlib 登場的時候了。 🎭

三、@contextmanager 裝飾器 — 最常用的工具
#

contextlib.contextmanager 讓你用一個 generator 函式 就能建立 Context Manager,不需要寫整個 class:

from contextlib import contextmanager

@contextmanager
def file_manager(filename, mode):
    f = open(filename, mode)
    print(f"📂 開啟檔案:{filename}")
    try:
        yield f  # yield 的值 = __enter__ 的回傳值
    finally:
        f.close()
        print(f"📁 關閉檔案:{filename}")

# 用法完全一樣!
with file_manager("hello.txt", "w") as f:
    f.write("拍拍君用 contextmanager 寫的!")

重點整理:

部分 對應 class 版本
yield 之前 __enter__()
yield 的值 __enter__() 的回傳值
yield 之後 __exit__()
try/finally 確保清理邏輯一定執行

實戰範例:計時器
#

from contextlib import contextmanager
import time

@contextmanager
def timer(label="耗時"):
    start = time.perf_counter()
    print(f"⏱️ [{label}] 開始計時...")
    try:
        yield
    finally:
        elapsed = time.perf_counter() - start
        print(f"⏱️ [{label}] 完成!耗時 {elapsed:.4f} 秒")

with timer("資料處理"):
    # 模擬一些耗時操作
    total = sum(range(10_000_000))

# ⏱️ [資料處理] 開始計時...
# ⏱️ [資料處理] 完成!耗時 0.2345 秒

實戰範例:臨時切換工作目錄
#

import os
from contextlib import contextmanager

@contextmanager
def cd(path):
    """臨時切換工作目錄,離開時自動切回"""
    old_dir = os.getcwd()
    os.chdir(path)
    try:
        yield
    finally:
        os.chdir(old_dir)

with cd("/tmp"):
    print(os.getcwd())  # /tmp
print(os.getcwd())  # 回到原本的目錄

實戰範例:資料庫連線
#

import sqlite3
from contextlib import contextmanager

@contextmanager
def db_connection(db_path):
    """管理資料庫連線和交易"""
    conn = sqlite3.connect(db_path)
    try:
        yield conn
        conn.commit()  # 正常結束 → commit
    except Exception:
        conn.rollback()  # 出錯 → rollback
        raise
    finally:
        conn.close()

with db_connection("app.db") as conn:
    cursor = conn.cursor()
    cursor.execute("CREATE TABLE IF NOT EXISTS users (name TEXT)")
    cursor.execute("INSERT INTO users VALUES (?)", ("拍拍君",))

如果你之前讀過拍拍君的 sqlite3 入門文章,搭配這個 Context Manager 會更優雅!

四、suppress — 優雅地忽略例外
#

有時候你想忽略特定的例外,傳統寫法:

try:
    os.remove("temp.txt")
except FileNotFoundError:
    pass

contextlib.suppress 可以更簡潔:

from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove("temp.txt")

一行搞定!而且語意更清楚:「我知道這可能會發生 FileNotFoundError,忽略它就好。」

也可以同時忽略多種例外:

with suppress(FileNotFoundError, PermissionError):
    os.remove("temp.txt")

⚠️ 注意:suppress 只應該用在你確定可以安全忽略的例外上。不要拿來忽略 ExceptionBaseException,那等於把所有錯誤都吞掉了!

五、redirect_stdoutredirect_stderr — 重導向輸出
#

想把某段程式碼的 print() 輸出抓到字串裡?

from contextlib import redirect_stdout
import io

buffer = io.StringIO()

with redirect_stdout(buffer):
    print("這段文字不會出現在終端機")
    print("而是被抓到 buffer 裡了!")

captured = buffer.getvalue()
print(f"抓到的內容:{captured}")
# 抓到的內容:這段文字不會出現在終端機
# 而是被抓到 buffer 裡了!

把輸出寫到檔案
#

from contextlib import redirect_stdout

with open("output.log", "w") as f:
    with redirect_stdout(f):
        print("這行會寫到 output.log")
        print("不會出現在終端機")

同樣的,redirect_stderr 可以重導向 stderr:

from contextlib import redirect_stderr
import io

buffer = io.StringIO()
with redirect_stderr(buffer):
    import warnings
    warnings.warn("這是一個警告!")

print(buffer.getvalue())  # 抓到警告訊息

六、ExitStack — 動態管理多個 Context Manager
#

當你需要管理數量不固定的資源時,ExitStack 就派上用場了:

from contextlib import ExitStack

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

# 先建立測試檔案
for name in filenames:
    with open(name, "w") as f:
        f.write(f"我是 {name}")

# 同時開啟所有檔案
with ExitStack() as stack:
    files = [
        stack.enter_context(open(name, "r"))
        for name in filenames
    ]
    for f in files:
        print(f.read())
# 離開 with 時,所有檔案都會被正確關閉!

進階用法:動態註冊清理回呼
#

from contextlib import ExitStack

with ExitStack() as stack:
    # 註冊清理函式(後註冊的先執行,像 stack 一樣 LIFO)
    stack.callback(print, "🧹 清理步驟 1")
    stack.callback(print, "🧹 清理步驟 2")
    stack.callback(print, "🧹 清理步驟 3")

    print("🔨 做一些工作...")

# 輸出:
# 🔨 做一些工作...
# 🧹 清理步驟 3
# 🧹 清理步驟 2
# 🧹 清理步驟 1

ExitStack 搭配資料庫和檔案
#

from contextlib import ExitStack, contextmanager
import sqlite3
import json

@contextmanager
def db_session(path):
    conn = sqlite3.connect(path)
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()

def export_data(db_path, output_path):
    with ExitStack() as stack:
        conn = stack.enter_context(db_session(db_path))
        out_file = stack.enter_context(open(output_path, "w"))

        cursor = conn.cursor()
        cursor.execute("SELECT * FROM users")
        data = [{"name": row[0]} for row in cursor.fetchall()]
        json.dump(data, out_file, ensure_ascii=False, indent=2)

七、closing — 讓沒有 Context Manager 的物件也能用 with
#

有些物件有 close() 方法,但沒有實作 __enter__ / __exit__,就不能直接用 withclosing 可以幫忙:

from contextlib import closing
from urllib.request import urlopen

with closing(urlopen("https://httpbin.org/get")) as response:
    data = response.read()
    print(f"收到 {len(data)} bytes")
# response.close() 會自動被呼叫

八、nullcontext — 條件性的 Context Manager
#

有時候你想根據條件決定是否使用某個 Context Manager:

from contextlib import nullcontext

def process_data(output_file=None):
    # 如果有指定檔案就寫檔案,否則就輸出到終端機
    cm = open(output_file, "w") if output_file else nullcontext()

    with cm as f:
        target = f if output_file else None
        print("處理結果:42", file=target)

process_data()                # 輸出到終端機
process_data("result.txt")   # 輸出到檔案

nullcontext 就是一個「什麼都不做」的 Context Manager,非常適合用在條件分支中保持程式碼結構一致。

也可以帶一個值:

from contextlib import nullcontext

# 根據條件選擇是否使用 GPU
use_gpu = False

with (nullcontext() if not use_gpu else cuda_context()):
    train_model()

九、chdir — Python 3.11 新增的好用工具
#

Python 3.11 新增了 contextlib.chdir,直接取代了我們前面手寫的切換目錄 Context Manager:

import os
from contextlib import chdir  # Python 3.11+

print(os.getcwd())  # /Users/pypyking/projects

with chdir("/tmp"):
    print(os.getcwd())  # /tmp

print(os.getcwd())  # /Users/pypyking/projects

十、實戰整合範例
#

結合前面學到的工具,來看一個更完整的例子:

from contextlib import contextmanager, suppress, ExitStack
import json
import os
import time

@contextmanager
def timer(label):
    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed = time.perf_counter() - start
        print(f"⏱️ {label}: {elapsed:.2f}s")

@contextmanager
def temp_env(**kwargs):
    """臨時設定環境變數"""
    old_values = {}
    for key, value in kwargs.items():
        old_values[key] = os.environ.get(key)
        os.environ[key] = value
    try:
        yield
    finally:
        for key, old_value in old_values.items():
            if old_value is None:
                os.environ.pop(key, None)
            else:
                os.environ[key] = old_value

def batch_process(input_files, output_dir):
    """批次處理多個檔案"""
    os.makedirs(output_dir, exist_ok=True)

    with timer("批次處理"), ExitStack() as stack:
        # 動態開啟所有輸入檔案
        readers = [
            stack.enter_context(open(f, "r"))
            for f in input_files
        ]

        for i, reader in enumerate(readers):
            output_path = os.path.join(output_dir, f"result_{i}.json")
            with open(output_path, "w") as writer:
                data = reader.read()
                result = {"index": i, "length": len(data), "preview": data[:50]}
                json.dump(result, writer, ensure_ascii=False, indent=2)

    # 忽略清理時可能的錯誤
    with suppress(OSError):
        os.remove("temp_processing.lock")

    print("✅ 批次處理完成!")

結語
#

Context Manager 是 Python 裡最優雅的資源管理方式,而 contextlib 讓你不用寫一堆 boilerplate 就能擁有這個超能力。

今天學到的工具包:

工具 用途
@contextmanager 用 generator 快速建立 Context Manager
suppress 優雅地忽略特定例外
redirect_stdout/stderr 重導向標準輸出/錯誤
ExitStack 動態管理多個 Context Manager
closing 包裝有 close() 但沒有 enter/exit 的物件
nullcontext 條件性 Context Manager 的佔位符
chdir 臨時切換工作目錄(3.11+)

下次寫程式遇到「需要設定然後復原」或「需要確保清理」的場景,想想 contextlib 吧!

拍拍君相信你已經掌握了 Context Manager 的進階魔法 💪 我們下篇文章見!

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 32: 本文

相關文章

sqlite3:Python 內建輕量資料庫完全攻略
·9 分鐘· loading · loading
Python Sqlite3 SQL 資料庫 Database
pathlib:優雅處理檔案路徑的現代方式
·6 分鐘· loading · loading
Python Pathlib 檔案處理 標準庫
httpx:Python 新世代 HTTP 客戶端完全攻略
·4 分鐘· loading · loading
Python Httpx HTTP Async Requests
Python collections 模組:讓你的資料結構更強大
·5 分鐘· loading · loading
Python Collections Counter Defaultdict Deque Namedtuple
Python 正規表達式完全攻略:re 模組從入門到實戰
·9 分鐘· loading · loading
Python Regex Re 正規表達式 文字處理
Python 裝飾器:讓你的函式穿上超能力外套
·7 分鐘· loading · loading
Python Decorator 裝飾器 進階語法 設計模式