Python基礎之錯誤和異常講解

上善若泪發表於2024-06-29

目錄
  • 1 錯誤和異常
    • 1.1 簡介
      • 1.1.1 語法錯誤
      • 1.1.2 異常
    • 1.2 丟擲異常
      • 1.2.1 丟擲原裝異常
      • 1.2.2 assert異常
      • 1.2.3 丟擲使用者自定義異常
    • 1.3 異常處理
      • 1.3.1 try/except
      • 1.3.2 try/except...else
      • 1.3.3 try-finally 語句
      • 1.3.4 with 關鍵字
    • 1.4 分析記錄錯誤
      • 1.4.1 分析錯誤
      • 1.4.2 記錄錯誤
    • 1.5 單元測試
      • 1.5.1 unittest單元測試
      • 1.5.2 setUp與tearDown
      • 1.5.3 文件測試

1 錯誤和異常

1.1 簡介

Python 有兩種錯誤很容易辨認:語法錯誤異常
Python assert(斷言)用於判斷一個表示式,在表示式條件為 false 的時候觸發異常。

1.1.1 語法錯誤

Python 的語法錯誤或者稱之為解析錯,是初學者經常碰到的,如下例項

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

這個例子中,函式 print() 被檢查到有錯誤,是它前面缺少了一個冒號 : 。
語法分析器指出了出錯的一行,並且在最先找到的錯誤的位置標記了一個小小的箭頭。

1.1.2 異常

即便 Python 程式的語法是正確的,在執行它的時候,也有可能發生錯誤。執行期檢測到的錯誤被稱為異常。

大多數的異常都不會被程式處理,都以錯誤資訊的形式展現在這裡:

>>> 10 * (1/0)             # 0 不能作為除數,觸發異常
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
ZeroDivisionError: division by zero
>>> 4 + spam*3             # spam 未定義,觸發異常
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
NameError: name 'spam' is not defined
>>> '2' + 2               # int 不能與 str 相加,觸發異常
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can only concatenate str (not "int") to str

異常以不同的型別出現,這些型別都作為資訊的一部分列印出來: 例子中的型別有 ZeroDivisionError,NameError 和 TypeError。
錯誤資訊的前面部分顯示了異常發生的上下文,並以呼叫棧的形式顯示具體資訊。

1.2 丟擲異常

1.2.1 丟擲原裝異常

Python 使用 raise 語句丟擲一個指定的異常。
raise語法格式如下:raise [Exception [, args [, traceback]]]

以下例項如果 x 大於 5 就觸發異常:

x = 10
if x > 5:
    raise Exception('x 不能大於 5。x 的值為: {}'.format(x))

執行以上程式碼會觸發異常:
Traceback (most recent call last):
  File "test.py", line 3, in <module>
    raise Exception('x 不能大於 5。x 的值為: {}'.format(x))
Exception: x 不能大於 5。x 的值為: 10

raise 唯一的一個引數指定了要被丟擲的異常。它必須是一個異常的例項或者是異常的類(也就是 Exception 的子類)。

如果只想知道這是否丟擲了一個異常,並不想去處理它,那麼一個簡單的 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 ?
NameError: HiThere

1.2.2 assert異常

Python assert(斷言)用於判斷一個表示式,在表示式條件為 false 的時候觸發異常。

斷言可以在條件不滿足程式執行的情況下直接返回錯誤,而不必等待程式執行後出現崩潰的情況,例如我們的程式碼只能在 Linux 系統下執行,可以先判斷當前系統是否符合條件。

語法格式如下:assert expression或者附帶引數assert expression [, arguments]
等價於:

if not expression:
    raise AssertionError
或者帶引數
if not expression:
    raise AssertionError(arguments)

以下為 assert 使用例項:

>>> assert True     # 條件為 true 正常執行
>>> assert False    # 條件為 false 觸發異常
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError
>>> assert 1==1    # 條件為 true 正常執行
>>> assert 1==2    # 條件為 false 觸發異常
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError

