正常情况下,Python按照语句的顺序从上到下执行。if、while、for等控制语句会改变这种简单的从上到下的语句执行顺序。另外,异常也会改变执行顺序。一旦有异常抛出,就会中断语句的顺序执行。
本质上,Python异常也是对象。有很多不同的异常类,而且我们也可以很容易地定义我们自己的异常类。它们的共同之处是,都继承自同一个内置类BaseException。
当发生异常时,所有本该执行的代码都会停止执行,反而进入异常处理的代码块。理解吗?不用担心,你会理解的!
触发异常最简单的方式就是做一些愚蠢的事!很有可能你已经做过并且看到过异常的输出。例如,当Python在你的程序中遇到不能理解的代码时,它将抛出SyntaxError,这也是一类异常。下面是一个常见的示例:
这个print()语句必须用括号将参数包裹起来。上面的代码中没有括号,所以Python 3解释器会抛出SyntaxError异常,也就是语法错误。
除了SyntaxError,通过下面的示例可以看到其他一些常见的异常:
我们可以大致地把这些异常分成4类。有些分类有点儿模糊,但有些边界是非常清晰的。
· 有些异常意味着我们的程序有明显错误,比如SyntaxError和NameError。这时,我们需要根据行号找到错误的代码,修正问题。
· 有些异常意味着Python自身或者第三方库出现Bug,可能会抛出Runtime-Exception(运行时异常)。我们通常可以通过下载和安装一个新版本的Python或者第三方库来解决。如果你用的是发布候选(Release Candidate)版本,你可以给开发者报告这个Bug。
· 有些异常是设计问题。我们可能忘记考虑一个边界值问题,或者试着计算一个空的列表的平均值。这时会抛出ZeroDivisionError。同样地,当发现这样的问题时,首先找到异常提示的行号。但找到异常后,我们需要分析引起异常的原因,找出有问题的对象。
· 大量的异常出现在程序对外的边界上。任何用户输入或者包括文件处理等操作系统请求都涉及程序之外的资源,可能会引发异常。我们可以把这些边界问题分为两个子类。
◆ 外界对象处于不正常或者和预期不符的状态。比如,文件不存在(可能因为路径拼写错误)或目录创建失败(可能因为上次程序崩溃时已经创建了目录)。这些属于有清晰合理原因的OSError(操作系统错误)。也有可能用户输入了不正确的值,甚至有人故意想要破坏系统。这些都是和软件本身相关的异常,我们应该编写代码来处理用户输入的异常数据,以及恶意的破坏性输入。
◆ 还有相对较少的异常是由系统混乱引起的。一个计算机系统是由很多相互关联的设备组成的,任何一个设备都可能出错。这些错误可能是难以预测也难以按计划预防的。小的物联网计算机可能只有少量的组件,但是它们会被部署在很复杂的外界环境中。在一个由成千上万个组件组成的企业级服务器系统中,即使每个组件的出错概率只有0.1%,也意味着任何时刻都有组件出错。
你可能已经注意到前面出现的所有Python内置异常的名称都以Error结尾。在Python中, error 和 exception 几乎是可以交换使用的。但有时错误比异常更严重,不过它们的处理方式完全相同。实际上,前面示例中所有的错误类都继承自Exception(其又继承自BaseException)。
我们马上会着手处理异常,但我们先来看看如何告诉用户或者调用者他们的输入不合法。我们可以使用和Python同样的机制。这里有一个简单的类,只能添加偶数到列表中:
这个类继承了内置的list对象,正如我们在第2章中所讨论的。我们使用类型提示表明它是一个只能包含整数对象的列表。通过重写append()方法来检验两个条件以确保输入的是偶数。我们首先检查输入是否为int类型的实例,然后用模运算符确保它可以被2整除。只要其中任一条件不满足,raise关键字将抛出异常。
raise关键字后面跟着需要被抛出的异常对象。在前面的示例中,我们构建了两个新的内置异常类TypeError和ValueError。被抛出的对象可以是我们自己创建的新异常类的实例(后面我们会看到创建新的异常类很简单),也可以是其他地方定义的异常,甚至可以是一个前面已经被抛出并处理过的异常对象。
如果我们在Python解释器中测试这个类,当遇到异常时,可以看到它输出有用的错误信息,就和前面的示例一样:
虽然这个类可以有效地演示如何抛出异常,但它本身的功能并不完善。我们仍然可能通过索引或切片语法将其他不是偶数的值添加到列表中。可以通过重写其他方法来避免这种情况,其中有些是双下画线的特殊方法。为了让这个类功能完善,我们还需要重写以下方法:extend()、insert()、__setitem__()、__init__()。
当抛出异常时,程序似乎会立即停止执行。抛出异常之后的所有代码都不会执行,除非有except语句处理这一异常,程序将会退出并输出错误信息。我们先看看未处理的异常,稍后会细致地学习如何处理异常。
看看下面这个简单的函数:
这里类型提示使用了NoReturn。这是告诉 mypy ,这个函数是没有返回值的,因此不用担心执行不到最后的return语句。( mypy 可以检测到最后有一个return语句,由于前面会抛出异常,这个return语句明显是不可能执行的,正常情况下, mypy 会给出一个警告,使用了NoReturn之后, mypy 就会忽略这个问题。)
如果我们执行这个函数,可以看到执行了第一个print()调用,然后抛出了异常。第二个print()函数调用永远不会执行,而且return语句也永远不会执行:
更进一步,如果一个函数调用另一个抛出了异常的函数,在前者中调用后者的位置之后的所有代码也不会执行。抛出异常会停止执行函数调用栈中的所有代码,直到异常被正确处理,或者强制退出Python解释器。为了说明这一点,我们用另一个函数来调用前面的never_returns()函数:
当我们调用这个函数时,可以发现第一个print语句被执行,同时还有never_returns()函数的第1行。但是一旦抛出异常,将不会执行其他代码:
注意, mypy 并没有识别出never_returns()会抛出异常给call_executor()函数。基于之前的示例,似乎call_exceptor()更适合被描述为NoReturn()函数。 mypy 的代码扫描范围是很小的,它只会比较独立地检测函数和方法,当检测call_exceptor()的时候,它并没有意识到never_returns()会抛出异常。
抛出异常后,我们可以控制异常的传播,我们选择在方法调用栈中的任一方法中处理这一异常。
异常输出的错误信息叫作 回溯 ( traceback ),它包含方法的调用栈。命令行(在回溯中显示为“<module>”)调用call_execptor(),call_execptor()再调用never_returns()。异常最初是在never_returns()函数内部抛出的。
异常会顺着调用栈向上传播。在call_exceptor()中,never_returns()函数被调用之后将异常传递给上层的调用函数。然后异常被进一步传递给更上层,也就是Python解释器。而解释器不知道应该如何处理这个异常,于是只能退出并打印回溯信息。
现在来看异常处理的另一面。如果遇到一个异常情况,我们的代码应该如何应对或者从中恢复呢?我们通过将可能抛出异常的代码(可能是会抛出异常的代码本身,或者调用一个可能抛出异常的函数或方法)包裹在try…except语句中来处理异常。最基本的语法看起来就像这样:
如果我们用前面的never_returns()函数运行这段简单的脚本,never_returns()总会抛出异常,我们将会得到如下输出:
never_returns()函数愉快地通知我们它将会抛出一个异常,然后抛出了一个异常。我们的handler()函数中的except语句捕获了这一异常。一旦捕获了异常,我们就能够进行善后清理工作(在这个示例中,通过输出信息说明我们正在处理这种情况),并且继续执行代码。never_returns()函数中的后续代码还是不会被执行,但是handler()函数中try…except语句之后的代码能够恢复并继续执行。
请注意try和except语法中的缩进。try语句包裹了所有可能抛出异常的代码,except语句回到与try相同层级的缩进。所有用于处理异常的代码都在except语句之后缩进一层。正常的代码又回到初始的缩进层级。
前面代码的问题在于,我们使用Exception类捕获所有异常。如果我们的代码可能抛出TypeError和ZeroDivisionError异常,该怎么办?我们可能只想捕获ZeroDivisionError异常,因为它反映的是数据问题,但我们或许希望针对其他异常直接报错,这样我们能马上发现程序中的Bug并修复它们。你能猜到它的语法吗?
下面这个有点儿傻的函数演示了这一语法:
这个函数做了一个简单的运算。我们用类型提示表明它的divisor参数是一个浮点数(float)。我们可以提供一个整数,Python的强制类型转换会把它转换成浮点数。 mypy 也知道整数可以转换为浮点数,因此不会要求一定是浮点数。
但我们确实要注意返回值类型。如果我们不抛出异常,将会计算并返回一个浮点数。如果抛出ZeroDivisionError异常,它将会被捕获并返回一个字符串。还有其他异常吗?我们试试看:
第1行输出显示,如果遇到0,我们能够正确处理。如果用合法的数字,可以正确执行。然而如果输入的是字符串(你刚刚还在想怎样才能得到一个TypeError,是吧?),将会抛出异常。如果只用一个空的except语句而不指定ZeroDivisionError,那么当我们传递一个字符串的时候,它将会提示我们正在除以0,也就是返回“Zero is not a good idea!”,但事实并不是这样。这不但没有帮助,而且很有误导性。
Python在语法上支持空的except语句。使用except:而不指定具体的异常是被广泛否定的做法,因为它会无脑地阻止程序崩溃,但有时候程序就应该崩溃,因为这样我们才能知道问题所在。我们一般使用except Exception:来显式地一次性捕获常见异常。
用空的except语法和使用except BaseException:是等价的。它试图捕获几乎不可能恢复的系统级异常。确实,这样会造成你的程序出现严重问题时仍然不崩溃,进而导致我们无法知道问题存在。
我们甚至可以用同样的代码一次处理两个或更多不同的异常。这里有一个可能抛出3个不同异常的示例。它用同一个异常处理器处理TypeError和ZeroDivisionError异常,但是如果你传入数字13,也可能抛出ValueError异常:
我们在except语句中提供了多个异常类。这让我们能够用同一段异常代码处理多种异常。我们可以用多个不同的值来测试它:
for循环语句用几个不同的值测试函数,并打印出结果。你是不是不清楚print()函数中end参数的意思,它将默认的打印输出换行符替换为空格,这样就能够与下一行的输出信息合并为一行。
数字0和字符串都被except语句捕获,并打印出合适的错误信息。数字13引起的异常没有被捕获,因为它是一个ValueError,ValueError不属于要处理的异常类型。目前一切正常,但是如果我们想要分别捕获不同的异常并做出不同的反应,该怎么办?或者可能我们想要针对某种异常执行某些操作之后再上传给上层函数,就像从来没有捕获一样?
针对这些情况,我们不需要新的语法。可以添加多个except语句,其中只有第一个匹配异常类型的语句才会被执行。对于第二个问题,在异常处理代码块中,直接用不加任何参数的raise关键字会再次抛出当前异常。看看下面的代码:
最后一行再次抛出ValueError,也就是在输出No,No,not 13!之后,会再次抛出这一异常。我们仍然可以在打印输出中看到原始的回溯信息。
如果像上面的示例一样,添加多个异常处理语句,就算有多个匹配的异常处理语句,也只有第一个匹配的语句才会被执行。怎么可能出现多个异常同时匹配的情况呢?记住异常也是对象,因此可能存在子类。我们将会在下一节中看到,大部分异常继承自Exception类(它又继承自BaseException)。如果我们在捕获TypeError之前捕获了Exception,那么只有处理Exception的语句会执行,因为从继承关系上来说,TypeError也是一个Exception。
如果我们想要针对特定的几个异常单独处理,然后将其他类型的异常统一处理,这种特性就很好用。只需要在处理完所有特定类型的异常之后再捕获Exception即可。
有时候,当我们捕获一个异常时,需要用到对Exception对象的引用。这通常发生在我们自己定义的有特定参数的异常中,但也有可能用在处理标准异常时。大部分异常类的构造方法接收一组参数,而且可能需要在处理异常时获取这些属性。如果我们定义了自己的Exception类,甚至可以在捕获到它的时候调用特定的方法。使用as关键字将捕获的异常作为变量使用的语法如下:
运行这段简单的代码,将会在处理异常时打印出我们在初始化ValueError时传递给它的字符串参数。
我们已经看到了异常处理语法的几种写法,但是仍然不知道如何做到无论是否遇到异常都执行某些代码,也不知道怎样才能在 只有不 发生任何异常时才执行某些代码。这需要另两个关键字finally和else,这两个关键字都不需要额外的参数。
我们会演示一个使用finally的示例。但在大部分情况下,会使用上下文管理器来替代异常处理代码块做这种最终处理。上下文管理器是实现不管是否有异常都要执行某段代码的更优雅的方式。主要想法是把相关的处理责任封装在上下文管理类中。
下面的示例随机选取一个异常并抛出,然后在简单的异常处理代码中使用了上面介绍的新语法:
这个示例几乎涵盖了所有能想到的异常处理语法。执行这个示例,将会看到如下输出:
注意,finally语句下的print无论在什么条件下都会执行。如果我们需要在代码执行完成之后执行特定的任务(即便遇到了异常),这将非常有用。一些常见的示例包括:
· 关闭打开的数据库链接。
· 关闭打开的文件。
· 关闭网络连接。
所有这些一般都使用上下文管理器,这是第8章中的一个主题。
虽然不推荐,但finally语句可以在try子句中的return语句之后执行,所以它可以用于执行返回后的处理,但它也可能让阅读代码的人感到困惑。
同时注意没有抛出异常时的输出: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()函数(例如,当用户选择了菜单中的“退出”选项,或者单击了窗口中的“关闭”按钮,或者输入指令关闭服务器,或者系统发送了终止应用的信号时)。设计这个异常的目的是,在程序最终退出之前完成清理工作。
如果我们确实想要处理SystemExit异常,通常会将其再次抛出,因为捕获这个异常将会导致我们的程序无法退出。你可能碰到过这样的情况,有时候我们无法停掉某个占用数据库锁的存在Bug的网络服务,除非重启服务器。我们不希望在捕获Exception时意外地捕获到SystemExit异常,这就是它直接继承自BaseException的原因。
KeyboardInterrupt异常常见于命令行程序。当用户执行和操作系统相关的按键组合(通常是 Ctrl + C 组合键)来中断程序时会抛出这个异常。在Linux和macOS下,使用kill-2 <pid>也是一样的效果。这是用户故意中断一个正在运行的程序的标准方法,与SystemExit类似,它也会导致程序结束。同样与SystemExit类似,它也可以在finally代码块中完成清理任务。
下面的类图可以完整地说明异常之间的层级,如图4.1所示。
当我们仅用except:语句而不添加任何类型的异常时,将会捕获BaseException的子类;也就是说,将捕获所有异常,包括那两个特殊的异常对象。由于我们通常想要特殊对待它们,所以不带参数的except:语句不是一个明智的选择。如果你想要捕获所有除了SystemExit和KeyboardInterrupt的其他异常,你应该明确指明捕获Exception。大部分Python程序员会把空的except:当成一种代码错误,会在代码审查时指出这个问题。
图4.1 异常的层级
有时候,当我们想要抛出一个异常时,却发现没有一个合适的内置异常。这里的关键问题在于,我们想要如何处理这个异常。当我们想要创建一种新的异常时,这一定是因为我们想要对这种异常情况做出特殊的处理。
如果对新异常的处理和对ValueError的处理是一样的,那根本没必要创建新的异常,可以直接抛出ValueError。幸运的是,定义我们自己的异常对象是很容易的。异常类的名称通常要表明发生了什么错误,而且可以向初始化函数中添加任何参数来提供额外的信息。
我们只需要继承Exception类或者它的子类,我们甚至不需要向类中添加任何内容!当然也可以直接继承BaseException,但是这将会导致它无法被except Exception从句捕获,我们很少需要这么做。
下面这个简单的异常可能用于银行应用:
最后一行说明了如何抛出这个新定义的异常。我们可以给异常传入任意数量的参数,通常会传入一个字符串,但也可以是对后续异常处理有用的任何对象。Exception.__init__()方法被设计成可以接收任意参数,这些参数被保存在名为args的元组属性中。这使得我们可以更容易地定义新的异常,而不需要重写__init__()方法。
当然,如果我们想要自定义初始化函数,也可以这么做。下面的这个异常类的初始化函数接收当前余额和用户取款数额作为参数。除此之外,它还有一个方法用于计算这次取款造成的透支数额:
既然我们在处理货币运算,使用了decimal模块中的Decimal类,那么我们不能使用Python默认的int或float类型来做货币运算。货币运算需要固定的小数位数,需要精确的十进制运算和复杂的小数舍入规则。
(同时,银行账号并没被传给异常,因为银行家不希望银行账号暴露在日志或者回溯中。)
下面是如何创建InvalidWithdrawal异常的示例:
下面是如何处理InvalidWithdrawal异常的示例:
在这里我们使用了as关键字把捕获到的异常赋值给局部变量ex。按照惯例,Python程序员通常将异常变量命名为ex、exc或者exception;当然,如果你愿意,你也可以把它命名为_exception_raised_above,甚至是aunt_sally。
我们定义自己的异常的理由可能有很多。通常是为了向异常中添加信息或以其他形式记录日志。但是自定义异常的真正优势通常体现在创建供他人使用的框架、库或API上。在这种情况下,需要注意确保你的代码抛出的异常对于使用你的库的程序员来说是易于理解的。下面是自定义异常的一些标准:
· 它们应该能够清楚地描述发生的情况。比如,KeyError表示无法找到某个Key,异常中应该提供是哪个Key无法找到。
· 调用者应该能够轻松地看出如何修复这些错误(如果抛出异常是因为代码中存在Bug)或者处理这些异常(如果是他们需要了解的情况)。
· 自定义异常的处理逻辑应该和其他异常不同。如果处理逻辑和其他异常一样,那么最好重用已有的异常。
现在我们已经学习了如何抛出异常和创建新的异常。关于异常数据和异常的处理有很多需要我们考虑的地方,有很多不同的设计选择。我们先从这个想法开始,那就是,在Python中异常可以用在一些严格来说并不算错误的地方。
新手程序员通常会认为异常只在例外情况下才有用。但是,例外情况的定义是非常模糊的,是我们可以自己定义的。看看下面两个函数:
这两个函数的行为完全相同。如果divisor是0,将会打印一条错误信息;否则,打印除法的计算结果。我们可以通过一条if语句进行检查,从而避免抛出ZeroDivisionError。在这个示例中,检查被除数是否为0是很简单的。而在某些情况下,这个检查可能很复杂,有时候可能需要计算中间结果。在最坏的情况下,这个检查涉及使用很多其他的方法或类来提前运行后续代码以判断是否会出错。
Python程序员倾向于这样一个原则: 请求宽恕比请求许可更容易 ( It's Easier to Ask Forgiveness than Permission ),简称为EAFP。也就是说,他们先执行代码,然后解决错误。另一种“ 三思而后行 ”( Look Before You Leap )的原则则是反其道而行之,简称LBYL,没有那么流行。使用前者有很多理由,但是最主要的一点是,没有必要消耗CPU资源去检查一些很少才会出现的情况。
因此,对于例外情况使用异常是很明智的,即使这些情况只是很少出现的例外。更深入地探讨这一点,可以发现处理异常的语法也能够非常有效地用于流程控制。像if语句一样,异常可以用于决策、分支和信息传递。
想象一个公司库存应用,用于售卖小工具和部件。当客户购买物品时,如果该物品存在,则从库存中将其移除并返回剩余数量,或者有可能缺货。对于库存应用来说,缺货是再正常不过的事,因此当然不是一种例外情况。但是如果缺货,我们应该返回什么?一个说明缺货的字符串?一个负数?不管是哪种情况,调用方法将必须检查返回值是正数或其他什么,从而判断是否缺货。这样看起来有点儿乱。
相反,我们可以抛出一个OutOfStock异常并用try语句来指导程序的流程控制。有道理吗?除此之外,我们想要确保不会将同一个物品卖给两个客户,或者出售一个没库存的物品。一种方式是给每种类型的物品上锁,以确保同一时间只有一个人可以更新它。用户必须给物品上锁,操作(购买、进货、清点等)物品之后解锁。(这就是一个上下文管理器,第8章的主题之一。)
下面有一个不完整的Inventory示例,用文档字符串描述方法的功能:
可以将这个对象原型交给一个开发者,让他按照文档实现方法,同时我们来完成“购买所需”的代码。根据购买时可能发生的不同情况,我们将用到Python健壮的异常处理功能来控制不同的分支。我们甚至可以写一个测试用例,确保这个类的实现符合设计的要求。
为了让这个示例更完善,下面是一个ItemType类的定义:
下面是在交互式解释器中使用Inventory类的示例:
以上示例使用了所有可能的异常来确保在正确的时间执行正确的操作。虽然OutOfStock异常并不是一个真正意义上的异常,但我们仍然可以用异常来处理。同样的代码可以用if…elif…else结构来完成,但是用异常处理结构更容易阅读和维护。
注意,其中一个异常消息There are {num_left} {item_to_buy.name}s left可能会有英语语法错误。当只有一件商品的时候,它需要被改写为There is {num_left}{item_to_buy.name}left。为了支持这种动态的语言变化,最好不要在f-string中掺杂语法细节,最好使用else:语句。下面是一个选择有效语法消息的示例:
也可以通过异常在不同方法之间传递消息。例如,如果想要通知客户商品的到货日期,可以在构造OutOfStock异常对象时提供一个必要参数back_in_stock。在处理这个异常时,我们可以检查这个值以将额外的信息提供给客户。异常对象附带的信息可以方便地在程序的不同位置之间传递。这个异常甚至可以提供一个方法来让库存对象重新下单。
用异常来进行流程控制,可以完成一些非常好用的程序设计。本节讨论的一个重点是,异常并不是我们应该尽量避免的坏事。发生异常也不意味着你应该阻止这种异常情况的发生。相反,它是在可能不方便直接相互调用的两段代码之间传递信息的一种强大的方式。