raise 例外
April 29, 2022在〈類別入門〉曾經建立過 Account
類別:
class Account:
def __init__(self, name, number, balance):
self.name = name
self.number = number
self.balance = balance
def deposit(self, amount):
if amount <= 0:
print('存款金額不得為負')
else:
self.balance += amount
def withdraw(self, amount):
if amount > self.balance:
print('餘額不足')
else:
self.balance -= amount
def __str__(self):
return f"Account('{self.name}', '{self.number}', {self.balance})"
當存款金額為負或餘額不足時,直接在程式流程使用 print
顯示訊息,如果 Account
實際上不是用在文字模式,而是 Web 應用程式或者其他環境呢?print
的訊息不會出現在這類環境的互動介面上。
引發例外
可以從另一方面來想,當存款金額為負時,會使得存款流程無法繼續而必須中斷,類似地,餘額不足時,會使得提款流程無法繼續而必須中斷,如果想讓呼叫方知道因為某些原因,流程無法繼續而必須中斷時,可以引發例外。
如果想引發例外,可以使用 raise
指定要引發的例外物件或型態,只指定例外型態的時候,會自動建立例外物件。例如:
class Account:
def __init__(self, name, number, balance):
self.name = name
self.number = number
self.balance = balance
def check_amount(self, amount):
if amount <= 0:
raise ValueError('金額必須是正數:' + str(amount))
def deposit(self, amount):
self.check_amount(amount)
self.balance += amount
def withdraw(self, amount):
self.check_amount(amount)
if amount > self.balance:
raise BankingException('餘額不足')
self.balance -= amount
def __str__(self):
return f"Account('{self.name}', '{self.number}', {self.balance})"
class BankingException(Exception):
def __init__(self, message: str):
super().__init__(message)
在這邊定義了一個 check_amount
方法,用來檢查傳入的金額是否為負,因為 deposit
跟 withdraw
不接受負數,傳入負數是個錯誤,對於引數方面的錯誤,可以引發內建的 ValueError
。對於 deposit
跟 withdraw
,一開始都會使用 check_amount
方法進行檢查。
至於餘額不足,是屬於銀行商務流程相關的問題,這部份建議自訂例外 BankingException
,withdraw
在餘額不足時引發此例外。可以為自己的 API 建立一個根例外,商務相關的例外可衍生自這個根例外,以方便 API 使用者必要時,可在 except
使用你的根例外來處理 API 相關的例外。
現在 Account
的使用者,若對 deposit
跟 withdraw
傳入負數,或者是提款時餘額不足時,都會引發例外了,那麼在發現例外時該怎麼處理呢?
對於 deposit
跟 withdraw
傳入負數而引發的 ValueError
例外,基本上不應該發生,因為正常來說,不應該在存款或提款時輸入負數,在設計使用者輸入介面時,本來就應該有這種防呆或防惡意的考量。
如果介面上沒有此設計,而使得 deposit
跟 withdraw
真的被輸入負數而引發例外,比較好的方式,就是不處理例外,讓例外浮現至使用者介面層面,看是要在使用者層面處理例外,或者是檢查使用者輸入,總之就是讓使用者知道他們做了不該做的事。
主動引發例外,並不是嫌程式中的臭蟲不夠多,而是對呼叫者善盡告知的責任。
因此,對於標準程式庫會引發的例外,也可以從開發標準程式庫的開發者角度來思考,為什麼他會想引發這樣的例外?主動讓我知道發生了例外,我可以有什麼好處?如此就會比較能知道該怎麼處理例外,例如該留下日誌訊息?轉為其他流程?重新引發例外?
在〈The art of throwing JavaScript errors〉中有個有趣的比擬,在程式碼的特定點規劃出失敗,總比在預測哪裡會出現失敗來得簡單,這就像是車體框架的設計,會希望撞擊發生時,框架能以一個可預測的方式潰散,如此製造商方能確保乘客的安全性。
捕捉或重拋?
如果真的要在底層呼叫 deposit
跟 withdraw
時處理 ValueError
例外,像是留下日誌訊息,那麼可以考量以下類似的流程:
try:
acct.deposit(-500)
except ValueError as err:
import logging, datetime
logging.getLogger(__name__).log(
logging.ERROR,
'Logging: {time}, {number}, {message}'.format(
time = datetime.datetime.now(),
number = acct.number,
message = err
)
)
raise
例外並沒有真的被解決,只是留下了一些日誌訊息,問題仍要向上呈現,因此最後直接使用 raise
,將 except
比對到的例外實例重新引發,就這邊的案例來說,會看到類似以下的訊息:
Logging: 2020-09-08 10:49:52.487011, 123-4567, 金額必須是正數:-500
Traceback (most recent call last):
File "C:\workspace\exceptions\bank_demo.py", line 18, in deposit
acct.deposit(-500)
File "C:\workspace\exceptions\bank.py", line 12, in deposit
self.check_amount(amount)
File "C:\workspace\exceptions\bank.py", line 9, in check_amount
raise ValueError('金額必須是正數:' + str(amount))
ValueError: 金額必須是正數:-500
若重新引發例外時,想要使用自訂的例外或其他例外類型,並且將 except
比對到的例外作為來源,可以使用 raise from
。例如:
try:
acct.deposit(-500)
except ValueError as err:
略…
raise bank.BankingException('輸入金額為負的行為已記錄') from err
在這邊,新建立的 BankingException
會包含 ValueError
,因此會看到類似以下的訊息:
Logging: 2020-09-08 10:51:45.076745, 123-4567, 金額必須是正數:-500
Traceback (most recent call last):
File "C:\workspace\exceptions\bank_demo.py", line 18, in deposit
acct.deposit(-500)
File "C:\workspace\exceptions\bank.py", line 12, in deposit
self.check_amount(amount)
File "C:\workspace\exceptions\bank.py", line 9, in check_amount
raise ValueError('金額必須是正數:' + str(amount))
ValueError: 金額必須是正數:-500
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "C:\workspace\exceptions\bank_demo.py", line 31, in <module>
deposit(acct)
File "C:\workspace\exceptions\bank_demo.py", line 29, in deposit
raise bank.BankingException('輸入金額為負的行為已記錄') from err
bank.BankingException: 輸入金額為負的行為已記錄
如果進一步使用 except
處理了重新引發的 BankingException
,可以透過例外實例的 __cause__
取得 raise from
時的來源例外。若例外在 except
中被引發,就算沒有使用 raise from
,原本比對到的例外,也會自動被設定給被引發例外的 __context__
屬性。
至於 withdraw
時因餘額而引發的 BankingException
例外,由於是個商務流程問題,這就看專案的規格書怎麼規範了,也許是在餘額不足時,轉而進行借貸流程。例如:
try:
acct.withdraw(2000)
except bank.BankingException as ex:
print(ex)
print('你要進行借貸嗎?')
# 其他借貸流程