part7-1 Python 的异常处理,try...except 捕获异常、异常类继承、访问异常信息、else、finally回收资源、raise引发异常、自定义异常类、except 和 raise 同时使用


异常机制是编程语言成熟的标准(注:C语言没有提供异常机制),异常机制可使程序中的异常处理代码和正常业务代码分离,提高程序健壮性。
Python 异常的5个关键字:try、except、else、finally 和 raise ,在 try 关键字后缩进的代码块称为 try 块,这里放置的可能会引发异常的代码;在 except 后对应的是异常类型和一个代码块,用于处理 try 块中产生的异常,except 块可以有多个;except 块后可以接一个 else 块,在程序不出现异常时要执行 else 块;最后可以接一个 finally 块,finally 块用于回收在 try 块里打开的物理资源,异常机制会保证 finally 块总被执行;raise 可用于引发一个实际的异常,可以做为单独的语句使用,引发一个具体的异常对象。
一、 异常概述
对于程序设计人员,要尽可能预知所有可能发生的情况,尽可能保证程序在所有糟糕的情形下都可以运行。以前面的五子棋程序为例,当用户输入下棋坐标时,程序要判断用户输入是否合法。如果要保证程序有较好的容错性,将会有如下伪代码:
if 用户输入包含逗号之外的其他非数字字符:
    alert 坐标只能是数值
    goto retry
elif 用户输入不包含逗号:
    alert 应使用逗号分隔两个坐标值
    goto retry
elif 用户输入的坐标值超出了有效范围:
    alert 用户输入的坐标应位于棋盘坐标之内
    goto retry
elif 用户输入的坐标已经有棋子:
    alert "只能在没有棋子的地方下棋"
    goto retry
else:
    # 业务实现代码
    ...

这段伪代码未涉及任何有效处理,只考虑了4种可能的错误,代码量就已经急剧增加。实际上只考虑这4种情况是远远不够的,程序可能发生的异常情况总是多于程序员能考虑到的意外情况。
上面的错误处理机制,有两个缺点:
(1)、无法穷举所有的异常情况,异常情况总是比可以考虑的情况多。
(2)、错误处理代码与业务实现代码混杂,严重影响程序的可读性,增加程序维护难度。
如果有一种强大的机制来解决上面的问题,希望将上面程序改成如下伪码:
if 用户输入不合法:
    alert 输入不合法
    goto retry
else:
    # 业务实现代码
    ...
上面伪码中的 if 块,在程序不管输入的是什么错误,只要用户输入不满足要求,就一次处理所有错误。这种处理方法好处是,使得错误代码变得更有条理,只需在一个地方处理错误。但问题是“用户输入不合法”这个条件该怎么定义?对于简单的用户输入情况,可使用正则表达式进行匹配,对于复杂的情形,就要使用 Python 的异常处理机制解决。

二、 异常处理机制


异常处理机制让程序有好的容错性,让程序更健壮。程序运行出现意外情况时,系统自动生成一个 Error 对象来通知程序,从而将“业务实现代码” 和 “错误处理代码”分离,提供更好的可读性。
1、 try...except 捕获异常
把业务实现代码放在 try 中定义,把所有的异常处理逻辑放在 except 块中进行处理。异常处理机制语法结构如下:
try:
    # 业务实现代码
    ...
excetp (Error1, Error2, ...) as e:
    alert 输入不合法
    goto retry
执行 try 块中的业务逻辑代码出现异常,系统自动生成一个异常对象,异常对象被提交给 Python 解释器,这个过程是引发异常。Python 解释器收到异常时,寻找能处理该异常的 except 块,如果找到合适的 except 块,就将该异常对象交给该 except 块处理,这个过程是捕获异常。如果 Python 解释器没有找到捕获异常的 except 块,则运行时环境终止,Python 解释器也将退出。
使用异常处理机制改写前面的五子棋游戏中用户下棋的部分代码,代码如下:
inputstr = input("请输入你下棋的坐标,以 x,y 的格式:\n")
while inputstr != None:
    try:
        # 将用户输入的字符串以逗号(,)作为分隔符,分隔成两个字符串
        x_str, y_str = inputstr.split(sep=",")
        # 如果要下棋的点不为空
        if board[int(y_str) - 1][int(x_str) - 1] != "┼":
            inputstr = input("你输入的坐标已有棋子,请重新输入 \n")
            continue
        # 把对应的列表元素赋为“●”
        board[int(y_str) - 1][int(x_str) - 1] = "●"
    except Exception:
        inputstr = input("你输入的坐标不合法,请重新输入,下棋坐标应以 x,y 的格式\n")
        continue
    ...
