Python3 錯誤和異常
作為Python初學者,在剛學習Python編程時,經常會看到一些報錯信息,在前面我們沒有提及,這章節我們會專門介紹。
Python有兩種錯誤很容易辨認:語法錯誤和異常。
python標准異常
異常名稱 | 描述 |
---|---|
BaseException | 所有異常的基類 |
SystemExit | 解釋器請求退出 |
KeyboardInterrupt | 用戶中斷執行(通常是輸入^C) |
Exception | 常規錯誤的基類 |
StopIteration | 迭代器沒有更多的值 |
GeneratorExit | 生成器(generator)發生異常來通知退出 |
StandardError | 所有的內建標准異常的基類 |
ArithmeticError | 所有數值計算錯誤的基類 |
FloatingPointError | 浮點計算錯誤 |
OverflowError | 數值運算超出最大限制 |
ZeroDivisionError | 除(或取模)零 (所有數據類型) |
AssertionError | 斷言語句失敗 |
AttributeError | 對象沒有這個屬性 |
EOFError | 沒有內建輸入,到達EOF 標記 |
EnvironmentError | 操作系統錯誤的基類 |
IOError | 輸入/輸出操作失敗 |
OSError | 操作系統錯誤 |
WindowsError | 系統調用失敗 |
ImportError | 導入模塊/對象失敗 |
LookupError | 無效數據查詢的基類 |
IndexError | 序列中沒有此索引(index) |
KeyError | 映射中沒有這個鍵 |
MemoryError | 內存溢出錯誤(對於Python 解釋器不是致命的) |
NameError | 未聲明/初始化對象 (沒有屬性) |
UnboundLocalError | 訪問未初始化的本地變量 |
ReferenceError | 弱引用(Weak reference)試圖訪問已經垃圾回收了的對象 |
RuntimeError | 一般的運行時錯誤 |
NotImplementedError | 尚未實現的方法 |
SyntaxError | Python 語法錯誤 |
IndentationError | 縮進錯誤 |
TabError | Tab 和空格混用 |
SystemError | 一般的解釋器系統錯誤 |
TypeError | 對類型無效的操作 |
ValueError | 傳入無效的參數 |
UnicodeError | Unicode 相關的錯誤 |
UnicodeDecodeError | Unicode 解碼時的錯誤 |
UnicodeEncodeError | Unicode 編碼時錯誤 |
UnicodeTranslateError | Unicode 轉換時錯誤 |
Warning | 警告的基類 |
DeprecationWarning | 關於被棄用的特征的警告 |
FutureWarning | 關於構造將來語義會有改變的警告 |
OverflowWarning | 舊的關於自動提升為長整型(long)的警告 |
PendingDeprecationWarning | 關於特性將會被廢棄的警告 |
RuntimeWarning | 可疑的運行時行為(runtime behavior)的警告 |
SyntaxWarning | 可疑的語法的警告 |
UserWarning | 用戶代碼生成的警告 |
什么是異常?
異常即是一個事件,該事件會在程序執行過程中發生,影響了程序的正常執行。
一般情況下,在Python無法正常處理程序時就會發生一個異常。
異常是Python對象,表示一個錯誤。
當Python腳本發生異常時我們需要捕獲處理它,否則程序會終止執行。
>>> 10 * (1/0) Traceback (most recent call last): File "<stdin>", line 1, in ? ZeroDivisionError: division by zero >>> 4 + spam*3 Traceback (most recent call last): File "<stdin>", line 1, in ? NameError: name 'spam' is not defined >>> '2' + 2 Traceback (most recent call last): File "<stdin>", line 1, in ? TypeError: Can't convert 'int' object to str implicitly
最后一行的錯誤消息指示發生了什么事。異常有不同的類型,其類型會作為消息的一部分打印出來:在這個例子中的類型有ZeroDivisionError
、NameError
和 TypeError
.打印出來的異常類型的字符串就是內置的異常的名稱。這對於所有內置的異常是正確的,但是對於用戶自定義的異常就不一定了(盡管這是非常有用的慣例)。標准異常的名稱都是內置的標識符(不是保留的關鍵字)。
這一行最后一部分給出了異常的詳細信息和引起異常的原因。
錯誤信息的前面部分以堆棧回溯的形式顯示了異常發生的上下文。通常調用棧里會包含源代碼的行信息,但是來自標准輸入的源碼不會顯示行信息。
內置的異常 列出了內置的異常以及它們的含義。
語法錯誤
Python 的語法錯誤或者稱之為解析錯,是初學者經常碰到的,如下實例
>>> while True print('Hello world') File "<stdin>", line 1, in ? while True print('Hello world') ^ SyntaxError: invalid syntax
這個例子中,函數 print() 被檢查到有錯誤,是它前面缺少了一個冒號(:)。語法分析器指出了出錯的一行,並且在最先找到的錯誤的位置標記了一個小小的’箭頭’。錯誤是由箭頭前面的標記引起的(至少檢測到是這樣的): 在這個例子中,檢測到錯誤發生在函數 print()
,因為在它之前缺少一個冒號(':'
)文件名和行號會一並輸出,所以如果運行的是一個腳本你就知道去哪里檢查錯誤了。
在程序運行過程中,總會遇到各種各樣的錯誤。
有的錯誤是程序編寫有問題造成的,比如本來應該輸出整數結果輸出了字符串,這種錯誤我們通常稱之為bug,bug是必須修復的。
有的錯誤是用戶輸入造成的,比如讓用戶輸入email地址,結果得到一個空字符串,這種錯誤可以通過檢查用戶輸入來做相應的處理。
還有一類錯誤是完全無法在程序運行過程中預測的,比如寫入文件的時候,磁盤滿了,寫不進去了,或者從網絡抓取數據,網絡突然斷掉了。這類錯誤也稱為異常,在程序中通常是必須處理的,否則,程序會因為各種問題終止並退出。
Python內置了一套異常處理機制,來幫助我們進行錯誤處理。
此外,我們也需要跟蹤程序的執行,查看變量的值是否正確,這個過程稱為調試。Python的pdb可以讓我們以單步方式執行代碼。
最后,編寫測試也很重要。有了良好的測試,就可以在程序修改后反復運行,確保程序輸出符合我們編寫的測試。
錯誤處理
在程序運行的過程中,如果發生了錯誤,可以事先約定返回一個錯誤代碼,這樣,就可以知道是否有錯,以及出錯的原因。在操作系統提供的調用中,返回錯誤碼非常常見。比如打開文件的函數open()
,成功時返回文件描述符(就是一個整數),出錯時返回
-1
。
用錯誤碼來表示是否出錯十分不便,因為函數本身應該返回的正常結果和錯誤碼混在一起,造成調用者必須用大量的代碼來判斷是否出錯:
def foo(): r = some_function() if r==(-1): return (-1) # do something return r def bar(): r = foo() if r==(-1): print('Error') else: pass
一旦出錯,還要一級一級上報,直到某個函數可以處理該錯誤(比如,給用戶輸出一個錯誤信息)。
所以高級語言通常都內置了一套try...except...finally...
的錯誤處理機制,Python也不例外。
try
讓我們用一個例子來看看try
的機制:
try: print('try...') r = 10 / 0 print('result:', r) except ZeroDivisionError as e: print('except:', e) finally: print('finally...') print('END')
當我們認為某些代碼可能會出錯時,就可以用try
來運行這段代碼,如果執行出錯,則后續代碼不會繼續執行,而是直接跳轉至錯誤處理代碼,即except
語句塊,執行完except
后,如果有finally
語句塊,則執行finally
語句塊,至此,執行完畢。
上面的代碼在計算10 / 0
時會產生一個除法運算錯誤:
try... except: division by zero finally... END
從輸出可以看到,當錯誤發生時,后續語句print('result:', r)
不會被執行,except
由於捕獲到ZeroDivisionError
,因此被執行。最后,finally
語句被執行。然后,程序繼續按照流程往下走。
如果把除數0
改成2
,則執行結果如下:
try... result: 5 finally... END
由於沒有錯誤發生,所以except
語句塊不會被執行,但是finally
如果有,則一定會被執行(可以沒有finally
語句)。
你還可以猜測,錯誤應該有很多種類,如果發生了不同類型的錯誤,應該由不同的except
語句塊處理。沒錯,可以有多個except
來捕獲不同類型的錯誤:
try: print('try...') r = 10 / int('a') print('result:', r) except ValueError as e: print('ValueError:', e) except ZeroDivisionError as e: print('ZeroDivisionError:', e) finally: print('finally...') print('END')
int()
函數可能會拋出ValueError
,所以我們用一個except
捕獲ValueError
,用另一個except
捕獲ZeroDivisionError
。
此外,如果沒有錯誤發生,可以在except
語句塊后面加一個else
,當沒有錯誤發生時,會自動執行else
語句:
try: print('try...') r = 10 / int('2') print('result:', r) except ValueError as e: print('ValueError:', e) except ZeroDivisionError as e: print('ZeroDivisionError:', e) else: print('no error!') finally: print('finally...') print('END')
Python的錯誤其實也是class,所有的錯誤類型都繼承自BaseException
,所以在使用except
時需要注意的是,它不但捕獲該類型的錯誤,還把其子類也“一網打盡”。比如:
try: foo() except ValueError as e: print('ValueError') except UnicodeError as e: print('UnicodeError')
第二個except
永遠也捕獲不到UnicodeError
,因為UnicodeError
是ValueError
的子類,如果有,也被第一個except
給捕獲了。
Python所有的錯誤都是從BaseException
類派生的,常見的錯誤類型和繼承關系看這里:
https://docs.python.org/3/library/exceptions.html#exception-hierarchy
使用try...except
捕獲錯誤還有一個巨大的好處,就是可以跨越多層調用,比如函數main()
調用foo()
,foo()
調用bar()
,結果bar()
出錯了,這時,只要main()
捕獲到了,就可以處理:
def foo(s): return 10 / int(s) def bar(s): return foo(s) * 2 def main(): try: bar('0') except Exception as e: print('Error:', e) finally: print('finally...')
也就是說,不需要在每個可能出錯的地方去捕獲錯誤,只要在合適的層次去捕獲錯誤就可以了。這樣一來,就大大減少了寫try...except...finally
的麻煩。
異常處理
捕捉異常可以使用try/except語句。
try/except語句用來檢測try語句塊中的錯誤,從而讓except語句捕獲異常信息並處理。
如果你不想在異常發生時結束你的程序,只需在try里捕獲它。
語法:
以下為簡單的try....except...else的語法:
try: <語句> #運行別的代碼 except <名字>: <語句> #如果在try部份引發了'name'異常 except <名字>,<數據>: <語句> #如果引發了'name'異常,獲得附加的數據 else: <語句> #如果沒有異常發生
try
語句按以下方式工作。
- 首先,執行 try 子句 (
try
和except
關鍵字之間的語句)。 - 如果未發生任何異常,忽略 except 子句 且
try
語句執行完畢。 - 如果在 try 子句執行過程中發生異常,跳過該子句的其余部分。如果異常的類型與
except
關鍵字后面的異常名匹配, 則執行 except 子句,然后繼續執行try
語句之后的代碼。 - 如果異常的類型與 except 關鍵字后面的異常名不匹配,它將被傳遞給上層的
try
語句;如果沒有找到處理這個異常的代碼,它就成為一個 未處理異常 ,程序會終止運行並顯示一條如上所示的信息。
try
語句可能有多個子句,以指定不同的異常處理程序。不過至多只有一個處理程序將被執行。處理程序只處理發生在相應 try 子句中的異常,不會處理同一個 try
子句的其他處理程序中發生的異常。一個 except 子句可以用帶括號的元組列出多個異常的名字,例如:
... except (RuntimeError, TypeError, NameError): ... pass
最后一個 except 子句可以省略異常名稱,以當作通配符使用。使用這種方式要特別小心,因為它會隱藏一個真實的程序錯誤!它還可以用來打印一條錯誤消息,然后重新引發異常 (讓調用者也去處理這個異常):
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
try
...except
語句有一個可選的 else 子句 ,其出現時,必須放在所有 except 子句的后面。如果需要在 try 語句沒有拋出異常時執行一些代碼,可以使用這個子句。例如:
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
子句中要好,因為它可以避免意外捕獲不是由 try
... except
語句保護的代碼所引發的異常。
當異常發生時,它可能帶有相關數據,也稱為異常的參數。參數的有無和類型取決於異常的類型。
except 子句可以在異常名之后指定一個變量。這個變量將綁定於一個異常實例,同時異常的參數將存放在 實例的args
中。為方便起見,異常實例定義了 __str__()
,因此異常的參數可以直接打印而不必引用 .args
。也可以在引發異常之前先實例化一個異常,然后向它添加任何想要的屬性。
>>> try: ... raise Exception('spam', 'eggs') ... except Exception as inst: ... print(type(inst)) # the exception instance ... 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
對於未處理的異常,如果它含有參數,那么參數會作為異常信息的最后一部分打印出來。
異常處理程序不僅處理直接發生在 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: int division or modulo by zero
可以通過編程來選擇處理部分異常。看一下下面的例子,它會一直要求用戶輸入直到輸入一個合法的整數為止,但允許用戶中斷這個程序(使用 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...") ...
Python 使用 raise 語句拋出一個指定的異常。例如:
>>> raise NameError('HiThere') Traceback (most recent call last): File "<stdin>", line 1, in ? NameError: HiThere
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
調用堆棧
如果錯誤沒有被捕獲,它就會一直往上拋,最后被Python解釋器捕獲,打印一個錯誤信息,然后程序退出。來看看err.py
:
# err.py: def foo(s): return 10 / int(s) def bar(s): return foo(s) * 2 def main(): bar('0') main()
執行,結果如下:
$ python3 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: division by zero
出錯並不可怕,可怕的是不知道哪里出錯了。解讀錯誤信息是定位錯誤的關鍵。我們從上往下可以看到整個錯誤的調用函數鏈:
錯誤信息第1行:
Traceback (most recent call last):
告訴我們這是錯誤的跟蹤信息。
第2~3行:
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
時出錯,至此,找到錯誤源頭。
記錄錯誤
如果不捕獲錯誤,自然可以讓Python解釋器來打印出錯誤堆棧,但程序也被結束了。既然我們能捕獲錯誤,就可以把錯誤堆棧打印出來,然后分析錯誤原因,同時,讓程序繼續執行下去。
Python內置的logging
模塊可以非常容易地記錄錯誤信息:
# err_logging.py import logging def foo(s): return 10 / int(s) def bar(s): return foo(s) * 2 def main(): try: bar('0') except Exception as e: logging.exception(e) main() print('END')
同樣是出錯,但程序打印完錯誤信息后會繼續執行,並正常退出:
$ python3 err_logging.py ERROR:root:division by zero Traceback (most recent call last): File "err_logging.py", line 13, in main bar('0') File "err_logging.py", line 9, in bar return foo(s) * 2 File "err_logging.py", line 6, in foo return 10 / int(s) ZeroDivisionError: division by zero END
通過配置,logging
還可以把錯誤記錄到日志文件里,方便事后排查。
拋出錯誤
因為錯誤是class,捕獲一個錯誤就是捕獲到該class的一個實例。因此,錯誤並不是憑空產生的,而是有意創建並拋出的。Python的內置函數會拋出很多類型的錯誤,我們自己編寫的函數也可以拋出錯誤。
如果要拋出錯誤,首先根據需要,可以定義一個錯誤的class,選擇好繼承關系,然后,用raise
語句拋出一個錯誤的實例:
# err_raise.py class FooError(ValueError): pass def foo(s): n = int(s) if n==0: raise FooError('invalid value: %s' % s) return 10 / n foo('0')
執行,可以最后跟蹤到我們自己定義的錯誤:
$ python3 err_raise.py Traceback (most recent call last): File "err_throw.py", line 11, in <module> foo('0') File "err_throw.py", line 8, in foo raise FooError('invalid value: %s' % s) __main__.FooError: invalid value: 0
只有在必要的時候才定義我們自己的錯誤類型。如果可以選擇Python已有的內置的錯誤類型(比如ValueError
,TypeError
),盡量使用Python內置的錯誤類型。
最后,我們來看另一種錯誤處理的方式:
# err_reraise.py def foo(s): n = int(s) if n==0: raise ValueError('invalid value: %s' % s) return 10 / n def bar(): try: foo('0') except ValueError as e: print('ValueError!') raise bar()
在bar()
函數中,我們明明已經捕獲了錯誤,但是,打印一個ValueError!
后,又把錯誤通過raise
語句拋出去了,這不有病么?
其實這種錯誤處理方式不但沒病,而且相當常見。捕獲錯誤目的只是記錄一下,便於后續追蹤。但是,由於當前函數不知道應該怎么處理該錯誤,所以,最恰當的方式是繼續往上拋,讓頂層調用者去處理。好比一個員工處理不了一個問題時,就把問題拋給他的老板,如果他的老板也處理不了,就一直往上拋,最終會拋給CEO去處理。
raise
語句如果不帶參數,就會把當前錯誤原樣拋出。此外,在except
中raise
一個Error,還可以把一種類型的錯誤轉化成另一種類型:
try: 10 / 0 except ZeroDivisionError: raise ValueError('input error!')
只要是合理的轉換邏輯就可以,但是,決不應該把一個IOError
轉換成毫不相干的ValueError
。
小結
Python內置的try...except...finally
用來處理錯誤十分方便。出錯時,會分析錯誤信息並定位錯誤發生的代碼位置才是最關鍵的。
程序也可以主動拋出錯誤,讓調用者來處理相應的錯誤。但是,應該在文檔中寫清楚可能會拋出哪些錯誤,以及錯誤產生的原因。
用戶自定義異常
程序可以通過創建新的異常類來命名自己的異常(Python 類的更多內容請參見 類 )。異常通常應該繼承 Exception
類,直接繼承或者間接繼承都可以。
異常類可以像其他類一樣做任何事情,但是通常都會比較簡單,只提供一些屬性以允許異常處理程序獲取錯誤相關的信息。創建一個能夠引發幾種不同錯誤的模塊時,一個通常的做法是為該模塊定義的異常創建一個基類,然后基於這個基類為不同的錯誤情況創建特定的子類:
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
不管有沒有發生異常,在離開 try
語句之前總是會執行 finally 子句。當 try
子句中發生了一個異常,並且沒有 except
字句處理(或者異常發生在 except
或 else
子句中),在執行完 finally
子句后將重新引發這個異常。try
語句由於 break
、contine
或return
語句離開時,同樣會執行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'
正如您所看到的,在任何情況下都會執行 finally
子句。由兩個字符串相除引發的 TypeError
異常沒有被 except
子句處理,因此在執行 finally
子句后被重新引發。
在真實的應用程序中, finally
子句用於釋放外部資源(例如文件或網絡連接),不管資源的使用是否成功。
大多數異常的名字都以"Error"結尾,類似於標准異常的命名。
很多標准模塊中都定義了自己的異常來報告在它們所定義的函數中可能發生的錯誤。類 這一章給出了類的詳細信息。
>>> 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__() 被覆蓋。
定義清理行為
try 語句還有另外一個可選的子句,它定義了無論在任何情況下都會執行的清理行為。 例如:
>>> try: ... raise KeyboardInterrupt ... finally: ... print('Goodbye, world!') ... Goodbye, world! KeyboardInterrupt Traceback (most recent call last): File "<stdin>", line 2, in ?
以上例子不管try子句里面有沒有發生異常,finally子句都會執行。
如果一個異常在 try 子句里(或者在 except 和 else 子句里)被拋出,而又沒有任何的 except 把它截住,那么這個異常會在 finally 子句執行后再次被拋出。
下面是一個更加復雜的例子(在同一個 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'
預定義的清理行為
一些對象定義了標准的清理行為,無論系統是否成功的使用了它,一旦不需要它了,那么這個標准的清理行為就會執行。
這面這個例子展示了嘗試打開一個文件,然后把內容打印到屏幕上:
for line in open("myfile.txt"): print(line, end="")
以上這段代碼的問題是,當執行完畢后,文件會保持打開狀態,並沒有被關閉。
關鍵詞 with 語句就可以保證諸如文件之類的對象在使用完之后一定會正確的執行他的清理方法:
with open("myfile.txt") as f: for line in f: print(line, end="")
執行該語句后,文件 f 將始終被關閉,即使在處理某一行時遇到了問題。提供預定義的清理行為的對象,和文件一樣,會在它們的文檔里說明。
調試
程序能一次寫完並正常運行的概率很小,基本不超過1%。總會有各種各樣的bug需要修正。有的bug很簡單,看看錯誤信息就知道,有的bug很復雜,我們需要知道出錯時,哪些變量的值是正確的,哪些變量的值是錯誤的,因此,需要一整套調試程序的手段來修復bug。第一種方法簡單直接粗暴有效,就是用print()
把可能有問題的變量打印出來看看:
def foo(s): n = int(s) print('>>> n = %d' % n) return 10 / n def main(): foo('0') main()
執行后在輸出中查找打印的變量值:
$ python3 err.py >>> n = 0 Traceback (most recent call last): ... ZeroDivisionError: integer division or modulo by zero
用print()
最大的壞處是將來還得刪掉它,想想程序里到處都是print()
,運行結果也會包含很多垃圾信息。所以,我們又有第二種方法。
斷言
凡是用print()
來輔助查看的地方,都可以用斷言(assert)來替代:
def foo(s): n = int(s) assert n != 0, 'n is zero!' return 10 / n def main(): foo('0')
assert
的意思是,表達式n != 0
應該是True
,否則,根據程序運行的邏輯,后面的代碼肯定會出錯。
如果斷言失敗,assert
語句本身就會拋出AssertionError
:
$ python3 err.py Traceback (most recent call last): ... AssertionError: n is zero!
程序中如果到處充斥着assert
,和print()
相比也好不到哪去。不過,啟動Python解釋器時可以用-O
參數來關閉assert
:
$ python3 -O err.py Traceback (most recent call last): ... ZeroDivisionError: division by zero
關閉后,你可以把所有的assert
語句當成pass
來看。
logging
把print()
替換為logging
是第3種方式,和assert
比,logging
不會拋出錯誤,而且可以輸出到文件:
import logging s = '0' n = int(s) logging.info('n = %d' % n) print(10 / n)
logging.info()
就可以輸出一段文本。運行,發現除了ZeroDivisionError
,沒有任何信息。怎么回事?
別急,在import logging
之后添加一行配置再試試:
import logging logging.basicConfig(level=logging.INFO)
看到輸出了:
$ python3 err.py INFO:root:n = 0 Traceback (most recent call last): File "err.py", line 8, in <module> print(10 / n) ZeroDivisionError: division by zero
這就是logging
的好處,它允許你指定記錄信息的級別,有debug
,info
,warning
,error
等幾個級別,當我們指定level=INFO
時,logging.debug
就不起作用了。同理,指定level=WARNING
后,debug
和info
就不起作用了。這樣一來,你可以放心地輸出不同級別的信息,也不用刪除,最后統一控制輸出哪個級別的信息。
logging
的另一個好處是通過簡單的配置,一條語句可以同時輸出到不同的地方,比如console和文件。
pdb
第4種方式是啟動Python的調試器pdb,讓程序以單步方式運行,可以隨時查看運行狀態。我們先准備好程序:
# err.py s = '0' n = int(s) print(10 / n)
然后啟動:
$ python3 -m pdb err.py > /Users/michael/Github/learn-python3/samples/debug/err.py(2)<module>() -> s = '0'
以參數-m pdb
啟動后,pdb定位到下一步要執行的代碼-> s = '0'
。輸入命令l
來查看代碼:
(Pdb) l 1 # err.py 2 -> s = '0' 3 n = int(s) 4 print(10 / n)
輸入命令n
可以單步執行代碼:
(Pdb) n > /Users/michael/Github/learn-python3/samples/debug/err.py(3)<module>() -> n = int(s) (Pdb) n > /Users/michael/Github/learn-python3/samples/debug/err.py(4)<module>() -> print(10 / n)
任何時候都可以輸入命令p 變量名
來查看變量:
(Pdb) p s '0' (Pdb) p n 0
輸入命令q
結束調試,退出程序:
(Pdb) q
這種通過pdb在命令行調試的方法理論上是萬能的,但實在是太麻煩了,如果有一千行代碼,要運行到第999行得敲多少命令啊。還好,我們還有另一種調試方法。
pdb.set_trace()
這個方法也是用pdb,但是不需要單步執行,我們只需要import pdb
,然后,在可能出錯的地方放一個pdb.set_trace()
,就可以設置一個斷點:
# err.py import pdb s = '0' n = int(s) pdb.set_trace() # 運行到這里會自動暫停 print(10 / n)
運行代碼,程序會自動在pdb.set_trace()
暫停並進入pdb調試環境,可以用命令p
查看變量,或者用命令c
繼續運行:
$ python3 err.py > /Users/michael/Github/learn-python3/samples/debug/err.py(7)<module>() -> print(10 / n) (Pdb) p n 0 (Pdb) c Traceback (most recent call last): File "err.py", line 7, in <module> print(10 / n) ZeroDivisionError: division by zero
這個方式比直接啟動pdb單步調試效率要高很多,但也高不到哪去。
IDE
如果要比較爽地設置斷點、單步執行,就需要一個支持調試功能的IDE。目前比較好的Python IDE有PyCharm:
http://www.jetbrains.com/pycharm/
另外,Eclipse加上pydev插件也可以調試Python程序。
小結
寫程序最痛苦的事情莫過於調試,程序往往會以你意想不到的流程來運行,你期待執行的語句其實根本沒有執行,這時候,就需要調試了。
雖然用IDE調試起來比較方便,但是最后你會發現,logging才是終極武器。
單元測試
如果你聽說過“測試驅動開發”(TDD:Test-Driven Development),單元測試就不陌生。單元測試是用來對一個模塊、一個函數或者一個類來進行正確性檢驗的測試工作。
比如對函數abs()
,我們可以編寫出以下幾個測試用例:
-
輸入正數,比如
1
、1.2
、0.99
,期待返回值與輸入相同; -
輸入負數,比如
-1
、-1.2
、-0.99
,期待返回值與輸入相反; -
輸入
0
,期待返回0
; -
輸入非數值類型,比如
None
、[]
、{}
,期待拋出TypeError
。
把上面的測試用例放到一個測試模塊里,就是一個完整的單元測試。
如果單元測試通過,說明我們測試的這個函數能夠正常工作。如果單元測試不通過,要么函數有bug,要么測試條件輸入不正確,總之,需要修復使單元測試能夠通過。
單元測試通過后有什么意義呢?如果我們對abs()
函數代碼做了修改,只需要再跑一遍單元測試,如果通過,說明我們的修改不會對abs()
函數原有的行為造成影響,如果測試不通過,說明我們的修改與原有行為不一致,要么修改代碼,要么修改測試。
這種以測試為驅動的開發模式最大的好處就是確保一個程序模塊的行為符合我們設計的測試用例。在將來修改的時候,可以極大程度地保證該模塊行為仍然是正確的。
我們來編寫一個Dict
類,這個類的行為和dict
一致,但是可以通過屬性來訪問,用起來就像下面這樣:
>>> d = Dict(a=1, b=2) >>> d['a'] 1 >>> d.a 1
mydict.py
代碼如下:
class Dict(dict): def __init__(self, **kw): super().__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
為了編寫單元測試,我們需要引入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.assertEqual(d.a, 1) self.assertEqual(d.b, 'test') self.assertTrue(isinstance(d, dict)) def test_key(self): d = Dict() d['key'] = 'value' self.assertEqual(d.key, 'value') def test_attr(self): d = Dict() d.key = 'value' self.assertTrue('key' in d) self.assertEqual(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
提供了很多內置的條件判斷,我們只需要調用這些方法就可以斷言輸出是否是我們所期望的。最常用的斷言就是
assertEqual(): self.assertEqual(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腳本運行:
$ python3 mydict_test.py
另一種方法是在命令行通過參數-m unittest
直接運行單元測試:
$ python3 -m unittest mydict_test ..... ---------------------------------------------------------------------- Ran 5 tests in 0.000s OK
這是推薦的做法,因為這樣可以一次批量運行很多單元測試,並且,有很多工具可以自動來運行這些單元測試。
setUp與tearDown
可以在單元測試中編寫兩個特殊的setUp()
和tearDown()
方法。這兩個方法會分別在每調用一個測試方法的前后分別被執行。
setUp()
和tearDown()
方法有什么用呢?設想你的測試需要啟動一個數據庫,這時,就可以在setUp()
方法中連接數據庫,在tearDown()
方法中關閉數據庫,這樣,不必在每個測試方法中重復相同的代碼:
class TestDict(unittest.TestCase): def setUp(self): print('setUp...') def tearDown(self): print('tearDown...')
可以再次運行測試看看每個測試方法調用前后是否會打印出setUp...
和tearDown...
。
小結
單元測試可以有效地測試某個程序模塊的行為,是未來重構代碼的信心保證。
單元測試的測試用例要覆蓋常用的輸入組合、邊界條件和異常。
單元測試代碼要非常簡單,如果測試代碼太復雜,那么測試代碼本身就可能有bug。
單元測試通過了並不意味着程序就沒有bug了,但是不通過程序肯定有bug。
文檔測試
如果你經常閱讀Python的官方文檔,可以看到很多文檔都有示例代碼。比如 re模塊就帶了很多示例代碼:>>> import re >>> m = re.search('(?<=abc)def', 'abcdef') >>> m.group(0) 'def'
可以把這些示例代碼在Python的交互式環境下輸入並執行,結果與文檔中的示例代碼顯示的一致。
這些代碼與其他說明可以寫在注釋中,然后,由一些工具來自動生成文檔。既然這些代碼本身就可以粘貼出來直接運行,那么,可不可以自動執行寫在注釋中的這些代碼呢?
答案是肯定的。
當我們編寫注釋時,如果寫上這樣的注釋:
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
類:
# mydict2.py 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()
運行python3 mydict2.py
:
$ python3 mydict2.py
什么輸出也沒有。這說明我們編寫的doctest運行都是正確的。如果程序有問題,比如把__getattr__()
方法注釋掉,再運行就會報錯:
$ python3 mydict2.py ********************************************************************** File "/Users/michael/Github/learn-python3/samples/debug/mydict2.py", line 10, in __main__.Dict Failed example: d1.x Exception raised: Traceback (most recent call last): ... AttributeError: 'Dict' object has no attribute 'x' ********************************************************************** File "/Users/michael/Github/learn-python3/samples/debug/mydict2.py", line 16, in __main__.Dict Failed example: d2.c Exception raised: Traceback (most recent call last): ... AttributeError: 'Dict' object has no attribute 'c' ********************************************************************** 1 items had failures: 2 of 9 in __main__.Dict ***Test Failed*** 2 failures.
注意到最后3行代碼。當模塊正常導入時,doctest不會被執行。只有在命令行直接運行時,才執行doctest。所以,不必擔心doctest會在非測試環境下執行。