>>> assert 1==2, '1 不等於 2'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError: 1 不等於 2
>>>

以下例項判斷當前系統是否為 Linux,如果不滿足條件則直接觸發異常,不必執行接下來的程式碼:

import sys
assert ('linux' in sys.platform), "該程式碼只能在 Linux 下執行"

1.2.3 丟擲使用者自定義異常

可以透過建立一個新的異常類來擁有自己的異常。異常類繼承自 Exception 類,可以直接繼承,或者間接繼承,例如:

>>> class MyError(Exception):
        def __init__(self, value):
            self.value = value
        def __str__(self):
            return repr(self.value)
   
>>> try:
        raise MyError(2*2)
    except MyError as e:
        print('My exception occurred, value:', e.value)
   
My exception occurred, value: 4
>>> raise MyError('oops!')
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
__main__.MyError: 'oops!'

在這個例子中,類 Exception 預設的 __init__() 被覆蓋。
當建立一個模組有可能丟擲多種不同的異常時,一種通常的做法是為這個包建立一個基礎異常類,然後基於這個基礎類為不同的錯誤情況建立不同的子類:

class Error(Exception):
    """Base class for exceptions in this module."""
    pass

class InputError(Error):
    """Exception raised for errors in the input.

    Attributes:
        expression -- input expression in which the error occurred
        message -- explanation of the error
    """

    def __init__(self, expression, message):
        self.expression = expression
        self.message = message

class TransitionError(Error):
    """Raised when an operation attempts a state transition that's not
    allowed.

    Attributes:
        previous -- state at beginning of transition
        next -- attempted new state
        message -- explanation of why the specific transition is not allowed
    """

    def __init__(self, previous, next, message):
        self.previous = previous
        self.next = next
        self.message = message

大多數的異常的名字都以"Error"結尾,就跟標準的異常命名一樣。

1.3 異常處理

1.3.1 try/except

異常捕捉可以使用 try/except 語句

以下例子中,讓使用者輸入一個合法的整數,但是允許使用者中斷這個程式(使用 Control-C 或者作業系統提供的方法)。使用者中斷的資訊會引發一個 KeyboardInterrupt 異常。

while True:
    try:
        x = int(input("請輸入一個數字: "))
        break
    except ValueError:
        print("您輸入的不是數字,請再次嘗試輸入!")

try 語句按照如下方式工作;

  • 執行 try 子句(在關鍵字 try 和關鍵字 except 之間的語句)。
  • 如果沒有異常發生,忽略 except 子句,try 子句執行後結束。
  • 如果在執行 try 子句的過程中發生了異常,那麼 try 子句餘下的部分將被忽略。如果異常的型別和 except 之後的名稱相符,那麼對應的 except 子句將被執行。
  • 如果一個異常沒有與任何的 except 匹配,那麼這個異常將會傳遞給上層的 try 中。

一個 try 語句可能包含多個except子句,分別來處理不同的特定的異常。最多隻有一個分支會被執行。
處理程式將只針對對應的 try 子句中的異常進行處理,而不是其他的 try 的處理程式中的異常。

一個except子句可以同時處理多個異常,這些異常將被放在一個括號裡成為一個元組,例如:

except (RuntimeError, TypeError, NameError):
    pass

最後一個except子句可以忽略異常的名稱,它將被當作萬用字元使用。可以使用這種方法列印一個錯誤資訊,然後使用raise再次把異常丟擲。

import sys

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

1.3.2 try/except...else

try/except 語句還有一個可選的 else 子句,如果使用這個子句,那麼必須放在所有的 except 子句之後。
else 子句將在 try 子句沒有發生任何異常的時候執行。

以下例項在 try 語句中判斷檔案是否可以開啟,如果開啟檔案時正常的沒有發生異常則執行 else 部分的語句,讀取檔案內容:

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

