错误处理
简述
在程序运行过程中我们总会遇到各种各样的错误。有的错误是程序编写有问题造成的,比如本来应该输出整数结果输出了字符串,这种错误我们通常称之为bug,bug是必须修复的;有的错误是用户输入造成的,比如让用户输入email地址,结果得到一个空字符串,这种错误可以通过检查用户输入来做相应的处理;还有一类错误是完全无法预测的,比如写入文件的时候,磁盘满了,写不进去了,或者从网络抓取数据,网络突然断掉了。这类错误也称为异常,在程序中通常是必须处理的,否则,程序会因为各种问题终止并退出。Python内置了一套异常处理机制,可以帮助我们处理这些错误。
此外,在编写代码时,我们可能会需要跟踪程序的执行,查看变量的值是否正确,然后再进行调整或者下一步操作,这个过程称为调试。Python的pdb可以让我们以单步方式执行代码,从而方便地调试程序。
最后,编写测试也很重要。编写好测试文件,这样当我们改动了代码或者实现了新的功能时,只需再运行一遍测试,就能知道原来的功能有没有出错,程序是否依然能输出我们期望的结果了。
错误码
在程序运行的过程中,如果发生了错误,可以返回一个事先约定的错误代码,这样,就可以知道是否有错,以及出错的原因。在操作系统提供的调用中,返回错误码非常常见。比如打开文件的函数 open()
,成功时返回文件描述符(就是一个整数),出错时返回-1。
用错误码来表示是否出错十分不便,因为函数本身既可能返回正常结果又可能返回错误码,所以调用者不得不用大量的代码来判断属于哪一种情况。例如:
def foo():
r = some_function()
if r==(-1):
return (-1)
# do something
return r
def bar():
r = foo()
if r==(-1):
print('Error')
else:
pass
函数 foo
既可能返回正常结果又可能返回错误码,因此调用 foo
的函数 bar
就不得不先进行判断,检查返回的是正常结果还是错误码。这种情况在有多种错误码时显得更为麻烦。
还有一个很大的缺点是,使用错误码时,一旦出错,就必须把这个错误码一级一级上报,直到某个函数可以处理该错误(比如,给用户输出一个错误信息)。假如上面例子中 bar
函数无法处理错误,就必须继续返回错误码给调用 bar
的上级函数,以此类推。并且在返回的过程中,我们在每个中间函数中都要对错误码进行判断,这样写出来的程序有“半壁江山”都被处理错误的逻辑占据了,着实可怕。。。
有没有可以替代错误码又能处理错误的方案呢?有的!基本上,所有高级语言都内置了一套 try...except...finally...
的错误处理机制,Python也不例外,在下一小节中将介绍这种错误处理机制。
try…except…finally
try...except...finally...
机制的工作方式是这样的:
- 当我们认为某段代码可能会出错时,可以用
try
来运行这段代码,如果运行出错,则这段代码会终止在错误出现的地方; - 如果后续代码中
except
语句成功捕获到错误,程序就会执行except
语句块内的代码处理错误。如果没有捕获到,则错误没有得到处理,程序就会停止运行; - 最后,无论是否出错,无论是否成功捕获到错误,
finally
语句块内的代码都会被执行。
try...except...finally...
机制中,我们可以不使用 finally
语句块,但 try
和 except
是一定要同时出现的,except
不一定能成功捕获 try
语句块内的错误,如果捕获不成功,程序就会终止运行。
接下来看一个使用 try...except...finally...
机制处理错误的具体案例:
try:
print('try...')
r = 10 / 0
print('result:', r)
except ZeroDivisionError as e:
print('except:', e)
finally:
print('finally...')
print('END')
上面的代码在计算 10 / 0
时会产生一个除零错误,得到输出:
try...
except: division by zero
finally...
END
从输出可以看到,当错误发生时,后续语句 print('result:', r)
不会被执行,由于 except
语句捕获到这个 ZeroDivisionError
错误,因此 except
语句块里的代码会被执行。最后,finally
语句块里的代码也会被执行。又因为错误得到了处理,所以之后程序会继续运行后续代码,输出 END
。
如果把除数0改成2,则执行结果如下:
try...
result: 5
finally...
END
由于没有错误发生,所以 except
语句块不会被执行,但是 finally
语句块只要存在,就一定会被执行。
除了上面出现的 ZeroDivisionError
错误,在实际运行中,还有可能出现各种不同类型的错误。不同类型的错误应该由不同的 except
语句块进行处理。我们可以使用多个 except
语句来捕获不同类型的错误:
try:
print('try...')
r = 10 / int('a')
print('result:', r)
except ValueError as e:
print('ValueError:', e)
except ZeroDivisionError as e:
print('ZeroDivisionError:', e)
finally:
print('finally...')
print('END')
因为当 int()
函数无法把参数转换为 int
类型时会抛出 ValueError
错误,我们用一个 except
来捕获和处理 ValueError
,用另一个 except
来捕获并处理做除法可能产生的 ZeroDivisionError
。
特别地,我们还可以在 except
语句块后面加一个 else
语句块。当错误没有发生时,就会执行 else
语句内的代码:
try:
print('try...')
r = 10 / int('2')
print('result:', r)
except ValueError as e:
print('ValueError:', e)
except ZeroDivisionError as e:
print('ZeroDivisionError:', e)
else:
print('no error!')
finally:
print('finally...')
print('END')
我们常说,在Python中一切皆对象。其实呀,Python中的错误也是采用面向对象实现的,每一种错误都是一个类,BaseException
类是所有错误类型最顶级的父类。在使用 except
时需要注意,它不但会捕获所指定类型的错误,还把属于该类型子类的错误一并捕获。比如:
try:
foo()
except ValueError as e:
print('ValueError')
except UnicodeError as e:
print('UnicodeError')
这里的第二个 except
永远也不会捕获到 UnicodeError
,因为 UnicodeError
是 ValueError
的子类,如果出现了 UnicodeError
就一定会被第一个 except
语句捕获。
常见的错误类型和继承关系看这里:
https://docs.python.org/3/library/exceptions.html#exception-hierarchy
在上一小节中,我们说到了使用错误码处理错误有两大缺点,一是函数既可能返回正常结果也可能返回错误码,二是一旦发生错误必须层层上报。那么使用 try...except...finally...
机制是否能克服这两个缺点呢?答案是肯定的!举个例子:
def foo(s):
return 10 / int(s)
def bar(s):
return foo(s) * 2
def main():
try:
bar('0')
except Exception as e:
print('Error:', e)
finally:
print('finally...')
这里我们在 main
函数中调用 bar
函数,在 bar
函数中调用 foo
函数。我们使用 try
模块来运行调用代码,当 foo
函数发生错误时,我们不需要返回错误码,也不需要一级级上报,程序会自动寻找对应的 except
语句进行错误处理。也即是说,不需要在每个可能出错的地方去捕获错误,只要在合适的层次去捕获错误就可以了。这样一来,我们就能使用非常简洁的方式来处理程序运行中可能出现的错误了。
错误的调用链
如果错误没有被捕获,就会一直往上抛,最后被Python解释器捕获,打印出错误信息,然后程序终止运行。
编写一个包含如下代码的 err.py
文件:
# err.py:
def foo(s):
return 10 / int(s)
def bar(s):
return foo(s) * 2
def main():
bar('0')
main()
执行该文件,结果如下:
$ python3 err.py
Traceback (most recent call last):
File "err.py", line 11, in <module>
main()
File "err.py", line 9, in main
bar('0')
File "err.py", line 6, in bar
return foo(s) * 2
File "err.py", line 3, in foo
return 10 / int(s)
ZeroDivisionError: division by zero
出错并不可怕,可怕的是不知道哪里出错了。解读错误信息是定位错误的关键。我们从上往下可以看到整个错误的函数调用链:
错误信息的第1行:
Traceback (most recent call last):
这句话告诉我们下面是错误的跟踪信息。
错误信息的第2~3行:
File "err.py", line 11, in <module>
main()
告诉我们调用 main()
出错了,具体是在代码文件 err.py
的第11行代码。
错误信息的第4~5行:
File "err.py", line 9, in main
bar('0')
告诉我们调用 bar('0')
出错了,具体是在代码文件 err.py
的第9行代码。
错误信息的第6~7行:
File "err.py", line 6, in bar
return foo(s) * 2
告诉我们调用 foo(s)
出错了,具体是在代码文件 err.py
的第6行代码。
错误信息的第8~9行:
File "err.py", line 3, in foo
return 10 / int(s)
告诉我们语句 return 10 / int(s)
出错了,具体是在代码文件 err.py
的第3行代码。这是错误的源头,因为下面打印了具体的错误原因:
ZeroDivisionError: integer division or modulo by zero
根据错误类型 ZeroDivisionError
,我们可以判断 int(s)
本身并没有出错,但是 int(s)
返回了0,在计算 10 / 0
时程序出错了。这和我们使用 except
来捕获错误信息时打印出的内容是一样的。
记录错误
上一小节讲到,如果不在代码中进行错误处理,Python解释器最终会捕获错误并打印出错误调用链,但同时程序也会终止运行。那么,有没有既能打印出错误调用链,帮助我们分析出错的原因和源头,同时又能让程序继续运行的方法呢?有的,Python内置的 logging
模块可以帮助我们非常容易地记录错误信息。
这里举一个简单的例子,首先编写 err_logging.py
文件:
import logging
def foo(s):
return 10 / int(s)
def bar(s):
return foo(s) * 2
def main():
try:
bar('0')
except Exception as e:
logging.exception(e) # 使用logging模块的exception方法打印错误信息
main()
print('END')
同样是打印出错误调用链,但程序打印完错误信息后会继续运行,并正常结束:
$ python3 err_logging.py
ERROR:root:division by zero
Traceback (most recent call last):
File "err_logging.py", line 13, in main
bar('0')
File "err_logging.py", line 9, in bar
return foo(s) * 2
File "err_logging.py", line 6, in foo
return 10 / int(s)
ZeroDivisionError: division by zero
END
此外,我们还可以借助 logging
模块把错误信息记录到日志文件里,方便事后排查,这里不作举例了。
抛出错误
前面我们说到,在Python中错误都是通过类来实现的,捕获一个错误就是捕获到该类的一个实例。错误并不是凭空产生的,而是有意地创建并抛出的。Python的内置函数会抛出很多不同类型的错误,我们自己编写函数时也可以这样做。
举一个简单的例子,首先编写 err_raise.py
文件:
class FooError(ValueError):
pass
def foo(s):
n = int(s)
if n==0:
raise FooError('invalid value: %s' % s)
return 10 / n
foo('0')
这里我们自定义了一个错误类型 FooError
,继承自 ValueError
。使用 raise
语句抛出一个错误的实例。执行 err_raise.py
,最终可以跟踪到我们自定义的错误类型:
$ python3 err_raise.py
Traceback (most recent call last):
File "err_throw.py", line 11, in <module>
foo('0')
File "err_throw.py", line 8, in foo
raise FooError('invalid value: %s' % s)
__main__.FooError: invalid value: 0
只有在必要的时候才自定义错误类型。如果可以使用Python内置的错误类型(比如ValueError,TypeError等等),就应尽量使用Python内置的错误类型。
最后,我们来看另一种错误处理的方式,首先编写 err_reraise.py
文件:
def foo(s):
n = int(s)
if n==0:
raise ValueError('invalid value: %s' % s)
return 10 / n
def bar():
try:
foo('0')
except ValueError as e:
print('ValueError!')
raise
bar()
在 bar()
函数中,我们明明已经捕获了错误,但是,打印一个 ValueError
之后,又把错误通过 raise
语句再次抛出去了,为什么呢?
其实这种错误处理方式并没有错,而且相当常见。有时候,捕获错误的目的只是记录一下,便于后续追踪。如果当前函数没有处理该错误的逻辑,最恰当的方式就是继续往上抛,让顶层调用者去处理。好比一个员工处理不了一个问题时,就把问题抛给他的老板,如果他的老板也处理不了,就一直往上抛,最终抛给CEO去处理。
特别地,当 raise
语句不带参数时,会把当前错误原样抛出。但既然我们可以在 except
语句块中使用 raise
语句,那就可以轻易地抛出一个别的错误,从而把一种错误类型转换成另一种错误类型。例如:
try:
10 / 0
except ZeroDivisionError:
raise ValueError('input error!')
当然,我们不能滥用这样的功能,只有在有必要进行转换时才进行合理的转换。
小结
使用Python内置的 try...except...finally
机制可以十分方便地处理错误。但出错时,会分析错误信息并定位错误发生的位置才是最关键的。
我们编写模块时可以在代码中主动抛出错误,让调用者来处理相应的错误。但是,我们应当在模块的文档中写清楚可能会抛出哪些错误,以及错误产生的原因。