Python实践提升-异常与错误处理

Python实践提升-异常与错误处理
多年前刚开始使用 Python 编程时,我一度非常讨厌“异常”(exception)。原因很简单,因为程序每次抛出异常,就代表肯定发生了什么意料之外的“坏事”。

比如,程序本应该调用远程 API 获取数据,却因为网络不好,调用失败了,这时我们就会看到大量的 HTTPRequestException 异常。又比如,程序本应把用户输入的内容存入数据库,却因为内容太长,保存失败,我们又会看到一大堆 DatabaseFieldError 异常。

为了让程序不至于被这些异常搞崩溃,我不得不在代码里加上许多 try/except 来捕获这些异常。所以,那时的异常处理对于我来说,就是一些不想做却又不得不做的琐事,少有乐趣可言。

但慢慢地,在写了越来越多的 Python 代码后,我发现不能简单地把异常和“意料之外的坏事”画上等号。异常实际上是 Python 这门编程语言里许多核心机制的基础,它在 Python 里无处不在。

比如,每当你按下 Ctrl + C 快捷键中断脚本执行时,Python 解释器就会抛出一个 Keyboard- Interrupt 异常;每当你用 for 循环完整遍历一个列表时,就有一个 StopIteration 异常被捕获。代码如下所示:

#使用 Ctrl + C 快捷键中断 Python 脚本执行
$ python keyboard_int.py
Input a string: ^C

#解释器打印的异常信息
Traceback (most recent call last):
  File "keyboard_int.py", line 4, in <module>
    s = input('Input a string: ')
KeyboardInterrupt

同时我开始认识到,错误处理不是什么编程的额外负担,它和所有其他工作一样重要。如果能善用异常机制优雅地处理好程序里的错误,我们就能用更少、更清晰的代码,写出更健壮的程序。

在本章中,我将分享自己对于异常和错误处理的一些经验。

5.1 基础知识
5.1.1 优先使用异常捕获
  假设我想写一个简单的函数,它接收一个整数参数,返回对它加 1 后的结果。为了让这个函数更通用,我希望当它接收到一个字符串类型的整数时,也能正常完成计算。

下面是我写好的 incr_by_one() 函数代码:

def incr_by_one(value):
    """对输入整数加1,返回新的值

    :param value: 整型,或者可以转成整型的字符串
    :return: 整型结果
    """
    if isinstance(value, int):
        return value + 1
    elif isinstance(value, str) and value.isdigit():
        return int(value) + 1
    else:
        print(f'Unable to perform incr for value: "{value}"')

它的执行结果如下:

#整数
>>> incr_by_one(5)
6

#整数字符串
>>> incr_by_one('73')
74

#其他无法转换为整数的参数
>>> incr_by_one('not_a_number')
Unable to perform incr for value: "not_a_number"
>>> incr_by_one(object())
Unable to perform incr for value: "<object object at 0x10e420cb0>"

在 incr_by_one() 函数里,因为参数 value 可能是任意类型,所以我写了两个条件分支来避免程序报错:

(1) 判断仅当类型是 int 时才执行加法操作;

(2) 判断仅当类型是 str,同时满足 .isdigit() 方法时才进行操作。

这几行代码看似简单,但其实代表了一种通用的编程风格:LBYL(look before you leap)。LBYL 常被翻译成“三思而后行”。通俗点儿说,就是在执行一个可能会出错的操作时,先做一些关键的条件判断,仅当条件满足时才进行操作。

LBYL 是一种本能式的思考结果,它的逻辑就像“如果天气预报说会下雨,那么我就不出门”一样直接。

而在 LBYL 之外,还有另一种与之形成鲜明对比的风格:EAFP(easier to ask for forgiveness than permission),可直译为“获取原谅比许可简单”。

获取原谅比许可简单
  EAFP“获取原谅比许可简单”是一种和 LBYL“三思而后行”截然不同的编程风格。

在 Python 世界里,EAFP 指不做任何事前检查,直接执行操作,但在外层用 try 来捕获可能发生的异常。如果还用下雨举例,这种做法类似于“出门前不看天气预报,如果淋雨了,就回家后洗澡吃感冒药”。

如果遵循 EAFP 风格,incr_by_one() 函数可以改成下面这样:

def incr_by_one(value):
    """对输入整数加1,返回新的值

    :param value: 整型,或者可以转成整型的字符串
    :return: 整型结果
    """
    try:
        return int(value) + 1
    except (TypeError, ValueError) as e:
        print(f'Unable to perform incr for value: "{value}", error: {e}')

和 LBYL 相比,EAFP 编程风格更为简单直接,它总是直奔主流程而去,把意外情况都放在异常处理 try/except 块内消化掉。

如果你问我:这两种编程风格哪个更好?我只能说,整个 Python 社区明显偏爱基于异常捕获的 EAFP 风格。这里面的原因很多。

一个显而易见的原因是,EAFP 风格的代码通常会更精简。因为它不要求开发者用分支完全覆盖各种可能出错的情况,只需要捕获可能发生的异常即可。另外,EAFP 风格的代码通常性能也更好。比如在这个例子里,假如你每次都用字符串 ‘73’ 来调用函数,这两种风格的代码在操作流程上会有如下区别。

(1) LBYL:每次调用都要先进行额外的 isinstance 和 isdigit 判断。

(2) EAFP:每次调用直接执行转换,返回结果。

另外,和许多其他编程语言不同,在 Python 里抛出和捕获异常是很轻量的操作,即使大量抛出、捕获异常,也不会给程序带来过多额外负担。

所以,每当直觉驱使你写下 if/else 来进行错误分支判断时,请先把这份冲动放一边,考虑用 try 来捕获异常是不是更合适。毕竟,Pythonista1 们喜欢“吃感冒药”胜过“看天气预报”。

