8. 錯誤和例外

到目前為止還沒有提到錯誤訊息,但如果你嘗試運行範例,你可能會發現一些錯誤訊息。常見的(至少)兩種不同的錯誤類別為:語法錯誤 (syntax error)例外 (exception)

8.1. 語法錯誤 (Syntax Error)

語法錯誤又稱剖析錯誤 (parsing error),它或許是學習 Python 的過程最常聽見的抱怨:

>>> while True print('Hello world')
  File "<stdin>", line 1
    while True print('Hello world')
               ^^^^^
SyntaxError: invalid syntax

The parser repeats the offending line and displays little arrows pointing at the token in the line where the error was detected. The error may be caused by the absence of a token before the indicated token. In the example, the error is detected at the function print(), since a colon (':') is missing before it. File name and line number are printed so you know where to look in case the input came from a script.

8.2. 例外 (Exception)

即使一段陳述式或運算式使用了正確的語法,嘗試執行時仍可能導致錯誤。執行時檢測到的錯誤稱為例外,例外不一定都很嚴重:你很快就能學會在 Python 程式中如何處理它們。不過大多數的例外不會被程式處理,並且會顯示如下的錯誤訊息:

>>> 10 * (1/0)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero
>>> 4 + spam*3
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'spam' is not defined
>>> '2' + 2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can only concatenate str (not "int") to str

錯誤訊息的最後一行指示發生了什麼事。例外有不同的類型,而類型名稱會作為訊息的一部份被印出。範例中的例外類型為:ZeroDivisionErrorNameErrorTypeError。作為例外類型被印出的字串,就是發生的內建例外 (built-in exception) 的名稱。所有的內建例外都是如此運作,但對於使用者自定的例外則不一定需要遵守(雖然這是一個有用的慣例)。標準例外名稱是內建的識別字 (identifier),不是保留關鍵字 (reserved keyword)。

此行其餘部分,根據例外的類型及導致例外的原因,說明例外的細節。

錯誤訊息的開頭,用堆疊回溯 (stack traceback) 的形式顯示發生例外的語境。一般來說,它含有一個列出源程式碼行 (source line) 的堆疊回溯;但它不會顯示從標準輸入中讀取的程式碼。

內建的例外章節列出內建的例外及它們的意義。

8.3. 處理例外

編寫程式處理選定的例外是可行的。以下範例會要求使用者輸入內容,直到有效的整數被輸入為止,但它允許使用者中斷程式(使用 Control-C 或作業系統支援的指令);請注意,由使用者產生的程式中斷會引發 KeyboardInterrupt 例外信號。

>>> while True:
...     try:
...         x = int(input("Please enter a number: "))
...         break
...     except ValueError:
...         print("Oops!  That was no valid number.  Try again...")
...

try 陳述式運作方式如下。

  • 首先,執行 try 子句tryexcept 關鍵字之間的陳述式)。

  • 如果沒有發生例外,則 except 子句會被跳過,try 陳述式執行完畢。

  • 如果執行 try 子句時發生了例外,則該子句中剩下的部分會被跳過。如果例外的類型與 except 關鍵字後面的例外名稱相符,則 except 子句被執行,然後,繼續執行 try/except 區塊之後的程式碼。

  • 如果發生的例外未符合 except 子句 中的例外名稱,則將其傳遞到外層的 try 陳述式;如果仍無法找到處理者,則它是一個未處理例外 (unhandled exception),執行將停止,並顯示錯誤訊息。

try 陳述式可以有不只一個 except 子句,為不同的例外指定處理者,而最多只有一個處理者會被執行。處理者只處理對應的 try 子句中發生的例外,而不會處理同一 try 陳述式裡其他處理者內的例外。一個 except 子句可以用一組括號內的 tuple 列舉多個例外,例如:

... except (RuntimeError, TypeError, NameError):
...     pass

A class in an except clause matches exceptions which are instances of the class itself or one of its derived classes (but not the other way around --- an except clause listing a derived class does not match instances of its base classes). For example, the following code will print B, C, D in that order:

class B(Exception):
    pass

class C(B):
    pass

class D(C):
    pass

for cls in [B, C, D]:
    try:
        raise cls()
    except D:
        print("D")
    except C:
        print("C")
    except B:
        print("B")

請注意,如果 except 子句的順序被反轉(把 except B 放到第一個),則會印出 B、B、B ­­——第一個符合的 except 子句會被觸發。

