所有入門單元免費開放 · 課程內容持續更新中

例外處理:讓程式出錯也不崩潰

使用者會打錯字、檔案會被別人改壞、Excel 會把檔案鎖住。這堂課學會讀懂錯誤訊息,用 try 和 except 接住「預料得到的意外」,讓記帳工具遇到問題時好好說明,而不是整個當掉。

第 3.3 堂約 13 分鐘

當掉,是程式在跟你說話

上一堂我們用 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 和說明;往上一段是真正出事的檔案 storage.py 第 15 行;再往上是誰呼叫了它,一路追到 main.py

  1. 最後一行:錯誤的種類(ValueError,值不對)和說明('85元' 不能轉成整數)。
  2. 往上一段:真正出事的地方,storage.py 的第 15 行。
  3. 再往上:是誰呼叫了它。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 接住之後,印出提示,迴圈再問一次。

示意圖:parse_amount 遇到不合理的金額時用 raise 丟出 ValueError,錯誤往上傳到呼叫它的 ask_amount;ask_amount 用 except 接住,印出提示並重新詢問。如果沒有人接住,錯誤會一路傳到最外層,程式就會停下並顯示 traceback

誰負責判斷、誰負責處理,分得清清楚楚。

第二個應用:讀到壞掉的資料,跳過但要說

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 把三種狀況都故意製造一次,確認程式的反應是你想要的。

請 Claude 幫你檢查例外處理

下面是我的 Python 專案裡的兩個檔案。請幫我找出「可能在執行時出錯、但目前沒有處理」的地方,列成表格:哪一行、可能發生哪種錯誤、在什麼情況下發生、建議在哪個函式接住。

規則:不要建議用沒有指定錯誤種類的 except,也不要建議把錯誤吞掉不提示。先只給表格,不要改程式。

(貼上 main.py 和 tracker/storage.py)

行動練習
  1. 新增 tracker/amount.py,照這堂課改好 ask_amount、load_records 和 add_record。
  2. 故意製造三種意外,確認程式都有好好說明、沒有當掉:
    • 記帳時金額輸入 abc、-50、1,200(最後一個應該要成功)
    • 用記事本把 expenses.csv 某一列的金額改成 85元,再啟動程式
    • 在 Windows 上用 Excel 打開 expenses.csv,同時記一筆新的
  3. 讀一次你自己製造的 traceback(把 try 暫時拿掉就能看到),找出「錯誤種類」和「真正出事的那一行」。
  4. 想一想:如果使用者刪掉了 CSV 的第一列(欄位名稱),程式會發生什麼錯誤?先猜,再試,再決定要不要處理它。

讀完了嗎?

標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。

卡住了?讓 AI 當你的家教複製這段提問範本,貼到 Claude 或其他 AI 工具,再補上你卡住的地方。

留言與討論

卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。

留言區即將開放。現在可以先到 GitHub 討論區 發問,或看看 交流頁 的其他管道。