使用 else 子句比把所有的語句都放在 try 子句裡面要好,這樣可以避免一些意想不到,而 except 又無法捕獲的異常。

異常處理並不僅僅處理那些直接發生在 try 子句中的異常,而且還能處理子句中呼叫的函式(甚至間接呼叫的函式)裡丟擲的異常。例如:

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

1.3.3 try-finally 語句

try-finally 語句無論是否發生異常都將執行最後的程式碼。
以下例項中 finally 語句無論異常是否發生都會執行:

try:
    runoob()
except AssertionError as error:
    print(error)
else:
    try:
        with open('file.log') as file:
            read_data = file.read()
    except FileNotFoundError as fnf_error:
        print(fnf_error)
finally:
    print('這句話,無論異常是否發生都會執行。')

下面是一個更加複雜的例子(在同一個 try 語句裡包含 except 和 finally 子句):

>>> 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 ?
  File "<stdin>", line 3, in divide
TypeError: unsupported operand type(s) for /: 'str' and 'str'

1.3.4 with 關鍵字

Python 中的 with 語句用於異常處理,封裝了 try…except…finally 編碼正規化,提高了易用性。
with 語句使程式碼更清晰、更具可讀性, 它簡化了檔案流等公共資源的管理。
在處理檔案物件時使用 with 關鍵字是一種很好的做法。

我們可以看下以下幾種程式碼例項:不使用 with,也不使用 try…except…finally

file = open('./test_runoob.txt', 'w')
file.write('hello world !')
file.close()

以上程式碼如果在呼叫 write 的過程中,出現了異常,則 close 方法將無法被執行,因此資源就會一直被該程式佔用而無法被釋放。 接下來我們呢可以使用 try…except…finally 來改進程式碼:

file = open('./test_runoob.txt', 'w')
try:
    file.write('hello world')
finally:
    file.close()

以上程式碼我們對可能發生異常的程式碼處進行 try 捕獲,發生異常時執行 except 程式碼塊,finally 程式碼塊是無論什麼情況都會執行,所以檔案會被關閉,不會因為執行異常而佔用資源。

使用 with 關鍵字:

with open('./test_runoob.txt', 'w') as file:
    file.write('hello world !')

使用 with 關鍵字系統會自動呼叫 f.close() 方法, with 的作用等效於 try/finally 語句是一樣的。

我們可以在執行 with 關鍵字後檢驗檔案是否關閉:

>>> with open('./test_runoob.txt') as f:
...     read_data = f.read()

>>> # 檢視檔案是否關閉
>>> f.closed
True

with 語句實現原理建立在上下文管理器之上。上下文管理器是一個實現 __enter____exit__ 方法的類。使用 with 語句確保在巢狀塊的末尾呼叫 __exit__ 方法。這個概念類似於 try...finally 塊的使用。

with open('./test_runoob.txt', 'w') as my_file:
    my_file.write('hello world!')

以上例項將 hello world! 寫到 ./test_runoob.txt 檔案上。

在檔案物件中定義了 __enter____exit__ 方法,即檔案物件也實現了上下文管理器,首先呼叫 __enter__ 方法,然後執行 with 語句中的程式碼,最後呼叫 __exit__ 方法。 即使出現錯誤,也會呼叫 __exit__ 方法,也就是會關閉檔案流。

1.4 分析記錄錯誤

1.4.1 分析錯誤

如果錯誤沒有被捕獲,它就會一直往上拋,最後被Python直譯器捕獲,列印一個錯誤資訊,然後程式退出。來看看err.py:

# err.py:
def foo(s):
    return 10 / int(s)

def bar(s):
    return foo(s) * 2

def main():
    bar('0')

main()
執行,結果如下:

$ python err.py
Traceback (most recent call last):
  File "err.py", line 11, in <module>
    main()
  File "err.py", line 9, in main
    bar('0')
  File "err.py", line 6, in bar
    return foo(s) * 2
  File "err.py", line 3, in foo
    return 10 / int(s)