當例外發生時,它可能有相關聯的值,也就是例外的引數。引數的存在與否及它的類型,是取決於例外的類型。

except 子句可以在例外名稱後面指定一個變數。這個變數被綁定到一個例外實例 (instance),其引數通常儲存在 args 屬性中。為了方便,內建例外型別定義了 __str__() 以印出所有引數而不需顯式地取用 .args

>>> try:
...     raise Exception('spam', 'eggs')
... except Exception as inst:
...     print(type(inst))    # the exception type
...     print(inst.args)     # arguments stored in .args
...     print(inst)          # __str__ allows args to be printed directly,
...                          # but may be overridden in exception subclasses
...     x, y = inst.args     # unpack args
...     print('x =', x)
...     print('y =', y)
...
<class 'Exception'>
('spam', 'eggs')
('spam', 'eggs')
x = spam
y = eggs

例外的 __str__() 輸出會被印在未處理例外訊息的最後一部分(「細節」)。

BaseException 是由全部的例外所共用的 base class。它的 subclass(子類別)之一,Exception,則是所有非嚴重例外 (non-fatal exception) 的 base class。有些例外不是 Exception 的 subclass,而它們通常不會被處理,因為它們是用來指示程式應該終止。這些例外包括了由 sys.exit() 所引發的 SystemExit,以及當使用者想要中斷程式時所引發的 KeyboardInterrupt

Exception 可以用作通配符 (wildcard) 來捕獲(幾乎)所有的例外。然而,比較好的做法是盡可能具體地說明我們打算處理的例外類型,並容許任何非預期例外的傳遞 (propagate)。

處理 Exception 的最常見模式,是先將該例外印出或記錄,然後再重新引發它(也允許一個呼叫函式 (caller) 來處理該例外):

import sys

try:
    f = open('myfile.txt')
    s = f.readline()
    i = int(s.strip())
except OSError as err:
    print("OS error:", err)
except ValueError:
    print("Could not convert data to an integer.")
except Exception as err:
    print(f"Unexpected {err=}, {type(err)=}")
    raise

try ... except 陳述式有一個選擇性的 else 子句,使用時,該子句必須放在所有 except 子句之後。如果一段程式碼必須被執行,但 try 子句又沒有引發例外時,這個子句很有用。例如:

for arg in sys.argv[1:]:
    try:
        f = open(arg, 'r')
    except OSError:
        print('cannot open', arg)
    else:
        print(arg, 'has', len(f.readlines()), 'lines')
        f.close()

使用 else 子句比向 try 子句添加額外的程式碼要好,因為這可以避免意外地捕獲不是由 try ... except 陳述式保護的程式碼所引發的例外。

例外的處理者不僅處理 try 子句內立即發生的例外,還處理 try 子句內(即使是間接地)呼叫的函式內部發生的例外。例如:

>>> def this_fails():
...     x = 1/0
...
>>> try:
...     this_fails()
... except ZeroDivisionError as err:
...     print('Handling run-time error:', err)
...
Handling run-time error: division by zero

8.4. 引發例外

raise 陳述式可讓程式設計師強制引發指定的例外。例如:

>>> raise NameError('HiThere')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: HiThere

raise 唯一的引數就是要引發的例外。該引數必須是一個例外實例或例外 class(衍生自 BaseException 的 class,例如 Exception 與它的 subclass)。如果一個例外 class 被傳遞,它會不含引數地呼叫它的建構函式 (constructor) ,使它被自動建立實例 (implicitly instantiated):

raise ValueError  # 'raise ValueError()' 的簡寫

如果你只想判斷是否引發了例外,但並不打算處理它,則可以使用簡單的 raise 陳述式來重新引發該例外:

>>> try:
...     raise NameError('HiThere')
... except NameError:
...     print('An exception flew by!')
...     raise
...
An exception flew by!
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
NameError: HiThere

8.5. 例外鏈接 (Exception Chaining)

如果在 except 段落內部發生了一個未處理的例外,則它會讓這個將要被處理的例外附加在後,並將其包含在錯誤訊息中:

>>> try:
...     open("database.sqlite")
... except OSError:
...     raise RuntimeError("unable to handle error")
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
FileNotFoundError: [Errno 2] No such file or directory: 'database.sqlite'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
RuntimeError: unable to handle error

為了表明一個例外是另一個例外直接造成的結果,raise 陳述式容許一個選擇性的 from 子句:

# exc 必須是例外實例或 None。
raise RuntimeError from exc

要變換例外時,這種方式很有用。例如:

>>> def func():
...     raise ConnectionError
...
>>> try:
...     func()
... except ConnectionError as exc:
...     raise RuntimeError('Failed to open database') from exc
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "<stdin>", line 2, in func
ConnectionError

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
RuntimeError: Failed to open database

它也容許使用慣用語 from None 來停用自動例外鏈接:

>>> try:
...     open('database.sqlite')
... except OSError:
...     raise RuntimeError from None
...
Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
RuntimeError

更多關於鏈接機制的資訊,詳見內建的例外

8.6. 使用者自定的例外

程式可以通過建立新的例外 class 來命名自己的例外(深入了解 Python class,詳見Class(類別))。不論是直接還是間接地,例外通常應該從 Exception class 衍生出來。

例外 class 可被定義來做任何其他 class 能夠做的事,但通常會讓它維持簡單,只提供一些屬性,讓關於錯誤的資訊可被例外的處理者抽取出來。

大多數的例外定義,都會以「Error」作為名稱結尾,類似於標準例外的命名。

許多標準模組會定義它們自己的例外,以報告在其定義的函式中發生的錯誤。

8.7. 定義清理動作

try 陳述式有另一個選擇性子句,用於定義在所有情況下都必須被執行的清理動作。例如:

>>> try:
...     raise KeyboardInterrupt
... finally:
...     print('Goodbye, world!')
...
Goodbye, world!
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
KeyboardInterrupt

如果 finally 子句存在,則 finally 子句會是 try 陳述式結束前執行的最後一項任務。不論 try 陳述式是否產生例外,都會執行 finally 子句。以下幾點將探討例外發生時,比較複雜的情況:

  • 若一個例外發生於 try 子句的執行過程,則該例外會被某個 except 子句處理。如果該例外沒有被 except 子句處理,它會在 finally 子句執行後被重新引發。

  • 一個例外可能發生於 exceptelse 子句的執行過程。同樣地,該例外會在 finally 子句執行後被重新引發。

  • 如果 finally 子句執行 breakcontinuereturn 陳述式,則例外不會被重新引發。

  • 如果 try 陳述式遇到 breakcontinuereturn 陳述式,則 finally 子句會在執行 breakcontinuereturn 陳述式之前先執行。

  • 如果 finally 子句中包含 return 陳述式,則回傳值會是來自 finally 子句的 return 陳述式的回傳值,而不是來自 try 子句的 return 陳述式的回傳值。

例如:

>>> def bool_return():
...     try:
...         return True
...     finally:
...         return False
...
>>> bool_return()
False

另一個比較複雜的範例:

>>> def divide(x, y):
...     try:
...         result = x / y
...     except ZeroDivisionError:
...         print("division by zero!")
...     else:
...         print("result is", result)
...     finally:
...         print("executing finally clause")
...
>>> divide(2, 1)
result is 2.0
executing finally clause
>>> divide(2, 0)
division by zero!
executing finally clause
>>> divide("2", "1")
executing finally clause
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in divide
TypeError: unsupported operand type(s) for /: 'str' and 'str'

如你所見,finally 子句在任何情況下都會被執行。兩個字串相除所引發的 TypeError 沒有被 except 子句處理,因此會在 finally 子句執行後被重新引發。

在真實應用程式中,finally 子句對於釋放外部資源(例如檔案或網路連線)很有用,無論該資源的使用是否成功。

8.8. 預定義的清理動作

某些物件定義了在物件不再被需要時的標準清理動作,無論使用該物件的作業是成功或失敗。請看以下範例,它嘗試開啟一個檔案,並印出檔案內容至螢幕。

for line in open("myfile.txt"):
    print(line, end="")

這段程式碼的問題在於,執行完該程式碼後,它讓檔案在一段不確定的時間內處於開啟狀態。在簡單腳本中這不是問題,但對於較大的應用程式來說可能會是個問題。with 陳述式讓物件(例如檔案)在被使用時,能保證它們總是及時、正確地被清理。

with open("myfile.txt") as f:
    for line in f:
        print(line, end="")

陳述式執行完畢後,就算是在處理內容時遇到問題,檔案 f 總是會被關閉。和檔案一樣,提供預定義清理動作的物件會在說明文件中表明這一點。

8.9. 引發及處理多個無關的例外

