前面兩篇都刻意留了同一個坑沒補。
商品管理程式那篇的選單,只要在「請輸入功能選項」打一個非數字,整支程式立刻中止;串列統計那篇也一樣,成績改成從鍵盤輸入就會遇到同樣的問題。當時說「防呆是另一個獨立主題,之後再補」——這篇就是來補的。
防呆不是寫得多小心就好,它有固定的套路:接住錯誤 → 抓對類型 → 讓使用者重來。三步做完,程式才算能交到別人手上。
崩潰現場:int(input()) 為什麼會炸
先看一次完整的崩潰。這支 menu.py 只有一行關鍵程式:
choice = int(input('請輸入功能選項:'))
print(f'你選了 {choice}')執行後在提示字元打 abc:
請輸入功能選項:abc
Traceback (most recent call last):
File "menu.py", line 1, in <module>
choice = int(input('請輸入功能選項:'))
~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: invalid literal for int() with base 10: 'abc'拆開來看這三個資訊,之後你除錯會快很多:
Traceback是「回溯」,Python 在告訴你出事的位置。line 1就是出問題那一行,底下那排~~~^^^更精準地指出是int(...)這段炸的,不是input(...)。ValueError是錯誤的「類型」。這個名字很重要,等一下要用它來指定接住哪一種錯誤。invalid literal for int() with base 10: 'abc'是錯誤訊息,直譯是「abc不是合法的十進位整數字面值」。
關鍵在於:input() 拿到的永遠是字串,就算你打 5 拿到的也是 '5' 而不是 5。int() 負責把字串轉成整數,轉得動就成功,轉不動就丟出 ValueError。而 Python 的例外只要沒人接,就會一路往外拋,最後由直譯器接手——印出 traceback、然後結束程式。
所以問題不是「使用者亂打字」,是我們沒有安排任何人去接這個錯誤。
實跑:輸入 abc 之後,連「程式正常結束」都看不到
下面用預先餵入 abc 的方式把課堂範例實跑一次(輸入以管線餵入,所以終端不會顯示你打的字):
age = int(input("年齡:"))
print(f"明年 {age + 1} 歲")
print("程式正常結束")執行結果:
年齡:Traceback (most recent call last):
File "C:\demo\demo.py", line 1, in <module>
age = int(input("年齡:"))
~~~^^^^^^^^^^^^^^^^^
ValueError: invalid literal for int() with base 10: 'abc'
讀錯誤訊息的順序:由下往上看。最後一行告訴你「錯誤種類+原因」,往上一行 File ... line 1 告訴你「第幾行」。注意 age + 1 與最後的 print 都沒有執行——例外一旦沒被接住,整支程式就在那一行停止。(Python 3.11 之後的錯誤訊息會多出 ~~~^^^ 標示錯誤位置,舊版沒有,版本不同箭頭符號會略有差異。)
寫例外之前,我會先讓程式自己報錯
怎麼知道一段程式會發生哪些例外?我教學生的最好方式是:先不要寫 try/except,讓程式自己引發錯誤,看清楚它丟出的是哪個例外類別,再來紀錄和撰寫。這樣不但寫得準,也能清楚知道這段程式會發生哪些例外。
學生最常遇到的,是輸入與運算的時候:轉型失敗、除以零、陣列(串列)取超過位置。另外像爬蟲,在取得遠端資料、解析資料的時候也都可能出問題,這種情況要把不同的例外分開寫好,才知道問題到底出在哪一塊。
try / except:把錯誤接住
try / except 的語意很直白:「試著執行 try 裡的程式;如果中途丟出例外,就跳到 except 去處理,不要讓程式死掉」。
try:
choice = int(input('請輸入功能選項:'))
print(f'你選了 {choice}')
except ValueError:
print('請輸入數字!')一樣打 abc:
請輸入功能選項:abc
請輸入數字!程式沒有崩潰,印出一句人看得懂的話就繼續往下走。
有兩個細節值得注意。第一,try 區塊裡出錯的那一行之後的程式不會執行——上面那個 print(f'你選了 {choice}') 完全沒印出來,因為程式在前一行就跳到 except 了。第二,except ValueError 後面接的是錯誤類型的名稱,就是剛才 traceback 最後一行看到的那個字。這不是巧合,Python 的錯誤類型本來就是設計來讓你指名接住的。
try 區塊要盡量只包「可能出錯的那幾行」。包太大範圍的話,你會分不清楚到底是哪一行出的問題,反而更難除錯。
別寫裸 except:——要抓對錯誤類型
新手常見的偷懶寫法是不指定類型,直接寫 except:。這樣確實什麼都接得住,但那正是問題所在。
先看為什麼需要「分類接」:
try:
n = int(input('請輸入除數:'))
print(100 / n)
except ValueError:
print('請輸入數字!')
except ZeroDivisionError:
print('除數不能是 0!')輸入 0:
請輸入除數:0
除數不能是 0!同一段程式有兩種出錯的方式——打字打錯是 ValueError,輸入 0 是 ZeroDivisionError——各自對應到不同的提示。如果混成一句「輸入有誤」,使用者根本不知道該怎麼改。except 可以疊好幾層,Python 會由上往下比對,用第一個對得上的。
那裸 except: 到底錯在哪?它會連你自己寫錯的 bug 一起吞掉。假設你把變數名稱打錯成 nubmer,正常會丟 NameError 讓你當場發現;被裸 except: 接住之後,程式只會印「請輸入數字!」然後若無其事地跑下去,你會對著一個永遠說「請輸入數字」的程式研究半天。它甚至會接住使用者按 Ctrl+C 想中斷程式的訊號,讓程式關不掉。
規則很單純:你預期會發生、而且知道怎麼處理的錯誤,才寫進 except。沒預期到的錯誤就讓它炸出來——那個 traceback 是在幫你,不是在找你麻煩。
裸 except: 會把你自己的打字錯誤也吞掉
程式出問題的原因不一定是使用者輸入。下面故意把 price 少打一個 e:
price = 30
quantity = 5
try:
total = pric * quantity # 少打一個 e
print("金額:", total)
except:
print("輸入錯誤,請重新輸入")執行結果:
輸入錯誤,請重新輸入畫面只說「輸入錯誤」,但這次根本沒有人輸入任何東西,真正的原因是變數名稱打錯,這個訊息會讓你去錯誤的地方找很久。把 except: 改成只接預期的錯誤 except ValueError:,同一支程式的行為變成:
Traceback (most recent call last):
File "C:\demo\demo.py", line 5, in <module>
total = pric * quantity # 少打一個 e
^^^^
NameError: name 'pric' is not defined. Did you mean: 'price'?
這就是「抓對類型」的價值:預期內的錯誤(使用者輸入不是數字)你處理;預期外的錯誤(你自己的 bug)讓它大聲報出來。
為什麼要把錯誤分類
裸 except: 看似省事,因為全部接起來就不會崩潰。但我的看法是:如果所有錯誤都指向同一個說明,最後客戶回報問題時,連我們自己也搞不清楚錯誤的根源。不如早點把 except 的類別分好,客服和後續修改的人都輕鬆很多。
分好類還有一個好處:可以針對不同的錯誤,產出不同的 LOG 報表,之後要統計哪一類錯誤最常發生就有依據。
多個 except 的順序
except 是由上往下依序比對,第一個符合的就執行。把範圍大的 Exception 寫在前面,後面的 ValueError 就永遠輪不到——而且 Python 不會警告你:
try:
n = int("abc")
except Exception:
print("發生錯誤")
except ValueError:
print("轉型失敗(想讓使用者看到這行,但永遠不會出現)")執行結果:
發生錯誤int("abc") 明明丟的是 ValueError,卻印出籠統的「發生錯誤」,我們想給使用者看的「轉型失敗」那行永遠不會出現。原則:子類別(具體)在前、父類別(籠統)在後。
配上 while,問到對為止
接住錯誤只是不崩潰,但使用者要的功能還是沒完成。真正堪用的防呆要能讓他重來,這就得靠 while 迴圈:
while True:
try:
choice = int(input('請輸入功能選項(1-5):'))
except ValueError:
print('那不是數字,請重新輸入')
continue
if 1 <= choice <= 5:
break
print('請輸入 1 到 5 之間的數字')
print(f'收到選項 {choice}')依序輸入 abc、99、3:
請輸入功能選項(1-5):abc
那不是數字,請重新輸入
請輸入功能選項(1-5):99
請輸入 1 到 5 之間的數字
請輸入功能選項(1-5):3
收到選項 3這個結構檢查了兩件不同的事,順序不能顛倒:先確認轉得成數字(try),再確認數字在合理範圍(if)。99 是完全合法的整數,int() 轉得過去、try 也不會有意見,但它不是選單上的選項,所以要另外用 if 擋。
continue 的作用是「這一輪不用再往下跑了,直接回到迴圈開頭重問」。少了它,程式會帶著一個沒有成功賦值的 choice 繼續執行下面的 if,反而引發新的錯誤。
1 <= choice <= 5 是 Python 特有的連續比較寫法,等同於 choice >= 1 and choice <= 5,但更接近數學上的直覺。
裝回商品管理程式
回到最初那支水果商品管理程式。把上面那段防呆抽成一個函式,選單就乾淨了:
def ask_choice():
while True:
try:
n = int(input('請輸入功能選項:'))
except ValueError:
print('請輸入數字 1-5')
continue
if 1 <= n <= 5:
return n
print('沒有這個選項,請輸入 1-5')
fruitPrices = {}
while True:
print('{:*^20}'.format('水果商品管理程式'))
choice = ask_choice()
if choice == 5:
print('離開程式')
break
elif choice == 1:
item = input('請輸入新產品:')
if item in fruitPrices:
print('已有該商品!')
else:
price = input(f'請輸入 {item} 價格:')
fruitPrices[item] = price
print(f'產品 {item} 新增完成!')
elif choice == 4:
print('目前產品:')
for key, value in fruitPrices.items():
print(f'{key}={value}', end=' ')
print()(為了聚焦在防呆,這裡只留新增與查詢兩個選項,刪除與修改照原文補回去即可。)
實際跑一輪,中間故意打一個 x:
******水果商品管理程式******
請輸入功能選項:1
請輸入新產品:蘋果
請輸入 蘋果 價格:30
產品 蘋果 新增完成!
******水果商品管理程式******
請輸入功能選項:x
請輸入數字 1-5
請輸入功能選項:4
目前產品:
蘋果=30
******水果商品管理程式******
請輸入功能選項:5
離開程式打錯字的那一輪只是被退回重問,資料還在、程式還活著。
把防呆包成 ask_choice() 而不是塞在主迴圈裡,有個實際的好處:主迴圈只剩下業務邏輯,一眼就看得出這支程式在做什麼;而「怎麼確保拿到合法選項」是另一件事,被關在函式裡不干擾閱讀。之後你要加「輸入價格也要防呆」,就再寫一個 ask_price(),同樣的套路再來一次——try 接住轉型失敗、while 讓他重來、if 擋掉不合理的值。
這三步幾乎可以套用在任何需要讀取使用者輸入的程式上。學會之後,你寫的東西才算真的能交給別人用,而不是只有自己小心翼翼地照著正確順序操作才不會壞。
工作上的兩個例子:例外處理沒寫會怎樣
我在實務上遇過兩次例外處理沒做好的後果:
- UI 面板上的數值輸入框:使用者輸入錯誤,轉換沒有處理好,程式跳出不預期的錯誤,整個當機。這和上面
int(input())輸入abc是同一件事,只是場景換成了操作面板。 - 重複批次操作指令:其中一次網路斷線,整批指令就報銷。加上
except之後,可以跳過那一次,並且做重連測試,不用整批重來。
所以我跟剛學的人說:例外處理不是為了讓畫面好看,是為了讓程式在別人手上遇到意外時,不會整個倒掉。