一、前言 #
嗨,大家好!我是拍拍君 ✨
你一定寫過這樣的程式碼:
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
流程解析:
with語句呼叫__enter__(),回傳值綁定到as後面的變數- 執行
with區塊內的程式碼 - 不管正常結束或發生例外,都會呼叫
__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只應該用在你確定可以安全忽略的例外上。不要拿來忽略Exception或BaseException,那等於把所有錯誤都吞掉了!
五、redirect_stdout 和 redirect_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__,就不能直接用 with。closing 可以幫忙:
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 官方文件 — contextlib
- 📖 PEP 343 — The “with” Statement
- 📝 Python 裝飾器入門 — 搭配
@contextmanager一起服用 - 📝 Python sqlite3 完全攻略 — Context Manager 在資料庫的實戰應用