1Pythonista 是编程社区对 Python 开发者的一个比较流行的称呼,其他编程语言也有类似的词,比如 Go 语言开发者常自称 Gopher。

5.1.2 try 语句常用知识
  在实践 EAFP 编程风格时,需要大量用到异常处理语句:try/except 结构。它的基础语法如下:

def safe_int(value):
    """尝试把输入转换为整数"""
    try:
        return int(value)
    except TypeError:
        # 当某类异常被抛出时,将会执行对应 except 下的语句
        print(f'type error: {type(value)} is invalid')
    except ValueError:
        # 你可以在一个 try 语句块下写多个 except
        print(f'value error: {value} is invalid')
    finally:
        # finally 里的语句,无论如何都会被执行,哪怕已经执行了 return
        print('function completed')

函数执行效果如下:

>>> safe_int(None)
type error: <class 'NoneType'> is invalid
function completed

在编写 try/except 语句时,有几个常用的知识点。

把更精确的 except 语句放在前面

当你在代码中写下 except SomeError: 后,如果程序抛出了 SomeError 类型的异常,便会被这条 except 语句所捕获。但是,这条语句能捕获的其实不止 SomeError,它还会捕获 SomeError 类型的所有派生类。

而 Python 的内置异常类之间存在许多继承关系,举个例子:

#BaseException 是一切异常类的父类,甚至包括 KeyboardInterrupt 异常
>>> issubclass(Exception, BaseException)
True
>>> issubclass(LookupError, Exception)
True
>>> issubclass(KeyError, LookupError)
True

上面的代码展示了一条异常类派生关系:BaseException → Exception → LookupError → KeyError。

如果一个 try 代码块里包含多条 except,异常匹配会按照从上而下的顺序进行。这时,假如你不小心把一个比较模糊的父类异常放在前面,就会导致在下面的 except 永远不会被触发。

比如在下面这段代码里,except KeyError: 分支下的内容永远不会被执行:

def incr_by_key(d, key):
    try:
        d[key] += 1
    except Exception as e:print(f'Unknown error: {e}')
    except KeyError:
        print(f'key {key} does not exists')

❶ 任何异常都会被它捕获

要修复这个问题,我们得调换两个 except 的顺序,把更精确的异常放在前面:

def incr_by_key(d, key):
    try:
        d[key] += 1
    except KeyError:
        print(f'key {key} does not exists')
    except Exception as e:
        print(f'Unknown error: {e}')

这样调整后,KeyError 异常就能被第一条 except 语句正常捕获了。

使用 else 分支

在用 try 捕获异常时,有时程序需要仅在一切正常时做某件事。为了做到这一点,我们常常需要在代码里设置一个专用的标记变量。

举个简单的例子:

#同步用户资料到外部系统,仅当同步成功时发送通知消息

sync_succeeded = False
try:
    sync_profile(user.profile, to_external=True)
    sync_succeeded = True
except Exception as e:
    print("Error while syncing user profile")

if sync_succeeded:
    send_notification(user, 'profile sync succeeded')

在上面这段代码里,我期望只有当 sync_profile() 执行成功时,才继续调用 send_notification() 发送通知消息。为此,我定义了一个额外变量 sync_succeeded 来作为标记。

如果使用 try 语句块里的 else 分支,代码可以变得更简单:

try:
    sync_profile(user.profile, to_external=True)
except Exception as e:
    print("Error while syncing user profile")
else:
    send_notification(user, 'profile sync succeeded')

上面的 else 和条件分支语句里的 else 虽然是同一个词,但含义不太一样。

异常捕获语句里的 else 表示:仅当 try 语句块里没抛出任何异常时,才执行 else 分支下的内容,效果就像在 try 最后增加一个标记变量一样。

和 finally 语句不同,假如程序在执行 try 代码块时碰到了 return 或 break 等跳转语句,中断了本次异常捕获,那么即便代码没抛出任何异常,else 分支内的逻辑也不会被执行。

难理解的 else 关键字

虽然异常语句里的 else 关键字我平时用的不少,但不得不承认,此处的 else 并不像其他 Python 语法一样那么直观、容易理解。

else 这个词,字面意义是“否则”,但当它紧随着 try 和 except 出现时,你其实很难分辨它到底代表哪一种“否则”——到底是有异常时的“否则”,还是没异常时的“否则”。因此,有些开发者认为,异常捕获里的 else 关键字,应当调整为 then:表示“没有异常后,接着做某件事”的意思。

但木已成舟,在可预见的未来,异常捕获里的 else 应该会继续存在下去。这点儿因不恰当的关键字带来的理解成本,只能由我们默默承受了。

使用空 raise 语句

在处理异常时,有时我们可能仅仅想记录下某个异常,然后把它重新抛出,交由上层处理。这时,不带任何参数的 raise 语句可以派上用场:

def incr_by_key(d, key):
    try:
        d[key] += 1
    except KeyError:
        print(f'key {key} does not exists, re-raise the exception')
        raise

当一个空 raise 语句出现在 except 块里时,它会原封不动地重新抛出当前异常。

5.1.3 抛出异常,而不是返回错误
  我们知道,Python 里的函数可以一次返回多个值(通过返回一个元组实现)。所以,当我们要表明函数执行出错时,可以让它同时返回结果与错误信息。

下面的 create_item() 函数就利用了这个特性:

def create_item(name):
    """接收名称,创建 Item 对象

    :return: (对象, 错误信息),成功时错误信息为 ''
    """
    if len(name) > MAX_LENGTH_OF_NAME:
        return None, 'name of item is too long'
    if len(get_current_items()) > MAX_ITEMS_QUOTA:
        return None, 'items is full'
    return Item(name=name), ''


