程序非常脆弱。如果代码总是返回一个有效的结果,但有时无法计算有效的结果,这将是理想的。例如,不可能被零除,也不可能访问五项列表中的第八项。
在过去,解决这个问题的唯一方法是严格检查每个函数的输入,以确保它们有意义。通常,函数有特殊的返回值来指示错误条件;例如,它们可以返回一个负数,表示无法计算正值。不同的数字可能意味着发生了不同的错误。任何调用此函数的代码都必须显式检查错误条件并相应地执行操作。很多代码都懒得这么做,程序干脆崩溃了。然而,在面向对象的世界中,情况并非如此。
在本章中,我们将研究异常,这些特殊的错误对象,只有在处理它们有意义时才需要处理。我们将特别介绍:
- 如何导致异常发生
- 发生异常时如何恢复
- 如何以不同的方式处理不同的异常类型
- 发生异常时进行清理
- 创建新类型的异常
- 为流控制使用异常语法
原则上,异常只是一个对象。有许多不同的异常类可用,我们可以轻松地定义更多自己的异常类。它们的一个共同点是,它们都继承自一个名为BaseException的内置类。当这些异常对象在程序的控制流中处理时,它们会变得特别。当异常发生时,原本应该发生的一切都不会发生,除非它是在异常发生时发生的。有道理?别担心,会的!
导致异常发生的最简单方法是做一些愚蠢的事情!很可能您已经这样做了,并且看到了异常输出。例如,每当 Python 在您的程序中遇到它无法理解的行时,它都会使用SyntaxError退出,这是一种异常类型。这里有一个常见的例子:
>>> print "hello world"
File "<stdin>", line 1
print "hello world"
^
SyntaxError: invalid syntax这个print语句在 Python2 和以前的版本中是有效的命令,但在 Python3 中,因为print现在是一个函数,所以我们必须将参数括在括号中。因此,如果我们在 Python3 解释器中键入前面的命令,就会得到SyntaxError。
除了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有时,这些异常是程序中出现错误的迹象(在这种情况下,我们将转到指定的行号并修复它),但它们也发生在合法的情况下。AZeroDivisionError并不总是意味着我们收到了无效的输入。这也可能意味着我们收到了不同的意见。用户可能错误地或有意地输入了零,或者它可能代表一个合法的值,例如空的银行帐户或新生儿的年龄。
您可能已经注意到前面所有的内置异常都以名称Error结尾。在 Python 中,error和exception几乎可以互换使用。错误有时被认为比异常更可怕,但它们的处理方式完全相同。实际上,前面示例中的所有错误类都将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)此类扩展了list内置对象,正如我们在第 2 章中所讨论的,Python 中的对象,并重写append方法来检查确保该项为偶数整数的两个条件。我们首先检查输入是否是int类型的实例,然后使用模运算符确保它可以被二整除。如果两个条件中的任何一个不满足,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我们将很快看到,当解释器实际上没有抄近路并立即退出时,我们可以对任何一种方法中的异常做出反应并进行处理。事实上,异常在最初提出后可以在任何级别进行处理。
从下至上查看异常的输出(称为回溯),注意这两种方法是如何列出的。在no_return中,最初会引发异常。然后,就在上面,我们看到在call_exceptor内部,那个讨厌的no_return函数被调用,异常冒泡到调用方法。从那里,它又上升了一级到主解释器,主解释器不知道还能用它做什么,于是放弃并打印了一份回溯。
现在让我们看一下例外硬币的尾部。如果我们遇到异常情况,我们的代码应该如何反应或从中恢复?我们通过将任何可能抛出异常的代码(无论是异常代码本身,还是对可能引发异常的任何函数或方法的调用)包装到try中来处理异常。。。except条款。最基本的语法如下所示:
try:
no_return()
except:
print("I caught an exception")
print("executed after the exception")如果我们使用我们现有的no_return函数运行这个简单的脚本,我们很清楚,这个函数总是抛出异常,我们会得到以下输出:
I am about to raise an exception
I caught an exception
executed after the exceptionno_return函数愉快地通知我们它将引发异常,但我们愚弄了它并捕获了异常。一旦被抓到,我们就能够自己清理(在这种情况下,通过输出我们正在处理的情况),继续前进,而不受攻击性功能的干扰。no_return函数中的其余代码仍然未执行,但调用该函数的代码能够恢复并继续。
注意try和except周围的压痕。try子句包装可能引发异常的任何代码。然后,except子句返回到与try行相同的缩进级别。任何处理异常的代码都缩进在except子句之后。然后,正常代码恢复到原始缩进级别。
前面代码的问题是它将捕获任何类型的异常。如果我们编写的代码可以同时引发一个TypeError和一个ZeroDivisionError呢?我们可能想捕获ZeroDivisionError,但让TypeError传播到控制台。你能猜出语法吗?
这里有一个相当愚蠢的函数,它就是这样做的:
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子句,那么当我们向它发送一个字符串时,它会指责我们除以零,这根本不是一种正确的行为。
我们甚至可以捕获两个或更多不同的异常,并用相同的代码处理它们。下面是一个引发三种不同类型异常的示例。它使用相同的异常处理程序处理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关键字没有参数,如果我们已经在异常处理程序中,它将重新调用最后一个异常。遵守以下代码:
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!后,会再次引发异常;我们仍将在控制台上获取原始堆栈跟踪。
如果我们像在前面的示例中那样堆叠异常子句,则只会运行第一个匹配子句,即使其中有多个匹配。多个子句如何匹配?请记住,异常是对象,因此可以子类化。正如我们将在下一节中看到的,大多数异常都扩展了Exception类(它本身派生自BaseException。如果在捕获TypeError之前先捕获Exception,则只执行Exception处理程序,因为TypeError是继承的Exception。
如果我们想专门处理一些异常,然后作为更一般的情况处理所有剩余的异常,那么这就很方便了。我们可以在捕获所有特定异常后简单地捕获Exception,并在那里处理一般情况。
有时,当我们捕获异常时,我们需要对Exception对象本身的引用。这通常发生在我们使用自定义参数定义自己的异常时,但也可能与标准异常相关。大多数异常类在其构造函数中接受一组参数,我们可能希望在异常处理程序中访问这些属性。如果我们定义自己的异常类,我们甚至可以在捕获它时调用它的自定义方法。将异常捕获为变量的语法使用as关键字:
try:
raise ValueError("This is an argument")
except ValueError as e:
print("The exception arguments were", e.args)如果我们运行这个简单代码段,它会打印出初始化时传入ValueError的字符串参数。
我们已经看到了处理异常语法的几种变体,但无论是否发生异常,我们仍然不知道如何执行代码。我们也不能指定只有在不发生异常时才应该执行的代码。另外两个关键字finally和else可以提供缺失的片段。两人都不接受任何额外的论点。下面的示例随机选择要抛出的异常并引发它。然后运行一些不太复杂的异常处理代码,说明了新引入的语法:
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")如果我们运行这个演示了几乎所有可能的异常处理场景的示例—几次,我们每次都会得到不同的输出,这取决于random选择的异常。以下是一些运行示例:
$ 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句柄仍将被执行。
另外,请注意没有引发异常时的输出:else和finally子句都被执行。else子句似乎是多余的,因为只有在没有引发异常时才应该执行的代码可以放在整个try之后。。。except块。不同之处在于,如果捕获并处理异常,else块仍将被执行。当我们稍后讨论使用异常作为流控制时,我们将看到更多关于这方面的内容。
在try块后可以省略except、else和finally中的任何一个子句(尽管else本身无效)。如果包含多个,则必须先包含except子句,然后是else子句,最后是finally子句。except子句的顺序通常从最具体到最一般。
我们已经看到了几个最常见的内置异常,在常规 Python 开发过程中,您可能会遇到其他异常。正如我们前面注意到的,大多数异常都是Exception类的子类。但并非所有例外情况都是如此。Exception本身实际上继承自一个名为BaseException的类。事实上,所有异常都必须扩展BaseException类或其子类之一。
有两个关键的例外情况,SystemExit和KeyboardInterrupt直接源自BaseException而非Exception。每当程序自然退出时,就会引发SystemExit异常,这通常是因为我们在代码中的某个地方调用了sys.exit函数(例如,当用户选择退出菜单项、单击窗口上的“关闭”按钮或输入关闭服务器的命令时)。这个异常允许我们在程序最终退出之前清理代码,所以我们通常不需要显式地处理它(因为清理代码发生在finally子句中)。
如果我们处理它,我们通常会重新抛出异常,因为捕获它会阻止程序退出。当然,在某些情况下,我们可能希望停止程序退出,例如,如果有未保存的更改,我们希望提示用户是否确实要退出。通常,如果我们处理SystemExit的话,那是因为我们想用它做一些特别的事情,或者直接期待它。我们尤其不希望它意外地被捕获在捕获所有正常异常的泛型子句中。这就是为什么它直接源自BaseException。
KeyboardInterrupt异常在命令行程序中很常见。当用户使用依赖操作系统的组合键(通常为Ctrl+C)显式中断程序执行时抛出。这是用户故意中断正在运行的程序的标准方式,就像SystemExit一样,它几乎总是通过终止程序来响应。此外,像SystemExit一样,它应该处理finally区块内的任何清理任务。
下面是一个类图,它充分说明了异常层次结构:
当我们使用except:子句而不指定任何类型的异常时,它将捕获BaseException的所有子类;也就是说,它将捕获所有异常,包括两个特殊的异常。因为我们几乎总是希望这些得到特殊的处理,所以在没有参数的情况下使用except:语句是不明智的。如果要捕获除SystemExit和KeyboardInterrupt之外的所有异常,请显式捕获Exception。
此外,如果您确实希望捕获所有异常,我建议使用语法except BaseException:而不是原始的except:。这有助于明确地告诉代码的未来读者,您是有意处理特殊情况异常的。
通常,当我们想要引发异常时,我们发现没有一个内置异常是合适的。幸运的是,定义我们自己的新异常并不重要。类的名称通常被设计用来传达出错的地方,我们可以在初始值设定项中提供任意参数以包含附加信息。
我们所要做的就是从Exception类继承。我们甚至不需要在课堂上添加任何内容!当然,我们可以直接扩展BaseException,但这样它就不会被泛型except Exception子句所捕获。
以下是我们可能在银行应用程序中使用的一个简单例外:
class InvalidWithdrawal(Exception):
pass
raise InvalidWithdrawal("You don't have $50 in your account")最后一行说明了如何引发新定义的异常。我们可以将任意数量的参数传递到异常中。通常使用字符串消息,但可以存储在以后的异常处理程序中可能有用的任何对象。Exception.__init__方法用于接受任何参数,并将它们作为元组存储在名为args的属性中。这使得异常更容易定义,而无需覆盖__init__。
当然,如果我们确实想自定义初始值设定项,我们可以自由地这样做。这里有一个例外,其初始值设定项接受当前余额和用户想要提取的金额。此外,它还添加了一种计算请求透支程度的方法:
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语句说明了如何构造此异常。正如你所看到的,我们可以做任何事情,除了对其他对象所做的例外。我们可以捕获一个异常并将其作为工作对象传递,尽管更常见的做法是在异常上包含对工作对象的引用作为属性并传递它。
下面是我们如何处理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。
定义我们自己的例外情况有很多原因。向异常添加信息或以某种方式记录异常通常很有用。但是,当创建一个供其他程序员访问的框架、库或 API 时,定制异常的实用性才真正显现出来。在这种情况下,请小心确保您的代码引发对客户端程序员有意义的异常。它们应该易于处理,并清楚地描述发生了什么。客户端程序员应该很容易看到如何修复错误(如果错误反映在他们的代码中)或处理异常(如果这是一种需要让他们知道的情况)。
例外并不例外。新手程序员倾向于认为异常只对异常情况有用。然而,例外情况的定义可能含糊不清,需要解释。考虑以下两个功能:
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来避免抛出ZeroDivisionError。类似地,我们可以通过显式检查参数是否在列表范围内来避免一个IndexError,通过检查键是否在字典中来避免一个KeyError。
但我们不应该这样做。首先,我们可以编写一个if语句来检查索引是否低于列表的参数,但忘记检查负值。
记住,Python 列表支持负索引;-1指列表中的最后一个元素。
最终,我们会发现这一点,并且必须找到我们检查代码的所有地方。但是,如果我们只是抓住IndexError并处理它,我们的代码就会正常工作。
Python 程序员倾向于遵循请求原谅而不是许可的模式,也就是说,他们执行代码,然后处理任何出错的事情。另一种选择是三思而后行,这通常是不被接受的。这有几个原因,但主要的一个原因是,不需要消耗 CPU 周期来寻找在代码的正常路径中不会出现的异常情况。因此,在特殊情况下使用例外是明智的,即使这些情况只是一点点例外。进一步讨论这个参数,我们实际上可以看到异常语法对于流控制也是有效的。与if语句类似,异常可用于决策、分支和消息传递。
想象一下,一家销售小部件和小工具的公司有一个库存应用程序。当客户进行购买时,该商品可以是可用的,在这种情况下,该商品将从库存中删除并返回剩余的商品数量,或者该商品可能已缺货。现在,在库存应用程序中,缺货是非常正常的事情。这当然不是一个例外情况。但是,如果它缺货了,我们怎么退货呢?一个字符串表示缺货?负数?在这两种情况下,调用方法都必须检查返回值是正整数还是其他值,以确定它是否缺货。看起来有点乱。相反,我们可以提出OutOfStockException并使用try语句来指导程序流控制。有道理?此外,我们希望确保不会将同一商品出售给两个不同的客户,也不会出售尚未上市的商品。实现这一点的一种方法是锁定每种类型的项目,以确保一次只有一个人可以更新它。用户必须锁定项目,操作项目(购买、添加库存、盘点剩余项目…),然后解锁项目。下面是一个带有 docstrings 的不完整的Inventory示例,它描述了一些方法应该做什么:
class Inventory:
def lock(self, item_type):
'''Select the type of item that is going to
be manipulated. This method will lock the
item so nobody else can manipulate the
inventory until it's returned. This prevents
selling the same item to two different
customers.'''
pass
def unlock(self, item_type):
'''Release the given type so that other
customers can access it.'''
pass
def purchase(self, item_type):
'''If the item is not locked, raise an
exception. If the item_type does not exist,
raise an exception. If the item is currently
out of stock, raise an exception. If the item
is available, subtract one item and return
the number of items left.'''
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结构,但不容易阅读或维护。
我们还可以使用异常在不同的方法之间传递消息。例如,如果我们想通知客户该商品预计何时再次上市,我们可以确保我们的OutOfStock对象在构建时需要一个back_in_stock参数。然后,当我们处理异常时,我们可以检查该值并向客户提供附加信息。附加到对象的信息可以在程序的两个不同部分之间轻松传递。该异常甚至可以提供一种方法,指示库存对象重新订购或延期订购项目。
将异常用于流控制可以实现一些方便的程序设计。从这场讨论中,重要的一点是,例外并不是一件坏事,我们应该尽量避免。发生异常并不意味着您应该阻止这种异常情况的发生。相反,它只是一种功能强大的方式,可以在两段代码之间传递信息,而这两段代码可能不会直接相互调用。
我们一直在以相当低的语法和定义详细程度来研究的使用和异常处理。本案例研究将有助于将其与前面的章节联系起来,这样我们就可以看到异常是如何在对象、继承和模块的更大上下文中使用的。
今天,我们将设计一个简单的中央身份验证和授权系统。整个系统将放在一个模块中,其他代码将能够查询该模块对象以进行身份验证和授权。我们应该承认,从一开始,我们就不是安全专家,我们正在设计的系统可能充满了安全漏洞。我们的目的是研究例外情况,而不是保护系统。不过,对于其他代码可以交互的基本登录和权限系统来说,这就足够了。稍后,如果其他代码需要更加安全,我们可以让安全或密码专家审查或重写我们的模块,最好不更改 API。
身份验证是确保用户确实是他们所说的人的过程。我们将学习今天常见的 web 系统,它们使用用户名和私有密码的组合。其他认证方法包括语音识别、指纹或视网膜扫描仪以及身份证。
另一方面,授权是关于确定是否允许给定(已验证的)用户执行特定操作。我们将创建一个基本权限列表系统,该系统存储允许执行每个操作的特定人员的列表。
此外,我们还将添加一些管理功能,以允许向系统中添加新用户。为简洁起见,我们将在添加密码或更改权限后不进行编辑,但这些(非常必要的)功能肯定会在将来添加。
有一个简单的分析;现在让我们继续设计。我们显然需要一个User类来存储用户名和加密密码。此类还允许用户通过检查提供的密码是否有效来登录。我们可能不需要Permission类,因为它们可以是映射到使用字典的用户列表的字符串。我们应该有一个中央Authenticator类来处理用户管理和登录或注销。最后一个难题是一个Authorizor类,它处理权限和检查用户是否可以执行某项活动。我们将在auth模块中提供这些类中每个类的一个实例,以便其他模块可以使用此中心机制满足其所有身份验证和授权需求。当然,如果他们想要实例化这些类的私有实例,对于非中心授权活动,他们可以自由地这样做。
我们还将定义几个例外情况。我们将从一个特殊的AuthException基类开始,该基类接受username和可选user对象作为参数;我们的大多数自定义异常都将继承自此异常。
让我们先建立User类;这似乎很简单。可以使用用户名和密码初始化新用户。密码将被加密存储,以减少其被盗的机会。我们还需要一个check_password方法来测试提供的密码是否正确。下面是全班的内容:
import hashlib
class User:
def __init__(self, username, password):
'''Create a new user object. The password
will be encrypted before storing.'''
self.username = username
self.password = self._encrypt_pw(password)
self.is_logged_in = False
def _encrypt_pw(self, password):
'''Encrypt the password with the username and return
the sha digest.'''
hash_string = (self.username + password)
hash_string = hash_string.encode("utf8")
return hashlib.sha256(hash_string).hexdigest()
def check_password(self, password):
'''Return True if the password is valid for this
user, false otherwise.'''
encrypted = self._encrypt_pw(password)
return encrypted == self.password由于__init__和check_password中都需要加密密码的代码,因此我们将其提取到自己的方法中。这样,如果有人意识到它不安全并且需要改进,那么只需要在一个地方进行更改。这个类可以很容易地扩展到包含强制性或可选的个人详细信息,例如姓名、联系信息和出生日期。
在我们编写代码来添加用户(这将发生在尚未定义的Authenticator类中)之前,我们应该检查一些用例。如果一切顺利,我们可以添加一个用户名和密码的用户;创建User对象并将其插入字典。但在哪些方面都不能顺利进行?很明显,我们不想添加一个用户名已经存在于字典中的用户。如果这样做,我们将覆盖现有用户的数据,新用户可能有权访问该用户的权限。所以,我们需要一个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):
passAuthException需要一个用户名,并且有一个可选的用户参数。第二个参数应该是与该用户名关联的User类的实例。我们定义的两个特定异常只需要通知调用类异常情况,因此不需要向它们添加任何额外的方法。
现在让我们开始Authenticator课程。它可以是用户名到用户对象的映射,因此我们将从初始化函数中的字典开始。添加用户的方法需要在创建新的User实例并将其添加到字典之前检查两个条件(密码长度和以前存在的用户):
class Authenticator:
def __init__(self):
'''Construct an authenticator to manage
users logging in and out.'''
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方法。如果我们现在没有考虑异常,我们可能只希望该方法返回True或False,这取决于登录是否成功。但我们正在考虑例外情况,这可能是一个在不太例外的情况下使用它们的好地方。我们可以提出不同的例外情况,例如,如果用户名不存在或密码不匹配。这将允许任何试图登录用户的人使用try/except/else条款优雅地处理情况。因此,我们首先添加这些新的例外情况:
class InvalidUsername(AuthException):
pass
class InvalidPassword(AuthException):
pass然后我们可以为我们的Authenticator类定义一个简单的login方法,在必要时引发这些异常。如果未登录,则将user标记为已登录并返回:
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。
我们还可以添加一个方法来检查特定用户名是否已登录。在这里决定是否使用异常更为棘手。如果用户名不存在,我们是否应该引发异常?如果用户未登录,是否应该引发异常?
要回答这些问题,我们需要考虑如何访问该方法。大多数情况下,此方法将用于回答是/否问题,“我是否应允许他们访问*<某些内容>*?”答案将是“是,用户名有效且他们已登录”,或“否,用户名无效或他们未登录”。因此,布尔返回值就足够了。这里不需要使用异常,只是为了使用异常。
def is_logged_in(self, username):
if username in self.users:
return self.users[username].is_logged_in
return False最后,我们可以在我们的模块中添加一个默认验证器实例,以便客户端代码可以使用auth.authenticator轻松访问它:
authenticator = Authenticator()该行在模块级,在任何类定义之外,因此可以访问 authenticator 变量auth.authenticator。现在我们可以从Authorizor类开始,它将权限映射到用户。如果用户未登录,Authorizor类不应允许用户访问权限,因此需要对特定身份验证器的引用。我们还需要在初始化时设置权限字典:
class Authorizor:
def __init__(self, authenticator):
self.authenticator = authenticator
self.permissions = {}现在,我们可以编写方法来添加新权限,并设置与每个权限关联的用户:
def add_permission(self, perm_name):
'''Create a new permission that users
can be added to'''
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):
'''Grant the given permission to the user'''
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)第一个方法允许我们创建一个新的权限,除非它已经存在,在这种情况下会引发异常。第二种方法允许我们向权限中添加用户名,除非该权限或用户名尚不存在。
我们对用户名使用set而不是list,因此,即使您多次授予用户权限,集合的性质意味着用户只在集合中存在一次。我们将在后面的章节中进一步讨论集合。
两种方法都提出了一个PermissionError。此新错误不需要用户名,因此我们将使其直接扩展Exception,而不是自定义AuthException:
class PermissionError(Exception):
pass最后,我们可以添加一个方法来检查用户是否有特定的permission。为了授予他们访问权限,他们必须同时登录到验证器和被授予访问该权限的人员集中。如果不满足上述任一条件,则会引发异常:
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调用的内部方法,以确保在继续之前允许用户访问。当然,这两种方法都是存根,但我们这里没有编写编辑器;我们通过测试身份验证和授权框架来演示异常和异常处理程序的使用!
如果您以前从未处理过异常,那么您需要做的第一件事就是查看您编写的任何旧 Python 代码,并注意是否有您应该处理异常的地方。你会如何处理它们?你需要处理它们吗?有时,让异常传播到控制台是与用户通信的最佳方式,特别是当用户也是脚本的编码者时。有时,您可以从错误中恢复并允许程序继续。有时,您只能将错误重新格式化为用户能够理解并向他们显示的内容。
一些常见的查找位置包括文件 I/O(您的代码是否可能尝试读取不存在的文件?)、数学表达式(您正在除以的值是否可能为零?)、列表索引(列表是否为空?)和字典(键是否存在?)。问问自己是否应该忽略这个问题,首先通过检查值来处理它,或者通过异常来处理它。特别注意您可能使用过finally和else的区域,以确保在所有情况下都能执行正确的代码。
现在编写一些新代码。考虑一个需要身份验证和授权的程序,并尝试编写一些使用我们在案例研究中构建的auth模块的代码。如果模块不够灵活,可以随意修改。尝试以合理的方式处理所有异常。如果您遇到需要身份验证的问题,请尝试将授权添加到记事本示例中第 2 章、Python 中的对象,或者将授权添加到auth模块本身—如果任何人都可以开始添加权限,那么这不是一个非常有用的模块!在允许添加或更改权限之前,可能需要管理员用户名和密码。
最后,试着在代码中找出可以引发异常的地方。它可以是您已经编写或正在编写的代码;或者你可以写一个新的项目作为练习。你很可能有幸设计出一个供其他人使用的小框架或 API;异常是您的代码和其他人的代码之间极好的通信工具。请记住,作为 API 的一部分,设计并记录任何自行引发的异常,否则他们将不知道是否或如何处理它们!
在本章中,我们详细介绍了异常的提出、处理、定义和操作。异常是一种传递异常情况或错误条件的强大方式,无需调用函数显式检查返回值。有许多内置异常,提出它们非常容易。有几种不同的语法用于处理不同的异常事件。
在下一章中,我们将讨论如何在 Python 应用程序中最好地应用面向对象编程原则和结构,并将讨论到目前为止我们所研究的所有内容。
