[TOC]
程序非常脆弱。如果代碼總是返回有效的結果,當然最好。但有時無法計算出一個有效的結果。例如,除以零是不可能的,或者訪問只有5個元素列表中的第8個元素。
</b>
在過去,解決這個問題的唯一方法是嚴格檢查每個函數的輸入,確保它們有意義。通常,函數會返回特殊的值,用來指示錯誤情況;例如,他們可能將返回一個負數表示一個正值無法被計算。不同的數字可能意味著出現了不同的錯誤。任何調用此函數的代碼都必須顯式檢查錯誤情況并采取相應措施。很多代碼都懶得這么做,然后程序崩潰了。然而,在面向對象的世界里,情況并非如此。
</b>
在這一章中,我們將研究**異常**,特殊錯誤對象只需要在合理的時候處理就好了。我們將涵蓋:
* 如何導致異常發生
* 發生異常時如何恢復
* 如何以不同的方式處理不同的異常類型
* 發生異常時進行清理
* 創建新類型的異常
* 使用異常語法進行工作流控制
## 拋出異常
原則上,異常只是一個對象。有許多不同的異常類,我們還可以輕松定義更多自己的異常類。他們唯一擁有的共同點是它們繼承自一個名為`BaseException`的內置類。當這些異常對象在程序流程控制中被處理時,它們會變得非常特別。當異常發生時,所有應該發生的事情都不會發生,除非它應該在異常發生時發生。有道理嗎?別擔心,是這樣的!
</b>
導致異常發生的最簡單的方法就是做一些傻事!給自己一些機會這樣做,就可以看到異常輸出。例如,任何時候Python在你的程序中遇到了一行它無法理解的代碼,它就會產生`SyntaxError`的語法錯誤,這就是一種異常。下面是一個常見的例子:
```
>>> print "hello world"
File "<stdin>", line 1
print "hello world"
^
SyntaxError: invalid syntax
```
該打印`print`語句是Python 2和早期版本中的有效命令,但是在Python 3中,因為`print`現在是一個函數,所以我們必須將參數包含在括號里。因此,如果我們將前面的命令鍵入Python 3解釋器中,我們將得到語法錯誤。
</b>
除了`SyntaxError`異常,我們還可以處理一些其他常見的異常,如下例所示:
```
>>> x = 5 / 0
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError: int division or modulo by zero
>>> lst = [1,2,3]
>>> print(lst[3])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: list index out of range
>>> lst + 2
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: can only concatenate list (not "int") to list
>>> lst.add
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'list' object has no attribute 'add'
>>> d = {'a': 'hello'}
>>> d['b']
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'b'
>>> print(this_is_not_a_var)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'this_is_not_a_var' is not defined
>>>
```
有時這些異常是我們程序中某些錯誤的標志(在這種情況下,我們轉到指定的行號并修復它們),但是它們也發生在合法的情況下。`ZeroDivisionError`并不總是意味著我們收到無效輸入。這也可能意味著我們收到了不同的輸入。用戶可能錯誤地或故意地輸入了零,或者它可能表示合法的值,如一個空的銀行賬戶或一個新生兒的年齡。
</b>
你可能已經注意到前面所有的內置異常都以`Error`結尾。在Python中,錯誤和異常這兩個詞幾乎可以互換使用。錯誤有時被認為比異常更可怕,但它們會被以完全相同的方式處理。事實上,前面示例中的所有錯誤類都以`Exception`(擴展`BaseException`)作為它們的超類。
### 拋出一個異常
我們很快就將處理異常,但是在這之前,讓我們看看,如果我們正在編寫一個程序,當輸入是無效的,我們應該如何通知用戶(或調用一個函數)呢?如果我們也能用和Python使用的一樣的機制,那不是很好嗎?好吧,我們可以這樣做!這里有一個給列表添加元素的簡單類,只有當它們是偶數的整數時,才會被添加到列表中:
```
class EvenOnly(list):
def append(self, integer):
if not isinstance(integer, int):
raise TypeError("Only integers can be added")
if integer % 2:
raise ValueError("Only even numbers can be added")
super().append(integer)
```
這個類擴展了內置列表,正如我們在第2章“Python中的對象”所討論,我們重寫了`append`方法來檢查兩個條件,以確保新增元素是偶數。我們首先檢查輸入是否是`int`類型的實例,然后使用模數運算符,以確保它可被2整除。如果不能滿足這兩個條件中的任何一個時,`raise`關鍵字就會拋出異常。`raise`關鍵字后面只是簡單地跟著作為異常的對象。在前面的例子中,兩個對象是從內置類`TypeError` 和`ValueError`中新構造出來的。異常對象也可以很容易地是我們自己創建的新異常類的實例(我們將很快看到),或者一個在其他地方定義的異常,甚至一個先前拋出和處理的異常對象。如果我們在Python解釋器測試這個類,我們可以看到,當異常出現時,它像以前一樣輸出有用的錯誤信息:
```
>>> e = EvenOnly()
>>> e.append("a string")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "even_integers.py", line 7, in add
raise TypeError("Only integers can be added")
TypeError: Only integers can be added
>>> e.append(3)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "even_integers.py", line 9, in add
raise ValueError("Only even numbers can be added")
ValueError: Only even numbers can be added
>>> e.append(2)
```
> 雖然這個類在演示異常時是有效的,但實際上它并沒有什么作用。仍然有可能使用索引或切片將其他值添加到列表中。這些問題可以通過重寫其他適當的方法來避免,其中一些方法是雙下劃線方法。
### 異常的影響
當異常被拋出時,它似乎會立即停止程序執行。拋出異常后,那些在異常之后應該運行的任何代碼都不會被執行,除非異常得到處理,否則程序將退出并顯示一條錯誤消息。看看下面這個簡單的函數:
```
def no_return():
print("I am about to raise an exception")
raise Exception("This is always raised")
print("This line will never execute")
return "I won't be returned"
```
如果我們執行這個函數,我們會看到第一個`print`調用被執行,然后拋出異常。第二個`print`語句永遠不會被執行,`return`語句也不會被執行:
```
>>> no_return()
I am about to raise an exception
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "exception_quits.py", line 3, in no_return
raise Exception("This is always raised")
Exception: This is always raised
```
此外,如果我們有一個函數調用另一個拋出異常的函數,在第二個函數被調用之后,第一個函數中將不會執行下去。拋出異常會通過函數調用停止所有執行堆棧,直到它被處理或強制解釋器退出。為了演示,讓我們添加調用前一個函數的第二個函數:
```
def call_exceptor():
print("call_exceptor starts here...")
no_return()
print("an exception was raised...")
print("...so these lines don't run")
```
當我們調用這個函數時,我們看到第一個`print`語句被執行了,然后`no_return`函數的第一行語句也被執行了。但是一旦拋出異常,剩下的語句就都沒有被執行了:
```
>>> call_exceptor()
call_exceptor starts here...
I am about to raise an exception
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "method_calls_excepting.py", line 9, in call_exceptor
no_return()
File "method_calls_excepting.py", line 3, in no_return
raise Exception("This is always raised")
Exception: This is always raised
```
我們很快就會看到,當解釋器實際上沒有抄近路立即離開時,我們可以對任一方法中的異常做出反應并進行處理。事實上,異常在最初被提出后,可以在任何級別進行處理。
</b>
從下到上查看異常的輸出(稱為回溯),注意這兩種方法是如何列出的。在`no_return`中,最先會拋出異常。然后,在它上面,我們看到在`call_exceptor`中,那個討厭的`no_return`函數被調用后,異常冒泡到調用方法中。從在那里,它又上升了一級到主解釋器,它不知道
還有什么可做的,于是放棄并打印了一份回溯。
### 處理異常
現在讓我們看看異常硬幣的另一面。如果我們遇到異常情況下,我們的代碼應該如何反應或從中恢復?我們一般這樣處理異常情況,通過使用`try ... except`包裝任何可能引發異常的代碼(無論它本身是異常代碼,還是對內部可能引發異常的任何函數或方法的調用)。最基本的語法如下:
```
try:
no_return()
except:
print("I caught an exception")
print("executed after the exception")
```
如果我們運行這個使用已存在`no_return`函數簡單的腳本,`no_return`總會拋出異常,我們得到這樣的輸出:
```
I am about to raise an exception
I caught an exception
executed after the exception
```
`no_return`函數愉快地通知我們,它將拋出異常,是的,它拋出了,但我們愚弄了它,并抓住了這個異常。一旦抓住這個異常,我們就能清理干凈異常(在這種情況下,通過輸出我們正在處理的情況),繼續運行,不受攻擊性函數干擾。`no_return`函數中的剩余代碼仍未執行,但是調用該函數的代碼能夠恢復并繼續。
</b>
請注意`try`和`except`周圍的縮進。`try`子句包裝任何可能會引發異常的代碼。`except`子句然后回到和`try`子句相同的縮進水平。任何處理異常的代碼都縮進在`except`子句之后。然后,正常代碼恢復到原始縮進級別。
</b>
前面代碼的問題是它捕獲所有類型的異常。如果我們正在編寫一些可以引發`TypeError`和`ZeroDivisionError`的代碼,我們可能想抓住`ZeroDivisionError`錯誤,但是讓`TypeError`傳播到控制臺。你能猜出語法嗎?
</b>
有一個相當愚蠢的函數可以做到這一點:
```
def funny_division(divider):
try:
return 100 / divider
except ZeroDivisionError:
return "Zero is not a good idea!"
print(funny_division(0))
print(funny_division(50.0))
print(funny_division("hello"))
```
該功能通過`print`語句進行測試,這些語句顯示其行為符合預期:
```
Zero is not a good idea!
2.0
Traceback (most recent call last):
File "catch_specific_exception.py", line 9, in <module>
print(funny_division("hello"))
File "catch_specific_exception.py", line 3, in funny_division
return 100 / anumber
TypeError: unsupported operand type(s) for /: 'int' and 'str'.
```
第一行輸出顯示,如果我們輸入0,我們會被正確地嘲笑。如果我們用有效的數字調用(注意它不是整數,但仍然是有效的除數),它工作正常。但是如果我們輸入一個字符串(你還在想如何得到一個`TypeError`錯誤,是嗎?),它失敗了,并拋出一個異常。如果我們用了一個空的沒有指定`ZeroDivisionError`錯誤的`except`語句,它會指控我們當我們發送一個字符串時除以零,這根本不是一個正確的行為。
</b>
我們甚至可以捕捉兩個或更多不同的異常,并用相同的方法處理它們。下面是一個引發三種不同類型異常的例子。它使用相同的異常處理程序處理`TypeError`和`ZeroDivisionError`,但如果你提供了數字13,它會拋出一個`ValueError`:
```
def funny_division2(anumber):
try:
if anumber == 13:
raise ValueError("13 is an unlucky number")
return 100 / anumber
except (ZeroDivisionError, TypeError):
return "Enter a number other than zero"
for val in (0, "hello", 50.0, 13):
print("Testing {}:".format(val), end=" ")
print(funny_division2(val))
```
底部的for循環遍歷幾個測試輸入并打印結果。如果你想知道`print`語句中的`end`參數的意義,它實際就是一個換行符。程序運行如下:
```
Testing 0: Enter a number other than zero
Testing hello: Enter a number other than zero
Testing 50.0: 2.0
Testing 13: Traceback (most recent call last):
File "catch_multiple_exceptions.py", line 11, in <module>
print(funny_division2(val))
File "catch_multiple_exceptions.py", line 4, in funny_division2
raise ValueError("13 is an unlucky number")
ValueError: 13 is an unlucky number
```
數字0和字符串都被`except`子句捕獲,并且打印錯誤信息。數字13的異常沒有被捕獲,因為它是一個`ValueError`異常,它不包括在正在處理的異常類型中。這一切都很好,但是如果我們想捕捉不同的異常并對它們做些不同的處理呢?或者也許我們想對異常做些事情然后允許它繼續冒泡到父函數,就好像它從來沒有被捕捉到?我們不需要任何新的語法來處理這些情況。將`except`子句堆棧是可能的,但只有第一個匹配將被執行。對于第二個問題,如果在異常處理程序中包含`raise`,不帶參數的`raise`關鍵字將重新拋出最后一個異常。觀察下面的代碼:
```
def funny_division3(anumber):
try:
if anumber == 13:
raise ValueError("13 is an unlucky number")
return 100 / anumber
except ZeroDivisionError:
return "Enter a number other than zero"
except TypeError:
return "Enter a numerical value"
except ValueError:
print("No, No, not 13!")
raise
```
最后一行重新拋出`ValueError`異常,所以在輸出`No, No, not 13!`之后,會再次拋出異常;我們仍然會在控制臺上獲得原始堆棧跟蹤。
</b>
如果我們像前面的例子那樣堆疊異常子句,那么只有第一個匹配子句將會運行,即使還有其他子句也滿足匹配的條件。如何處理多個匹配子句呢?請記住,異常是對象,因此可以子類化。正如我們將在下一節中看到的,大多數異常都擴展自`Exception`類(它本身是從`BaseException`派生的)。如果我們在捕獲`TypeError`之前捕捉到`Exception`,那么只有`Exception`處理程序被執行,因為`TypeError`繼承自`Exception`。
</b>
這在我們想要處理一些特定異常的情況下會很有用,然后在更一般的情況處理所有剩余的異常。我們可以在捕獲所有特定異常之后,簡單的捕捉`Exception`,并作為一般情況處理。
</b>
有時,當我們捕獲異常時,我們需要一個`Exception`對象本身的引用。這種情況經常發生在我們自定義包含參數的異常,但也可能出現與標準異常相關的情況。大多數異常類在它們的構造函數中接受一組參數,我們可能想要訪問這些異常處理程序中的屬性。如果我們定義自己的異常類,我們甚至可以在捕捉異常時,調用它的自定義方法。將捕獲到的異常作為變量的語法為是使用`as`關鍵字:
```
try:
raise ValueError("This is an argument")
except ValueError as e:
print("The exception arguments were", e.args)
```
如果我們運行這個簡單的片段,它會打印出我們傳遞到`ValueError`的初始化字符串參數值。(譯注:結果如下圖)

