例外處理:讓程式出錯也不崩潰
使用者會打錯字、檔案會被別人改壞、Excel 會把檔案鎖住。這堂課學會讀懂錯誤訊息,用 try 和 except 接住「預料得到的意外」,讓記帳工具遇到問題時好好說明,而不是整個當掉。
當掉,是程式在跟你說話
上一堂我們用 uv 讓記帳專案有了穩定的環境。環境穩了,接下來要面對的是「外面的世界」:
- 使用者在金額輸入
1,200,或手滑打成85元 - 你用 Excel 打開
expenses.csv手動改了一筆,不小心把金額改成「八十五」 expenses.csv還在 Excel 裡開著,你就跑去記新的一筆
這些狀況,現在的程式不是當掉就是存檔失敗。最慘的是第二種:CSV 裡只要有一列看不懂,程式連打開都打不開。
這堂課要做的,就是讓程式面對這些「預料得到的意外」時,能好好地說明、好好地處理。
先學會讀錯誤訊息
故意製造一次錯誤:用記事本打開 expenses.csv,把某一列的金額改成 85元,存檔,再執行 uv run main.py。你會看到類似這樣的畫面:
Traceback (most recent call last):
File "C:\Projects\expense-tracker\main.py", line 43, in <module>
main()
File "C:\Projects\expense-tracker\main.py", line 27, in main
records = load_records()
File "C:\Projects\expense-tracker\tracker\storage.py", line 15, in load_records
r["amount"] = int(r["amount"])
ValueError: invalid literal for int() with base 10: '85元'
這一大段叫 traceback(錯誤追蹤)。第一次看很嚇人,但它其實很有條理,而且讀法是從最下面往上讀:
- 最後一行:錯誤的種類(
ValueError,值不對)和說明('85元'不能轉成整數)。 - 往上一段:真正出事的地方,
storage.py的第 15 行。 - 再往上:是誰呼叫了它。
main呼叫load_records,一路追回最外層。
讀完你就知道:問題不在 main.py,而是 storage.py 把 '85元' 交給 int() 時出了事。把這段完整貼給 Claude,它也能很快找到原因,但你自己先讀一遍,會學得更快。
常見的錯誤種類有這些,先認得名字就好:
| 錯誤種類 | 白話意思 | 例子 |
|---|---|---|
ValueError |
型別對,但值不合理 | int("85元") |
FileNotFoundError |
找不到檔案 | 檔名打錯、資料夾不對 |
PermissionError |
沒有權限動這個檔案 | 檔案被 Excel 鎖住 |
KeyError |
字典裡沒有這個鍵 | CSV 的欄位名稱被改掉 |
TypeError |
型別根本不對 | 把文字和數字相加 |
try 和 except:先說好出錯時怎麼辦
例外(exception)是 Python 對「執行時發生的錯誤」的正式說法。處理例外的基本寫法是:
try:
amount = int(text)
except ValueError:
print("請輸入數字")
意思是:「試試看執行 try 底下的程式;如果中途發生 ValueError,不要當掉,改成執行 except 底下的程式。」沒出錯的話,except 那段就整個跳過。
except 後面寫的錯誤種類很重要。它的意思是「我只處理這一種」。如果發生的是別種錯誤,Python 還是會照常顯示 traceback,因為那是你沒預料到的問題,本來就該讓你看到。
第一個應用:金額檢查交給專門的函式
以前我們用 .isdigit() 檢查金額,但它不接受 1,200 這種有逗號的寫法。現在我們在 tracker 套件裡新增一個模組 amount.py,專門負責「把文字變成金額」:
# tracker/amount.py:金額的檢查與轉換
def parse_amount(text):
"""把文字轉成金額(正整數),例如 " 1,200 " 會變成 1200。看不懂就丟出 ValueError。"""
cleaned = text.strip().replace(",", "")
amount = int(cleaned)
if amount <= 0:
raise ValueError(f"金額必須大於 0,收到的是:{text}")
return amount
這裡有一個新東西:raise。
int(cleaned) 遇到 85元 會自己丟出 ValueError。但 -50 和 0 對 int() 來說是合法的數字,只是對記帳來說不合理。這時我們用 raise 主動丟出錯誤,意思是:「這個資料我不接受,請呼叫我的人處理。」
parse_amount 不負責問使用者、不負責印提示,它只負責判斷。要怎麼處理,交給呼叫它的人。於是 main.py 的 ask_amount 變成:
from tracker.amount import parse_amount
def ask_amount(question):
while True:
text = input(question)
try:
return parse_amount(text)
except ValueError:
print("請輸入大於 0 的整數,例如 85 或 1,200")
parse_amount 一丟出錯誤,就會往上傳到 ask_amount;ask_amount 接住之後,印出提示,迴圈再問一次。
誰負責判斷、誰負責處理,分得清清楚楚。
第二個應用:讀到壞掉的資料,跳過但要說
storage.py 也來用 parse_amount。讀到看不懂的列時,不要讓整個程式打不開,而是跳過那一列、說明是第幾列,並且先備份原始檔:
# tracker/storage.py(只列出有改的部分)
import csv
import shutil
from pathlib import Path
from tracker.amount import parse_amount
FILE = Path("expenses.csv")
BACKUP = Path("expenses_backup.csv")
FIELDS = ["date", "category", "item", "amount"]
def load_records():
if not FILE.exists():
return []
records = []
skipped = 0
with FILE.open(encoding="utf-8-sig", newline="") as f:
for line_no, row in enumerate(csv.DictReader(f), start=2):
try:
row["amount"] = parse_amount(row["amount"] or "")
except ValueError:
print(f"第 {line_no} 列的金額「{row['amount']}」看不懂,先跳過")
skipped += 1
continue
records.append(row)
if skipped:
shutil.copy(FILE, BACKUP)
print(f"原始檔已備份到 {BACKUP},請照著備份把那幾列修好")
return records
幾個細節:
enumerate(..., start=2)會幫每一列編號。因為 CSV 第 1 列是欄位名稱,資料從第 2 列開始,這樣印出來的列數才會和 Excel 對得上。continue的意思是「這一圈到此為止,直接處理下一列」。row["amount"] or ""是一個保險:如果某一列整個少了金額欄,拿到的會是None,先換成空字串,讓parse_amount統一用ValueError回報。
為什麼要備份?因為下一次記帳存檔時,save_records 會把目前讀到的紀錄整個寫回檔案,被跳過的那幾列就不見了。處理錯誤時,順便想一想「接下來會發生什麼事」,才不會修好一個問題、又製造另一個問題。
第三個應用:存檔失敗,不要假裝成功
在 Windows 上,如果 expenses.csv 正被 Excel 開著,寫入時會發生 PermissionError。以前程式會直接當掉;更糟的是,記錄已經加進了串列,畫面上看起來好像記好了。
def add_record(records):
category = input("類別(例如 餐費、交通):")
item = input("項目:")
amount = ask_amount("金額:")
records.append({"date": date.today().isoformat(), "category": category,
"item": item, "amount": amount})
try:
save_records(records)
except PermissionError:
records.pop()
print("存檔失敗:expenses.csv 可能正被 Excel 打開,請關掉後再記一次。")
return
print("已記下!")
records.pop() 會把剛剛加進去的最後一筆拿掉,讓程式裡的資料和檔案保持一致。好的錯誤訊息有兩個部分:發生了什麼事,以及你可以怎麼做。
改完之後,用 uv run main.py 把三種狀況都故意製造一次,確認程式的反應是你想要的。
下面是我的 Python 專案裡的兩個檔案。請幫我找出「可能在執行時出錯、但目前沒有處理」的地方,列成表格:哪一行、可能發生哪種錯誤、在什麼情況下發生、建議在哪個函式接住。
規則:不要建議用沒有指定錯誤種類的 except,也不要建議把錯誤吞掉不提示。先只給表格,不要改程式。
(貼上 main.py 和 tracker/storage.py)
- 新增
tracker/amount.py,照這堂課改好ask_amount、load_records和add_record。 - 故意製造三種意外,確認程式都有好好說明、沒有當掉:
- 記帳時金額輸入
abc、-50、1,200(最後一個應該要成功) - 用記事本把
expenses.csv某一列的金額改成85元,再啟動程式 - 在 Windows 上用 Excel 打開
expenses.csv,同時記一筆新的
- 記帳時金額輸入
- 讀一次你自己製造的 traceback(把
try暫時拿掉就能看到),找出「錯誤種類」和「真正出事的那一行」。 - 想一想:如果使用者刪掉了 CSV 的第一列(欄位名稱),程式會發生什麼錯誤?先猜,再試,再決定要不要處理它。
讀完了嗎?
標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。
留言與討論
卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。