跳至主要内容

錯誤處理

錯誤處理

上課的時候我總是跟同學們說,犯錯是很正常的,不用擔心,只要學會如何處理錯誤以及下次不要再犯就好。在程式語言中,錯誤處理是一個很重要的概念,因為程式碼本身或是程式執行過程中可能會有很多不可預測的錯誤,例如使用者輸入了一些預期之外的結果,原本應該輸入身高體重的數值,結果使用者卻輸入了英文字母或是小於零的負數而造成計算錯誤,這時候我們就需要透過錯誤處理來解決這些問題。

錯誤的種類

現代的程式語言幾乎都有針對不同的錯誤類型做出不同的處理方式,Python 也不例外。Python 中的錯誤常見的錯誤有分好幾種,以下這些是比較常見的:

  • SyntaxError:語法錯誤,這大概是新手最常見的錯誤,通常這就是程式碼寫錯了,這在程式碼編譯的過程就會被抓出來,例如多了一個小括號、少了個引號或是忘記寫冒號,Python 通常會幫我們標記出來是哪一行寫錯,這還滿容易被發現的。
  • IndentationError:縮排錯誤,這是因為程式碼的縮排不正確,也是新手常見錯誤,這在編譯過程就會被抓出來,也還算容易解決。
  • NameError:名稱錯誤,通常是因為變數或函數名稱不存在,可能是作用域(Scope)不對或是根本就寫錯字了。
  • TypeError:類型錯誤,通常是因為變數的類型不正確,例如拿數字 1 跟字串 "1" 進行加總。
  • ValueError:數值錯誤,例如 int("a") 試著把字串 "a" 轉換成整數,這會造成錯誤。
  • ZeroDivisionError:除以零錯誤,這是把數字 0 拿來當分母,有些程式語言會得到無限大,但這在 Python 會出錯。
  • IndexError:在前面的串列章節看過幾次,原因是因為索引值超出範圍了。
  • KeyError:當字典裡沒有指定的 Key 的時候會發生的錯誤。
  • FileNotFoundError:這在後面章節介紹讀取檔案的時候就有機會遇到,原因是因為檔案不存在。
  • AttributeError:當試著取用物件身上沒有指定的屬性或方法時會發生的錯誤。

還有非常多種類。錯誤並不可怕,通常只要知道是什麼錯,大多都不難解決。

接下來我想先跟大家介紹兩個名詞,一個是「錯誤(Error)」,另一個是「例外(Exception)」,雖然看起來都是錯誤,但其實有些微的差異...

錯誤 vs 例外

「錯誤」是一個較為廣泛的術語,用來描述各種錯誤情況,包括語法錯誤或是程式執行的時候出錯等等。錯誤通常是不可恢復的問題,只要出現就會導致程式停止執行。「例外」通常是指更特定類型的錯誤,例外也是一種錯誤,也就是說例外是錯誤的子集合。跟錯誤不同的是,例外只要透過適當的處理,通常是可以救回來並且繼續執行。雖然意義上有些不同,而且也不是所有的例外都是錯誤,不過實際在開發的過程中,我很常聽到工程師們把這兩個詞都稱做「錯誤」,這也沒什麼太大的毛病。

有些程式語言會直接在名稱上做出區別,例如 Java 有一個叫做 ArithmeticException 的類別,一看就知道它是個例外,當在 Java 做數字計算出問題的時候(例如把數字 0 拿來當分母)就會出現這個例外,在 Python 也有類似的東西,但它的名字叫做 ArithmeticError

關於命名,在官方的 PEP-8 裡有提到這件事:

Exception Names

Because exceptions should be classes, the class naming convention applies here. However, you should use the suffix “Error” on your exception names (if the exception actually is an error).

在 Python 的世界裡,例外沒有特別加上 Exception 這個詞,像是鍵盤中斷的 KeyboardInterrupt 或是上一章學到的產生器的 StopIteration,都是 Python 內建的例外,都沒有在名字上特別標註。倒是如果你看到名字裡帶有 Error 的話,通常就表示它是一個錯誤,或至少在 Python 的世界裡被認定是一種錯誤,例如 NameErrorSyntaxError 或是 TypeError。雖然名字裡帶了 Error 字樣,但如果你去追它們的繼承關係,你會發現它們都是繼承自 Exception 類別。所以,在 Python 的世界裡,「錯誤」反而是「例外」的子集合,這個設計還滿特別的。