上面代码中,将用户输入的字符串代码放在 try 块里执行,只要用户输入的字符串不是有效坐标值,系统就将引发一个异常对象,并将这个异常对象交给 except 块处理。在 except 块中提示用户坐标不合法,然后使用 continue 忽略本次循环,开始下一次循环。这样保证游戏的容错性,不会因为用户输入错误造成程序终止,而是让用户重新输入。
2、 异常类的继承体系
在 try...except 代码块中(except 块可以有多个),每个 except 块是专门用于处理异常类及其子类的异常实例。当 Python 解释器收到异常对象后,会依次判断异常对象是否是 except 块后的异常类或其子类的实例,如果是,Python 解释器将调用该 except 块来处理该异常;否则,再次将该异常对象和下一个 except 块里的异常类进行比较。
Python 的所有异常类都从 BaseException 派生而来,提供了丰富的异常类,这些异常类之间有严格的继承关系。BaseException 的主要子类是 Exception(从 BaseException的源代码可知),不管是系统的异常类,还是用户自定义的异常类,都应该从 Exception 派生。下面代码是一个异常捕获例子,代码如下:
import sys
try:
    a = int(sys.argv[1])
    b = int(sys.argv[2])
    c = a / b
    print("你输入的两个数相除结果是:", c)
except IndexError:
    print("索引错误:运行程序时输入的参数个数不够")
except ValueError:
    print("数值错误:程序只能接收整数参数")
except ArithmeticError:
    print("算术错误")
except Exception:
    print("未知异常")
上面代码通过 sys 模块的 argv 列表来获取运行 Python 程序时提供的参数。其中 sys.argv[0] 代表的是正在运行的 Python 程序名(或者Python文件名),sys.argv[1] 代表运行程序所提供的第一个参数,sys.argv[2]代表运行程序所提供的第二个参数......。
上面程序中对 IndexError、ValueError、ArithmeticError 类型的异常做了专门的异常处理逻辑。该程序运行时的异常处理逻辑可能有如下几种情形:
(1)、如果在运行该程序时输入的参数不够,将会发生索引错误,Python 将调用 IndexError 对应用的 except 块处理该异常。
(2)、如果输入的参数不是数字,而是字母,将引发 ValueError 异常,对应的调用 ValueError 的 except 块处理该异常。
(3)、如果第二个参数是 0 将引发 ArithmeticError 异常,对应的调用 ArithmeticError 的 except 块处理该异常。
(4)、如果出现其它异常,这些异常对象总是 Exception 类或其子类的实例,Python 将调用 Exception 对应的 except 块处理该异常。
这里出现的三种异常(IndexError、ValueError、ArithmeticError)都是常见的运行时异常,在实际运用中要知道哪些情况下可能会出现这些异常。另外在捕获异常时,通常是把 Exception 类对应的 except 块放在最后,这样让在前面的子类异常能得到合适的处理。所以在处理异常时,要先处理小异常,再处理大异常
在命令行运行三次该文件(文件名是exception_test.py),运行结果如下所示:
python exception_test.py 10
索引错误:运行程序时输入的参数个数不够

python exception_test.py 10 123.456
数值错误:程序只能接收整数参数

python exception_test.py 10 0
算术错误

3、 多异常捕获
可使用一个 except 块捕获多种类型的异常时,可将多个异常类用括号括起来,中间用逗号分隔,其实是在构建多个异常类的元组。示例如下:
import sys
try:
    a = int(sys.argv[1])
    b = int(sys.argv[2])
    c = a / b
    print("你输入的两个数相除结果是:", c)
except (IndexError, ValueError, ArithmeticError):
    print("程序发生了数组越界、数字格式异常、算术异常之一")