def create_from_input():
    name = input()
    item, err_msg = create_item(name)
    if err_msg:
        print(f'create item failed: {err_msg}')
    else:
        print('item<{name}> created')

在这段代码里,create_item() 函数的功能是创建新的 Item 对象。

当上层调用 create_item() 函数时,如果执行失败,函数会把错误原因放到第二个结果中返回。而当函数执行成功时,为了保持返回值结构统一,函数同样会返回错误原因,只是内容为空字符串 ‘’。

乍看上去,这种做法似乎很自然,对那些有 Go 语言编程经验的人来说更是如此。但在 Python 世界里,返回错误并非解决此类问题的最佳办法。这是因为这种做法会增加调用方处理错误的成本,尤其是当许多函数遵循这个规范,并且有很多层调用关系时。

Python 有完善的异常机制,并且在某种程度上鼓励我们使用异常(见 5.1.1 节)。所以,用异常来进行错误处理才是更地道的做法。

通过引入自定义异常类,上面的代码可以改写成下面这样:

class CreateItemError(Exception):
    """创建 Item 失败"""


def create_item(name):
    """创建一个新的 Item

    :raises: 当无法创建时抛出 CreateItemError
    """
    if len(name) > MAX_LENGTH_OF_NAME:
        raise CreateItemError('name of item is too long')
    if len(get_current_items()) > MAX_ITEMS_QUOTA:
        raise CreateItemError('items is full')
    return Item(name=name), ''


def create_from_input():
    name = input()
    try:
        item = create_item(name)
    except CreateItemError as e:
        print(f'create item failed: {e}')
    else:
        print(f'item<{name}> created')

用抛出异常替代返回错误后,整个代码结构乍看上去变化不大,但细节上的改变其实非常多。

新函数拥有更稳定的返回值类型,它永远只会返回 Item 类型或是抛出异常。
虽然我们鼓励使用异常,但异常总是会不可避免地让人“感到惊讶”,所以,最好在函数文档里说明可能抛出的异常类型。
不同于返回值,异常在被捕获前会不断往调用栈上层汇报。因此 create_item() 的直接调用方也可以完全不处理 CreateItemError,而交由更上层处理。异常的这个特点给了我们更多灵活性,但同时也带来了更大的风险。具体来说,假如程序缺少一个顶级的统一异常处理逻辑,那么某个被所有人忽视了的异常可能会层层上报,最终弄垮整个程序。
处理异常的题外话

如何在编程语言里处理错误,是一个至今仍然存在争议的话题。比如像上面不推荐的多返回值方式,正是缺乏异常的 Go 语言中的核心错误处理机制。另外,即使是异常机制本身,在不同编程语言之间也存在差别。比如 Java 的异常机制就和 Python 里的很不一样。

异常,或是不异常,都是由编程语言设计者进行多方取舍后的结果,更多时候不存在绝对的优劣之分。但单就 Python 而言,使用异常来表达错误无疑更符合 Python 哲学,更应该受到推崇。

5.1.4 使用上下文管理器
  当 Python 程序员们谈到异常处理时,第一个想到的往往是 try 语句。但除了 try 以外,还有一个关键字和异常处理也有着密切的关系,它就是 with。

你可能早就用过 with 了,比如用它来打开一个文件:

#使用 with 打开文件,文件描述符会在作用域结束后自动被释放
with open('foo.txt') as fp:
    content = fp.read()

with 是一个神奇的关键字,它可以在代码中开辟一段由它管理的上下文,并控制程序在进入和退出这段上下文时的行为。比如在上面的代码里,这段上下文所附加的主要行为就是:进入时打开某个文件并返回文件对象,退出时关闭该文件对象。

并非所有对象都能像 open(‘foo.txt’) 一样配合 with 使用,只有满足上下文管理器(context manager)协议的对象才行。

上下文管理器是一种定义了“进入”和“退出”动作的特殊对象。要创建一个上下文管理器,只要实现 enterexit 两个魔法方法即可。

下面这段代码实现了一个简单的上下文管理器:

class DummyContext:
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        # __enter__ 会在进入管理器时被调用,同时可以返回结果
        # 这个结果可以通过 as 关键字被调用方获取
        #
        # 此处返回一个增加了随机后缀的 name
        return f'{self.name}-{random.random()}'

    def __exit__(self, exc_type, exc_val, exc_tb):
        # __exit__ 会在退出管理器时被调用
        print('Exiting DummyContext')
        return False

它的执行效果如下:

>>> with DummyContext('foo') as name:
...     print(f'Name: {name}')
...
Name: foo-0.021691996029607252
Exiting DummyContext

上下文管理器功能强大、用处很多,其中最常见的用处之一,就是简化异常处理工作。

用于替代 finally 语句清理资源

在编写 try 语句时,finally 关键字经常用来做一些资源清理类工作,比如关闭已创建的网络连接:

conn = create_conn(host, port, timeout=None)
try:
    conn.send_text('Hello, world!')
except Exception as e:
    print(f'Unable to use connection: {e}')
finally:
    conn.close()

上面这种写法虽然经典,却有些烦琐。如果使用上下文管理器,这类资源回收代码可以变得更简单。

当程序使用 with 进入一段上下文后,不论里面发生了什么,它在退出这段上下文代码块时,必定会调用上下文管理器的 exit 方法,就和 finally 语句的行为一样。

因此,我们完全可以用上下文管理器来替代 finally 语句。做起来很简单,只要在 exit 里增加需要的回收语句即可:

class create_conn_obj:
“”“创建连接对象,并在退出上下文时自动关闭”“”

  def __init__(self, host, port, timeout=None):
        self.conn = create_conn(host, port, timeout=timeout)

    def __enter__(self):
        return self.conn

    def __exit__(self, exc_type, exc_value, traceback):
        # __exit__ 会在管理器退出时调用
        self.conn.close()
        return False

使用 create_conn_obj 可以创建会自动关闭的连接对象:

#使用上下文管理器创建连接
with create_conn_obj(host, port, timeout=None) as conn:
    try:
        conn.send_text('Hello, world!')
    except Exception as e:
        print(f'Unable to use connection: {e}')

除了回收资源外,你还可以用 exit 方法做许多其他事情,比如对异常进行二次处理后重新抛出,又比如忽略某种异常,等等。

用于忽略异常

在执行某些操作时,有时程序会抛出一些不影响正常执行逻辑的异常。

打个比方,当你在关闭某个连接时,假如它已经是关闭状态了,解释器就会抛出 AlreadyClosedError 异常。这时,为了让程序正常运行下去,你必须用 try 语句来捕获并忽略这个异常:

try:
    close_conn(conn)
except AlreadyClosedError:
    pass

虽然这样的代码很简单,但没法复用。当项目中有很多地方要忽略这类异常时,这些 try/except 语句就会分布在各个角落,看上去非常凌乱。

如果使用上下文管理器,我们可以很方便地实现可复用的“忽略异常”功能——只要在 exit 方法里稍微写几行代码就行:

class ignore_closed:
    """忽略已经关闭的连接"""

    def __enter__(self):
        pass

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type == AlreadyClosedError:
            return True
        return False

当你想忽略 AlreadyClosedError 异常时,只要把代码用 with 语句包裹起来即可:

with ignore_closed():
    close_conn(conn)

通过 with 实现的“忽略异常”功能,主要利用了上下文管理器的 exit 方法。

exit 接收三个参数:exc_type、exc_value 和 traceback。

在代码执行时,假如 with 管辖的上下文内没有抛出任何异常,那么当解释器触发 exit 方法时,上面的三个参数值都是 None;但如果有异常抛出,这三个参数就会变成该异常的具体内容。

(1) exc_type:异常的类型。

(2) exc_value:异常对象。

(3) traceback:错误的堆栈对象。

此时,程序的行为取决于 exit 方法的返回值。如果 exit 返回了 True,那么这个异常就会被当前的 with 语句压制住,不再继续抛出,达到“忽略异常”的效果;如果 exit 返回了 False,那这个异常就会被正常抛出,交由调用方处理。

因此,在上面的 ignore_closed 上下文管理器里,任何 AlreadyClosedError 类型的异常都会被忽略,而其他异常会被正常抛出。

如果你在真实项目中要忽略某类异常,可以直接使用标准库模块 contextlib 里的 suppress 函数,它提供了现成的“忽略异常”功能。

使用 contextmanager 装饰器

虽然上下文管理器很好用,但定义一个符合协议的管理器对象其实挺麻烦的——得首先创建一个类,然后实现好几个魔法方法。为了简化这部分工作,Python 提供了一个非常好用的工具:@contextmanager 装饰器。

@contextmanager 位于内置模块 contextlib 下,它可以把任何一个生成器函数直接转换为一个上下文管理器。

举个例子,我在前面实现的自动关闭连接的 create_conn_obj 上下文管理器,假如用函数来改写,可以简化成下面这样:

from contextlib import contextmanager

@contextmanager
def create_conn_obj(host, port, timeout=None):
    """创建连接对象,并在退出上下文时自动关闭"""
    conn = create_conn(host, port, timeout=timeout)
    try:
        yield conn ➊
    finally: ➋
        conn.close()