ZeroDivisionError: integer division or modulo by zero

出錯並不可怕,可怕的是不知道哪裡出錯了。解讀錯誤資訊是定位錯誤的關鍵。我們從上往下可以看到整個錯誤的呼叫函式鏈:
錯誤資訊第1行:

Traceback (most recent call last):

告訴我們這是錯誤的跟蹤資訊。
第2行:

  File "err.py", line 11, in <module>
    main()

呼叫main()出錯了,在程式碼檔案err.py的第11行程式碼,但原因是第9行:

  File "err.py", line 9, in main
    bar('0')

呼叫bar('0')出錯了,在程式碼檔案err.py的第9行程式碼,但原因是第6行:

  File "err.py", line 6, in bar
    return foo(s) * 2

原因是return foo(s) * 2這個語句出錯了,但這還不是最終原因,繼續往下看:

  File "err.py", line 3, in foo
    return 10 / int(s)

原因是return 10 / int(s)這個語句出錯了,這是錯誤產生的源頭,因為下面列印了:

ZeroDivisionError: integer division or modulo by zero

根據錯誤型別ZeroDivisionError,我們判斷,int(s)本身並沒有出錯,但是int(s)返回0,在計算10 / 0時出錯,至此,找到錯誤源頭。

1.4.2 記錄錯誤

如果不捕獲錯誤,自然可以讓Python直譯器來列印出錯誤堆疊,但程式也被結束了。既然我們能捕獲錯誤,就可以把錯誤堆疊列印出來,然後分析錯誤原因,同時,讓程式繼續執行下去。

Python內建的logging模組可以非常容易地記錄錯誤資訊:

# err.py
import logging

def foo(s):
    return 10 / int(s)

def bar(s):
    return foo(s) * 2

def main():
    try:
        bar('0')
    except StandardError, e:
        logging.exception(e)

main()
print 'END'

同樣是出錯,但程式列印完錯誤資訊後會繼續執行,並正常退出:

$ python err.py
ERROR:root:integer division or modulo by zero
Traceback (most recent call last):
  File "err.py", line 12, in main
    bar('0')
  File "err.py", line 8, in bar
    return foo(s) * 2
  File "err.py", line 5, in foo
    return 10 / int(s)
ZeroDivisionError: integer division or modulo by zero
END

透過配置,logging還可以把錯誤記錄到日誌檔案裡,方便事後排查。

1.5 單元測試

1.5.1 unittest單元測試

為了編寫單元測試,我們需要引入Python自帶的unittest模組,編寫mydict_test.py如下:

import unittest
from mydict import Dict

class TestDict(unittest.TestCase):

    def test_init(self):
        d = Dict(a=1, b='test')
        self.assertEquals(d.a, 1)
        self.assertEquals(d.b, 'test')
        self.assertTrue(isinstance(d, dict))

    def test_key(self):
        d = Dict()
        d['key'] = 'value'
        self.assertEquals(d.key, 'value')

    def test_attr(self):
        d = Dict()
        d.key = 'value'
        self.assertTrue('key' in d)
        self.assertEquals(d['key'], 'value')

    def test_keyerror(self):
        d = Dict()
        with self.assertRaises(KeyError):
            value = d['empty']

    def test_attrerror(self):
        d = Dict()
        with self.assertRaises(AttributeError):
            value = d.empty

編寫單元測試時,我們需要編寫一個測試類,從unittest.TestCase繼承。
以test開頭的方法就是測試方法,不以test開頭的方法不被認為是測試方法,測試的時候不會被執行。

對每一類測試都需要編寫一個test_xxx()方法。由於unittest.TestCase提供了很多內建的條件判斷,我們只需要呼叫這些方法就可以斷言輸出是否是我們所期望的。最常用的斷言就是assertEquals()self.assertEquals(abs(-1), 1) , 斷言函式返回的結果與1相等