except:
    print("未知异常")
上面代码中第一个 except 后使用了元组(IndexError, ValueError, ArithmeticError)来指定要捕获的异常类型,这里表明该 except 块可同时捕获这三种类型的异常。另外这里的第二个 except 后面没有指定要捕获的异常类型,这种省略异常类的 except 语句也是合法的,表示可捕获所有类型的异常,通常会作为异常捕获的最后一个 except 块。
4、 访问异常信息
要在 except 块中访问异常对象的相关信息,可为异常对象声明变量来实现。当 Python 解释器调用某个 except 块来处理异常对象时,会将异常对象赋值给 except 块后的异常变量,程序可通过该变量来获得异常对象的相关信息。所有的异常对象都包含了以下几个常用属性和方法。
(1)、args属性:返回异常的错误编号和描述字符串。
(2)、errno属性:返回异常的错误编号。
(3)、strerror属性:返回异常的描述字符串。
(4)、with_traceback()方法:通过该方法可处理异常的传播轨迹信息。
下面代码示例异常信息的访问:
def foo():
    try:
        f = open('a.txt')
    except Exception as e:
        # 访问异常的错误编号和详细信息
        print(e.args)
        # 访问异常的错误编号
        print(e.errno)
        # 访问异常的详情信息
        print(e.strerror)
foo()

运行结果如下所示:
(2, 'No such file or directory')
2
No such file or directory
要访问异常对象,需要在单个异常类或异常类元组后使用 as 再加异常变量即可。上面代码中调用了 Exception 对象的 args 属性(同时返回 errno 属性和 strerror 属性)访问异常的错误编号和详细信息。从输出可知,由于程序打开的文件不存在,引发的异常错误编号为2,异常详细信息是 No such file or directory。
5、 else 块
在处理异常时,还可添加一个 else 块,当 try 块没有出现异常时,就执行 else 块。大部分的编程语言的异常处理没有 else 块,而是将 else 块的代码直接放在 try 块的代码后面,对于部分使用场景而言是这样的。
Python 的异常处理使用 else 块不是多余的语法,在 try 块中没有异常,else 块有异常时,就能体现出 else 块的作用。示例如下:
def else_test():
    s = input("请输入除数:")
    result = 1 / int(s)
    print('1除以%s的结果是:%g' % (s, result) )
def right_main():
    try:
        print('try 块的代码没有异常')
    except:
        print("程序出现异常")
    else:
        # 将 else_test 放在 else 块中
        else_test()
def wrong_main():
    try:
        print('try块的代码没有异常')
        # 将 else_test 放在 try 块的代码后面
        else_test()
    except:
        print("程序出现异常")
wrong_main()
right_main()
上面代码中定义的 else_test() 函数可能会因为输入数据的不同产生异常。在 right_main() 函数中将 else_test() 函数放在 else 块内,在 wrong_main() 函数中将 else_test() 函数放在 try 块的代码后面。当两个函数运行都没有产生异常时,运行结果是一样的;如果 else_test() 函数产生异常,两个函数的运行结果就不一样,当 else_test() 函数放在 try 块的代码后面时,else_test() 函数产生的异常会被 try 块对应的 except 捕获,当 else_test() 函数放在 else 块中时,else_test() 函数出现的异常没有except 块来处理,该异常会传给 Python 解释器,导致程序中止。所以在 else 块中产生的异常不会被 except 块捕获。如果希望某段代码的异常被后面的 except 块捕获,就应将这段代码放在 try 块的代码之后;如果希望某段代码的异常能向外传播,则应将这段代码放在 else 块中。上面程序运行示例如下:
# 正常运行结果如下:
try块的代码没有异常
请输入除数:5
1除以5的结果是:0.2
try 块的代码没有异常
请输入除数:4
1除以4的结果是:0.25

# 异常运行结果如下:
try块的代码没有异常
请输入除数:0
程序出现异常
try 块的代码没有异常
请输入除数:0
Traceback (most recent call last):
  File "else_test.py", line 49, in <module>
    right_main()
  File "else_test.py", line 40, in right_main
    else_test()
  File "else_test.py", line 31, in else_test
    result = 1 / int(s)