在某些情況下,必須回報已經發生的多個例外。在並行框架 (concurrency framework) 中經常會出現這種情況,當平行的 (parallel) 某些任務可能已經失效,但還有其他用例 (use case) 希望能繼續執行並收集多個例外,而不是只有引發第一個例外時。

內建的 ExceptionGroup 會包裝一個例外實例 (exception instance) 的 list(串列),使得它們可以一起被引發。由於它本身就是一個例外,因此它也可以像任何其他例外一樣被捕獲。

>>> def f():
...     excs = [OSError('error 1'), SystemError('error 2')]
...     raise ExceptionGroup('there were problems', excs)
...
>>> f()
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 1, in <module>
  |   File "<stdin>", line 3, in f
  | ExceptionGroup: there were problems
  +-+---------------- 1 ----------------
    | OSError: error 1
    +---------------- 2 ----------------
    | SystemError: error 2
    +------------------------------------
>>> try:
...     f()
... except Exception as e:
...     print(f'caught {type(e)}: e')
...
caught <class 'ExceptionGroup'>: e
>>>

若使用 except* 代替 except,我們可以選擇性地只處理該群組中與特定類型匹配的例外。在以下範例中,展示了一個巢狀的例外群組 (exception group),每個 except* 子句分別從該群組中提取一個特定類型的例外,同時讓所有其他的例外都傳遞到其他子句,最後再被重新引發。

>>> def f():
...     raise ExceptionGroup(
...         "group1",
...         [
...             OSError(1),
...             SystemError(2),
...             ExceptionGroup(
...                 "group2",
...                 [
...                     OSError(3),
...                     RecursionError(4)
...                 ]
...             )
...         ]
...     )
...
>>> try:
...     f()
... except* OSError as e:
...     print("There were OSErrors")
... except* SystemError as e:
...     print("There were SystemErrors")
...
There were OSErrors
There were SystemErrors
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 2, in <module>
  |   File "<stdin>", line 2, in f
  | ExceptionGroup: group1
  +-+---------------- 1 ----------------
    | ExceptionGroup: group2
    +-+---------------- 1 ----------------
      | RecursionError: 4
      +------------------------------------
>>>

請注意,被巢套在例外群組中的例外必須是實例,而不是類型。這是因為在實務上,這些例外通常是已經被程式引發並捕獲的例外,類似以下的模式:

>>> excs = []
... for test in tests:
...     try:
...         test.run()
...     except Exception as e:
...         excs.append(e)
...
>>> if excs:
...    raise ExceptionGroup("Test Failures", excs)
...

8.10. 用註解使例外更詳細

當一個例外是為了被引發而建立時,它通常會伴隨著一些資訊被初始化,這些資訊描述了當下發生的錯誤。在某些情況,在例外被捕獲之後添加資訊會很有用。為此,例外具有一個 add_note(note) method(方法),它可以接受一個字串並將其添加到例外的註解清單中。標準的回溯呈現會在例外之後列出所有的註解,並按照其被添加的順序來排列。

>>> try:
...     raise TypeError('bad type')
... except Exception as e:
...     e.add_note('Add some information')
...     e.add_note('Add some more information')
...     raise
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
TypeError: bad type
Add some information
Add some more information
>>>

例如,在將例外收集到例外群組中時,我們可能希望為各個錯誤添加一些上下文的資訊。在以下範例中,群組中的每個例外都有一條註解,指示此錯誤是在何時發生。

>>> def f():
...     raise OSError('operation failed')
...
>>> excs = []
>>> for i in range(3):
...     try:
...         f()
...     except Exception as e:
...         e.add_note(f'Happened in Iteration {i+1}')
...         excs.append(e)
...
>>> raise ExceptionGroup('We have some problems', excs)
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 1, in <module>
  | ExceptionGroup: We have some problems (3 sub-exceptions)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "<stdin>", line 3, in <module>
    |   File "<stdin>", line 2, in f
    | OSError: operation failed
    | Happened in Iteration 1
    +---------------- 2 ----------------
    | Traceback (most recent call last):
    |   File "<stdin>", line 3, in <module>
    |   File "<stdin>", line 2, in f
    | OSError: operation failed
    | Happened in Iteration 2
    +---------------- 3 ----------------
    | Traceback (most recent call last):
    |   File "<stdin>", line 3, in <module>
    |   File "<stdin>", line 2, in f
    | OSError: operation failed
    | Happened in Iteration 3
    +------------------------------------
>>>