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 方法,用來檢查傳入的金額是否為負,因為 depositwithdraw 不接受負數,傳入負數是個錯誤,對於引數方面的錯誤,可以引發內建的 ValueError。對於 depositwithdraw,一開始都會使用 check_amount 方法進行檢查。

至於餘額不足,是屬於銀行商務流程相關的問題,這部份建議自訂例外 BankingExceptionwithdraw 在餘額不足時引發此例外。可以為自己的 API 建立一個根例外,商務相關的例外可衍生自這個根例外,以方便 API 使用者必要時,可在 except 使用你的根例外來處理 API 相關的例外。

現在 Account 的使用者,若對 depositwithdraw 傳入負數,或者是提款時餘額不足時,都會引發例外了,那麼在發現例外時該怎麼處理呢?

對於 depositwithdraw 傳入負數而引發的 ValueError 例外,基本上不應該發生,因為正常來說,不應該在存款或提款時輸入負數,在設計使用者輸入介面時,本來就應該有這種防呆或防惡意的考量。

如果介面上沒有此設計,而使得 depositwithdraw 真的被輸入負數而引發例外,比較好的方式,就是不處理例外,讓例外浮現至使用者介面層面,看是要在使用者層面處理例外,或者是檢查使用者輸入,總之就是讓使用者知道他們做了不該做的事。

主動引發例外,並不是嫌程式中的臭蟲不夠多,而是對呼叫者善盡告知的責任。

因此,對於標準程式庫會引發的例外,也可以從開發標準程式庫的開發者角度來思考,為什麼他會想引發這樣的例外?主動讓我知道發生了例外,我可以有什麼好處?如此就會比較能知道該怎麼處理例外,例如該留下日誌訊息?轉為其他流程?重新引發例外?

在〈The art of throwing JavaScript errors〉中有個有趣的比擬,在程式碼的特定點規劃出失敗,總比在預測哪裡會出現失敗來得簡單,這就像是車體框架的設計,會希望撞擊發生時,框架能以一個可預測的方式潰散,如此製造商方能確保乘客的安全性。

捕捉或重拋?

如果真的要在底層呼叫 depositwithdraw 時處理 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('你要進行借貸嗎?')
    # 其他借貸流程

分享到 LinkedIn 分享到 Facebook 分享到 Twitter