ZeroDivisionError: division by zero

6、 使用 finally 回收资源
在 try 块中打开的物理资源(如数据库连接、网络连接、磁盘文件等),都必须被显式回收。Python 的垃圾回收机制不会回收任何物理资源,只回收在内存中被对象所占用的内存。
回收这些物理资源不能在 try 块和 except 块中进行,因为在 try 块中如果产生了异常,异常后面的代码就得不到执行,另外在except 块中的代码完全有可能得不到执行机会,这些都将导致不能及时回收资源。
为了保存在 try 块中打开的物理资源得到回收,异常处理机制提供了 finally 块。不管 try 块中的代码是否出现异常,也不管哪一个 except 块被执行,甚至在 try 块或 except 块中执行 return 语句,finally 块总会被执行。完整异常处理语法结构如下:
try:
    # 业务实现代码
    ...
except SubExecption as e:
    # 异常处理块1
    ...
...
else:
    # 正常处理块
finally:
    # 资源回收块
    ...
在异常处理语法结构中,只有 try 块是必须的,except 块和 finally 块是可选的,但 except 块和 finally 块至少要出现一个,也可同时出现;except 块可以有多个,但捕获父类异常的 except 块应该位于捕获子类异常的 except 块的后面;不能只有 try 块,即没有 except 块,也没有 finally 块;多个 except 块必须位于 try 块之后,finally 块必须位于所有的 except 块之后。示例如下:
 1 import os
 2 def test():
 3     f = None
 4     try:
 5         f = open('a.txt')
 6     except OSError as e:
 7         print(e.strerror)
 8         # 下面使用 return 语句强制方法返回
 9         return
10         # os._exit(1)
11     finally:
12         # 关闭磁盘文件,回收资源
13         if f is not None:
14             try:
15                 # 关闭资源
16                 f.close()
17             except OSError as ioe:
18                 print(ioe.strerror)
19         print("执行 finally 块里的资源回收!")
20 test()
21 
22 程序运行结果如下:
23 No such file or directory
24 执行 finally 块里的资源回收!
上面代码中,finally 块用于回收在 try 块中打开的物理资源,在 except 块有一条 return 语句,return 语句是强制方法返回。但是在这里执行到 return 语句时,虽然 return 语句已强制方法结束,但一定会先执行 finally 块的代码。所以运行程序看到上面的输出结果。
在上面代码中 return 语句后面有一条 os._exit(1) 语句,现在将 return 语句注释,os._exit(1) 语句取消注释,重新运行这段代码,运行结果如下所示:
No such file or directory
从运行结果中可知,finally 块没有执行。即在异常处理代码中使用 os._exit(1) 语句来退出 Python 解释器,则 finally 块将失去执行机会。
要注意的是:除非在 try 块、except 块中调用了退出 Python 解释器的方法,否则不管在 try 块、except 块中执行怎样的代码,出现怎样的情况,异常处理的 finally 块都会被执行,调用 sys.exti() 方法退出程序也不能阻止 finally 块的执行,这是因为sys.exit() 方法本身就是通过引发 SystemExit 异常来退出程序的。
通常情况下,不要在 finally 块中使用 return 或 raise 等导致方法中止的语句,一旦在 finally 块中使用 return 或 raise 语句,将会导致 try 块、except 块中的 return 、raise 语句失效。示例如下:
def test2():
    try:
        # 因为在 finally 块中使用 return 语句,下面的 return 语句失去作用
        return True
    finally:
        return False
