Python 檔案與例外處理:讓程式記住東西,也接得住意外
從 open 與 with 上下文管理、讀寫文字與 CSV,到 try/except/finally 與 raise,最後深入上下文管理協定、例外鏈與 EAFP 風格。
把一份名單存成檔案:當程式需要記住東西
想像你寫了一個小程式,幫課堂統計每位同學的出席次數。程式跑完,畫面印出漂亮的結果——然後你關掉終端機,一切歸零。下次再跑,又得從頭輸入一遍。
電腦的記憶體(memory)是健忘的:程式一結束,變數裡的資料就煙消雲散。要讓資料「活過」這次執行,我們需要把它寫進檔案(file)。而一旦牽涉到檔案,麻煩也跟著來:檔案可能不存在、磁碟可能滿了、別的程式可能正佔用著它。這時就輪到 Python 的例外處理(exception handling)登場,讓你的程式遇到意外時不會直接崩潰,而是優雅地接住問題。
這篇文章帶你把這兩件事一次學會:怎麼正確地讀寫檔案,以及怎麼在事情出錯時還能保持冷靜。

open 與檔案模式:先打開門,才能進去
讀寫檔案的第一步永遠是 open(),它回傳一個檔案物件(file object),後續所有讀寫都透過這個物件進行。
f = open("attendance.txt", "w", encoding="utf-8")
f.write("小明,5\n")
f.write("小華,3\n")
f.close()
open() 的第二個參數是模式(mode),決定你要對檔案做什麼。常見模式如下:
| 模式 | 意義 | 檔案不存在時 | 已有內容時 |
|---|---|---|---|
"r" |
讀取(read) | 拋出例外 | 保留 |
"w" |
寫入(write) | 建立新檔 | 整個清空 |
"a" |
附加(append) | 建立新檔 | 接在尾端 |
"x" |
獨佔建立 | 建立新檔 | 拋出例外 |
"r+" |
讀寫並存 | 拋出例外 | 保留 |
這裡有個初學者最常踩的雷:"w" 會毫不留情地清空原本的檔案內容。如果你只是想多加幾筆資料而不是重寫整份,請用 "a"。
另外請務必養成習慣:永遠明確指定 encoding="utf-8"。Python 在不同作業系統上的預設編碼不一樣(Windows 常是 cp950,Linux 多為 utf-8),不寫清楚的話,存中文時很容易在別台電腦上變成亂碼。
with:別忘了關門,讓 Python 幫你關
上面的範例有個隱患:如果 f.write() 中途出錯,f.close() 就永遠不會被執行,檔案會一直開著佔用系統資源。手動 close() 既容易忘記,又難以在出錯時保證執行。
Python 提供了上下文管理器(context manager),搭配 with 陳述式,無論區塊內是正常結束還是中途出錯,檔案都會自動關閉:
with open("attendance.txt", "w", encoding="utf-8") as f:
f.write("小明,5\n")
f.write("小華,3\n")
# 離開 with 區塊,檔案已自動關閉,不需要再呼叫 f.close()
with 是寫檔案的標準慣例,符合 PEP 8 精神。從今天起,請把「開檔一定用 with」變成肌肉記憶。反過來說,看到別人手動 open() 配 close() 而沒有用 with,那通常是該被改進的反模式(anti-pattern)。
讀檔同樣如此。讀取整份內容有好幾種方式:
# 一次讀進整個字串
with open("attendance.txt", "r", encoding="utf-8") as f:
content = f.read()
print(content)
# 逐行讀取(推薦:記憶體友善,大檔案也不怕)
with open("attendance.txt", "r", encoding="utf-8") as f:
for line in f:
print(line.strip()) # strip() 去掉每行尾端的換行符號 \n
直接 for line in f 逐行迭代是處理大檔案的好習慣:它一次只把一行載入記憶體,而不是把整個檔案塞進來。對一個有上百萬行的紀錄檔,這個差別可能是程式能不能跑得動的關鍵。
讀寫 CSV:結構化資料的通用語
純文字檔適合存隨意內容,但當資料有「欄位」概念(像姓名、出席次數),CSV(Comma-Separated Values,逗號分隔值)格式更合適。它本質上就是用逗號分隔欄位的文字檔,Excel、試算表、資料庫都讀得懂。
你當然可以自己用 split(",") 硬切,但一旦欄位內容本身含有逗號或換行,手刻解析就會出錯。Python 標準函式庫的 csv 模組已經幫你處理好這些邊角情況,直接用就對了:
import csv
# 寫入 CSV
rows = [
["姓名", "出席次數"],
["小明", 5],
["小華", 3],
]
with open("attendance.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerows(rows)
注意寫 CSV 時多了一個 newline="" 參數——這是官方文件明確要求的,能避免在 Windows 上每行之間多出一個空白列。
讀 CSV 時,csv.DictReader 特別好用:它會把第一列當成欄位名稱,之後每一列都變成一個字典(dict),存取欄位時用名字而非索引,可讀性大增:
import csv
with open("attendance.csv", "r", encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
for row in reader:
# row 是 {"姓名": "小明", "出席次數": "5"}
print(f'{row["姓名"]} 出席了 {row["出席次數"]} 次')
# 輸出:
# 小明 出席了 5 次
# 小華 出席了 3 次
提醒一點:從 CSV 讀出來的值一律是字串(str)。上面的 "5" 是字串而不是整數,如果你要做加總計算,記得先 int(row["出席次數"]) 轉型。
例外處理:當事情不照劇本走
現在回到開頭的擔憂。如果使用者輸入了一個不存在的檔名,會發生什麼事?
with open("不存在的檔案.txt", "r", encoding="utf-8") as f:
content = f.read()
# FileNotFoundError: [Errno 2] No such file or directory: '不存在的檔案.txt'
程式直接崩潰,後面的程式碼全都不執行了。這種執行期間發生的錯誤,Python 稱為例外(exception)。好消息是,我們可以用 try / except 把它「接住」:
try:
with open("不存在的檔案.txt", "r", encoding="utf-8") as f:
content = f.read()
print(content)
except FileNotFoundError:
print("找不到這個檔案,請確認檔名是否正確。")
# 輸出:
# 找不到這個檔案,請確認檔名是否正確。
try 區塊裡是「可能出事」的程式碼;一旦發生指定型別的例外,執行會立刻跳到對應的 except 區塊,程式就不會崩潰了。
你可以針對不同例外做不同處理,分開撰寫多個 except:
try:
with open("data.csv", "r", encoding="utf-8") as f:
first = f.readline()
count = int(first)
except FileNotFoundError:
print("檔案不存在")
except ValueError:
print("檔案第一行不是有效的數字")
try 還有兩個搭檔子句,合起來就是完整的四件套:
try:
f = open("report.txt", "r", encoding="utf-8")
data = f.read()
except FileNotFoundError:
print("檔案不存在") # 只有出錯才執行
else:
print("讀取成功,長度為", len(data)) # 只有「沒出錯」才執行
finally:
print("無論如何都會執行的收尾工作") # 一定執行
except:抓到指定例外時執行。else:try區塊完全沒出錯才執行,適合放「成功後才該做的事」。finally:無論有沒有出錯都會執行,常用來做清理收尾(關連線、釋放資源)。即使try裡有return,finally仍會在離開前執行。
一個反模式:別寫「裸 except」
# ❌ 不要這樣做
try:
risky_operation()
except: # 連 Ctrl+C 中斷、系統結束訊號都會被吞掉
pass
光禿禿的 except:(或 except Exception: 後什麼都不做)會把所有錯誤無聲無息地吞掉,讓你日後 debug 時找不到問題根源。請永遠抓明確的例外型別,並且至少留下有意義的訊息。
raise:主動拋出你自己的例外
例外不只能被動接住,你也能在發現資料不合理時主動拋出,用 raise。這在驗證輸入時非常實用:
def parse_attendance(count_str):
count = int(count_str)
if count < 0:
raise ValueError(f"出席次數不能是負數:{count}")
return count
try:
parse_attendance("-3")
except ValueError as e:
print("資料有誤:", e)
# 輸出:
# 資料有誤: 出席次數不能是負數:-3
as e 讓你拿到例外物件,可以印出它攜帶的訊息。讓函式在「明顯錯誤的輸入」上及早拋出例外,比讓壞資料默默流到程式深處才爆炸要好得多——這就是所謂的「fail fast(快速失敗)」原則。
動手寫一段:統計出席名單
把上面所有概念串起來,寫一個有頭有尾、能容錯的小程式。它會讀取一份 CSV、加總所有出席次數,並妥善處理檔案不存在或資料格式錯誤的情況。
import csv
def total_attendance(path):
"""讀取出席 CSV,回傳總出席次數。"""
total = 0
try:
with open(path, "r", encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
for row in reader:
count = int(row["出席次數"]) # 字串轉整數
if count < 0:
raise ValueError(f"出現負數:{row['姓名']}")
total += count
except FileNotFoundError:
print(f"找不到檔案:{path}")
return None
except ValueError as e:
print(f"資料格式錯誤:{e}")
return None
else:
return total
finally:
print("=== 統計流程結束 ===")
# 先準備一份測試資料
with open("attendance.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(["姓名", "出席次數"])
writer.writerow(["小明", 5])
writer.writerow(["小華", 3])
writer.writerow(["小美", 4])
result = total_attendance("attendance.csv")
print("總出席次數:", result)
# 輸出:
# === 統計流程結束 ===
# 總出席次數: 12
試著把檔名改成不存在的路徑,或在 CSV 裡塞一個負數,看看程式如何接住錯誤而不崩潰——這就是穩健程式的樣子。
重點回顧:初學者最常踩的雷
"w"模式會清空檔案:想保留舊內容只是「多寫幾行」,請改用"a"。看到資料莫名消失,先檢查是不是用錯模式。- 忘記指定 encoding:不寫
encoding="utf-8"在跨平台時很容易中文亂碼。養成每次都寫的習慣。 - 裸
except:吞掉一切:請抓明確的例外型別。無差別捕捉會讓 bug 藏得無影無蹤。 - CSV 讀出來都是字串:
int(row["欄位"])別忘記轉型,否則"5" + "3"會得到"53"而不是8。 - 忘了關檔:只要堅持用
with,這個問題就根本不會發生。不要再手動open()/close()。
深入探討(研究所視角)
上下文管理協定:with 背後到底發生了什麼
with 看起來像魔法,但它其實只是語法糖。任何物件只要實作了上下文管理協定(context manager protocol)——也就是 __enter__ 與 __exit__ 兩個特殊方法——就能用在 with 裡。檔案物件之所以能搭配 with,正是因為它實作了這兩個方法。
我們可以自己手刻一個,看清楚它的運作:
class Timer:
def __enter__(self):
import time
self.start = time.perf_counter()
return self # 回傳值會綁到 as 後面的變數
def __exit__(self, exc_type, exc_value, traceback):
import time
elapsed = time.perf_counter() - self.start
print(f"耗時 {elapsed:.4f} 秒")
return False # 回傳 False:不吞掉例外,讓它繼續往外傳
with Timer() as t:
total = sum(range(1_000_000))
# 輸出(數值依機器而異):
# 耗時 0.0123 秒
關鍵細節在 __exit__ 的三個參數:當 with 區塊內發生例外時,Python 會把例外的型別、值、追溯資訊(traceback)傳進來;正常結束時則三個都是 None。__exit__ 的回傳值決定例外的命運——回傳真值(truthy)代表「我處理好了,吞掉這個例外」,回傳 False 或 None 則讓例外照常往外傳播。這也解釋了為什麼 with open(...) 即使區塊內爆炸,檔案依然會被關閉:關檔邏輯就寫在 __exit__ 裡,而它保證會被呼叫。標準函式庫的 contextlib.contextmanager 裝飾器更讓你能用一個帶 yield 的生成器函式來寫上下文管理器,省去寫整個類別的工夫。
例外鏈:別讓原始錯誤消失
當你在處理一個例外的過程中又拋出新例外,Python 會自動把兩者串接(chain)起來,保留完整的因果脈絡:
def load_config(path):
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except FileNotFoundError as e:
raise RuntimeError("設定載入失敗") from e # 明確標示因果
raise ... from e 會在 traceback 印出「The above exception was the direct cause of the following exception」,讓除錯者同時看到「表層錯誤」與「底層真因」。如果你不寫 from e,Python 仍會隱式鏈接(顯示為「During handling of the above exception...」);但用 from 明確表達意圖是更專業的做法。若想刻意切斷鏈結,可用 from None。
EAFP vs LBYL:兩種程式設計哲學
處理「可能出錯」的情況,有兩種根本不同的風格。
LBYL(Look Before You Leap,三思而後行)——先檢查條件再動作:
import os
if os.path.exists(path): # 先檢查
with open(path) as f:
data = f.read()
else:
data = None
EAFP(Easier to Ask Forgiveness than Permission,先做了再說,出錯再道歉)——直接做,用例外接住失敗:
try:
with open(path) as f:
data = f.read()
except FileNotFoundError:
data = None
Python 社群慣用且推薦 EAFP,原因不只是風格偏好。LBYL 有個隱蔽的競態條件(race condition)問題:在你 os.path.exists() 檢查通過、到真正 open() 的這短短一瞬間,檔案可能被另一個行程刪除——這就是經典的 TOCTOU(Time-Of-Check to Time-Of-Use)漏洞。EAFP 直接嘗試操作,把檢查與動作合為一個原子步驟,從根本上避開了這個縫隙。此外,當「正常路徑」遠比「出錯路徑」常見時,EAFP 省去了每次都做檢查的成本——try 區塊在沒有例外時幾乎是零開銷($O(1)$ 且常數極小),只有真正拋出例外時才付出代價。
理解了這三件事——上下文管理協定如何保證資源釋放、例外鏈如何保存除錯線索、EAFP 如何兼顧安全與效能——你對 Python 例外與檔案處理的掌握就從「會用」進階到「懂為什麼」了。接下來,不妨把今天的出席統計程式擴充成能處理多個班級檔案的版本,親手體會這些機制在真實情境裡的價值。