❶ 以 yield 关键字为界,yield 前的逻辑会在进入管理器时执行(类似于 enter),yield 后的逻辑会在退出管理器时执行(类似于 exit

❷ 如果要在上下文管理器内处理异常,必须用 try 语句块包裹 yield 语句

在日常工作中,我们用到的大多数上下文管理器,可以直接通过“生成器函数 + @contextmanager”的方式来定义,这比创建一个符合协议的类要简单得多。

5.2 案例故事
  假如你和几年前的我一样,简单地认为异常是一种会让程序崩溃的“坏家伙”,就难免会产生这种想法:“好的程序就应该尽量捕获所有异常,让一切都平稳运行。”

但讽刺的是,假如你真的带着这种想法去写代码,反而容易给自己带来一些意料之外的麻烦。下面小 R 的这个故事就是一个例子。

5.2.1 提前崩溃也挺好
  小 R 是一位刚接触 Python 不久的程序员。因为工作需要,他要写一个简单的程序来抓取特定网页的标题,并将其保存在本地文件中。

在学习了 requests 模块和 re 模块后,他很快写出了脚本,如代码清单 5-1 所示。

代码清单 5-1 抓取网页标题脚本

import requests
import re


def save_website_title(url, filename):
    """获取某个地址的网页标题,然后将其写入文件中

    :return: 如果成功保存,返回 True;否则打印错误,返回 False
    """
    try:
        resp = requests.get(url)
        obj = re.search(r'<title>(.*)</title>', resp.text)
        if not obj:
            print('save failed: title tag not found in page content')
            return False

        title = obj.grop(1)
        with open(filename, 'w') as fp:
            fp.write(title)
            return True
    except Exception:
        print(f'save failed: unable to save title of {url} to {filename}')
        return False


def main():
    save_website_title('https://www.qq.com', 'qq_title.txt')


if __name__ == '__main__':
    main()

脚本里的 save_website_title() 函数做了好几件事情。它首先通过 requests 模块获取网页内容,然后用正则表达式提取网页标题,最后将标题写在本地文件里。

而小 R 认为,整个过程中有两个步骤很容易出错:网络请求与本地文件操作。所以在写代码时,他用一个庞大的 try/except 语句块,把这几个步骤全都包在了里面——毕竟安全第一。

那么,小 R 写的这段代码到底藏着什么问题呢?

小 R 的无心之失

如果你旁边刚好有一台装了 Python 的电脑,那么可以试着运行一遍上面的脚本。你会发现,无论怎么修改网址和目标文件参数,这段程序都不能正常运行,而会报错:save failed: unable to …。这是为什么呢?

问题就藏在这个庞大的 try/except 代码块里。如果你非常仔细地逐行检查这段代码,就会发现在编写函数时,小 R 犯了一个小错误:他把获取正则匹配串的方法错打成了 obj.grop(1)——少了一个字母 u(正确写法:obj.group(1))。

但因为那段异常捕获范围过大、过于含糊,所以这个本该被抛出的 AttibuteError 异常被吞噬了,函数的 debug 过程变得难上加难:

>>> obj.grop(1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 're.Match' object has no attribute 'grop'

这个 obj.grop(1) 可能只是小 R 的一次无心之失。但我们可以透过它窥见一个新问题,那就是:“我们为什么要捕获异常?”

为什么要捕获异常

“为什么要捕获异常?”这个问题看上似乎有点儿小儿科。捕获异常,不就是为了避免程序崩溃吗?但如果这就是正确答案,为什么小 R 写的程序没崩溃,却反而比崩溃更糟糕呢?

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

异常捕获不是在拿着捕虫网玩捕虫游戏,谁捕的虫子多谁就获胜。弄一个庞大的 try 语句,把所有可能出错、不可能出错的代码,一股脑儿地全部用 except Exception:包起来,显然是不妥当的。

如果坚持做最精准的异常捕获,小 R 脚本里的问题根本就不会发生,精准捕获包括:

永远只捕获那些可能会抛出异常的语句块;
尽量只捕获精确的异常类型,而不是模糊的 Exception;
如果出现了预期外的异常,让程序早点儿崩溃也未必是件坏事。依照这些原则,小 R 的代码应该改成代码清单 5-2 这样。
代码清单 5-2 抓取网页标题脚本(精确捕获异常)

import re
from requests.exceptions import RequestException
 
 
def save_website_title(url, filename):
    # 抓取网页
    try:
        resp = requests.get(url)
    except RequestException as e:
        print(f'save failed: unable to get page content: {e}')
        return False

    # 获取标题
    obj = re.search(r'<title>(.*)</title>', resp.text)
    if not obj:
        print('save failed: title tag not found in page content')
        return False
    title = obj.group(1)

    # 保存文件
    try:
        with open(filename, 'w') as fp:
            fp.write(title)
    except IOError as e:
        print(f'save failed: unable to write to file {filename}: {e}')
        return False
    else:
        return True

和旧代码相比,新代码去掉了大块的 try,拆分出了两段更精确的异常捕获语句。

对于用正则获取标题那段代码来说,它本来就不应该抛出任何异常,所以我们没必要使用 try 语句包裹它。如果将 group 误写成了 grop,也没关系,程序马上就会通过 AttributeError 来告诉我们。

5.2.2 异常与抽象一致性
  下面这个故事来自我的亲身经历。

在若干年前,当时我正在参与某移动应用程序的后端 API 开发。如果你也开发过后端 API,肯定知道经常需要制定一套“API 错误码规范”,来为客户端处理错误提供方便。

当时我们制定的错误码响应大概如下所示:

// HTTP Status Code: 400
// Content-Type: application/json
{
    "code": "UNABLE_TO_UPVOTE_YOUR_OWN_REPLY",
    "detail": "你不能推荐自己的回复"
}

制定好规范后,接下来的任务就是决定如何实现它。项目当时用的是 Django 框架,而 Django 的错误页面正是利用异常机制实现的。

举个例子,如果你想让一个请求返回 404 错误页面,那么只需要在该请求过程中执行 raise Http404 抛出异常即可。

所以,我们很自然地从 Django 那儿获得了灵感。我们在项目内定义了错误码异常类:APIErrorCode,然后写了很多继承该类的错误码异常。当需要返回错误信息给用户时,只需要做一次 raise 就能搞定:

raise error_codes.UNABLE_TO_UPVOTE
raise error_codes.USER_HAS_BEEN_BANNED
... ...

毫不意外,所有人都很喜欢用这种方式来返回错误码。因为它用起来非常方便:无论当前调用栈有多深,只要你想给用户返回错误码,直接调用 raise error_codes.ANY_THING 就行。

无法复用的 process_image() 函数

随着产品的不断演进,项目规模变得越来越庞大。某日,当我正准备复用一个底层图片处理函数时,突然看到一段让我非常纠结的代码,如代码清单 5-3 所示。

代码清单 5-3 某个图像处理模块内部:

{PROJECT}/util/image/processor.py

def process_image(...):
    try:
        image = Image.open(fp)
    except Exception:
        raise error_codes.INVALID_IMAGE_UPLOADED
    ...

process_image() 函数会尝试打开一个文件对象。假如该文件不是有效的图片格式,就抛出 error_codes.INVALID_IMAGE_UPLOADED 异常。该异常会被 Django 中间件捕获,最终给用户返回“INVALID_IMAGE_UPLOADED”(上传的图片格式有误)错误码响应。

这段代码为什么让我纠结?下面我从头理理这件事。

最初编写 process_image() 时,调用这个函数的就只有“处理用户上传图片的 POST 请求”而已。所以为了偷懒,我让该函数直接抛出 APIErrorCode 异常来完成错误处理工作。

再回到问题本身,当时我需要写一个在后台运行的图片批处理脚本,而它刚好可以复用 process_image() 函数所实现的功能。

但这时事情开始变得不对劲起来,如果我想复用该函数,那么:

必须引入 APIErrorCode 异常类依赖来捕获异常——哪怕脚本和 Django API 根本没有任何关系;
必须捕获 INVALID_IMAGE_UPLOADED 异常——哪怕图片根本就不是由用户上传的。

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

这就是异常类与模块抽象级别不一致导致的结果。APIErrorCode 异常类的意义在于,表达一种能直接被终端用户(人)识别并消费的“错误代码”。它是整个项目中最高层的抽象之一。

但是出于方便,我在一个底层图像处理模块里抛出了它。这打破了 process_image() 函数的抽象一致性,导致我无法在后台脚本里复用它。

这类情况属于模块抛出了高于所属抽象级别的异常。避免这类错误需要注意以下两点:

让模块只抛出与当前抽象级别一致的异常;
在必要的地方进行异常包装与转换。
为了满足这两点,我需要对代码做一些调整:

image.processer 模块应该抛出自己封装的 ImageOpenError 异常;
在贴近高层抽象(视图 View 函数)的地方,将图像处理模块的低级异常 ImageOpenError 包装为高级异常 APIErrorCode。
修改后的代码如代码清单 5-4 和代码清单 5-5 所示。

代码清单 5-4 图像处理模块:

{PROJECT}/util/image/processor.py

class ImageOpenError(Exception):
    """图像打开错误异常类

    :param exc: 原始异常
    """

    def __init__(self, exc):
        self.exc = exc
        # 调用异常父类方法,初始化错误信息
        super().__init__(f'Image open error: {self.exc}')

def process_image(...):
    try:
        image = Image.open(fp)
    except Exception as e:
        raise ImageOpenError(exc=e)
    ... ...

代码清单 5-5 API 视图模块:

{PROJECT}/app/views.py

def foo_view_function(request):
    try:
        process_image(fp)
    except ImageOpenError:
        raise error_codes.INVALID_IMAGE_UPLOADED

这样调整以后,我就能愉快地在后台脚本里复用 process_image() 函数了。

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

除了应该避免抛出高于当前抽象级别的异常外,我们同样应该避免泄露低于当前抽象级别的异常。

如果你使用过第三方 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 真的要修改低层实现时,这些包装过的异常类,就可以避免对用户侧的错误处理逻辑产生不良影响。

有关函数与抽象级别的话题,你可以在 7.3.2 节找到更多相关内容。
5.3 编程建议
5.3.1 不要随意忽略异常
  在 5.1.4 节中,我介绍了如何使用上下文管理器来忽略某种异常。但必须要补充的是,在实际工作中,直接忽略异常其实非常少见,因为这么做风险很高。

当编码者决定让自己的代码抛出异常时,他肯定不是临时起意,而一定是希望调用自己代码的人对这个异常做点儿什么。面对异常,调用方可以:

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

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

假如 send_sms_notification() 执行失败,抛出了 RequestError 异常,它会直接被 except 忽略,就好像异常从未发生过一样。

当然,代码肯定不是平白无故写成这样的。编码者会说:“这个短信通知根本不重要,即使失败了也没关系。”但即便这样,通过日志记录下这个异常总会更好:

try:
    send_sms_notification(user, message)
except RequestError:
    logger.warning('RequestError while sending SMS notification to %s', user.username)

有了错误日志后,假如某个用户反馈自己没收到通知,我们可以马上从日志里查到是否有失败记录,不至于无计可施。此外,这些日志还可以用来做许多有趣的事情,比如统计所有短信的发送失败比例,等等。

综上所述,除了极少数情况外,不要直接忽略异常。

“Python 之禅”里也提到了这个建议:“除非有意静默,否则不要无故忽视异常。”(Errors should never pass silently. Unless explicitly silenced.)
5.3.2 不要手动做数据校验
  在日常编码时,很大比例的错误处理工作和用户输入有关。当程序里的某些数据直接来自用户输入时,我们必须先校验这些输入值,再进行之后的处理,否则就会出现难以预料的错误。

举个例子,我在写一个命令行小程序,它要求用户输入一个 0~100 范围的数字。假如用户输入的内容无效,就要求其重新输入。

小程序的代码如代码清单 5-6 所示。

代码清单 5-6 要求用户输入数字的脚本(手动校验)

def input_a_number():
    """要求用户输入一个 0~100 的数字,如果无效则重新输入"""
    while True:
        number = input('Please input a number (0-100): ')

        # 下面的三条 if 语句都是对输入值的校验代码
        if not number:
            print('Input can not be empty!')
            continue
        if not number.isdigit():
            print('Your input is not a valid number!')
            continue
        if not (0 <= int(number) <= 100):
            print('Please input a number between 0 and 100!')
            continue

        number = int(number)
        break

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

执行效果如下:

Please input a number (0-100):
Input can not be empty!
Please input a number (0-100): foo
Your input is not a valid number!
Please input a number (0-100): 65
Your number is 65

这个函数共包含 14 行有效代码,其中有 9 行 if 都在校验数据。也许你觉得这样的代码结构很正常,但请想象一下,假如我们需要校验的输入不止一个,校验逻辑也比这个复杂怎么办?

那样的话,这些数据校验代码就会变得又臭又长,占满整个函数。

如何改进这段代码呢?假如把数据校验代码抽成一个独立函数,和核心逻辑隔离开,代码肯定会变得更清晰。不过比这更重要的是,我们要把“输入数据校验”当作一个独立的领域,挑选更适合的模块来完成这项工作。

在数据校验这块,pydantic 模块是一个不错的选择。如果用它来做校验,上面的代码可以改写成代码清单 5-7。

代码清单 5-7 要求用户输入数字的脚本(使用 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}')

使用专业的数据校验模块后,整段代码变得简单了许多。

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

假如你在开发 Web 应用,数据校验工作通常来说比较容易。比如 Django 框架就有自己的表单验证模块,Flask 也可以使用 WTForms 模块来进行数据校验。
5.3.3 抛出可区分的异常
  当开发者编写自定义异常类时,似乎不需要遵循太多原则。常见的几条是:要继承 Exception 而不是 BaseException;异常类名最好以 Error 或 Exception 结尾等。但除了这些以外,设计异常的人其实还需要考虑一个重要指标——调用方是否能清晰区分各种异常。

以 5.1.3 节的代码为例,在调用 create_item() 函数时,程序可能会抛出 CreateItemError 异常。所以调用方得用 try 来捕获该异常:

def create_from_input():
    name = input()
    try:
        item = create_item(name)
    except CreateItemError as e:
        print(f'create item failed: {e}')
    else:
        print(f'item<{name}> created')

假如调用方只需要像上面这样,简单判断创建过程有没有出错,现在的异常设计可以说已经足够了。

但是,如果调用方想针对“items 已满”这类错误增加一些特殊逻辑,比如清空所有 items,我们就得把上面的代码改成下面这样:

def create_from_input():
    name = input()
    try:
        item = create_item(name)
    except CreateItemError as e:
        # 如果已满,清空所有 items
        if str(e) == 'items is full':
            clear_all_items()

        print(f'create item failed: {e}')
    else:
        print(f'item<{name}> created')

虽然这段代码通过对比错误字符串实现了需求,但这种做法其实非常脆弱。假如 create_item() 未来稍微调整了一下异常错误信息,代码逻辑就会崩坏。

为了解决这个问题,我们可以利用异常间的继承关系,设计一些更精准的异常子类:

class CreateItemError(Exception):
    """创建 Item 失败"""


class CreateErrorItemsFull(CreateItemError):
    """当前的 Item 容器已满"""



def create_item(name):
    if len(name) > MAX_LENGTH_OF_NAME:
        raise CreateItemError('name of item is too long')
    if len(get_current_items()) > MAX_ITEMS_QUOTA:
        raise CreateErrorItemsFull('items is full')
    return Item(name=name)

这样做以后,调用方就能用额外的 except 子句来单独处理“items 已满”异常了,如下所示:

def create_from_input():
    name = input()
    try:
        item = create_item(name)
    except CreateErrorItemsFull as e:
        clear_all_items()
        print(f'create item failed: {e}')
    except CreateItemError as e:
        print(f'create item failed: {e}')
    else:
        print(f'item<{name}> created')

除了设计更精确的异常子类外,你还可以创建一些包含额外属性的异常类,比如包含“错误代码”

(error_code)的 CreateItemError 类:

class CreateItemError(Exception):
    """创建 Item 失败

    :param error_code: 错误代码
    :param message: 错误信息
    """

    def __init__(self, error_code, message):
        self.error_code = error_code
        self.message = message
        super().__init__(f'{self.error_code} - {self.message}')


# 抛出异常时指定 error_code
raise CreateItemError('name_too_long', 'name of item is too long')
raise CreateItemError('items_full', 'items is full')

这样调用方在捕获异常后,也能根据异常对象的 error_code 来精确分辨异常类型。

5.3.4 不要使用 assert 来检查参数合法性
  assert 是 Python 中用来编写断言语句的关键字,它可以用来测试某个表达式是否成立。比如:

>>> value = 10
>>> assert value > 100
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError

当 assert 后面的表达式运行结果为 False 时,断言语句会马上抛出 AssertionError 异常。因此,有人可能会想着拿它来检查函数参数是否合法,就像下面这样:

def print_string(s):
    assert isinstance(s, str), 's must be string'
    print(s)

但这样做其实并不对。assert 是一个专供开发者调试程序的关键字。它所提供的断言检查,可以在执行 Python 时使用 -O 选项直接跳过:

$ python -O
# -O 选项表示让所有 assert 断言语句无效化
# 开启该选项后,下面的 assert 语句不会抛出任何异常
>>> assert False

因此,请不要拿 assert 来做参数校验,用 raise 语句来替代它吧:

def print_string(s):
    if not isinstance(s, str):
        raise TypeError('s must be string')
    print(s)

5.3.5 无须处理是最好的错误处理
  虽然我们学习了许多错误处理技巧,但无论如何,对于所有编写代码的程序员来说,错误处理永远是一种在代码主流程之外的额外负担。

假如在一个理想的环境里,我们的程序根本不需要处理任何错误,那该有多好。你别说,在 A Philosophy of Software Design 一书中,作者 John Ousterhout 分享过一个与之相关的有趣故事。

在设计 Tcl 编程语言时,作者直言自己曾犯过一个大错误。在 Tcl 语言中,有一个用来删除某个变量的 unset 命令。在设计这个命令时,作者认为当人们用 unset 删除一个不存在的变量时,一定是不正常的,程序自然应该抛出一个错误。

但在 Tcl 语言发布之后,作者惊奇地发现,当人们调用 unset 时,其实常常处在一种模棱两可的程序状态中——不确定变量是否存在。这时,unset 的设计就会让它用起来非常尴尬。大部分人在使用 unset 时,几乎都需要编写额外的代码来捕获 unset 可能抛出的错误。

John Ousterhout 直言,如果可以重新设计 unset 命令,他会对它的职责做一些调整:不再把 unset 当成一种可能会失败的删除变量行为,而是把它当作一种确保某变量不存在的命令。当 unset 的职责改变后,即使变量不存在,它也可以不抛出任何错误,直接返回就好。

unset 命令的例子体现出了一种程序设计技巧:在设计 API 时,如果稍微调整一下思考问题的角度,修改 API 的抽象定义,那么那些原本需要处理的错误,也许就会神奇地消失。假如 API 不抛出错误,调用方也就不需要处理错误,这会大大减轻大家的心智负担。

除了在设计 API 时考虑减少错误以外,“空对象模式”也是一个通过转换观念来避免错误处理的好例子。

空对象模式
  Martin Fowler 在他的经典著作《重构》中,用一章详细说明了“空对象模式”(null object pattern)。简单来说,“空对象模式”就是本该返回 None 值或抛出异常时,返回一个符合正常结果接口的特制“空类型对象”来代替,以此免去调用方的错误处理工作。

我们来看一个例子。现在有多份问卷调查的得分记录,全部为字符串格式,存放在一个列表中:

data = ['piglei 96', 'joe 100', 'invalid-data', 'roland $invalid_points', ...]

正常的得分记录是 {username} {points} 格式,但你会发现,有些数据明显不符合规范(比如 invalid-data)。现在我想写一个脚本,统计合格(大于等于 80)的得分记录总数,如代码清单 5-8 所示。

代码清单 5-8 统计合格的得分记录总数

QUALIFIED_POINTS = 80

class CreateUserPointError(Exception):
    """创建得分纪录失败时抛出"""

class UserPoint:
    """用户得分记录"""

    def __init__(self, username, points):
        self.username = username
        self.points = points

    def is_qualified(self):
        """返回得分是否合格"""
        return self.points >= QUALIFIED_POINTS

def make_userpoint(point_string):
    """从字符串初始化一条得分记录

    :param point_string: 形如 piglei 1 的表示得分记录的字符串
    :return: UserPoint 对象
    :raises: 当输入数据不合法时返回 CreateUserPointError
    """
    try:
        username, points = point_string.split()
        points = int(points)
    except ValueError:
        raise CreateUserPointError(
            'input must follow pattern "{username} {points}"'
        )

    if points < 0:
        raise CreateUserPointError('points can not be negative')
    return UserPoint(username=username, points=points)

def count_qualified(points_data):
    """计算得分合格的总人数

    :param points_data: 字符串格式的用户得分列表
    """
    result = 0
    for point_string in points_data:
        try:
            point_obj = make_userpoint(point_string)
        except CreateUserPointError:
            pass
        else:
            result += point_obj.is_qualified()
    return result

data = [
    'piglei 96',
    'nobody 61',
    'cotton 83',
    'invalid_data',
    'roland $invalid_points',
    'alfred -3',
]

print(count_qualified(data))
# 输出结果:
# 2

在上面的代码里,因为输入数据可能不符合要求,所以 make_userpoint() 方法在解析输入数据、创建 UserPoint 对象的过程中,可能会抛出 CreateUserPointError 异常来通知调用方。

因此,每当调用方使用 make_userpoint() 时,都必须加上 try/except 语句来捕获异常。

假如引入“空对象模式”,上面的异常处理逻辑可以完全消失,如代码清单 5-9 所示。

代码清单 5-9 统计合格的得分记录总数(空对象模式)

QUALIFIED_POINTS = 80

class UserPoint:
    """用户得分记录"""

    def __init__(self, username, points):
        self.username = username
        self.points = points

    def is_qualified(self):
        """返回得分是否合格"""
        return self.points >= QUALIFIED_POINTS

class NullUserPoint:
    """一个空的用户得分记录"""

    username = ''
    points = 0

    def is_qualified(self):
    return False

def make_userpoint(point_string):
    """从字符串初始化一条得分记录

    :param point_string: 形如 piglei 1 的表示得分记录的字符串
    :return: 如果输入合法,返回 UserPoint 对象,否则返回 NullUserPoint
    """
    try:
        username, points = point_string.split()
        points = int(points)
    except ValueError:
        return NullUserPoint()

    if points < 0:
        return NullUserPoint()
    return UserPoint(username=username, points=points)

在新版代码里,我定义了一个代表“空得分记录”的新类型:NullUserPoint,每当 make_userpoint() 接收到无效的输入,执行失败时,就会返回一个 NullUserPoint 对象。

这样修改后,count_qualified() 就不再需要处理任何异常了:

def count_qualified(points_data):
    """计算得分合格的总人数

    :param points_data: 字符串格式的用户得分列表
    """
    return sum(make_userpoint(s).is_qualified() for s in points_data)

❶ 这里的 make_userpoint() 总是会返回一个符合要求的对象(UserPoint() 或 NullUserPoint())

同前面 unset 命令的故事一样,“空对象模式”也是一种转换设计观念以避免错误处理的技巧。当函数进入边界情况时,“空对象模式”不再抛出错误,而是让其返回一个类似于正常结果的特殊对象,因此使用方自然就不必处理任何错误,人们写起代码来也会更轻松。

在 Python 世界中,“空对象模式”并不少见,比如大名鼎鼎的 Django 框架里的 AnonymousUser 设计就应用了这个模式。
5.4 总结
  在本章中,我们学习了在 Python 中使用异常和处理错误的一些经验和技巧。基础知识部分简单介绍了 LBYL 和 EAFP 两种编程风格。编写代码时,Pythonista 更倾向于使用基于异常捕获的 EAFP 风格。

虽然 Python 函数允许我们同时返回结果和错误信息,但更地道的做法是抛出自定义异常。除了 try 语句外,with 语句也经常用来处理异常,自定义上下文管理器可以有效复用异常处理逻辑。

在捕获异常时,过于模糊是不可取的,精确的异常捕获有助于我们写出更健壮的代码。有时,让程序提前崩溃也不一定是什么坏事。

以下是本章要点知识总结。

(1) 基础知识

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

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

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值