Python异常处理机制的常见错误总结

异常处理机制是各种编程语言的基础特性之一。Python和多数面向对象语言类似,采用了基于抛出、捕获异常对象的逻辑。这种处理模式尽管在功能上十分强大,但是在工程化的编码实践中极易被滥用,为项目的编码质量带来负面影响。这种负面影响主要体现在错误被忽略、调试信息不充分,最终导致软件可靠性与可维护性下降等方面。

本文首先与读者一起复习python中的异常处理方法,然后总结了在异常处理时常出现的错误做法以及其负面影响。

1. Python异常处理语法与相关标准库复习

1.1 基本处理语法

以下代码片段大致概括了Python中常用的错误处理语法:

try:
    acquire_a_lock()
    do_something()
except KeyError:
    print("caught a KeyError")
    raise
except ValueError as err:
    print("Caught a ValueError with error message: %s" % str(err))
except (SomeError, AnotherError) as err:
    print("Caught SomeError or AnotherError")
    if some_condition:
        raise NestedError("something happened during error handling")
    else:
        raise WrappingError("got SomeError or AnotherError") from err
else:
    print("Everything is fine")
finally:
    release_a_lock()

本文假设读者对Python以有一定认识,因此我们不详细解释每行代码,只是提示读者异常处理机制具有以下需要注意的特性:

  1. 使用as语句可以将异常实例捕获至某一变量中
  2. except语句捕获声明的异常类型及其子类,其逻辑与isinstance函数一致。
  3. 使用异常类型组成的元组来同时捕获多种类型的异常。实际上,语句isinstance(KeyError(), (KeyError, TypeError))的值为True。
  4. 在except语句中使用空raise语句可以将异常重新抛出,其关联的调用栈等信息不会改变。
  5. 在except语句中抛出其它异常对象,则外层函数将无法再次捕获原异常,而只能捕获嵌套的异常。此时原异常信息仍保留在调用栈信息内(见1.2节)。打印错误信息时,提示信息为处理过程中出现其它异常。
  6. else块在try块内无异常时执行。
  7. finally块在任何情况下都会执行,包括但不限于try块有无异常,except内重新抛出或抛出新异常,在try/except块内使用return语句(2.4节),在else块内抛出异常,等等。
  8. 抛出新异常时使用from + 旧异常可以改变5的提示信息。此时提示信息为旧异常是新异常的直接原因。

下面使用代码实例来展示上述第5点和第8点的区别:

def run():
    try:
        raise ValueError('123')
    except ValueError:
        raise ValueError('456')
Traceback (most recent call last):
   File "/home/zxy/PycharmProjects/examples/main.py", line 6, in run
     raise ValueError('123')
 ValueError: 123

 During handling of the above exception, another exception occurred:

 Traceback (most recent call last):
   File "/home/zxy/PycharmProjects/examples/main.py", line 12, in 
     run()
   File "/home/zxy/PycharmProjects/examples/main.py", line 8, in run
     raise ValueError('456')
 ValueError: 456
def run():
    try:
        raise ValueError('123')
    except ValueError as err:
        raise ValueError('456') from err
Traceback (most recent call last):
   File "/home/zxy/PycharmProjects/examples/main.py", line 6, in run
     raise ValueError('123')
 ValueError: 123

 The above exception was the direct cause of the following exception:

 Traceback (most recent call last):
   File "/home/zxy/PycharmProjects/examples/main.py", line 12, in 
     run()
   File "/home/zxy/PycharmProjects/examples/main.py", line 8, in run
     raise ValueError('456') from err
 ValueError: 456
 Process finished with exit code 1

此外需要提示的是,第二种写法仅在Python3中可用。

1.2 打印异常调用栈信息

python使用三个变量来描述一次异常,其常用命名为etypevaluetb ,函数sys.exc_info()可以返回一个元组,依次包含这三个变量。etype是异常的类型。Python作为一种动态性极强的语言,其类本身也是对象,是所谓“元类”的实例。因此这里我们可以在运行时拿到错误类型(如KeyError)本身。value即为实例化后的异常对象。tb是traceback的缩写,其类型为 types.TracebackType。该类型包含了一个栈帧的信息、行号以及一个tb_next引用指向下一级调用栈。

标准库中的traceback提供了若干用于快速获得异常信息的接口,下面简要介绍:

traceback.print_exc() 函数可以在except语句内使用,立即打印当前的错误调用栈和详细信息。此错误信息与未进行任何错误处理时由python内部打印的错误信息完全一致,因此十分有助于在不中止进程的情况下协助定位未知错误。默认输出至stderr,但可使用file参数重定向输出至其它IO对象。

import traceback

def divide(x, y):
    return x / y