</b>
我們已經在處理異常的語法上看到了一些變化,但是我們仍然不知道,不管異常是否發生的情況下,如何執行代碼。我們也沒有指定,在沒有異常情況下,應該執行的代碼。還有兩個關鍵詞,`finally`和`else`,可以提供缺失的部分。這兩個關鍵字都不接受任何額外的參數。以下示例隨機選擇異常并拋出它,然后運行一些不那么復雜的`Exception`處理程序說明新引入的語法:
```
import random
some_exceptions = [ValueError, TypeError, IndexError, None]
try:
choice = random.choice(some_exceptions)
print("raising {}".format(choice))
if choice:
raise choice("An error")
except ValueError:
print("Caught a ValueError")
except TypeError:
print("Caught a TypeError")
except Exception as e:
print("Caught some other error: %s" %
( e.__class__.__name__))
else:
print("This code called if there is no exception")
finally:
print("This cleanup code is always called")
```
如果我們運行這個例子——它展示了幾乎所有可以想到的異常處理場景—有幾次,我們每次都會得到不同的輸出,具體取決于隨機選擇哪個異常。以下是一些運行示例:
```
$ python finally_and_else.py
raising None
This code called if there is no exception
This cleanup code is always called
$ python finally_and_else.py
raising <class 'TypeError'>
Caught a TypeError
This cleanup code is always called
$ python finally_and_else.py
raising <class 'IndexError'>
Caught some other error: IndexError
This cleanup code is always called
$ python finally_and_else.py
raising <class 'ValueError'>
Caught a ValueError
This cleanup code is always called
```
注意`finally`子句中的`print`語句在任何情況下都執行了。當我們需要在之后執行某些任務時,這非常有用,當我們想在代碼已經運行完畢(即使發生了異常)后執行某些特定的任務。一些常見的例子包括:
* 清理打開的數據庫連接
* 關閉打開的文件
* 通過網絡發送結束握手的命令
當我們從`try`子句內部執行`return`語句時,`finally`子句也非常重要。`finally`處理程序仍將在`return`語句之前執行。
</b>
此外,當沒有異常發生時,請注意輸出:`else`和`finally`子句仍然被執行。`else`子句似乎是多余的,因為僅當沒有異常時才應該執行的代碼,其實可以放在整個`try...except`語句塊之后。不同之處在于,如果你真的把`else`塊放在`try...except`語句塊之后,如果異常被捕獲并處理,`else`塊仍將被執行。我們稍后會繼續討論使用異常作為流控制的情況。
</b>
可以省略`try`塊之后的`except`、`else`和`finally`子句中的任何一個(盡管只出現`else`是無效的)。如果包含它們中的幾個,則為`except`子句必須放在最前面,然后是`else`子句,最后是`finally`子句。`except`子句的順序通常從最特殊到最一般。
(譯注:如果只保留`else`)

