第 6 章 异常
异常处理是一种停止“正常“程序流程并从周围的上下文或代码块处继续进行的机制。
中断正常流程的行为被称为“抛出(raising)”异常。
在使用异常时,Python哲学处于自由主义的极端。异常在Python中是普遍存在的,了解如何处理它们至关重要。
6.1 异常与控制流程
一个名为exceptional.py的模块:
"""用于演示异常的模块。"""
def convert(s):
"""转换成一个整数。"""
x = int(s)
return x
在Python的REPL中执行:
>>> from exceptional import convert
>>> convert("33")
33
>>> convert('hedgehog')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "D:\python\写给程序员的Python教程\pyfund\exceptional.py", line 5, in convert
x = int(s)
^^^^^^
ValueError: invalid literal for int() with base 10: 'hedgehog'
传入一个不能转换为整数的对象来调用convert()函数,会从int()调用中得到一个回溯(traceback)。
在这里,int()抛出了异常,因为它不能正常地进行转换。这里没有一个适当的异常处理程序,所以REPL捕获了该异常,并显示了堆栈跟踪信息。换句话说,这个异常是未经处理的。
堆栈跟踪中引用的ValueError是异常对象的类型,错误消息是“invalid literal for int() with base 10: 'hedgehog'”,该消息是异常对象有效内容的一部分,REPL获取该信息并将其输出。
请注意,异常在调用堆栈中会跨多个级别进行传播:
调用堆栈 | 效果 |
int() | 在这抛出了异常 |
convert() | 异常经过这里 |
REPL | 在这里捕获了异常 |
处理异常
使用try...except语句来处理异常。
try和except关键字都引入了新的代码块。try代码块包含可能抛出异常的代码,而except代码块包含在抛出异常的情况下用于执行错误处理的代码。
修改convert()函数:
"""用于演示异常的模块。"""
def convert(s):
"""转换成一个整数。"""
try:
x = int(s)
print('Conversion succeeded! x =', x)
except ValueError:
print('Conversion failed!')
x = -1
return x
重启REPL,进行交互式的测试:
>>> from exceptional import convert
>>> convert('34')
Conversion succeeded! x = 34
34
>>> convert('giraffe')
Conversion failed!
-1
发生异常执行except代码块。
向int()构造函数传入其它类型数据测试:
>>> convert([4, 5, 6])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "D:\python\写给程序员的Python教程\pyfund\exceptional.py", line 6, in convert
x = int(s)
^^^^^^
TypeError: int() argument must be a string, a bytes-like object or a real number, not 'list'
此时异常处理程序并没有拦截到异常,因为这次收到了一个TypeError——一种不同类型的异常。
6.2 处理多异常
每个try代码块都可以有多个相应的异常代码块,它们可以拦截不同类型的异常。下面为TypeError添加一个处理程序:
def convert(s):
"""转换成一个整数。"""
try:
x = int(s)
print('Conversion succeeded! x =', x)
except ValueError:
print('Conversion failed!')
x = -1
except TypeError:
print('Conversion failed!')
x = -1
return x
重启REPL,进行交互式的测试:
>>> convert([1, 2, 3])
Conversion failed!
-1
去除重复的赋值语句:
def convert(s):
"""转换成一个整数。"""
x = -1
try:
x = int(s)
print('Conversion succeeded! x =', x)
except ValueError:
print('Conversion failed!')
except TypeError:
print('Conversion failed!')
return x
然后,由于两个处理程序事实上是在做相同的事情,所以我们将它们合并成一个。这里用到了except语句的一个功能,它可以接收一个异常类型的元组:
def convert(s):
"""转换成一个整数。"""
x = -1
try:
x = int(s)
print('Conversion succeeded! x =', x)
except (ValueError, TypeError):
print('Conversion failed!')
return x
重启REPL,进行交互式的测试: 一切正常
>>> from exceptional import convert
>>> convert(29)
Conversion succeeded! x = 29
29
>>> convert('elephant')
Conversion failed!
-1
>>> convert([4, 5, 6])
Conversion failed!
-1
6.3 程序员的错误
def convert(s):
"""转换成一个整数。"""
x = -1
try:
x = int(s)
print('Conversion succeeded! x =', x)
except (ValueError, TypeError):
return x
>>> from exceptional import convert
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "D:\python\写给程序员的Python教程\pyfund\exceptional.py", line 10
return x
^
IndentationError: expected an indented block after 'except' statement on line 9
一些异常类型(如IndentationError、SyntaxError和NameError)是由程序员的错误而导致的,我们应在开发过程中识别并改正这些错误,而不是在运行时处理。
6.4 空代码块——pass语句
pass关键字,一个恰好不做任何事情的特殊语句!它是一个空操作,目的只是允许我们构造语法上允许的主义空代码块:
def convert(s):
"""转换成一个整数。"""
x = -1
try:
x = int(s)
print('Conversion succeeded! x =', x)
except (ValueError, TypeError):
pass
return x
使用多个返回语句来进一步简化程序。
def convert(s):
"""转换成一个整数。"""
try:
return int(s)
except (ValueError, TypeError):
return -1
6.5 异常对象
获取异常对象,在except语句的末尾使用一个as子句和一个变量名,该变量名称会被绑定到异常对象上,通过这种方式可以获取异常对象的命名引用:
def convert(s):
"""转换成一个整数。"""
try:
return int(s)
except (ValueError, TypeError) as e:
return -1
将异常的详细信息输出到stderr流。导入sys模块,将sys.stderr作为一个名为file的关键字参数传递给print():
def convert(s):
"""转换成一个整数。"""
try:
return int(s)
except (ValueError, TypeError) as e:
print('Conversion error: {}'.format(str(e)), file=sys.stderr)
return -1
在REPL中运行:
>>> from exceptional import convert
>>> convert('fail')
Conversion error: invalid literal for int() with base 10: 'fail'
-1
6.6 不明智的返回码
为该模块添加第二个函数string_log(),该函数调用convert()函数并计算结果的自然对数:
from math import log
def string_log(s):
v = convert(s)
return log(v)
log()未对v值进行检查,当传入错误的负数代码值时,该函数就会失败:
>>> from exceptional import string_log
>>> string_log('ouch!')
Conversion error: invalid literal for int() with base 10: 'ouch!'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "D:\python\写给程序员的Python教程\pyfund\exceptional.py", line 8, in string_log
return log(v)
^^^^^^
ValueError: math domain error
当然,log()失败会抛出另一个异常,该异常是ValueError。
更好且更Python的做法是,完全忘记错误返回码,并且从convert()中抛出一个异常。
6.7 重抛异常
可以简单地发出错误信息,并重抛正在处理的异常对象,而不是返回一个unPythonic的错误代码。具体做法是在异常处理代码块的末尾用raise语句替换return -1:
在REPL中进行测试,最初的异常类型被重新抛出,不管它是ValueError还是TypeError,并且转换错误信息一直被输出到stderr中:
>>> from exceptional import string_log
>>> string_log('25')
3.2188758248682006
>>> string_log('cat')
Conversion error: invalid literal for int() with base 10: 'cat'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "D:\python\写给程序员的Python教程\pyfund\exceptional.py", line 7, in string_log
v = convert(s)
^^^^^^^^^^
File "D:\python\写给程序员的Python教程\pyfund\exceptional.py", line 14, in convert
return int(s)
^^^^^^
ValueError: invalid literal for int() with base 10: 'cat'
>>> string_log([5, 3, 2])
Conversion error: int() argument must be a string, a bytes-like object or a real number, not 'list'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "D:\python\写给程序员的Python教程\pyfund\exceptional.py", line 7, in string_log
v = convert(s)
^^^^^^^^^^
File "D:\python\写给程序员的Python教程\pyfund\exceptional.py", line 14, in convert
return int(s)
^^^^^^
TypeError: int() argument must be a string, a bytes-like object or a real number, not 'list'
6.8 异常是函数API的一部分
异常是函数API的一个重要方面。函数的调用者需要知道在各种条件下会出现哪些异常,以便可以在适当的地方进行恰当的异常处理。下面使用平方根的查找作为示例,使用了由海伦(Heron)提供的平方根函数。
sqrt.py文件:
def sqrt(x):
"""使用来自亚历山大的Heron的方法计算平方根
Args:
x: 需要计算平方根的数字
Returns:
x的平方根
"""
guess = x
i = 0
while guess * guess != x and i < 20:
guess = (guess + x / guess) / 2.0
i += 1
return guess
def main():
print(sqrt(9))
print(sqrt(2))
if __name__ == '__main__':
main()
逻辑与(and)运算符测试两个条件是否都为True。
逻辑或(or)运算符测试其操作数中的一个或两个是否为True。
PS D:\python\写给程序员的Python教程\pyfund> python sqrt.py
3.0
1.414213562373095
6.8.1 Python抛出的异常
在main()函数中添加一行,求-1的平方根得到一个新异常:
def main():
print(sqrt(9))
print(sqrt(2))
print(sqrt(-1))
PS D:\python\写给程序员的Python教程\pyfund> py sqrt.py
3.0
1.414213562373095
Traceback (most recent call last):
File "D:\python\写给程序员的Python教程\pyfund\sqrt.py", line 24, in <module>
main()
File "D:\python\写给程序员的Python教程\pyfund\sqrt.py", line 20, in main
print(sqrt(-1))
^^^^^^^^
File "D:\python\写给程序员的Python教程\pyfund\sqrt.py", line 13, in sqrt
guess = (guess + x / guess) / 2.0
~~^~~~~~~
ZeroDivisionError: float division by zero
6.8.2 捕获异常
使用try...except代码块处理所有对sqrt()的调用。同时添加了第三个输出语句来显示封闭代码块的执行如何终止:
def main():
try:
print(sqrt(9))
print(sqrt(2))
print(sqrt(-1))
print('This is never printed.')
except ZeroDivisionError:
print('cannot compute square root of a negative number.')
print('Program execution continues normally here.')
PS D:\python\写给程序员的Python教程\pyfund> py sqrt.py
3.0
1.414213562373095
cannot compute square root of a negative number.
Program execution continues normally here.
6.8.3 明确地抛出异常
用raise关键字抛出一个新创建的异常对象,然后通过调用ValueError构造函数来创建该对象。
处理除以零的情况。使用try...except ZeroDivisionError语句来包裹根查找的while循环,然后在异常处理程序内部抛出一个新的ValueError异常。
def sqrt(x):
"""使用来自亚历山大的Heron的方法计算平方根
Args:
x: 需要计算平方根的数字
Returns:
x的平方根
"""
guess = x
i = 0
try:
while guess * guess != x and i < 20:
guess = (guess + x / guess) / 2.0
i += 1
except ZeroDivisionError:
raise ValueError()
return guess
虽然该程序可以正常运行,但这是很浪费内存的:进行了大量无意义的计算。
6.9 守卫子句
上文介绍的程序在处理负数时总是失败,所以可以预先检测这个前提条件,并招聘一个异常,这个技术叫做守卫子句(guard clause):
def sqrt(x):
"""使用来自亚历山大的Heron的方法计算平方根
Args:
x: 需要计算平方根的数字
Returns:
x的平方根
"""
if x < 0:
raise ValueError('Cannot compute square root of negative number {}'.format(x))
guess = x
i = 0
while guess * guess != x and i < 20:
guess = (guess + x / guess) / 2.0
i += 1
return guess
如果运行程序,可以得到一个回溯和一个不优雅的程序退出:
PS D:\python\写给程序员的Python教程\pyfund> py sqrt.py
3.0
1.414213562373095
Traceback (most recent call last):
File "D:\python\写给程序员的Python教程\pyfund\sqrt.py", line 33, in <module>
main()
File "D:\python\写给程序员的Python教程\pyfund\sqrt.py", line 24, in main
print(sqrt(-1))
^^^^^^^^
File "D:\python\写给程序员的Python教程\pyfund\sqrt.py", line 11, in sqrt
raise ValueError('Cannot compute square root of negative number {}'.format(x))
ValueError: Cannot compute square root of negative number -1
这是因为我们忘记修改异常处理程序来捕获ValueError而不是ZeroDivisionError了。修改调用调用代码来捕获正确的异常类,并将捕获到的异常对象赋值给一个命名变量,这样就可以在捕获异常之后对其进行询问了。在这种情况下只询问输出的异常对象,它知道如何将自己显示为stderr的消息:
import sys
def main():
try:
print(sqrt(9))
print(sqrt(2))
print(sqrt(-1))
print('This is never printed.')
except ValueError as e:
print(e, file=sys.stderr)
print('Program execution continues normally here.')
再次运行程序,可以看到异常被优雅地处理:
PS D:\python\写给程序员的Python教程\pyfund> py sqrt.py
3.0
1.414213562373095
Cannot compute square root of negative number -1
Program execution continues normally here.
6.10 异常、API以及协议
异常是函数API的一部分,更广义地讲,它应该是某些协议的一部分。例如,实现序列协议的对象应该为超出范围的索引抛出IndexError异常。
抛出的异常应该与函数接收的参数一样,都是函数规范的一部分,必须对其进行适当的文档化。
Python中有一些常见的异常类型,当需要在自己的代码中抛出异常时,这些异常类型都是不错的选择。几乎很少会需要用户自己定义新的异常类型。
下面来看几个常见的异常类型。
6.10.1 IndexError
当整数索引超出区间时,程序会抛出IndexError。
当索引超过列表的长度时,就会看到该异常:
>>> z = [1, 4, 2]
>>> z[4]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: list index out of range
6.10.2 ValueError
当对象是正确的类型但包含不适当的值时,程序会抛出ValueError。
尝试将非数字字符串构造成一个整型时,就会看到该异常:
>>> int('Alice')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: 'Alice'
6.10.3 KeyError
当在映射中查找失败时,程序会抛出KeyError。
当在字典中查找不存在的键时,会看到:
>>> codes['de']
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'de'
6.11 不使用守卫子句处理TypeError
我们倾向于防范Python中的TypeError。TypeError违背了Python中的动态类型,并限制了我们编写的代码的可重用的潜力。
例如,可以使用内置的isinstance()函数测试参数是否为str,如果还是,则抛出TypeError异常:
def convert(s):
"""将一个字符串转换成一个整数。"""
if not isinstance(s, str):
raise TypeError('Argument nust be a string.')
try:
return int(s)
except (ValueError, TypeError) as e:
print('Conversion error: {}'.format(str(e)), file=sys.stderr)
raise
但是,我们也希望可以使用float实例的参数。如果想检查该函数是否可以处理其他类型,例如有理数、负数以及其他类型的数字,那么事情就会变得复杂。
在Python中,在函数中添加类型检查通常是不值得的。如果一个函数适用于特定的类型——即使是你设计函数时也不了解的类型——那真是“天降大福”。如果不是,无论如何运行代码都可能会导致TypeError。同样,我们也不会非常频繁地使用except代码块来捕获TypeError。
6.12 Pythonic风格——EAFP与LBYL
Python哲学和文化的另一个原则——要求原谅比许可更容易。
处理可能失败的程序操作只有两种方法:
- 第一种方法是在尝试操作之前,检查满足失败倾向操作的所有前提条件。
- 第二种方法是盲目的乐观,但如果出现问题,就要做好处理后果的准备。
在Python文化中,这两种哲学被称为三思而后行(Look Before you Leap, LBYL)和要求原谅比许可更容易(Easier to Ask for Forgiveness than for Permission, EAFP),顺口一提,这是由编译器的发明者海军上将格雷斯·霍珀(Grace Hopper)创造的。
Python强烈赞成EAFP,因为它以更加可读的形式组织程序主逻辑,与正常流程的处理分开,而不是散布在主流程中。
来考虑这样一个例子——处理文件。处理的细节是不相关的。需要知道的是,process_file()函数将打开一个文件并从中读取一些数据。
首先,LBYL版本:
import os
p = '/path/to/datafile.dat'
if os.path.exists(p):
process_file(p)
else:
print('No such file as {}'.format(p))
在尝试调用process_file()之前,先检查该文件是否存在,如果没有,那么避免进行调用而是输出有用的消息。这种方法有几个弊端,有些是显而易见的,有些是隐匿的。一个明显的弊端是我们只执行存在检查。如果文件存在但是包含垃圾怎么办?如果路径引用的是目录而不是文件怎么办?根据LBYL,我们也应该为这些问题增加预先的测试。
一个更微妙的问题是这里存在竞态条件(race condition)。在存在的检查和process_file()调用之间的文件可能被另一个进程删除,这就是一个经典的竞态条件。处理这个没有什么好办法——在任何情况下都需要处理来自process_file()的错误!
现在考虑替代方案,使用更Pythonic的EAFP方法:
f = '/path/to/datafile.dat'
try:
process_file(f)
except OSError as e:
print('Could not process file because {}'.format(str(e)))
在这个版本中,我们预先尝试了未经检查的操作,但是我们有一个异常处理程序可以处理任何问题。我们甚至不需要知道很多细节,如究竟是什么出了问题。在这里我们捕获OSError,OSError涵盖了所有的条件,如文件未找到,或者使用了目录而不是期望的文件。
在Python中,EAFP是标准,遵循这一理念主要是为了处理异常。若没有异常,且被迫使用错误代码代替,那需要直接在逻辑主流程中进行错误处理。由于异常会中断主流程,因此可以不在本地处理异常情况。
与EAFP相结合的异常也是优越的,因为异常与错误代码不同,它不容易被忽略。默认情况下异常会产生很大的影响,而默认情况下错误代码一般是沉默的。因此,我们很难忽略基于EAFP的风格的异常。
6.13 清理操作
try...finally语句
考虑下面这个函数,它使用标准库os模块的功能来更改当前目录、在该位置创建一个新目录以及回到最初的工作目录:
import os
def make_at(path, dir_name):
original_path = os.getcwd()
os.chdir(path)
os.mkdir(dir_name)
os.chdir(original_path)
乍一看,这似乎是合理的,但是由于某种原因,程序os.mkdir()调用可能会失败,Python进程的当前工作目录无法回到最初的工作目录,而make_at()函数将会产生一个意外的副作用。
要解决这个问题,希望函数可以在任何情况下都能回到最初的工作目录。可以通过tyr...finally代码来完成。finally代码块始终都会被执行,不管是正常地执行到了代码块的结尾,还是程序抛出了异常。
这个语句可以与except代码块组合使用,用于添加简单的故障日志记录功能:
import os
import sys
def make_at(path, dir_name):
original_path = os.getcwd()
try:
os.chdir(path)
os.mkdir(dir_name)
except OSError as e:
print(e, file=sys.stderr)
raise
finally:
os.chdir(original_path)
现在,如果os.mkdir()抛出一个OSError,那OSError处理程序将被运行,且异常将被重新抛出。由于finally代码块总是处于运行状态,所以无论try代码块如何结束,在所有情况下目录最终都会被更改到原始目录。
6.14 禅之刻
绝不应该无声无息地忽略错误,除非明确要求要保持沉默;
如果让它们保持沉默,那它们就毫无用处。
6.15 平台特定的代码
用Python检测单个按键,例如,检测在控制台上“按任意键继续”的功能需要使用操作系统特定的模块。不能使用内置的input()函数,因为它在输出字符串之前需要等待用户按Enter键。在Windows系统上需要使用其特有的msvcrt模块的功能;在Linux系统和Mac OS 系统上,除了sys模块之外,不需要使用UNIX系统特有的tty和termios模块的功能。
下面的的例子演示了许多Python语言的特性,包括import和def as语句,而不仅仅是声明:
"""keypress - 一个用于检测单个按键的模块。"""
try:
import msvcrt
def getkey():
"""等待按键,并返回单个字符。"""
return msvcrt.getch()
except ImportError:
import sys
import tty
import termios
def getkey():
"""等待按键,并返回单个字符。"""
fd = sys.stdin.fileno()
original_attributes =termios.tcgetattr(fd)
try:
tty.setraw(sys.stdin.fileno())
ch = sys.stdin.read(l)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, original_attributes)
return ch
# 如果没有找到任何UNIX特定的tty或termios模块
# 那么ImportError可以从这里传播
顶层模块代码在首次导入时被执行。在第一个try代码块中,尝试导入Microsoft Visual C运行时的msvcrt。如果导入成功,就定义函数getkey(),该函数代表着msvcrt.getch()函数。即便现在程序运行到了try代码块,这个函数也会被声明在当前作用域也就是模块范围中。
如果没有在Windows系统上运行,msvcrt导入失败,程序将抛出一个ImportError,执行转移到except代码块上。这是一个错误被明确地沉默处理的情况,这是因为我们将在异常处理程序中尝试一个替代的操作。
在except代码块中,我们要导入3个模块,这些模块用于在类UNIX系统上实现getkey(),然后继续执行getkey()的替代定义,该定义再次将函数实现绑定到模块范围中的名称上。
这个UNIX系统实现的getkey()函数使用了try finally语句,该语句将终端从用于读取单个字符的原始模式恢复为各种终端属性。
如果程序在既不是Windows也不是类UNIX的系统上运行,那么import tty语句会引发第二个IndexError。这次我们不会试图拦截这个异常:允许它传到调用者——任何尝试导入这个keypress模块的程序。我们知道如何表示这个错误,但不知道如何处理它,所以将决定推给调用者。错误不会被无声无息地忽略。
如果调用者有更多的知识或替代的可用策略,那么用户可以反过来拦截这个异常并采取适当的行动——也许会降级使用Python的input()内置函数且向用户提供不同的消息。
6.16 小结
- 异常的抛出会中断正常的程序流程并将控制权转移到异常处理程序上。
- 异常处理程序用try...except语句定义。
- try代码块定义了可以检测异常的上下文。
- 相应的except代码块定义了特定类型异常的处理程序。
- Python普遍使用异常,许多内置的语言特性依赖于异常。
- except代码块可以捕获一个异常对象,该对象通常是一个标准的类型,例如:ValueError、KeyError或IndexError。
- 程序员的错误(如IndentationError和SyntaxError)通常不会被处理。
- 可以使用接收单个异常对象参数的raise关键字发出异常条件。
- 在except代码块中的无参raise会重抛正在处理的异常。
- 我们通常不检查TypeErrors。检查TypeErrors会否定Python动态类型系统为我们提供的灵活性。
- 为了输出消息的有效载荷,可以使用str()构造函数将异常对象转换为字符串。
- 当抛出异常时,应该使用合适的内置异常类型。
- 可以使用try...finally语句来执行清理和恢复操作,该语句还可以与except代码块结合使用。
- 可以使用可选file参数将用于输出的print()函数重定向到stderr。
- Python支持逻辑运算符and和or,它们可以用于组合布尔表达式。
- 返回码太容易被忽略。
- 平台特定的操作可以使用EAFP方法实现,该方法主要使用可以拦截ImportErrors并提供替代方案的实现方式。