【Python系列专栏】第二十三篇 Python中的错误处理

错误处理

简述

在程序运行过程中我们总会遇到各种各样的错误。有的错误是程序编写有问题造成的,比如本来应该输出整数结果输出了字符串,这种错误我们通常称之为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 语句块,但 tryexcept 是一定要同时出现的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,因为 UnicodeErrorValueError 的子类,如果出现了 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 机制可以十分方便地处理错误。但出错时,会分析错误信息并定位错误发生的位置才是最关键的。

我们编写模块时可以在代码中主动抛出错误,让调用者来处理相应的错误。但是,我们应当在模块的文档中写清楚可能会抛出哪些错误,以及错误产生的原因。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Mrrunsen

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值