t = test2()
print(t)        # 输出: False
如果 Python 程序在执行 try 块、except 块时遇到 return 或 raise 语句,这两条语句都会导致该方法立即结束,那么系统执行这两个语句并不会结束该方法,而是去寻找该异常处理流程中的 finally 块,如果没有找到 finally 块,程序立即执行 return 或raise 语句,方法中止;如果找到 finally 块,系统立即开始执行 finally 块,只有当 finally 块执行完成后,系统才会再次跳回来执行 try 块、except 块里的 return 或 raise 语句;如果在 finally 块里也使用了 return 或 raise 等导致方法中止的语句,finally 块已经中止了方法,系统将不会跳回去执行 try 块、except 块里的任何代码。
所以,尽量不要在 finally 块里使用 return 或 raise 等导致方法中止的语句,否则可能出现一些奇怪的情况。
异常处理嵌套:在 try 块、except 块或 finally 块中包含完整的异常处理流程的情形称为异常处理嵌套。异常处理流程代码可放在任何能放可执行代码的地方,因此完整的异常处理可放在 try 块里,也可放在 except 块里,还可放在 finally 块里。异常处理嵌套的深度没有限制,但尽量不超过两层,嵌套层次太深易导致程序可读性降低。
三、 使用 raise 引发异常
在 Python 中可使用 raise 语句自行引发异常。
1、 引发异常
使用 raise 语句可在程序中自行引发异常,通常放在程序没有按照既定预期运行的地方。raise 语句三种常用用法:
(1)、raise:单独一个 raise 。该语句引发当前上下文中捕获的异常(比如在 except 块中),或默认引发 RuntimeError 异常。
(2)、raise 异常类:raise 后带一个异常类。该语句引发指定异常类的默认实例。
(3)、raise 异常对象:引发指定的异常对象。
这三种用法最终都是引发一个异常实例,raise 语句每次只能引发一个异常实例。利用 raise 语句再次改写五子棋游戏中用户输入的代码:
try:
    # 将用户输入的字符串以逗号(,)作为分隔符,分隔成两个字符串
    x_str, y_str = inputstr.split(sep=",")
    # 如果要下棋的点不为空
    if board[int(y_str) - 1][int(x_str) - 1] != "┼":
        # 引发默认的 RuntimeError 异常
        raise
    # 把对应的列表元素赋为“●”
    board[int(y_str) - 1][int(x_str) - 1] = "●"
except Exception as e:
    print(type(e))
    inputstr = input("你输入的坐标不合法,请重新输入,下棋坐标应以 x,y 的格式\n")
    continue
上面代码中使用 raise 语句来自行引发异常,程序判断当向已有棋子的坐标点下棋时就是异常。当 Python 解释器接收到开发者自行引发的异常时,同样会中止当前的执行流,跳到该异常对应的 except 块,由该 exept 块来处理该异常。即不管是系统自动引发的异常,还是程序员手动引发的异常,Python 解释器对异常的处理没有任何差别。
开发者自行引发的异常,也可使用 try...except 来捕获。也可以不用管它,该异常向上(先调用者)传播,如果该异常传到 Python 解释器,那么程序就会中止。下面代码示例了用两种方式来处理用户引发的异常方式。
def main():
    try:
        # 使用 try...except 来捕获异常
        # 此时程序出现异常也不会传播给 main() 函数
        mtd(3)
    except Exception as e:
        print("程序出现异常:", e)
    # 不使用 try...except 捕获异常,异常会传播出来导致程序中止
    mtd(3)

def mtd(a):
    if a > 0:
        raise ValueError('a 的值大于0,不符合要求')
main()

运行程序,输出如下:
程序出现异常: a 的值大于0,不符合要求
Traceback (most recent call last):
  File "raise_test.py", line 95, in <module>
    main()
  File "raise_test.py", line 90, in main
    mtd(3)
  File "raise_test.py", line 94, in mtd
    raise ValueError('a 的值大于0,不符合要求')