另一種重要的斷言就是期待丟擲指定型別的Error,比如透過d['empty']訪問不存在的key時,斷言會丟擲KeyError:

with self.assertRaises(KeyError):
    value = d['empty']

而透過d.empty訪問不存在的key時,我們期待丟擲AttributeError:

with self.assertRaises(AttributeError):
    value = d.empty

執行單元測試
一旦編寫好單元測試,我們就可以執行單元測試。最簡單的執行方式是在mydict_test.py的最後加上兩行程式碼:

if __name__ == '__main__':
    unittest.main()

這樣就可以把mydict_test.py當做正常的python指令碼執行,另一種更常見的方法是在命令列透過引數-m unittest直接執行單元測試:$ python -m unittest mydict_test

1.5.2 setUp與tearDown

可以在單元測試中編寫兩個特殊的setUp()和tearDown()方法。這兩個方法會分別在每呼叫一個測試方法的前後分別被執行。

setUp()和tearDown()方法有什麼用呢?設想測試需要啟動一個資料庫,這時,就可以在setUp()方法中連線資料庫,在tearDown()方法中關閉資料庫,這樣,不必在每個測試方法中重複相同的程式碼:

class TestDict(unittest.TestCase):

    def setUp(self):
        print 'setUp...'

    def tearDown(self):
        print 'tearDown...'

可以再次執行測試看看每個測試方法呼叫前後是否會列印出setUp...和tearDown...。

1.5.3 文件測試

當我們編寫註釋時,如果寫上這樣的註釋:

def abs(n):
    '''
    Function to get absolute value of number.

    Example:

    >>> abs(1)
    1
    >>> abs(-1)
    1
    >>> abs(0)
    0
    '''
    return n if n >= 0 else (-n)

無疑更明確地告訴函式的呼叫者該函式的期望輸入和輸出。並且,Python內建的文件測試(doctest)模組可以直接提取註釋中的程式碼並執行測試。
doctest嚴格按照Python互動式命令列的輸入和輸出來判斷測試結果是否正確。只有測試異常的時候,可以用...表示中間一大段煩人的輸出。

用doctest來測試上次編寫的Dict類:

class Dict(dict):
    '''
    Simple dict but also support access as x.y style.

    >>> d1 = Dict()
    >>> d1['x'] = 100
    >>> d1.x
    100
    >>> d1.y = 200
    >>> d1['y']
    200
    >>> d2 = Dict(a=1, b=2, c='3')
    >>> d2.c
    '3'
    >>> d2['empty']
    Traceback (most recent call last):
        ...
    KeyError: 'empty'
    >>> d2.empty
    Traceback (most recent call last):
        ...
    AttributeError: 'Dict' object has no attribute 'empty'
    '''
    def __init__(self, **kw):
        super(Dict, self).__init__(**kw)

    def __getattr__(self, key):
        try:
            return self[key]
        except KeyError:
            raise AttributeError(r"'Dict' object has no attribute '%s'" % key)

    def __setattr__(self, key, value):
        self[key] = value

if __name__=='__main__':
    import doctest
    doctest.testmod()

執行python mydict.py:
$ python mydict.py

什麼輸出也沒有。這說明我們編寫的doctest執行都是正確的。如果程式有問題,比如把__getattr__()方法註釋掉,再執行就會報錯:

$ python mydict.py
**********************************************************************
File "mydict.py", line 7, in __main__.Dict
Failed example:
    d1.x
Exception raised:
    Traceback (most recent call last):
      ...
    AttributeError: 'Dict' object has no attribute 'x'
**********************************************************************
File "mydict.py", line 13, in __main__.Dict
Failed example:
    d2.c
Exception raised:
    Traceback (most recent call last):
      ...
    AttributeError: 'Dict' object has no attribute 'c'
**********************************************************************

注意到最後兩行程式碼。當模組正常匯入時,doctest不會被執行。只有在命令列執行時,才執行doctest。所以,不必擔心doctest會在非測試環境下執行。

相關文章