(譯注:如果只保留`finally`)

### 異常層次結構
我們已經看到了幾個最常見的內置異常,你可能會在常規的Python開發過程中遇到其他情況。正如我們之前注意到的,大多數異常都是`Exception`類的子類。但并非所有的異常都是如此。`Exception`類本身實際上繼承自一個名為`BaseException`的類。事實上,所有異常都必須擴展自`BaseException`類或一個`BaseException`類的子類。
</b>
有兩個關鍵異常,`SystemExit`和`KeyboardInterrupt`,直接從`BaseException`類而不是從`Exception`類那里繼承的。`SystemExit`是當程序自然退出時拋出的異常,通常是因為我們在代碼中調用了`sys.exit`函數(例如,當用戶在菜單項選擇退出時,單擊窗口上的“關”按鈕,或輸入關閉命令關閉服務器)。該異常旨在允許我們在程序最終退出前清理代碼,我們通常不需要顯式處理它(因為清理代碼發生在finally子句中)。
</b>
如果我們處理它,通常會重新拋出異常,因為捕捉它會阻止程序退出。當然,在某些情況下,我們可能想要要阻止程序退出,例如,如果有未保存的更改,并且我們希望提示用戶是否真的想退出。通常,如果我們處理`SystemExit`,都是因為我們想用它做一些特別的事情,或者直接預測它。我們尤其不希望它意外地被包含正常異常的子句所捕獲。這也是它直接從`BaseException`派生的原因。
</b>
`KeyboardInterrupt`異常在命令行程序中很常見。當用戶按下與操作系統相關的組合鍵(通常為Ctrl + C),就會拋出這個異常。這是一種標準的用戶故意中斷正在運行程序的方法,就像`SystemExit`一樣,它應該總是通過終止程序來響應。同樣,像`SystemExit`一樣,它應該在`finally`塊中處理任何清理任務。
</b>
下面是一個類圖,它充分說明了異常層次結構:

當我們使用`except:`子句而不指定任何類型的異常時,它將捕獲`BaseException`的所有子類;也就是說,它將捕獲所有異常,包括這兩個特別的異常(中文書翻譯有誤)。因為我們幾乎總是希望對這些進行特別處理,使用沒有參數`except:`語句沒有參數是不明智的。如果你愿意要捕獲除`SystemExit`和`KeyboardInterrupt`之外的所有異常,請顯式捕捉`Exception`。
</b>
此外,如果您確實想捕獲所有異常,我建議使用語法`except BaseException:`,而不是`except:`。這有助于明確地告訴你代碼的讀者,你有意處理特殊情況下的異常。
### 定義我們自己的異常
通常,當我們想要引發異常時,我們發現沒有一個內置異常是合適的。幸運的是,定義我們自己的新異常是很容易的一件事。這個異常類的名字通常是為了說明出了什么問題,我們可以在初始化函數中包含附加信息的任意參數。
</b>
我們所要做的就是從`Exception`類繼承。我們甚至不需要添加任何類內容!當然,我們可以直接擴展`BaseException`類,但是它將不會被`except Exception`子句捕獲。
</b>
我們可以在銀行應用程序中使用一個簡單的異常:
```
class InvalidWithdrawal(Exception):
pass
raise InvalidWithdrawal("You don't have $50 in your account")
```
最后一行說明了如何拋出新定義的異常。我們能夠傳遞任意數量的參數進入這個異常。通常使用字符串消息,但在以后的異常處理程序中可能有用的任何對象都可以被存儲。`Exception.__init__`方法旨在接受任何參數,并把它們作為屬性存儲在名為`args`的元組中。這使得異常更容易定義,且不需要重新定義`__init__`。
</b>
當然,如果我們確實想定制初始化函數,我們可以自由地這樣做。這里的異常表示,其初始值函數接受當前余額和用戶想提取的金額。此外,它還添加了一種計算透支程度的方法:
```
class InvalidWithdrawal(Exception):
def __init__(self, balance, amount):
super().__init__("account doesn't have ${}".format(amount))
self.amount = amount
self.balance = balance
def overage(self):
return self.amount - self.balance
raise InvalidWithdrawal(25, 50)
```
最后的`raise`語句說明了如何構造這個異常。像你看到那樣,我們可以對異常做任何事情,就像我們可以對其他對象做的事情一樣。我們可以捕捉一個異常,并將其作為工作對象傳遞,盡管更常見的做法是將對工作對象的引用作為異常屬性,并在內部進行傳遞。
</b>
以下是我們如何處理`InvalidWithdrawal`異常(如果出現的話):
```
try:
raise InvalidWithdrawal(25, 50)
except InvalidWithdrawal as e:
print("I'm sorry, but your withdrawal is "
"more than your balance by "
"${}".format(e.overage()))
```
這里我們看到`as`關鍵字的有效使用。按照慣例,大多數python程序員會將異常命名為變量`e`,盡管像往常一樣,你也可以自由地將其稱為`ex`、`exception`,或者`aunt_sally`,如果你喜歡的話。
</b>
有很多自定義異常的原因。在異常中添加信息或者以某種方式記錄信息,通常很有用。但當創建一個框架、庫或API供其他程序員訪問時,自定義異常會很有用。在這種情況下,請小心確保你的代碼拋出的異常,對客戶端程序員時有意義。它們應該很容易處理,并清楚地描述發生了什么。客戶端程序員應該很容易明白如何修復錯誤(如果異常反映了他們代碼中的錯誤)或處理異常(如果這是他們需要被告知的情況)。
</b>
異常并不是例外。新手程序員傾向于將異常視為僅在特殊情況下有用。然而,例外環境的定義可能是模糊的,可能會有解釋。考慮以下兩種函數:
```
def divide_with_exception(number, divisor):
try:
print("{} / {} = {}".format(
number, divisor, number / divisor * 1.0))
except ZeroDivisionError:
print("You can't divide by zero")
def divide_with_if(number, divisor):
if divisor == 0:
print("You can't divide by zero")
else:
print("{} / {} = {}".format(
number, divisor, number / divisor * 1.0))
```
這兩個函數行為相同。如果除數`divisor`為零,則錯誤消息將被印刷出來;否則,將顯示打印除法結果的消息。我們可以通過使用`if`測試來避免引發`ZeroDivisionError`錯誤。同樣,我們可以通過顯式檢查參數是否在列表的范圍內來避免引發`IndexError`,通過檢查`key`是否在字典里來避免引發`KeyError`。
</b>
但我們不應該這么做。我們可以寫一個`if`語句來檢查索引是否低于列表參數的數量,但忘記檢查負值。
> 請記住,Python列表支持負索引;-1是指列表中的最后一個元素。
最終,我們會發現,我們必須找到我們曾經去過的所有地方檢查代碼。但是如果我們只是抓住了`IndexError`并處理了它,我們的代碼可以正常工作。
</b>
Python程序員傾向于*遵循請求原諒而不是許可*的模式,也就是說,他們執行代碼,然后處理任何出錯的地方。另一種選擇是三思而后行,這通常是不可取的。有幾個不可取的原因,但最主要的一個是,沒有必要浪費CPU的能量在普通的代碼中去尋找那些極其罕見的情況。因此,在特殊情況下使用異常處理是明智的,即使這些情況只是有點例外。沿著這個論點推進一步,我們實際上可以看到異常語法對于流程控制也是有效的。與`if`語句類似,異常可用于決策、分支和消息傳遞。
</b>
想象一個銷售小部件和小工具的公司的庫存應用程序。當顧客進行購買時,物品要么可以是可用的,在這種情況下,物品將從庫存中被移除,并返回剩余的物品數量,要么可能庫存沒有貨。現在,缺貨是庫存應用程序中非常正常的事情。這當然不是一個例外情況。但是,如果缺貨的話,我們應該返回些什么?一串說缺貨的話?負數?在這兩種情況下,調用方法必須檢查返回值是正整數還是其他什么。另一件事,就是確定是否缺貨。這看起來有點亂。相反,我們可以拋出`OutOfStockException`異常,并使用`try`語句進行流程控制。有道理嗎?此外,我們希望確保我們不會將相同的商品出售給兩個不同的顧客,或者出售一個還沒有在庫的商品。解決這個問題的一種方法是鎖定每種類型的商品,以確保一次只有一個人可以更新它。用戶必須鎖定物品,操作物品(購買,增加庫存,清點剩余物品...),然后解鎖該項目。下面是一個不完整的Inventory示例,其中包含文檔字符串,用來描述一些方法應該做什么:
```
class Inventory:
def lock(self, item_type):
'''選擇要操縱的項目類型。此方法將鎖定項目,
所以沒有其他人可以操縱庫存,直到它被返回。
這防止了將同一商品賣給兩個不同的人顧客。'''
pass
def unlock(self, item_type):
'''釋放給定類型,以便其他客戶可以訪問它。'''
pass
def purchase(self, item_type):
'''如果項目未鎖定,拋出異常。如果項目類型不存在,拋出異常。
如果項目當前是缺貨,拋出異常。如果物品
可用,減去一個項目并返回剩余的項目數。'''
pass
```
我們可以將這個對象原型交給開發人員,讓他們按照文檔實現購買方法,同時我們在這些代碼之上工作,以完成購買。我們將使用Python強大的異常處理,根據購買情況,來處置不同的分支:
```
item_type = 'widget'
inv = Inventory()
inv.lock(item_type)
try:
num_left = inv.purchase(item_type)
except InvalidItemType:
print("Sorry, we don't sell {}".format(item_type))
except OutOfStock:
print("Sorry, that item is out of stock.")
else:
print("Purchase complete. There are "
"{} {}s left".format(num_left, item_type))
finally:
inv.unlock(item_type)
```
注意所有可能被使用的異常處理子句,確保正確的行動發生在正確的時間。盡管`OutOfStock`不是一個非常特殊的情況,我們在這里使用異常來處理它是合適的。同樣的代碼可以用`if...elif...else`結構編制,但不是那么容易閱讀或維護。
</b>
我們也可以使用異常在不同的方法之間傳遞消息。例如,如果我們想告知客戶該商品再次有庫存時的預期銷售日期,我們需要確保我們構造`OutOfStock`對象時,應該有一個補貨參數`back_in_stock`。然后,當我們處理異常時,我們可以檢查這個參數值,并向客戶提供附加信息。綁定在對象上的信息可以很容易地在程序的兩個不同部分之間傳遞。該異常甚至可以提供一種方法指示庫存對象重新訂貨或延期交貨。
</b>
對流控制使用異常會有助于一些便利的程序設計。在我們的討論中,有一點很重要:異常并不是一件我們應該盡量避免的壞事。發生異常并不意味著你應該阻止這種特殊情況的發生。相反,它只是一種在代碼的兩個部分之間傳遞信息的強大方式,而不用直接彼此相互調用。
</b>
## 個案研究
我們一直在相當低的水平(語法和定義)上研究異常的使用和處理細節。這個案例研究將有助于把這一切與我們前幾章聯系在一起,這樣我們就可以看到異常是如何在對象、繼承和模塊中使用。
</b>
今天,我們將設計一個簡單的中央認證和授權系統。整個系統將被放在一個模塊中,其他代碼將能夠查詢用于身份驗證和授權目的的模塊對象。我們得承認,從一開始,我們就不是安全專家,我們正在設計的系統可能布滿了安全漏洞。我們的目的是研究異常,而不是確保系統安全。然而,對于一個基本的可以與其他代碼交互的登錄和許可系統,目前的設計已經足夠了。稍后,如果需要使其他代碼更加安全,我們可以讓安全或密碼學專家審查或重寫我們的模塊,最好不改變API。
</b>
身份驗證是確保用戶真的是他們所說的那個人的過程。今天,我們將跟隨通用網絡系統的主流做法,即使用用戶名和私人密碼的組合。其他認證方法包括語音識別、指紋或視網膜掃描儀以及識別卡。
</b>
另一方面,授權就是確定一個給定(經過身份驗證的)用戶允許執行特定行為的權限。我們將創建一個基本的許可列表系統,存儲允許特定人員執行可能行為的列表。
</b>
此外,我們將添加一些管理功能,以允許向系統中添加新用戶。為簡潔起見,一旦新用戶被添加,我們將省略對密碼的編輯或對權限的改變,但是這些(非常必要的)功能將來肯定會增加上的。
</b>
這是一個簡單的分析;現在讓我們繼續設計。我們顯然會需要一個存儲用戶名和加密密碼的用戶`User`類。這個類還將通過檢查提供的密碼是否有效允許用戶來登錄。我們可能不需要權限`Permission`類,因為這些只是使用字典映射到用戶列表的字符串。我們應該有一個中央驗證`Authenticator`類,它處理用戶管理和登錄或注銷。拼圖的最后一塊是`Authorizor`類,處理權限并檢查用戶是否可以進行一項活動。我們將在`auth`模塊提供這些類的單例,以便其他模塊可以為了認證和授權需求使用這個中央機制。當然,如果它們想實例化私有實例,用于非中央授權活動,它們可以自由地這樣做。
</b>
我們還將定義幾個異常。我們先創建一個`AuthException`基類,接受用戶名`username`和可選用戶`user`對象作為參數;我們大多數自定義的異常都將繼承于這個基類。
</b>
讓我們首先構建用戶`User`類;這看起來很簡單。可以初始化一個有用戶名和密碼的新用戶。密碼將被加密存儲以減少它被盜的可能性。我們還需要一個檢查密碼`check_password`的方法來測試提供的密碼是否正確。這是完整的類:
```
import hashlib
class User:
def __init__(self, username, password):
'''創建新的用戶對象。密碼
將在存儲前加密。'''
self.username = username
self.password = self._encrypt_pw(password)
self.is_logged_in = False
def _encrypt_pw(self, password):
'''對用戶名和密碼進行加密并返回sha摘要。'''
hash_string = (self.username + password)
hash_string = hash_string.encode("utf8")
return hashlib.sha256(hash_string).hexdigest()
def check_password(self, password):
'''如果密碼對該用戶有效,則返回True,否則為False'''
encrypted = self._encrypt_pw(password)
return encrypted == self.password
```
因為加密密碼的代碼在`__init__`和`check_password`中都是必需的,我們把它提取出來放到它自己的方法里。這樣,如果有人意識到不安全,需要改進,只需要在一個地方改變。這個類可以很容易擴展,包括強制或可選的個人詳細信息,例如姓名、聯系信息和出生日期。
</b>
在我們編寫代碼添加用戶之前(這將發生在尚未定義的`Authenticator`類),我們應該檢查一些用例。如果一切順利,我們可以添加具有用戶名和密碼的用戶;創建并插入用戶對象到字典里。但是有什么方面會不順利呢?顯然我們不想使用字典中已經存在的用戶名添加用戶。如果我們這樣做了,我們會覆蓋現有用戶的數據,新用戶可能有權訪問該用戶的數據特權。所以,我們需要一個用戶名已經存在`UsernameAlreadyExists`的異常。另外,為了安全起見,如果密碼太短,我們可能會拋出一個異常。這兩個異常都是擴展自我們前面提到的`AuthException`。所以,在編寫`Authenticator`類之前,讓我們定義這三個異常類:
```
class AuthException(Exception):
def __init__(self, username, user=None):
super().__init__(username, user)
self.username = username
self.user = user
class UsernameAlreadyExists(AuthException):
pass
class PasswordTooShort(AuthException):
pass
```
`AuthException`異常需要用戶名和一個可選的用戶參數。第二個參數應該是與用戶名關聯的用戶`User`類的實例。我們正在定義的兩個特殊異常只需在異常情況下通知調用類即可,所以我們不需要添加任何額外的方法。
</b>
現在讓我們從`Authenticator`類開始。它僅僅是把用戶名映射到用戶對象上,所以我們將從初始化函數中的字典開始。添加用戶的方法,在創建新的用戶實例并將其添加到字典之前,需要檢查兩個條件(密碼長度和以前已經存在的用戶):
```
class Authenticator:
def __init__(self):
'''構建要驗證器管理用戶登錄和注銷。'''
self.users = {}
def add_user(self, username, password):
if username in self.users:
raise UsernameAlreadyExists(username)
if len(password) < 6:
raise PasswordTooShort(username)
self.users[username] = User(username, password)
```
當然,如果密碼太容易以其他方式破解的話,我們可以擴展密碼驗證來引發密碼異常。現在讓我們準備登錄`login`方法。如果我們剛才沒有考慮異常,我們可能只是想方法根據登錄成功與否返回真或假。但是我們正在考慮異常,這可能是一個使用它們的不太特殊的情況。例如,我們可能想拋出不同的異常,例如用戶名不存在或密碼不匹配。這將允許嘗試登陸的任何人使用`try / except / else`子句優雅地處理這些情況。首先,我們添加這些新的異常:
```
class InvalidUsername(AuthException):
pass
class InvalidPassword(AuthException):
pass
```
然后,我們可以為我們的`Authenticator`類定義一個簡單的登錄`login`方法。如有必要,我們拋出這些異常。如果沒有,它會用標記戶已登錄并返回:
```
def login(self, username, password):
try:
user = self.users[username]
except KeyError:
raise InvalidUsername(username)
if not user.check_password(password):
raise InvalidPassword(username, user)
user.is_logged_in = True
return True
```
請注意`KeyError`是如何被處理的。也可以通過`if
username not in self.users:`來處理,但我們選擇了直接處理異常。我們已經消化了第一個異常,我們也可以提出一個全新的、自定義的、更適合面向用戶API的異常。
</b>
我們還可以添加一個方法來檢查特定用戶名是否已登錄。決定是否在這里使用異常是很棘手的事情。如果出現以下情況,我們是否應該提出用戶名不存在的異常?如果用戶沒有登錄,我們應該拋出異常嗎?
</b>
為了回答這些問題,我們需要思考這個方法是如何被訪問的?大多數情況下,這種方法將用于回答是/否問題,“我應該允許他們訪問*某些內容*嗎?”答案要么是,“是的,用戶名有效并且他們已登錄”,或者“不,用戶名無效或者他們未登錄”。因此,布爾返回值是足夠的,沒必要只是為了使用異常而使用異常。
```
def is_logged_in(self, username):
if username in self.users:
return self.users[username].is_logged_in
return False
```
最后,我們可以在模塊中添加一個默認的驗證器實例,這樣客戶端代碼可以使用`auth.authenticator`輕松訪問它:
```
authenticator = Authenticator()
```
這行代碼屬于模塊級別,在任何類定義之外,所以驗證器變量可以作為`auth.authenticator`訪問。現在我們可以開始了`Authorizor`類,它將權限映射到用戶。如果用戶未登錄,`Authorizor`類應該不允許他們訪問權限,因此他們需要對特定驗證者的引用。我們還需要設置初始化時的許可字典:
```
class Authorizor:
def __init__(self, authenticator):
self.authenticator = authenticator
self.permissions = {}
```
現在我們可以編寫方法來添加新的權限和設置與每個權限相關聯的那些用戶:
```
def add_permission(self, perm_name):
'''創建用戶可以被加入的新權限'''
try:
perm_set = self.permissions[perm_name]
except KeyError:
self.permissions[perm_name] = set()
else:
raise PermissionError("Permission Exists")
def permit_user(self, perm_name, username):
'''向用戶授予給定的權限'''
try:
perm_set = self.permissions[perm_name]
except KeyError:
raise PermissionError("Permission does not exist")
else:
if username not in self.authenticator.users:
raise InvalidUsername(username)
perm_set.add(username)
```
第一種方法允許我們在中創建新的權限,如果它已經存在,則拋出異常。第二個方法允許我們將用戶名添加到一個權限里,除非權限或用戶名尚不存在。
</b>
我們使用`set`而不是`list`作為用戶名的容器(譯注:見最后一句`perm_set.add(username)`,這里用的是`set`的`add`方法,`list`沒有`add`方法),這樣即使你授予用戶多次權限,集合的性質意味著用戶只在集合中出現一次。我們將在下一章進一步討論集合。
</b>
這兩種方法都會拋出`PermissionError`異常。這個新異常不需要用戶名,所以我們可以直接擴展`Exception`獲得`PermissionError`,而不是繼承自我們的自定義的`AuthException`:
```
class PermissionError(Exception):
pass
```
最后,我們可以添加一個方法來檢查用戶是否有特定的權限。為了授予他們訪問權限,他們必須同時滿足通過登錄驗證和存在于被放授予該特權的人的集合中。如果不滿足這些條件中的任何一個,則會引發異常:
```
def check_permission(self, perm_name, username):
if not self.authenticator.is_logged_in(username):
raise NotLoggedInError(username)
try:
perm_set = self.permissions[perm_name]
except KeyError:
raise PermissionError("Permission does not exist")
else:
if username not in perm_set:
raise NotPermittedError(username)
else:
return True
```
這里有兩個新的異常;他們都帶用戶名,所以我們定義它們是`AuthException`的子類:
```
class NotLoggedInError(AuthException):
pass
class NotPermittedError(AuthException):
pass
```
最后,我們可以添加一個默認`authorizor`作為我們的默認驗證器:
```
authorizor = Authorizor(authenticator)
```
這就完成了一個基本的認證/授權系統。我們可以在Python提示符下測試這個系統,檢查用戶joe是否被允許在油漆部門工作:
```
>>> import auth
>>> auth.authenticator.add_user("joe", "joepassword")
>>> auth.authorizor.add_permission("paint")
>>> auth.authorizor.check_permission("paint", "joe")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "auth.py", line 109, in check_permission
raise NotLoggedInError(username)
auth.NotLoggedInError: joe
>>> auth.authenticator.is_logged_in("joe")
False
>>> auth.authenticator.login("joe", "joepassword")
True
>>> auth.authorizor.check_permission("paint", "joe")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "auth.py", line 116, in check_permission
raise NotPermittedError(username)
auth.NotPermittedError: joe
>>> auth.authorizor.check_permission("mix", "joe")
Traceback (most recent call last):
File "auth.py", line 111, in check_permission
perm_set = self.permissions[perm_name]
KeyError: 'mix'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "auth.py", line 113, in check_permission
raise PermissionError("Permission does not exist")
auth.PermissionError: Permission does not exist
>>> auth.authorizor.permit_user("mix", "joe")
Traceback (most recent call last):
File "auth.py", line 99, in permit_user
perm_set = self.permissions[perm_name]
KeyError: 'mix'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "auth.py", line 101, in permit_user
raise PermissionError("Permission does not exist")
auth.PermissionError: Permission does not exist
>>> auth.authorizor.permit_user("paint", "joe")
>>> auth.authorizor.check_permission("paint", "joe")
True
```
雖然冗長,但前面的輸出顯示了我們的所有代碼和大部分在起作用的異常,但是為了真正理解我們定義的API,我們應該編寫一些實際使用它的異常處理代碼。這是一個基本菜單界面允許某些用戶更改或測試程序:
```
import auth
# Set up a test user and permission
auth.authenticator.add_user("joe", "joepassword")
auth.authorizor.add_permission("test program")
auth.authorizor.add_permission("change program")
auth.authorizor.permit_user("test program", "joe")
class Editor:
def __init__(self):
self.username = None
self.menu_map = {
"login": self.login,
"test": self.test,
"change": self.change,
"quit": self.quit
}
def login(self):
logged_in = False
while not logged_in:
username = input("username: ")
password = input("password: ")
try:
logged_in = auth.authenticator.login(
username, password)
except auth.InvalidUsername:
print("Sorry, that username does not exist")
except auth.InvalidPassword:
print("Sorry, incorrect password")
else:
self.username = username
def is_permitted(self, permission):
try:
auth.authorizor.check_permission(
permission, self.username)
except auth.NotLoggedInError as e:
print("{} is not logged in".format(e.username))
return False
except auth.NotPermittedError as e:
print("{} cannot {}".format(
e.username, permission))
return False
else:
return True
def test(self):
if self.is_permitted("test program"):
print("Testing program now...")
def change(self):
if self.is_permitted("change program"):
print("Changing program now...")
def quit(self):
raise SystemExit()
def menu(self):
try:
answer = ""
while True:
print("""
Please enter a command:
\tlogin\tLogin
\ttest\tTest the program
\tchange\tChange the program
\tquit\tQuit
""")
answer = input("enter a command: ").lower()
try:
func = self.menu_map[answer]
except KeyError:
print("{} is not a valid option".format(
answer))
else:
func()
finally:
print("Thank you for testing the auth module")
Editor().menu()
```
這個相當長的例子在概念上非常簡單。`is_permitted`方法可能是最有趣的;它是`test`和`change`都調用的主要內部方法,用于確保用戶被允許訪問。當然,這兩種方法都是存根,但是我們這里沒有編寫編輯器;我們在這里主要通過測試身份驗證和授權框架展示異常和異常處理程序!
## 摘要
在這一章中,我們討論了拋出、處理、定義和操縱異常的細節。異常是一種強有力的、不需要調用函數顯式檢查返回值的方式。有許多內置的異常,拋出它們很簡單。有很多不同的語法來處理不同的異常事件。
</b>
在下一章中,我們到目前為止所學的、所討論的一切匯集在一起,看看如何最好地在Python應用程序中應用面向對象的編程原則和結構。