Skip to content

五、异常与错误处理

基础知识

优先使用异常捕获

try 语句常用知识

抛出异常,而不是返回错误

使用上下文管理器

案例故事

提前崩溃

Q:为什么要捕获异常?

A:在代码中捕获异常,表面上是避免程序因为异常发生而直接崩溃,但它的核心,其实是编码者对处于程序主流程之外的、已知或未知情况的一种妥当处置。而妥当这个词正是异常处理的关键。

不应弄一个庞大的try语句,把所有可能出错、不可能出错的代码,一股脑儿地全部用 except Exception:包裹起来,而应做最精准的异常捕获。

其中,精准捕获包括如下: 1. 永远只捕获那些可能会抛出异常的语句块; 2. 尽量只捕获精确的异常类型,而不是模糊的 Exception; 3. 如果出现了预期外的异常,让程序早点儿崩溃也未必是件坏事。

异常与抽象一致性

  1. 避免抛出抽象级别高于当前模块的异常

  2. 让模块只抛出与当前抽象级别一致的异常;

  3. 在必要的地方进行异常包装与转换。

  4. 包装抽象级别低于当前模块的异常

除了应该避免抛出高于当前抽象级别的异常外,我们同样应该避免泄露低于当前抽象级别的异常。这样做同样是为了保证异常类的抽象一致性

Example

HTTP 工具库 requests,在请求出错时所抛出的异常,并不是它在底层所使用的 urllib3 模块的原始异常,而是经过 requests.exceptions 包装过的异常

>>> try:
...     requests.get('https://www.invalid-host-foo.com')
... except Exception as e:
...     print(type(e))
...
<class 'requests.exceptions.ConnectionError'>

urllib3 模块是 requests 依赖的低层实现细节,而这个细节在未来是有可能变动的。当某天 requests 真的要修改低层实现时,这些包装过的异常类,就可以避免对用户侧的错误处理逻辑产生不良影响。

编程建议

不要随意忽略异常

面对异常,调用方可以: - 在 except 语句里捕获并处理它,继续执行后面的代码; - 在 except 语句里捕获它,将错误通知给终端用户,中断执行; - 不捕获异常,让异常继续往堆栈上层走,最终可能导致程序崩溃。 - 无论选择哪种方案,都比下例中的直接忽略异常更好。

try:
    send_sms_notification(user, message)
except RequestError:
    pass

不要手动做数据校验

在日常编码时,很大比例的错误处理工作和用户输入有关。

我们要把“输入数据校验”当作一个独立的领域,挑选更适合的模块来完成这项工作。pydantic 模块是一个不错的数据校验工具。

from pydantic import BaseModel, conint, ValidationError
class NumberInput(BaseModel):
    # 使用类型注解 conint 定义 number 属性的取值范围
    number: conint(ge=0, le=100)


def input_a_number_with_pydantic():
    while True:
        number = input('Please input a number (0-100): ')

        # 实例化为 pydantic 模型,捕获校验错误异常
        try:
            number_input = NumberInput(number=number)
        except ValidationError as e:
            print(e)
            continue

        number = number_input.number
        break

    print(f'Your number is {number}')

Tips

在编写代码时,我们应当尽量避免手动校验任何数据。因为数据校验任务独立性很强,所以应该引入合适的第三方校验模块(或者自己实现),让它们来处理这部分专业工作。

Web 应用的数据校验工作通常比较容易。比如 Django 框架有自己的表单验证模块,Flask 也可以使用 WTForms 模块来进行数据校验。

抛出可区分的异常

不要使用 assert 来检查参数合法性

无须处理是最好的错误处理

总结

基础知识部分简单介绍了 LBYL 和 EAFP 两种编程风格。

Pythonista 更倾向于使用基于异常捕获的 EAFP 风格。

(1)基础知识

  • 一个 try 语句支持多个 except 子句,但请记得把更精确的异常类放在前面
  • try 语句的 else 分支会在没有异常时执行,因此它可用来替代标记变量
  • 不带任何参数的 raise 语句会重复抛出当前异常
  • 上下文管理器经常用来处理异常,它最常见的用途是替代 finally 子句
  • 上下文管理器可以用来忽略某段代码里的异常
  • 使用 @contextmanager 装饰器可以轻松定义上下文管理器

(2)错误处理与参数校验

  • 当你可以选择编写条件判断或异常捕获时,优先选异常捕获(EAFP)
  • 不要让函数返回错误信息,直接抛出自定义异常吧
  • 手动校验数据合法性非常烦琐,尽量使用专业模块来做这件事
  • 不要使用 assert 来做参数校验,用 raise 替代它
  • 处理错误需要付出额外成本,假如能通过设计避免它就再好不过了
  • 在设计 API 时,需要慎重考虑是否真的有必要抛出错误
  • 使用“空对象模式”能免去一些针对边界情况的错误处理工作

(3)当你捕获异常时:

  • 过于模糊和宽泛的异常捕获可能会让程序免于崩溃,但也可能会带来更大的麻烦
  • 异常捕获贵在精确,只捕获可能抛出异常的语句,只捕获可能的异常类型
  • 有时候,让程序提早崩溃未必是什么坏事
  • 完全忽略异常是风险非常高的行为,大多数情况下,至少记录一条错误日志

(4)当你抛出异常时: - 保证模块内抛出的异常与模块自身的抽象级别一致 - 如果异常的抽象级别过高,把它替换为更低级的新异常 - 如果异常的抽象级别过低,把它包装成更高级的异常,然后重新抛出 - 不要让调用方用字符串匹配来判断异常种类,尽量提供可区分的异常