Python 入门指南(五)(3)https://developer.aliyun.com/article/1507422
异常层次结构
我们已经看到了几个最常见的内置异常,你可能会在你的常规 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
。大多数 Python 开发人员认为没有指定类型的except:
是一个错误,并会在代码审查中标记它。如果你真的想捕获所有异常,只需明确使用except BaseException:
。
定义我们自己的异常
偶尔,当我们想要引发一个异常时,我们发现没有一个内置的异常适合。幸运的是,定义我们自己的新异常是微不足道的。类的名称通常设计为传达出了什么问题,我们可以在初始化程序中提供任意参数以包含额外的信息。
我们所要做的就是继承Exception
类。我们甚至不必向类中添加任何内容!当然,我们可以直接扩展BaseException
,但我从未遇到过这种情况。
这是我们在银行应用程序中可能使用的一个简单的异常:
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__(f"account doesn't have ${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 " f"${e.overage()}")
在这里,我们看到了as
关键字的有效使用。按照惯例,大多数 Python 程序员将异常命名为e
或ex
变量,尽管通常情况下,你可以自由地将其命名为exception
,或者如果你愿意的话,可以称之为aunt_sally
。
定义自己的异常有很多原因。通常,向异常中添加信息或以某种方式记录异常是很有用的。但是,自定义异常的实用性在创建面向其他程序员访问的框架、库或 API 时才真正显现出来。在这种情况下,要小心确保代码引发的异常对客户程序员有意义。它们应该易于处理,并清楚地描述发生了什么。客户程序员应该很容易看到如何修复错误(如果它反映了他们代码中的错误)或处理异常(如果这是他们需要知道的情况)。
异常并不是异常的。新手程序员倾向于认为异常只对异常情况有用。然而,异常情况的定义可能模糊不清,而且可能会有不同的解释。考虑以下两个函数:
def divide_with_exception(number, divisor): try: print(f"{number} / {divisor} = {number / divisor}") 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(f"{number} / {divisor} = {number / divisor}")
这两个函数的行为是相同的。如果divisor
为零,则打印错误消息;否则,显示除法结果的消息。我们可以通过使用if
语句来避免抛出ZeroDivisionError
。同样,我们可以通过明确检查参数是否在列表范围内来避免IndexError
,并通过检查键是否在字典中来避免KeyError
。
但我们不应该这样做。首先,我们可能会编写一个if
语句,检查索引是否低于列表的参数,但忘记检查负值。
记住,Python 列表支持负索引;-1
指的是列表中的最后一个元素。
最终,我们会发现这一点,并不得不找到我们检查代码的所有地方。但如果我们简单地捕获IndexError
并处理它,我们的代码就可以正常工作。
Python 程序员倾向于遵循“宁可请求原谅,而不是事先征得许可”的模式,也就是说,他们执行代码,然后处理任何出现的问题。相反,先“三思而后行”的做法通常不太受欢迎。这样做的原因有几个,但主要原因是不应该需要消耗 CPU 周期来寻找在正常代码路径中不会出现的异常情况。因此,明智的做法是将异常用于异常情况,即使这些情况只是稍微异常。进一步地,我们实际上可以看到异常语法对于流程控制也是有效的。与if
语句一样,异常可以用于决策、分支和消息传递。
想象一家销售小部件和小工具的公司的库存应用程序。当客户购买商品时,商品可以是有库存的,这种情况下商品会从库存中移除并返回剩余商品数量,或者可能是缺货的。现在,缺货在库存应用程序中是一件完全正常的事情。这绝对不是一个异常情况。但如果缺货了,我们应该返回什么呢?一个显示缺货的字符串?一个负数?在这两种情况下,调用方法都必须检查返回值是正整数还是其他值,以确定是否缺货。这似乎有点混乱,特别是如果我们在代码中忘记做这个检查。
相反,我们可以引发OutOfStock
并使用try
语句来控制程序流程。有道理吗?此外,我们还要确保不会将同一商品卖给两个不同的客户,或者出售还未备货的商品。促进这一点的一种方法是锁定每种商品,以确保一次只有一个人可以更新它。用户必须锁定商品,操作商品(购买、补充库存、计算剩余商品数量…),然后解锁商品。以下是一个带有描述部分方法应该做什么的文档字符串的不完整的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 {num_left} {item_type}s left") finally: inv.unlock(item_type)
注意所有可能的异常处理子句是如何用来确保在正确的时间发生正确的操作。尽管OutOfStock
并不是一个非常异常的情况,但我们能够使用异常来适当地处理它。这段代码也可以用if...elif...else
结构来编写,但这样不容易阅读和维护。
我们还可以使用异常来在不同的方法之间传递消息。例如,如果我们想要告知客户商品预计何时会再次有货,我们可以确保我们的OutOfStock
对象在构造时需要一个back_in_stock
参数。然后,当我们处理异常时,我们可以检查该值并向客户提供额外的信息。附加到对象的信息可以很容易地在程序的两个不同部分之间传递。异常甚至可以提供一个方法,指示库存对象重新订购或预订商品。
使用异常来进行流程控制可以设计出一些方便的程序。从这次讨论中要记住的重要事情是异常并不是我们应该尽量避免的坏事。发生异常并不意味着你应该阻止这种异常情况的发生。相反,这只是一种在两个可能不直接调用彼此的代码部分之间传递信息的强大方式。
案例研究
我们一直在比较低级的细节层面上看异常的使用和处理——语法和定义。这个案例研究将帮助我们将这一切与之前的章节联系起来,这样我们就能看到异常在对象、继承和模块的更大背景下是如何使用的。
今天,我们将设计一个简单的中央认证和授权系统。整个系统将放置在一个模块中,其他代码将能够查询该模块对象以进行认证和授权。我们应该承认,从一开始,我们并不是安全专家,我们设计的系统可能存在许多安全漏洞。
我们的目的是研究异常,而不是保护系统。然而,对于其他代码可以与之交互的基本登录和权限系统来说,这是足够的。以后,如果其他代码需要更安全,我们可以请安全或密码专家审查或重写我们的模块,最好不要改变 API。
认证是确保用户确实是他们所说的人的过程。我们将遵循当今常见的网络系统的做法,使用用户名和私人密码组合。其他的认证方法包括语音识别、指纹或视网膜扫描仪以及身份证。
授权,另一方面,完全取决于确定特定(经过身份验证的)用户是否被允许执行特定操作。我们将创建一个基本的权限列表系统,该系统存储了允许执行每个操作的特定人员的列表。
此外,我们将添加一些管理功能,以允许新用户加入系统。为简洁起见,我们将省略密码编辑或一旦添加后更改权限,但是这些(非常必要的)功能当然可以在将来添加。
这是一个简单的分析;现在让我们继续设计。显然,我们需要一个存储用户名和加密密码的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): pass
AuthException
需要用户名,并且有一个可选的用户参数。第二个参数应该是与该用户名关联的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()
这一行放在模块级别,不在任何类定义之外,因此可以通过auth.authenticator
访问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
模块的代码。如果模块不够灵活,可以随意修改模块。尝试处理
以明智的方式处理所有异常。如果你在想出需要身份验证的东西时遇到麻烦,可以尝试在第十六章的记事本示例中添加授权,Python 中的对象,或者在auth
模块本身添加授权——如果任何人都可以开始添加权限,这个模块就不是一个非常有用的模块!也许在允许添加或更改权限之前需要管理员用户名和密码。
最后,试着想想你的代码中可以引发异常的地方。可以是你写过或正在处理的代码;或者你可以编写一个新的项目作为练习。你可能最容易设计一个小型框架或 API,供其他人使用;异常是你的代码和别人之间的绝妙沟通工具。记得设计和记录任何自引发的异常作为 API 的一部分,否则他们将不知道是否以及如何处理它们!
总结
在这一章中,我们深入讨论了引发、处理、定义和操纵异常的细节。异常是一种强大的方式,可以在不要求调用函数显式检查返回值的情况下,传达异常情况或错误条件。有许多内置的异常,引发它们非常容易。处理不同异常事件有几种不同的语法。
在下一章中,我们将讨论到目前为止所学的一切如何结合在一起,讨论面向对象编程原则和结构在 Python 应用程序中应该如何最好地应用。