if __name__ == '__main__':
    try:
        divide(100, 0)
    except ZeroDivisionError:
        traceback.print_exc()

>>> Traceback (most recent call last):
>>>    File "/home/zxy/PycharmProjects/examples/main.py", line 10, in 
>>>      divide(100, 0)
>>>    File "/home/zxy/PycharmProjects/examples/main.py", line 5, in divide
>>>      return x / y
>>>  ZeroDivisionError: division by zero

traceback.format_exc() 函数与print_exc()完全一致,但返回一个格式化后的字符串而非直接输出。这在使用某些日志库的时候会比较实用。

2. 异常处理中的常见错误做法

2.1 捕获过于宽泛的异常

随意忽略错误是工程化开发中的大忌,不仅导致难以调试和定位问题,还可能由于允许程序在错误状态下继续运行而破坏数据完整性,导致难以恢复的错误。认真对待错误这一原则在异常处理机制的使用上则主要表达为以下三个需要注意的地方:

1. 尽可能使用具体的异常类型。常见错误做法是直接捕获Exception类型。

2. 在try块内包含尽可能少的代码。例如以下代码片段,如果我们只能处理嵌套字典的KeyError,则try块就不能捕获外层字典上的异常。

name_of_alice = None
try:
    name_of_alice = bob['friends']['alice']['name']
except KeyError:
    print('alice is not a friend of bob')  
    # 错误做法。try块内有三处可能抛出KeyError的地方,
    # 但我们实际上只能处理第二个下标不存在的情况

name_of_alice = None
friends = bob['friends']
try:
    alice = friends['alice']  # 正确做法
except KeyError:
    print('alice is not a friend of bob')
else:
    name_of_alice = alice['name']

3. 不滥用异常处理机制忽略错误

在实际的开发中,我们需要根据自己的业务逻辑定义新的异常类。以下代码片段给出了一种比较典型的异常类型定义模式,其主要特征有:1. 和内置异常类型类似,具有一定的继承关系,表示从一般的异常“概念”到具体的异常。2. 和普通类型类似,定义了__init__方法并设置了类、对象成员变量,用于按照一定格式传递错误信息。3. 使用super语句调用Exception类的__init__方法,传递一个格式化后的字符串作为可读的错误消息。

class HttpException(Exception):
    status_code = 500
    msg = "Unknown internal server error"

    def __init__(self, **kwargs):
        super(HttpException, self).__init__(self.msg.format(**kwargs))


class NotFound(HttpException):
    status_code = 404
    msg = "Requested resource is not found"


class UserNotFound(NotFound):
    msg = 'Username {name} does not exist'  # 最终的具体异常类定义了消息格式

    def __init__(self, username):
        self.username = username  
        # 使用成员变量存储信息,捕获异常时可以直接使用,无需解析字符串
        super(UserNotFound, self).__init__(name=username) 
        # 使用父类能力产生可读的错误消息

2.2 使用异常处理代替输入数据校验

质量良好的程序应该在用户输入侧完成所有数据的校验,并明确拒绝非法输入。但可能是由于程序员的惰性,我们时常见到一些程序不按照这种规范编写,而是在用到可能出错的数据时在捕获由于非法输入导致的错误。这种做法有很多缺点。一是一般而言,合法数据的定义本身就包含了业务逻辑。编写统一的校验是以高内聚的方式将这些校验逻辑放到一起,有助于后续维护。二是一处的异常捕获很可能不完整,只能发现部分非法输入。随着系统规模扩大,最终会导致系统内各个函数和方法都无法假设自己的输入数据是正确的,最终在运行时检查上浪费性能,代码越发混乱,并使得分散在各处的校验代码成为无人敢于触碰的祖传代码。

2.3使用空except语句或者捕获BaseException类型

我们常使用except Exception语句以捕获“所有”异常,但实际上Exception并非Python中最基础的异常类型。except: 和except BaseException: 是两种等价的写法,二者均会捕获python中“真正的”基础异常类型BaseException。Exception是该类型的子类,并衍生出绝大多数一般意义上的异常,例如ValueError, TypeError等。我们自定义的异常类型也应该继承Exception及其子类而不是BaseException。

除Exception外,BaseException还具有以下三个子类:

  1. SystemExit, 由sys.exit()抛出,用于终止当前进程。
  2. KeyboardInterrupt, 在命令行按下Ctrl+C(准确而言应该是收到SIGTERM信号时)在主线程当前执行的语句(准确而言是字节码)处抛出。也用于终止进程。
  3. GeneratorExit(), 由生成器或协程上的close方法抛出,用于终止生成器和协程。

如前所述,若使用者不了解Python异常的继承结构,则很容易在这里捕获预期外的异常。例如,使用Ctrl+C将无法终止以下程序:

from time import sleep

def run():
    try:
        sleep(1)
        print('running')
    except BaseException as exc:
        print('Caught %s, not exiting!' % type(exc).__name__)

if __name__ == '__main__':
    while True:
        run()

2.4 在try/except/else/finally块内使用return语句

前文提到过,在try…finally的用法中,finally中的语句无论如何都会执行。因此,如果在try/except/else块中使用return语句,finally块中的代码将在return语句之后执行。如果此时finally块中也有return语句,将导致本函数执行两个return语句。下面举一个例子来描述以上行为:

def first_function():
    print("The first function is called")
    return 'first'

def second_function():
    print("The second function is called")
    return 'second'

def run():
    try:
        return first_function()
    finally:
        return second_function()

if __name__ == '__main__':
    print(run())

>>> The first function is called
>>> The second function is called
>>> second

从以上代码可以看出,两个return语句确实均会执行,且finally中的return覆盖了try块中的return。类似地,如果在finally块中再抛出嵌套的异常,则最后函数会以抛出异常的形式结束。总之,这样的写法会导致代码逻辑混乱,与其耗费时间记住其行为,不如在编码过程中杜绝这类做法。

2.5 不打印预期外异常的详细信息

我们常称try…except语句是异常“处理”机制,但并不是所有的except语句都是在“处理”异常。例如,对于长期运行的Web服务器一类应用,我们会在最外层包裹一个except Exception: 语句来捕获所有异常,然后使服务器向客户端返回500状态码和相应错误信息。这里我们并未“处理”任何一场,系统完全有可能处于错误或不一致的状态。我们只是从“顾全大局”的角度来看,(很不可靠地)希望同一进程内的其它功能仍能正常工作。在这样的场景中,输出过少或过于隐蔽(例如日志等级太低)的信息可能对调试工作产生很大的负面影响。

需要使进程长期运行时,较好的做法是首先捕获并处理具体已知的异常,然后再捕获宽泛未知的异常并将详细错误信息和上下文打印至日志内。

def main():
    while True:
        try:
            do_something()
        except SomeSpecificError:
            print("SomeSpecificError happens")
        except Exception:
            print('unknown exception during do_something')
            traceback.print_exc()

笔者建议对于任何未完全处理的异常都应导致现有处理流程中止,并在捕获时打印与当前业务逻辑相关的上下文信息以及异常调用栈(生产环境可考虑不打印调用栈以避免大量日志占用资源)。事实上,对于一些非长期运行,或者数据完整性十分重要的程序,其应当在发生任何错误时立即崩溃退出以避免破坏数据、返回错误结果,或者说“在错误的道路上越走越远”。

2.6 使用异常处理代替正常的控制流

许多编程语言都有异常处理开销明显高于正常调用的现象。Python作为公认的性能较差的语言,其异常处理机制的性能开销问题尤为突出。考虑1.2节给出的sys.exc_info()函数。该函数返回一个相当复杂的Traceback对象。该对象的构造需要占用不少时间。事实上,Java等语言都有所谓zero-cost exception机制,在无异常时将try语句本身的开销降至接近于零的水平。但Python直到本文写作时才开发不久的3.11版本(bpo-40222)才引入这一特性,因此在当前的python中,try语句本身都会引入一定的性能开销。因此,编写代码时,除非是处理真正意义上的异常,否则不要使用异常处理机制。

观察以下程序的运行结果,注意到使用异常处理来判断key是否位于字典内时,较使用if语句慢了接近一倍。

import time


def without_try_statement(key, mapping):
    result = None
    if key in mapping:
        result = mapping[key]
    return result

def with_try_statement(key, mapping):
    result = None
    try:
        result = mapping[key]
    except KeyError:
        pass
    return result

if __name__ == '__main__':
    start = time.time()
    for i in range(100000):
        without_try_statement('b', {'a': 'b'})
    mid = time.time()
    for i in range(100000):
        with_try_statement('b', {'a': 'b'})
    final = time.time()
    print("run time without try statement: %.5f" % (mid - start))
    print("run time with try statement and exception: %.5f" % (final - mid))

>>> run time without try statement: 0.02281
>>> run time with try statement and exception: 0.04349

2.7 在except块中使用未经定义的变量

最后一个常见的错误是一个细节问题,即在异常处理中使用未经定义的变量。这类问题是由于无法确保try块内的变量赋值已经执行导致的。尽管这是一个低级错误,但出现此问题的代码往往违反了2.1节指出的一个基本原则,即try块应当包含尽可能少的代码,以避免捕获不应当被忽略/处理的异常。

发表评论

您的电子邮箱地址不会被公开。