ValueError: a 的值大于0,不符合要求
上面程序中,第一次调用 mtd(3) 时使用 try...except 来捕获异常,该异常被 except 块捕获,不会传播给调用它的函数;第二次是直接调用 mtd(3) ,这样该函数的异常会直接传播给它的调用函数,如果该函数不处理该异常,程序就会中止。如输出所示。
在上面的输出中,第二次调用 mtd(3) 引发的以 'File' 开头的三行输出,其实显示的就是异常的传播轨迹信息。也就是说,如果程序不对异常进行处理,Python 默认会在控制台输出异常的传播轨迹信息。
2、 自定义异常类
程序可以引发自定义异常,异常的类名包含了该异常的有用信息。在引发异常时,应该选择合适的异常类,以达到明确地描述该异常的情况。
自定义的异常都要继承 Exception 基类或 Exception 的子类,在自定义的异常类中不需写太多的代码,只要指定异常类的父类即可。例如下面这行代码就创建了一个自定义异常类:
class AuctionException(Exception): pass
这行代码创建了 AuctionException 异常类,这个异常类没有类体,使用 pass 语句作为占位符。多数情况下创建自定义异常类都可采用这种方式来完成,只需要改变异常的类名即可,异常类名可以准确的描述该异常。
3、 except 和 raise 同时使用
对异常的复杂处理方式:当一个异常出现时,用一个方法不能完全处理该异常,需要几个方法协作才可完全处理该异常。所以要在处理异常的方法中再次引发异常,让该方法的调用者也能捕获到异常。要实现多个方法协作处理同一个异常的情形,可在 except 块中结合 raise 语句来完成。示例如下:
 1 class AuctionException(Exception): pass
 2 class AuctionTest:
 3     def __init__(self, init_price):
 4         self.init_price = init_price
 5     def bid(self, bid_price):
 6         d = 0.0
 7         try:
 8             d = float(bid_price)
 9         except Exception as e:
10             # 打印异常信息
11             print("转换出异常:", e)
12             # 再次引发自定义异常
13             raise AuctionException('竞拍价必须是数值,不能包含其他字符!')
14         if self.init_price > d:
15             raise AuctionException('竞拍价比起拍价低,不允许竞拍!')
16         initPrice = d
17 def main():
18     at = AuctionTest(31.33)
19     try:
20         at.bid('py')
21     except AuctionException as ae:
22         # 再次捕获到 bid() 方法中的异常,并对该异常进行处理
23         print('main 函数捕获的异常:', ae)
24 main()
25 
26 程序运行结果如下:
27 转换出异常: could not convert string to float: 'py'
28 main 函数捕获的异常: 竞拍价必须是数值,不能包含其他字符!
上面代码中的 bid() 方法中的 except 块捕获到异常后,首先打印异常的字符串信息,接着引发一个 AuctionException 异常,通知该方法的调用者再次处理该 AuctionException 异常。所以 bid() 方法的调用者 main() 函数中可以再次捕获 AuctionException 异常,并将该异常的详细描述信息打印出来。
excetp 和 raise 结合使用情况在实际应用中非常使用。实际应用中对异常的处理可分成两个部分:
(1)、应用后台需要通过日志来记录异常发生的详细情况;
(2)、应用还需要根据异常向应用使用者传达某种提示。
上面两种情况都需要使用两个方法共同完成,也就必须将 except 和 raise 结合使用。另外,如果需要将原始异常的详细信息直接传播出去,Python 也允许用自定义异常对原始异常进行包装,只需要使用 raise AuctionException(e) 即可。这种方式称为异常包装或异常转译
4、 raise 不需要参数
当使用 raise 语句不带参数时,raise 语句又在 except 块中,它将自动引发当前上下文激活的异常;否则,默认引发 RuntimeError 异常。例如下面代码示例所示:
 1 class AuctionException(Exception): pass
 2 class AuctionTest:
 3     def __init__(self, init_price):
 4         self.init_price = init_price
 5     def bid(self, bid_price):
 6         d = 0.0
 7         try:
 8             d = float(bid_price)
 9         except Exception as e:
10             # 打印异常信息
11             print("转换出异常:", e)
12             # 再次引发当前激活的异常
13             raise
14         if self.init_price > d:
15             raise AuctionException('竞拍价比起拍价低,不允许竞拍!')
16         initPrice = d
17 def main():
18     at = AuctionTest(31.33)
19     try:
20         at.bid('py')
21     except Exception as ae:
22         # 再次捕获到 bid() 方法中的异常,并对该异常进行处理
23         print('main 函数捕获的异常:', type(ae))
24 main()
25 
26 运行程序输出如下:
27 转换出异常: could not convert string to float: 'py'
28 main 函数捕获的异常: <class 'ValueError'>
这里在 bid() 方法中,try 块后面的 except 块只是简单的使用 raise 语句来引发异常,该 raise 语句会再次引发该 except 块捕获的异常。在 mail() 函数中通过 Exception 类来捕获异常。所以 main() 函数捕获了 ValueError,它是在 bid() 方法中except 块所捕获的原始异常。