不管是不可恢復的錯誤或是還是有機會救回來的例外,身為工程師的我們就是得想辦法處理它!

主動丟出錯誤

雖然程式在執行或編譯的過程中可能會產生錯誤,但如果想要產生錯誤,就算沒有錯我們自己也可以使用 raise 關鍵字丟出錯誤出來:

>>> raise NameError
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError
>>> raise NameError("這裡沒有人")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: 這裡沒有人

raise 後面可以接錯誤的種類,同時還可以在裡面加入錯誤的訊息,讓錯誤看起來更知道錯在哪裡。當程式碼執行到有 raise 的時候,不管這是 Python 丟出來的還是我們自己丟出來的,如果沒有特別的處理的話,程式就會停止執行並且會在畫面上看到錯誤訊息。

製作自己的錯誤類別

Python 本身內建的錯誤類別大部份時候是很夠用的,但有時候我們會想要為我們自己的專案自己定義一些專屬的錯誤種類,這樣可以讓我們的程式碼更有組織性,也可以讓我們更容易找到錯誤的來源。要製作自己的錯誤類別,只要繼承 Exception 類別就可以了:

class PokemonCardError(Exception):
def __init__(self, message, code):
self.message = message
self.code = code

def __str__(self):
return f'{self.message} (error code: {self.code})'

類別或是繼承的語法,或是裡面的 __init__() 以及 __str__() 方法,再過兩個章節就會看到更詳細的說明。定義了專屬的錯誤種類之後,就可以在程式碼中使用這個錯誤類別:

>>> raise PokemonCardError("噴火龍卡片無法使用", code=418)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
PokemonCardError: 噴火龍卡片無法使用 (error code: 418)

到時候如果在程式運作的過程中看到 PokemonCardError 再搭配錯誤訊息跟代碼,就更容易找到發生錯誤的原因。

錯誤處理

try 與 except

當錯誤發生的時候,如果沒人處理,程式就會停止執行,例如:

1/0                   # 這行會出錯!
print("Hello Kitty") # 這行不會執行

這裡我故意寫了一個會出錯的程式碼,當程式執行到 1/0 的時候,因為數字 0 不能當分母,所以會出現錯誤訊息然後程式中止,下一行的 print() 函數就不會被執行。像這樣出錯就中斷的情況是好事還是壞事我不敢說,但如果我們希望程式在發生錯誤的時候,經過適當的處理後還可以繼續執行的話,就可以使用錯誤處理的手法。在 Python 錯誤處理是使用 tryexcept 的關鍵字組合,把可能會出事的程式碼放到 try 區塊裡,except 區塊則是放萬一真的出事了應該怎麼辦的流程:

try:
1/0 # 這行會出錯
except:
print("出事了阿伯!")

print("Hello Kitty")

同樣是出錯,但這次因為有使用 try 關鍵字所以程式不會立刻停止,而是接著跳轉到 except 區塊。不管在 try 區塊有幾行程式碼或是執行到第幾行都沒關係,只要在 try 區塊的任何一行發生問題,程式就會馬上跳轉到 except 區塊;相對的,如果在 try 區塊裡什麼事都沒發生,順順的走完流程的話,except 區塊的程式碼就不會被執行。以上面這個例子來說,執行之後會得到的結果是:

出事了阿伯!
Hello Kitty

雖然 try 可以把可能發生問題的程式碼包起來,萬一出錯的時候丟給 except 區塊處理,但也不會包山包海把所有的程式碼都丟進 try 區塊,只要把我們認為有可能會出事的程式碼包起來就好。

在上面的範例裡,關鍵字 except 後面沒有指定任何錯誤類別,表示會抓到所有種類的錯誤,看起來好像很方便,但這並不是個好的做法,因為這可能會抓到一些不是我們預期的錯誤而造成偽陽性(False Positive)的誤判。大部份時候我們應該都有辦法預測使用者可能會做什麼操作或輸入什麼值,然後預判在 try 區塊可能會出現的錯誤。以剛才的例子來說,我猜使用者可能會輸入數字 0,所以我們可以把 except 後面加上 ZeroDivisionError 錯誤類別:

try:
1/0 # 這行會出錯
except ZeroDivisionError as err:
print(f"出事了阿伯! 原因:{err}")

使用 as 給它一個別名的話,還能順便把錯誤訊息印出來。

你可能會想,我們怎麼可能知道使用者會做什麼事情或是預測哪裡會出錯?相信我,如果程式是你自己寫的,你一定會知道使用者會怎麼操作或可能輸入什麼值,如果不知道,那就是你可能根本不知道自己在寫什麼或是這段程式碼不知道從哪裡撿來的,也許該好好檢討一下自己。

如果錯誤可能會有好幾種可能的話該怎麼寫?可以分成多個 except 來寫:

try:
1/0 # 這行會出錯
except ZeroDivisionError as err:
print(f"出事了阿伯! 原因:{err}")
except NameError as err:
print(f"出事了阿伯! 原因:{err}")

或是把它合併在一起寫,要是擔心有沒考慮到的錯誤的話,最後可再加個 except 來捕捉漏網之魚:

try:
1/0 # 這行會出錯
except (ZeroDivisionError, NameError) as err:
print(f"出事了阿伯! 原因:{err}")
except:
# 這是一個通用的錯誤處理
print("出事了阿伯!")

是說,會不會在處理錯誤的 except 區塊本身自己也出錯?當然有可能,except 區塊裡面的也是程式碼,只要是程式碼就有機會出錯。萬一如果在 except 裡出錯,就要看它的外層還有沒有別的 try...except... 來接手了,不然程式還是會停下來。

finally 與 else

try...except... 的錯誤處理的組合裡,還有幾個關鍵字可以一起合併使用,其中一個是 finally 關鍵字,它會放在 try...except... 區塊的後面。不管 try 區塊有沒有出錯,finally 區塊裡面的程式碼都會被執行。這個關鍵字通常用來做一些清理、善後的工作,例如關閉檔案、關閉資料庫連線等等,寫起來大概像這樣:

try:
1/0 # 這行會出錯
except ZeroDivisionError as err:
print(f"出事了阿伯! 原因:{err}")
finally:
print("日子還是要過下去!")

另一個關鍵字是 else,當 try 區塊沒有發生問題的時候,else 區塊裡面的程式碼才會被執行:

try:
1/1 # 這行可以正常運作
except ZeroDivisionError as err:
print(f"出事了阿伯! 原因:{err}")
else:
print("又是風和日麗的一天!")
finally:
print("日子還是要過下去的!")

以這個例子來說,因為 try 區塊裡的程式碼沒有出錯,所以 elsefinally 兩個區塊都會被執行。

這個章節最後用一個不太實用的冷知識做為收尾,大家先看看以下這個範例:

def hello():
try:
return "world"
finally:
return "kitty"

hello() 函數裡面我放了一個 tryfinally 區塊,這時候如果執行 hello() 函數,你覺得會回傳的結果是什麼?是回傳 "world" 還是 "kitty" 呢?還是這根本就會造成語法錯誤?

當執行 hello() 函數的時候,會從 try 區塊開始執行,這沒問題,照理說在 try 區塊裡看到了 return 關鍵字的時候就知道函數差不多該結束了,但這裡還有個 finally 區塊,而 finally 區塊的內容不管 try 的過程成功還是失敗都會執行,所以原本應該是 return "world" 結束這回合的,但在結束之前必須再執行 finally 區塊的程式碼,所以執行 hello() 函數最後的回傳的會是 "kitty"

沒事不要這樣寫!

工商服務

想學 Python 嗎?我教你啊 :)

想要成為軟體工程師嗎?這不是條輕鬆的路,除了興趣之外,還需要足夠的決心、設定目標並持續學習,我們的ASTROCamp 軟體工程師培訓營提供專業的前後端課程培訓,幫助你在最短時間內建立正確且扎實的軟體開發技能,有興趣而且不怕吃苦的話不妨來試試看!